The ORLY relay implements a comprehensive policy system that provides fine-grained control over event storage and retrieval. This guide explains how to configure and use the policy system to implement custom relay behavior.
The policy system allows relay operators to:
Set the environment variable to enable policy checking:
export ORLY_POLICY_ENABLED=true
Create the policy file at ~/.config/ORLY/policy.json:
{
"default_policy": "allow",
"global": {
"max_age_of_event": 86400,
"max_age_event_in_future": 300,
"size_limit": 100000
},
"rules": {
"1": {
"description": "Text notes - basic validation",
"max_age_of_event": 3600,
"size_limit": 32000
}
}
}
# Restart your relay to load the policy
sudo systemctl restart orly
{
"default_policy": "allow|deny",
"kind": {
"whitelist": ["1", "3", "4"],
"blacklist": []
},
"global": { ... },
"rules": { ... },
"owners": ["hex_pubkey_1", "hex_pubkey_2"],
"policy_admins": ["hex_pubkey_1", "hex_pubkey_2"],
"policy_follow_whitelist_enabled": true
}
Determines the fallback behavior when no specific rules apply:
"allow": Allow events unless explicitly denied (default)"deny": Deny events unless explicitly allowedControls which event kinds are processed:
"kind": {
"whitelist": ["1", "3", "4", "9735"],
"blacklist": []
}
whitelist: Only these kinds are allowed (if present)blacklist: These kinds are denied (if present)Specifies relay owners via the policy configuration file. This is particularly useful for cloud deployments where environment variables cannot be modified at runtime.
{
"owners": [
"4a93c5ac0c6f49d2c7e7a5b8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
"5b84d6bd1d7e5a3d8e8b6c9e0f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0"
]
}
Key points:
ORLY_OWNERS)Example use case - Cloud deployment:
When deploying to a cloud platform where you cannot set environment variables:
~/.config/ORLY/policy.json:{
"default_policy": "allow",
"owners": ["your_hex_pubkey_here"]
}
export ORLY_POLICY_ENABLED=true
The relay will recognize your pubkey as an owner, granting full administrative access.
Rules that apply to all events regardless of kind:
"global": {
"description": "Site-wide security rules",
"write_allow": [],
"write_deny": [],
"read_allow": [],
"read_deny": [],
"size_limit": 100000,
"content_limit": 50000,
"max_age_of_event": 86400,
"max_age_event_in_future": 300,
"privileged": false
}
Rules that apply to specific event kinds:
"rules": {
"1": {
"description": "Text notes",
"write_allow": [],
"write_deny": [],
"read_allow": [],
"read_deny": [],
"size_limit": 32000,
"content_limit": 10000,
"max_age_of_event": 3600,
"max_age_event_in_future": 60,
"privileged": false
}
}
Control who can publish events:
{
"write_allow": ["npub1allowed...", "npub1another..."],
"write_deny": ["npub1blocked..."]
}
write_allow: Only these pubkeys can write (empty = allow all)write_deny: These pubkeys cannot writeControl who can read events:
{
"read_allow": ["npub1trusted..."],
"read_deny": ["npub1suspicious..."]
}
read_allow: Only these pubkeys can read (empty = allow all)read_deny: These pubkeys cannot readMaximum total event size in bytes:
{
"size_limit": 32000
}
Includes ID, pubkey, sig, tags, content, and metadata.
Maximum content field size in bytes:
{
"content_limit": 10000
}
Only applies to the content field.
Maximum age of events in seconds (prevents replay attacks):
{
"max_age_of_event": 3600
}
Events older than current_time - max_age_of_event are rejected.
Maximum time events can be in the future in seconds:
{
"max_age_event_in_future": 300
}
Events with created_at > current_time + max_age_event_in_future are rejected.
Require events to be authored by authenticated users or contain authenticated users in p-tags:
{
"privileged": true
}
Useful for private content that should only be accessible to specific users.
Path to a custom script for complex validation logic:
{
"script": "/path/to/custom-policy.sh"
}
See the script section below for details.
Specifies the maximum allowed expiry time using ISO-8601 duration format. Events must have an expiration tag within this duration from their created_at time.
{
"max_expiry_duration": "P7D"
}
ISO-8601 Duration Format: P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S
P - Required prefix (Period)Y - Years (approximate: 365 days)M - Months in date part (approximate: 30 days)W - Weeks (7 days)D - DaysT - Required separator before time componentsH - Hours (requires T separator)M - Minutes in time part (requires T separator)S - Seconds (requires T separator)Examples:
P7D - 7 daysP30D - 30 daysPT1H - 1 hourPT30M - 30 minutesP1DT12H - 1 day and 12 hoursP1DT2H30M - 1 day, 2 hours and 30 minutesP1W - 1 weekP1M - 1 month (30 days)Example - Ephemeral notes with 24-hour expiry:
{
"rules": {
"20": {
"description": "Ephemeral events must expire within 24 hours",
"max_expiry_duration": "P1D"
}
}
}
Note: This field takes precedence over the deprecated max_expiry (which uses raw seconds).
Requires events to have a - tag (NIP-70 protected events). Protected events signal that they should only be published to relays that enforce access control.
{
"protected_required": true
}
Example - Require protected tag for DMs:
{
"rules": {
"4": {
"description": "Encrypted DMs must be protected",
"protected_required": true,
"privileged": true
}
}
}
This ensures clients mark their sensitive events appropriately for access-controlled relays.
A regex pattern that d tag identifiers must conform to. This is useful for enforcing consistent identifier formats for replaceable events.
{
"identifier_regex": "^[a-z0-9-]{1,64}$"
}
Example patterns:
^[a-z0-9-]{1,64}$ - Lowercase alphanumeric with hyphens, max 64 chars^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$ - UUID format^[a-zA-Z0-9_]+$ - Alphanumeric with underscoresExample - Long-form content with slug identifiers:
{
"rules": {
"30023": {
"description": "Long-form articles with URL-friendly slugs",
"identifier_regex": "^[a-z0-9-]{1,64}$"
}
}
}
Note: If identifier_regex is set, events MUST have at least one d tag, and ALL d tags must match the pattern.
Specifies admin pubkeys (hex-encoded) whose follows are whitelisted for this specific rule. Unlike WriteAllowFollows which uses the global PolicyAdmins, this allows per-rule admin configuration.
{
"follows_whitelist_admins": ["hex_pubkey_1", "hex_pubkey_2"]
}
Example - Community-curated content:
{
"rules": {
"30023": {
"description": "Long-form articles from community curators' follows",
"follows_whitelist_admins": [
"4a93c5ac0c6f49d2c7e7a5b8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
"5b84d6bd1d7e5a3d8e8b6c9e0f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0"
]
}
}
}
Integration with application: At startup, the application should:
policy.GetAllFollowsWhitelistAdmins() to get all admin pubkeyspolicy.UpdateRuleFollowsWhitelist(kind, follows) or policy.UpdateGlobalFollowsWhitelist(follows) to populate the cacheNote: The relay will NOT automatically fail to start if follow list events are missing. The application layer should implement this validation if desired.
The new fields can be combined with each other and with existing fields:
Example - Strict long-form content policy:
{
"default_policy": "deny",
"rules": {
"30023": {
"description": "Curated long-form articles with strict requirements",
"max_expiry_duration": "P30D",
"protected_required": true,
"identifier_regex": "^[a-z0-9-]{1,64}$",
"follows_whitelist_admins": ["curator_pubkey_hex"],
"tag_validation": {
"t": "^[a-z0-9-]{1,32}$"
},
"size_limit": 100000,
"content_limit": 50000
}
}
}
This policy:
d tag identifiers to be lowercase URL slugst tags to be lowercase topic tagsExample - Global protected requirement with per-kind overrides:
{
"default_policy": "allow",
"global": {
"protected_required": true,
"max_expiry_duration": "P7D"
},
"rules": {
"1": {
"description": "Text notes - shorter expiry",
"max_expiry_duration": "P1D"
},
"0": {
"description": "Metadata - no expiry requirement",
"max_expiry_duration": ""
}
}
}
For complex validation logic, use custom scripts that receive events via stdin and return decisions via stdout.
Input: JSON event objects, one per line:
{
"id": "event_id",
"pubkey": "author_pubkey",
"kind": 1,
"content": "Hello, world!",
"tags": [["p", "recipient"]],
"created_at": 1640995200,
"sig": "signature"
}
Additional fields provided:
logged_in_pubkey: Hex pubkey of authenticated user (if any)ip_address: Client IP addressOutput: JSONL responses:
{"id": "event_id", "action": "accept", "msg": ""}
{"id": "event_id", "action": "reject", "msg": "Blocked content"}
{"id": "event_id", "action": "shadowReject", "msg": ""}
accept: Store/retrieve the event normallyreject: Reject with OK=false and messageshadowReject: Accept with OK=true but don't store (useful for spam filtering)#!/bin/bash
while read -r line; do
if [[ -n "$line" ]]; then
event_id=$(echo "$line" | jq -r '.id')
# Check for spam content
if echo "$line" | jq -r '.content' | grep -qi "spam"; then
echo "{\"id\":\"$event_id\",\"action\":\"reject\",\"msg\":\"Spam detected\"}"
else
echo "{\"id\":\"$event_id\",\"action\":\"accept\",\"msg\":\"\"}"
fi
fi
done
#!/usr/bin/env python3
import json
import sys
def process_event(event):
event_id = event.get('id', '')
content = event.get('content', '')
pubkey = event.get('pubkey', '')
logged_in = event.get('logged_in_pubkey', '')
# Block spam
if 'spam' in content.lower():
return {
'id': event_id,
'action': 'reject',
'msg': 'Content contains spam'
}
# Require authentication for certain content
if 'private' in content.lower() and not logged_in:
return {
'id': event_id,
'action': 'reject',
'msg': 'Authentication required'
}
return {
'id': event_id,
'action': 'accept',
'msg': ''
}
for line in sys.stdin:
if line.strip():
try:
event = json.loads(line)
response = process_event(event)
print(json.dumps(response))
sys.stdout.flush()
except json.JSONDecodeError:
continue
Place scripts in a secure location and reference them in policy:
{
"rules": {
"1": {
"script": "/etc/orly/policy/text-note-policy.py",
"description": "Custom validation for text notes"
}
}
}
Ensure scripts are executable and have appropriate permissions.
1. Output Only JSON to stdout
Scripts MUST write ONLY JSON responses to stdout. Any other output (debug messages, logs, etc.) will break the JSONL protocol and cause errors.
Debug Output: Use stderr for debug messages - all stderr output from policy scripts is automatically logged to the relay log with the prefix [policy script /path/to/script].
// ❌ WRONG - This will cause "broken pipe" errors
console.log("Policy script starting..."); // This goes to stdout!
console.log(JSON.stringify(response)); // Correct
// ✅ CORRECT - Use stderr or file for debug output
console.error("Policy script starting..."); // This goes to stderr (appears in relay log)
fs.appendFileSync('/tmp/policy.log', 'Starting...\n'); // This goes to file (OK)
console.log(JSON.stringify(response)); // Stdout for JSON only
2. Flush stdout After Each Response
Always flush stdout after writing a response to ensure immediate delivery:
# Python
print(json.dumps(response))
sys.stdout.flush() # Critical!
// Node.js (usually automatic, but can be forced)
process.stdout.write(JSON.stringify(response) + '\n');
3. Run as a Long-Lived Process
Scripts should run continuously, reading from stdin in a loop. They should NOT:
// ✅ CORRECT - Long-lived process
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
rl.on('line', (line) => {
const event = JSON.parse(line);
const response = processEvent(event);
console.log(JSON.stringify(response));
});
4. Handle Errors Gracefully
Always catch errors and return a valid JSON response:
rl.on('line', (line) => {
try {
const event = JSON.parse(line);
const response = processEvent(event);
console.log(JSON.stringify(response));
} catch (err) {
// Log to stderr or file, not stdout!
console.error(`Error: ${err.message}`);
// Return reject response
console.log(JSON.stringify({
id: '',
action: 'reject',
msg: 'Policy script error'
}));
}
});
5. Response Format
Every response MUST include these fields:
{
"id": "event_id", // Must match input event ID
"action": "accept", // Must be: accept, reject, or shadowReject
"msg": "" // Required (can be empty string)
}
Broken Pipe Error
ERROR: policy script /path/to/script.js stdin closed (broken pipe)
Causes:
Solutions:
console.log() statements except JSON responsesconsole.error() or log files for debuggingResponse Timeout
WARN: policy script /path/to/script.js response timeout - script may not be responding correctly
Causes:
Solutions:
sys.stdout.flush() (Python) after each responseInvalid JSON Response
ERROR: failed to parse policy response from /path/to/script.js
WARN: policy script produced non-JSON output on stdout: "Debug message"
Solutions:
Before deploying, test your script:
# 1. Test basic functionality
echo '{"id":"test123","pubkey":"abc","kind":1,"content":"test","tags":[],"created_at":1234567890,"sig":"def"}' | node policy-script.js
# 2. Check for non-JSON output
echo '{"id":"test123","pubkey":"abc","kind":1,"content":"test","tags":[],"created_at":1234567890,"sig":"def"}' | node policy-script.js 2>/dev/null | jq .
# 3. Test error handling
echo 'invalid json' | node policy-script.js
Expected output (valid JSON only):
{"id":"test123","action":"accept","msg":""}
#!/usr/bin/env node
const readline = require('readline');
// Use stderr for debug logging - appears in relay log automatically
function debug(msg) {
console.error(`[policy] ${msg}`);
}
// Create readline interface
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
debug('Policy script started');
// Process each event
rl.on('line', (line) => {
try {
const event = JSON.parse(line);
debug(`Processing event ${event.id}, kind: ${event.kind}, access: ${event.access_type}`);
// Your policy logic here
const action = shouldAccept(event) ? 'accept' : 'reject';
if (action === 'reject') {
debug(`Rejected event ${event.id}: policy violation`);
}
// ONLY JSON to stdout
console.log(JSON.stringify({
id: event.id,
action: action,
msg: action === 'reject' ? 'Policy rejected' : ''
}));
} catch (err) {
debug(`Error: ${err.message}`);
// Still return valid JSON
console.log(JSON.stringify({
id: '',
action: 'reject',
msg: 'Policy script error'
}));
}
});
rl.on('close', () => {
debug('Policy script stopped');
});
function shouldAccept(event) {
// Your policy logic
if (event.content.toLowerCase().includes('spam')) {
return false;
}
// Different logic for read vs write
if (event.access_type === 'write') {
// Write control logic
return event.content.length < 10000;
} else if (event.access_type === 'read') {
// Read control logic
return true; // Allow all reads
}
return true;
}
Relay Log Output Example:
INFO [policy script /home/orly/.config/ORLY/policy.js] [policy] Policy script started
INFO [policy script /home/orly/.config/ORLY/policy.js] [policy] Processing event abc123, kind: 1, access: write
INFO [policy script /home/orly/.config/ORLY/policy.js] [policy] Processing event def456, kind: 1, access: read
Scripts receive additional context fields:
{
"id": "event_id",
"pubkey": "author_pubkey",
"kind": 1,
"content": "Event content",
"tags": [],
"created_at": 1234567890,
"sig": "signature",
"logged_in_pubkey": "authenticated_user_pubkey",
"ip_address": "127.0.0.1",
"access_type": "read"
}
access_type values:
"write": Event is being stored (EVENT message)"read": Event is being retrieved (REQ message)Use this to implement different policies for reads vs writes.
Events are evaluated in this order:
The first rule that makes a decision (allow/deny) stops evaluation.
When ORLY_POLICY_ENABLED=true, each incoming EVENT is checked:
// Pseudo-code for policy integration
func handleEvent(event *Event, client *Client) {
decision := policy.CheckPolicy("write", event, client.Pubkey, client.IP)
if decision.Action == "reject" {
client.SendOK(event.ID, false, decision.Message)
return
}
if decision.Action == "shadowReject" {
client.SendOK(event.ID, true, "")
return
}
// Store event
storeEvent(event)
client.SendOK(event.ID, true, "")
}
Events returned in REQ responses are filtered:
func handleReq(filter *Filter, client *Client) {
events := queryEvents(filter)
filteredEvents := []Event{}
for _, event := range events {
decision := policy.CheckPolicy("read", &event, client.Pubkey, client.IP)
if decision.Action != "reject" {
filteredEvents = append(filteredEvents, event)
}
}
sendEvents(client, filteredEvents)
}
{
"global": {
"max_age_of_event": 86400,
"size_limit": 100000
},
"rules": {
"1": {
"script": "/etc/orly/scripts/spam-filter.sh",
"max_age_of_event": 3600,
"size_limit": 32000
}
}
}
{
"default_policy": "deny",
"global": {
"write_allow": ["npub1trusted1...", "npub1trusted2..."],
"read_allow": ["npub1trusted1...", "npub1trusted2..."]
}
}
{
"rules": {
"1": {
"script": "/etc/orly/scripts/content-moderation.py",
"description": "AI-powered content moderation"
}
}
}
{
"global": {
"script": "/etc/orly/scripts/rate-limiter.sh"
}
}
Combined with ACL system:
export ORLY_ACL_MODE=follows
export ORLY_ADMINS=npub1admin1...,npub1admin2...
export ORLY_POLICY_ENABLED=true
Policy decisions are logged:
policy allowed event <id>
policy rejected event <id>: reason
policy filtered out event <id> for read access
Script failures are logged:
policy rule for kind <N> is inactive (script not running), falling back to default policy (allow)
policy rule for kind <N> failed (script processing error: timeout), falling back to default policy (allow)
Use the policy test tools:
# Test policy with sample events
./scripts/run-policy-test.sh
# Test policy filter integration
./scripts/run-policy-filter-test.sh
Test scripts independently:
# Test script with sample event
echo '{"id":"test","kind":1,"content":"test message"}' | ./policy-script.sh
# Expected output:
# {"id":"test","action":"accept","msg":""}
shadowReject for non-blocking filteringprivileged: true for sensitive contentCheck file permissions and path:
ls -la ~/.config/ORLY/policy.json
cat ~/.config/ORLY/policy.json
Verify script is executable and working:
ls -la /path/to/script.sh
./path/to/script.sh < /dev/null
Enable debug logging:
export ORLY_LOG_LEVEL=debug
Check logs for policy decisions and errors.
Both owners and policy admins can update the relay policy dynamically by publishing kind 12345 events. This enables runtime policy changes without relay restarts, with different permission levels for each role.
ORLY uses a layered permission model for policy updates:
| Role | Source | Can Modify | Restrictions |
|---|---|---|---|
| Owner | ORLY_OWNERS env or owners in policy.json | All fields | Owners list must remain non-empty |
| Policy Admin | policy_admins in policy.json | Extend rules, add blacklists | Cannot modify owners or policy_admins, cannot reduce permissions |
Policy updates from owners and policy admins compose as follows:
owners and policy_adminswrite_allow and read_allow listswrite_deny and read_deny lists to blacklist malicious userskind.whitelist and kind.blacklistsize_limit, content_limit, etc.)write_allow_follows for additional rulesowners fieldpolicy_admins field{
"default_policy": "allow",
"owners": ["YOUR_HEX_PUBKEY_HERE"],
"policy_admins": ["ADMIN_HEX_PUBKEY_HERE"],
"policy_follow_whitelist_enabled": false
}
Important: The owners list must contain at least one pubkey to prevent lockout.
export ORLY_POLICY_ENABLED=true
Send a kind 12345 event with the new policy configuration as JSON content:
As Owner (full control):
{
"kind": 12345,
"content": "{\"default_policy\": \"deny\", \"owners\": [\"OWNER_HEX\"], \"policy_admins\": [\"ADMIN_HEX\"], \"kind\": {\"whitelist\": [1,3,7]}}",
"tags": [],
"created_at": 1234567890
}
As Policy Admin (extensions only):
{
"kind": 12345,
"content": "{\"default_policy\": \"deny\", \"owners\": [\"OWNER_HEX\"], \"policy_admins\": [\"ADMIN_HEX\"], \"kind\": {\"whitelist\": [1,3,7,30023], \"blacklist\": [4]}, \"rules\": {\"1\": {\"write_deny\": [\"BAD_ACTOR_HEX\"]}}}",
"tags": [],
"created_at": 1234567890
}
Note: Policy admins must include the original owners and policy_admins values unchanged.
When policy_follow_whitelist_enabled is true, the relay automatically grants access to all pubkeys followed by policy admins.
{
"policy_admins": ["ADMIN_PUBKEY_HEX"],
"policy_follow_whitelist_enabled": true
}
write_allow_follows rule option grants both read AND write access to followsownersCommon validation errors:
| Error | Cause |
|---|---|
owners list cannot be empty | Owner tried to remove all owners |
cannot modify the 'owners' field | Policy admin tried to change owners |
cannot modify the 'policy_admins' field | Policy admin tried to change admins |
cannot remove kind X from whitelist | Policy admin tried to reduce permissions |
cannot reduce size_limit for kind X | Policy admin tried to make limits stricter |
cannot blacklist owner X | Policy admin tried to blacklist an owner |
cannot blacklist policy admin X | Policy admin tried to blacklist another admin |
When writing tests for the policy system, the following edge cases were discovered:
NewWithManager() with enabled=true requires the XDG config file (~/.config/APP_NAME/policy.json) to exist before initialization. Tests must create this file first.invalid policy_admin pubkey) - tests should match this exact format.tag.ValueHex() instead of tag.Value() due to binary optimization.sync.RWMutex for thread-safe access to the follows list during updates.# Run all policy package tests
CGO_ENABLED=0 go test -v ./pkg/policy/...
# Run handler tests for kind 12345
CGO_ENABLED=0 go test -v ./app/... -run "PolicyConfig|PolicyAdmin"
# Run specific test categories
CGO_ENABLED=0 go test -v ./pkg/policy/... -run "ValidateJSON|Reload|Follow|TagValidation"
Use different policies for different relay instances:
# Production relay
export ORLY_APP_NAME=production
# Policy at ~/.config/production/policy.json
# Staging relay
export ORLY_APP_NAME=staging
# Policy at ~/.config/staging/policy.json
Policies can be updated without restart by modifying the JSON file. Changes take effect immediately for new events.
Scripts can integrate with external services:
import requests
def check_external_service(content):
response = requests.post('http://moderation-service:8080/check',
json={'content': content}, timeout=5)
return response.json().get('approved', False)
See the docs/ directory for complete examples:
example-policy.json: Complete policy configurationexample-policy.sh: Sample policy scriptscripts/For issues with policy configuration:
docs/Replace simple filters with policy rules:
// Before: Simple size limit
// After: Policy-based size limit
{
"global": {
"size_limit": 50000
}
}
Migrate custom validation logic to policy scripts:
{
"rules": {
"1": {
"script": "/etc/orly/scripts/custom-validation.py"
}
}
}
The policy system provides a flexible, maintainable way to implement complex relay behavior while maintaining performance and security.