20 KiB
ORLY Policy System Usage Guide
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.
Overview
The policy system allows relay operators to:
- Control which events are stored and retrieved
- Implement custom validation logic
- Set size and age limits for events
- Define access control based on pubkeys
- Use scripts for complex policy rules
- Filter events by content, kind, or other criteria
Quick Start
1. Enable the Policy System
Set the environment variable to enable policy checking:
export ORLY_POLICY_ENABLED=true
2. Create a Policy Configuration
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
}
}
}
3. Restart the Relay
# Restart your relay to load the policy
sudo systemctl restart orly
Configuration Structure
Top-Level Configuration
{
"default_policy": "allow|deny",
"kind": {
"whitelist": ["1", "3", "4"],
"blacklist": []
},
"global": { ... },
"rules": { ... }
}
default_policy
Determines the fallback behavior when no specific rules apply:
"allow": Allow events unless explicitly denied (default)"deny": Deny events unless explicitly allowed
kind Filtering
Controls 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)- Empty arrays allow all kinds
Global Rules
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
}
Kind-Specific Rules
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
}
}
Policy Fields
Access Control
write_allow / write_deny
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 write
read_allow / read_deny
Control 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 read
Size Limits
size_limit
Maximum total event size in bytes:
{
"size_limit": 32000
}
Includes ID, pubkey, sig, tags, content, and metadata.
content_limit
Maximum content field size in bytes:
{
"content_limit": 10000
}
Only applies to the content field.
Age Validation
max_age_of_event
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.
max_age_event_in_future
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.
Advanced Options
privileged
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.
script
Path to a custom script for complex validation logic:
{
"script": "/path/to/custom-policy.sh"
}
See the script section below for details.
Policy Scripts
For complex validation logic, use custom scripts that receive events via stdin and return decisions via stdout.
Script Interface
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 address
Output: JSONL responses:
{"id": "event_id", "action": "accept", "msg": ""}
{"id": "event_id", "action": "reject", "msg": "Blocked content"}
{"id": "event_id", "action": "shadowReject", "msg": ""}
Actions
accept: Store/retrieve the event normallyreject: Reject with OK=false and messageshadowReject: Accept with OK=true but don't store (useful for spam filtering)
Example Scripts
Bash Script
#!/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
Python Script
#!/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
Script Configuration
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.
Script Requirements and Best Practices
Critical Requirements
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.
// ❌ 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 (OK)
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:
- Exit after processing one event
- Use batch processing
- Close stdin/stdout prematurely
// ✅ 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)
}
Common Issues and Solutions
Broken Pipe Error
ERROR: policy script /path/to/script.js stdin closed (broken pipe)
Causes:
- Script exited prematurely
- Script wrote non-JSON output to stdout
- Script crashed or encountered an error
- Script closed stdin/stdout incorrectly
Solutions:
- Remove ALL
console.log()statements except JSON responses - Use
console.error()or log files for debugging - Add error handling to catch and log exceptions
- Ensure script runs continuously (doesn't exit)
Response Timeout
WARN: policy script /path/to/script.js response timeout - script may not be responding correctly
Causes:
- Script not flushing stdout
- Script processing taking > 5 seconds
- Script not responding to input
- Non-JSON output consuming a response slot
Solutions:
- Add
sys.stdout.flush()(Python) after each response - Optimize processing logic to be faster
- Check that script is reading from stdin correctly
- Remove debug output from stdout
Invalid 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:
- Validate JSON before outputting
- Use a JSON library, don't build strings manually
- Move debug output to stderr or files
Testing Your Script
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":""}
Node.js Example (Complete)
#!/usr/bin/env node
const fs = require('fs');
const readline = require('readline');
// Use stderr or file for debug logging
const logFile = '/tmp/policy-debug.log';
function debug(msg) {
fs.appendFileSync(logFile, `${Date.now()}: ${msg}\n`);
}
// 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}`);
// Your policy logic here
const action = shouldAccept(event) ? 'accept' : 'reject';
// 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
return !event.content.toLowerCase().includes('spam');
}
Event Fields
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.
Policy Evaluation Order
Events are evaluated in this order:
- Global Rules - Applied first to all events
- Kind Filtering - Whitelist/blacklist check
- Kind-specific Rules - Rules for the event's kind
- Script Rules - Custom script logic (if configured)
- Default Policy - Fallback behavior
The first rule that makes a decision (allow/deny) stops evaluation.
Event Processing Integration
Write Operations (EVENT)
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, "")
}
Read Operations (REQ)
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)
}
Common Use Cases
Basic Spam Filtering
{
"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
}
}
}
Private Relay
{
"default_policy": "deny",
"global": {
"write_allow": ["npub1trusted1...", "npub1trusted2..."],
"read_allow": ["npub1trusted1...", "npub1trusted2..."]
}
}
Content Moderation
{
"rules": {
"1": {
"script": "/etc/orly/scripts/content-moderation.py",
"description": "AI-powered content moderation"
}
}
}
Rate Limiting
{
"global": {
"script": "/etc/orly/scripts/rate-limiter.sh"
}
}
Follows-Based Access
Combined with ACL system:
export ORLY_ACL_MODE=follows
export ORLY_ADMINS=npub1admin1...,npub1admin2...
export ORLY_POLICY_ENABLED=true
Monitoring and Debugging
Log Messages
Policy decisions are logged:
policy allowed event <id>
policy rejected event <id>: reason
policy filtered out event <id> for read access
Script Health
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)
Testing Policies
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
Debugging Scripts
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":""}
Performance Considerations
Script Performance
- Scripts run synchronously and can block event processing
- Keep script logic efficient (< 100ms per event)
- Consider using
shadowRejectfor non-blocking filtering - Scripts should handle malformed input gracefully
Memory Usage
- Policy configuration is loaded once at startup
- Scripts are kept running for performance
- Large configurations may impact startup time
Scaling
- For high-throughput relays, prefer built-in policy rules over scripts
- Use script timeouts to prevent hanging
- Monitor script performance and resource usage
Security Considerations
Script Security
- Scripts run with relay process privileges
- Validate all inputs in scripts
- Use secure file permissions for policy files
- Regularly audit custom scripts
Access Control
- Test policy rules thoroughly before production use
- Use
privileged: truefor sensitive content - Combine with authentication requirements
- Log policy violations for monitoring
Data Validation
- Age validation prevents replay attacks
- Size limits prevent DoS attacks
- Content validation prevents malicious payloads
Troubleshooting
Policy Not Loading
Check file permissions and path:
ls -la ~/.config/ORLY/policy.json
cat ~/.config/ORLY/policy.json
Scripts Not Working
Verify script is executable and working:
ls -la /path/to/script.sh
./path/to/script.sh < /dev/null
Unexpected Behavior
Enable debug logging:
export ORLY_LOG_LEVEL=debug
Check logs for policy decisions and errors.
Common Issues
- Script timeouts: Increase script timeouts or optimize script performance
- Memory issues: Reduce script memory usage or use built-in rules
- Permission errors: Fix file permissions on policy files and scripts
- Configuration errors: Validate JSON syntax and field names
Advanced Configuration
Multiple Policies
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
Dynamic Policies
Policies can be updated without restart by modifying the JSON file. Changes take effect immediately for new events.
Integration with External Systems
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)
Examples Repository
See the docs/ directory for complete examples:
example-policy.json: Complete policy configurationexample-policy.sh: Sample policy script- Various test scripts in
scripts/
Support
For issues with policy configuration:
- Check the logs for error messages
- Validate your JSON configuration
- Test scripts independently
- Review the examples in
docs/ - Check file permissions and paths
Migration from Other Systems
From Simple Filtering
Replace simple filters with policy rules:
// Before: Simple size limit
// After: Policy-based size limit
{
"global": {
"size_limit": 50000
}
}
From Custom Code
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.