README.md raw

ORLY Policy System

The policy system provides fine-grained control over event storage and retrieval in the ORLY Nostr relay. It allows relay operators to define rules based on event kinds, pubkeys, content size, timestamps, tags, and custom scripts.

Table of Contents

- Top-Level Fields - Kind Filtering - Rule Fields

- Permissive Mode Examples

Overview

The policy system evaluates every event against configured rules before allowing storage (write) or retrieval (read). Rules are evaluated as AND operations—all configured criteria must be satisfied for an event to be allowed.

Key capabilities:

Quick Start

1. Enable the Policy System

export ORLY_POLICY_ENABLED=true

2. Create a Policy Configuration

Create ~/.config/ORLY/policy.json:

{
  "default_policy": "allow",
  "global": {
    "max_age_of_event": 86400,
    "size_limit": 100000
  },
  "rules": {
    "1": {
      "description": "Text notes",
      "size_limit": 32000,
      "max_expiry_duration": "P7D"
    }
  }
}

3. Restart the Relay

sudo systemctl restart orly

Configuration Structure

{
  "default_policy": "allow|deny",
  "kind": {
    "whitelist": [1, 3, 4],
    "blacklist": []
  },
  "global": { /* Rule fields applied to all events */ },
  "rules": {
    "1": { /* Rule fields for kind 1 */ },
    "30023": { /* Rule fields for kind 30023 */ }
  },
  "policy_admins": ["hex_pubkey_1", "hex_pubkey_2"],
  "policy_follow_whitelist_enabled": false
}

Policy Fields Reference

Top-Level Fields

FieldTypeDefaultDescription
default_policystring"allow"Fallback behavior when no rules match: "allow" or "deny"
kindobject{}Kind whitelist/blacklist configuration
globalobject{}Rule applied to ALL events regardless of kind
rulesobject{}Map of kind number (as string) to rule configuration
policy_adminsarray[]Hex-encoded pubkeys that can update policy via kind 12345 events
policy_follow_whitelist_enabledbooleanfalseEnable follows-based whitelisting for write_allow_follows

Kind Filtering

"kind": {
  "whitelist": [1, 3, 4, 7, 9735],
  "blacklist": [4]
}
FieldTypeDescription
whitelistarrayOnly these kinds are allowed. If present, all others are denied.
blacklistarrayThese kinds are denied. Only evaluated if whitelist is empty.

Precedence: Whitelist takes precedence over blacklist. If whitelist has entries, blacklist is ignored.

Rule Fields

Rules can be applied globally (in global) or per-kind (in rules). All configured criteria are evaluated as AND operations.

Description

{
  "description": "Human-readable description of this rule"
}

Access Control Lists

FieldTypeDescription
write_allowarrayHex pubkeys allowed to write. If present, all others denied.
write_denyarrayHex pubkeys denied from writing. Only evaluated if write_allow is empty.
read_allowarrayHex pubkeys allowed to read. If present, all others denied.
read_denyarrayHex pubkeys denied from reading. Only evaluated if read_allow is empty.
{
  "write_allow": ["npub1...", "npub2..."],
  "write_deny": ["npub3..."],
  "read_allow": [],
  "read_deny": ["npub4..."]
}

Size Limits

FieldTypeUnitDescription
size_limitintegerbytesMaximum total serialized event size
content_limitintegerbytesMaximum content field size
{
  "size_limit": 100000,
  "content_limit": 50000
}

Timestamp Validation

FieldTypeUnitDescription
max_age_of_eventintegersecondsMaximum age of event's created_at (prevents replay attacks)
max_age_event_in_futureintegersecondsMaximum time event can be in the future
{
  "max_age_of_event": 86400,
  "max_age_event_in_future": 300
}

Expiry Enforcement

FieldTypeDescription
max_expiryintegerDeprecated. Maximum expiry time in raw seconds.
max_expiry_durationstringMaximum expiry time in ISO-8601 duration format. Takes precedence over max_expiry.

When set, events must have an expiration tag, and the expiry time must be within the specified duration from the event's created_at time.

{
  "max_expiry_duration": "P7D"
}

Required Tags

FieldTypeDescription
must_have_tagsarrayTag key letters that must be present on the event
{
  "must_have_tags": ["d", "t"]
}

Privileged Events

FieldTypeDescription
privilegedbooleanOnly parties involved (author or p-tag recipients) can read/write
{
  "privileged": true
}

Protected Events (NIP-70)

FieldTypeDescription
protected_requiredbooleanRequires events to have a - tag (NIP-70 protected marker)

Protected events signal that they should only be published to relays that enforce access control.

{
  "protected_required": true
}

Identifier Regex

FieldTypeDescription
identifier_regexstringRegex pattern that d tag values must match

When set, events must have at least one d tag, and all d tags must match the pattern.

{
  "identifier_regex": "^[a-z0-9-]{1,64}$"
}

Tag Validation

FieldTypeDescription
tag_validationobjectMap of tag name to regex pattern

Validates that tag values match the specified regex patterns. Only validates tags that are present—does not require tags to exist.

{
  "tag_validation": {
    "d": "^[a-z0-9-]{1,64}$",
    "t": "^[a-z0-9]+$"
  }
}

Follows-Based Whitelisting

FieldTypeDescription
write_allow_followsbooleanDEPRECATED. Grant read+write access to policy admin follows
follows_whitelist_adminsarrayDEPRECATED. Per-rule admin pubkeys whose follows are whitelisted
read_follows_whitelistarrayPubkeys whose follows can READ events. Restricts read access when set.
write_follows_whitelistarrayPubkeys whose follows can WRITE events. Restricts write access when set.

See Follows-Based Whitelisting for details.

Permissive Mode Overrides

FieldTypeDescription
read_allow_permissivebooleanOverride kind whitelist for READ access (reads allowed for all kinds)
write_allow_permissivebooleanOverride kind whitelist for WRITE access (writes use global rule only)

These fields, when set on the global rule, allow independent control over read and write access relative to the kind whitelist/blacklist:

{
  "kind": {
    "whitelist": [1, 3, 5, 7]
  },
  "global": {
    "read_allow_permissive": true,
    "size_limit": 100000
  }
}

In this example:

Important constraints:

See Permissive Mode Examples for detailed use cases.

Rate Limiting

FieldTypeUnitDescription
rate_limitintegerbytes/secondMaximum data rate per authenticated connection
{
  "rate_limit": 10000
}

Custom Scripts

FieldTypeDescription
scriptstringPath to external validation script

See Policy Scripts for details.

ISO-8601 Duration Format

The max_expiry_duration field uses strict ISO-8601 duration format, parsed by the sosodev/duration library.

Format

P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S
ComponentMeaningExample
PRequired prefix (Period)P1D
YYears (~365.25 days)P1Y
MMonths (~30.44 days) - date partP1M
WWeeks (7 days)P2W
DDaysP7D
TRequired separator before timePT1H
HHours (requires T)PT2H
MMinutes (requires T) - time partPT30M
SSeconds (requires T)PT90S

Examples

DurationMeaningSeconds
P1D1 day86,400
P7D7 days604,800
P30D30 days2,592,000
PT1H1 hour3,600
PT30M30 minutes1,800
PT90S90 seconds90
P1DT12H1 day 12 hours129,600
P1DT2H30M1 day 2 hours 30 minutes95,400
P1W1 week604,800
P1M1 month2,628,000
P1Y1 year31,536,000
PT1.5H1.5 hours5,400
P0.5D12 hours43,200

Important Notes

  1. P prefix is required: 1D is invalid, use P1D
  2. T separator is required before time: P1H is invalid, use PT1H
  3. Date components before T: PT1D is invalid (D is a date component)
  4. Case insensitive: p1d and P1D are equivalent
  5. Fractional values supported: PT1.5H, P0.5D

Invalid Examples

InvalidWhyCorrect
1DMissing P prefixP1D
P1HH needs T separatorPT1H
PT1DD is date componentP1D
P30SS needs T separatorPT30S
P-5DNegative not allowedP5D
PDMissing numberP1D

Access Control

Default-Permissive Access Model

The policy system uses a default-permissive model for both read and write access:

Restrictions become active when any of the following fields are set:

AccessRestrictions
Readread_allow, read_follows_whitelist, or privileged
Writewrite_allow, write_follows_whitelist

Important: privileged ONLY applies to READ operations.

Write Access Evaluation (Default-Permissive)

1. Universal constraints (size, tags, age) - must pass
2. If pubkey in write_deny → DENY
3. If write_allow_follows enabled and pubkey in admin follows → ALLOW
4. If write_follows_whitelist set and pubkey in follows → ALLOW
5. If write_allow set and pubkey in list → ALLOW
6. If ANY write restriction is set → DENY (not in any whitelist)
7. Otherwise → ALLOW (default-permissive)

Read Access Evaluation (Default-Permissive)

1. If pubkey in read_deny → DENY
2. If read_allow_follows enabled and pubkey in admin follows → ALLOW
3. If read_follows_whitelist set and pubkey in follows → ALLOW
4. If read_allow set and pubkey in list → ALLOW
5. If privileged set and pubkey is party to event → ALLOW
6. If ANY read restriction is set → DENY (not in any whitelist)
7. Otherwise → ALLOW (default-permissive)

Privileged Events (Read-Only)

When privileged: true, only the author and p-tag recipients can access the event:

{
  "rules": {
    "4": {
      "description": "Encrypted DMs",
      "privileged": true
    }
  }
}

Follows-Based Whitelisting

The policy system supports whitelisting pubkeys based on follow lists (kind 3 events). There are two approaches:

1. Separate Read/Write Follows Whitelists (Recommended)

Use read_follows_whitelist and write_follows_whitelist for fine-grained control:

{
  "global": {
    "read_follows_whitelist": ["curator_pubkey_hex"],
    "write_follows_whitelist": ["moderator_pubkey_hex"]
  },
  "rules": {
    "30023": {
      "description": "Articles - curated reading, moderated writing",
      "read_follows_whitelist": ["article_curator_hex"],
      "write_follows_whitelist": ["article_moderator_hex"]
    }
  }
}

How it works:

Important: The relay will fail to start if the named pubkeys don't have kind 3 follow list events in the database. This ensures the follow lists are available for access control.

2. Legacy: Global Policy Admin Follows (DEPRECATED)

Enable whitelisting for all pubkeys followed by policy admins:

{
  "policy_admins": ["admin_pubkey_hex"],
  "policy_follow_whitelist_enabled": true,
  "rules": {
    "1": {
      "write_allow_follows": true
    }
  }
}

When write_allow_follows is true, pubkeys in the policy admins' kind 3 follow lists get both read AND write access.

3. Legacy: Per-Rule Follows Whitelist (DEPRECATED)

Configure specific admins per rule:

{
  "rules": {
    "30023": {
      "description": "Long-form articles from curator's follows",
      "follows_whitelist_admins": ["curator_pubkey_hex"]
    }
  }
}

This allows different rules to use different admin follow lists.

Loading Follow Lists at Startup

The application must load follow lists at startup. The new API provides separate methods:

// Get all pubkeys that need follow lists loaded (combines read + write + legacy)
allPubkeys := policy.GetAllFollowsWhitelistPubkeys()

// Or get them separately
readPubkeys := policy.GetAllReadFollowsWhitelistPubkeys()
writePubkeys := policy.GetAllWriteFollowsWhitelistPubkeys()
legacyAdmins := policy.GetAllFollowsWhitelistAdmins()

// Load follows and update the policy
for _, pubkeyHex := range readPubkeys {
    follows := loadFollowsFromKind3(pubkeyHex)
    // Update read follows whitelist for specific kinds
    policy.UpdateRuleReadFollowsWhitelist(kind, follows)
    // Or for global rule
    policy.UpdateGlobalReadFollowsWhitelist(follows)
}

for _, pubkeyHex := range writePubkeys {
    follows := loadFollowsFromKind3(pubkeyHex)
    policy.UpdateRuleWriteFollowsWhitelist(kind, follows)
    // Or for global rule
    policy.UpdateGlobalWriteFollowsWhitelist(follows)
}

Tag Validation

Using tag_validation

Validate multiple tags with regex patterns:

{
  "rules": {
    "30023": {
      "tag_validation": {
        "d": "^[a-z0-9-]{1,64}$",
        "t": "^[a-z0-9]+$",
        "title": "^.{1,100}$"
      }
    }
  }
}

Using identifier_regex

Shorthand for d tag validation:

{
  "identifier_regex": "^[a-z0-9-]{1,64}$"
}

This is equivalent to:

{
  "tag_validation": {
    "d": "^[a-z0-9-]{1,64}$"
  }
}

Important: When identifier_regex is set, events must have at least one d tag.

Common Patterns

PatternDescription
^[a-z0-9-]{1,64}$URL-friendly slug
^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$UUID
^[a-zA-Z0-9_]+$Alphanumeric with underscores
^.{1,100}$Any characters, max 100

Policy Scripts

External scripts provide custom validation logic.

Script Interface

Input: JSON event objects on stdin (one per line):

{
  "id": "event_id_hex",
  "pubkey": "author_pubkey_hex",
  "kind": 1,
  "content": "Hello, world!",
  "tags": [["p", "recipient_hex"]],
  "created_at": 1640995200,
  "sig": "signature_hex",
  "logged_in_pubkey": "authenticated_user_hex",
  "ip_address": "127.0.0.1",
  "access_type": "write"
}

Output: JSON response on stdout:

{"id": "event_id_hex", "action": "accept", "msg": ""}

Actions

ActionOK ResponseEffect
accepttrueStore/retrieve event normally
rejectfalseReject with error message
shadowRejecttrueSilently drop (appears successful to client)

Script Requirements

  1. Long-lived process: Read stdin in a loop, don't exit after one event
  2. JSON only on stdout: Use stderr for debug logging
  3. Flush after each response: Call sys.stdout.flush() (Python) or equivalent
  4. Handle errors gracefully: Always return valid JSON

Example Script (Python)

#!/usr/bin/env python3
import json
import sys

def process_event(event):
    if 'spam' in event.get('content', '').lower():
        return {'id': event['id'], 'action': 'reject', 'msg': 'Spam detected'}
    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:
            print(json.dumps({'id': '', 'action': 'reject', 'msg': 'Invalid JSON'}))
            sys.stdout.flush()

Configuration

{
  "rules": {
    "1": {
      "script": "/etc/orly/scripts/spam-filter.py"
    }
  }
}

Dynamic Policy Updates

Policy admins can update configuration at runtime by publishing kind 12345 events.

Setup

{
  "policy_admins": ["admin_pubkey_hex"],
  "default_policy": "allow"
}

Publishing Updates

Send a kind 12345 event with the new policy as JSON content:

{
  "kind": 12345,
  "content": "{\"default_policy\": \"deny\", \"kind\": {\"whitelist\": [1,3,7]}}",
  "tags": [],
  "created_at": 1234567890
}

Security

Evaluation Order

Events are evaluated in this order:

  1. Global Rules - Applied to all events first
  2. Kind Filtering - Whitelist/blacklist check
  3. Kind-Specific Rules - Rules for the event's kind
  4. Script Evaluation - If configured and running
  5. Default Policy - Fallback if no rules deny

The first rule that denies access stops evaluation. If all rules pass, the event is allowed.

Rule Criteria (AND Logic)

Within a rule, all configured criteria must be satisfied:

access_allowed = (
    pubkey_check_passed AND
    size_check_passed AND
    timestamp_check_passed AND
    expiry_check_passed AND
    tag_check_passed AND
    protected_check_passed AND
    script_check_passed
)

Examples

Open Relay with Size Limits

{
  "default_policy": "allow",
  "global": {
    "size_limit": 100000,
    "max_age_of_event": 86400,
    "max_age_event_in_future": 300
  }
}

Private Relay

{
  "default_policy": "deny",
  "global": {
    "write_allow": ["trusted_pubkey_1", "trusted_pubkey_2"],
    "read_allow": ["trusted_pubkey_1", "trusted_pubkey_2"]
  }
}

Ephemeral Events with Expiry

{
  "default_policy": "allow",
  "rules": {
    "20": {
      "description": "Ephemeral events must expire within 24 hours",
      "max_expiry_duration": "P1D"
    }
  }
}

Long-Form Content with Strict Validation

{
  "default_policy": "deny",
  "rules": {
    "30023": {
      "description": "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
    }
  }
}

Encrypted DMs with Privacy

{
  "default_policy": "allow",
  "rules": {
    "4": {
      "description": "Encrypted DMs - private and protected",
      "protected_required": true,
      "privileged": true
    }
  }
}

Community-Curated Content

{
  "default_policy": "deny",
  "policy_admins": ["community_admin_hex"],
  "policy_follow_whitelist_enabled": true,
  "rules": {
    "1": {
      "description": "Only community members can post",
      "write_allow_follows": true,
      "size_limit": 32000
    }
  }
}

Kind Whitelist with Global Limits

{
  "default_policy": "deny",
  "kind": {
    "whitelist": [0, 1, 3, 4, 7, 9735, 30023]
  },
  "global": {
    "size_limit": 100000,
    "max_age_of_event": 604800,
    "max_age_event_in_future": 60
  }
}

Permissive Mode Examples

Read-Permissive Relay (Write-Restricted)

Allow anyone to read all events, but restrict writes to specific kinds:

{
  "default_policy": "allow",
  "kind": {
    "whitelist": [1, 3, 7, 9735]
  },
  "global": {
    "read_allow_permissive": true,
    "size_limit": 100000
  }
}

Behavior:

This is useful for relays that want to serve as aggregators (read any event type) but only accept specific event types from clients.

Write-Permissive with Read Restrictions

Allow writes of any kind (with global constraints), but restrict reads:

{
  "default_policy": "allow",
  "kind": {
    "whitelist": [0, 1, 3]
  },
  "global": {
    "write_allow_permissive": true,
    "size_limit": 50000,
    "max_age_of_event": 86400
  }
}

Behavior:

This is useful for relays that want to accept any event type but only serve a curated subset.

Archive Relay (Read Any, Accept Specific)

Perfect for archive/backup relays:

{
  "default_policy": "allow",
  "kind": {
    "whitelist": [0, 1, 3, 4, 7, 30023]
  },
  "global": {
    "read_allow_permissive": true,
    "size_limit": 500000
  },
  "rules": {
    "30023": {
      "description": "Long-form articles with validation",
      "identifier_regex": "^[a-z0-9-]{1,64}$",
      "max_expiry_duration": "P365D"
    }
  }
}

Behavior:

Testing

Run Policy Tests

CGO_ENABLED=0 go test -v ./pkg/policy/...

Test Scripts Manually

echo '{"id":"test","kind":1,"content":"test"}' | ./policy-script.py

Expected output:

{"id":"test","action":"accept","msg":""}

Troubleshooting

Policy Not Loading

# Check file exists and is valid JSON
cat ~/.config/ORLY/policy.json | jq .

Script Not Working

# Check script is executable
ls -la /path/to/script.py

# Test script independently
echo '{"id":"test","kind":1}' | /path/to/script.py

Enable Debug Logging

export ORLY_LOG_LEVEL=debug

Common Issues

IssueCauseSolution
"invalid ISO-8601 duration"Wrong formatUse P1D not 1d
"H requires T separator"Missing TUse PT1H not P1H
Script timeoutScript not respondingEnsure flush after each response
Broken pipeScript exitedScript must run continuously