feat(relay): enforce [limits] + [authorization] config sections

Wires the parsed-but-unenforced config sections into actual relay
behavior via five new IRelayPolicy implementations under
quartz-relay/.../policies/. They compose through the existing
PolicyStack so operators stack only what they need; cheap rejection
paths run before expensive ones (rate-limit → AUTH → future/size/lists
→ signature verification).

New policies:
  - KindAllowDenyPolicy           — kind_whitelist + kind_blacklist
  - PubkeyAllowDenyPolicy         — pubkey_whitelist + pubkey_blacklist
                                    (case-insensitive)
  - RejectFutureEventsPolicy      — options.reject_future_seconds
  - MaxEventBytesPolicy           — limits.max_event_bytes
                                    (size of canonical NIP-01 JSON form)
  - RateLimitPolicy               — token-bucket per session for
                                    messages_per_sec + subscriptions_per_min;
                                    monotonic clock so wall-time jumps
                                    don't reset buckets.

Plus:
  - LocalRelayServer now honors limits.max_ws_frame_bytes /
    limits.max_ws_message_bytes via Ktor's WebSockets.maxFrameSize.
  - Main.kt builds the policy stack from RelayConfig in composePolicy().
    Warning surface is reduced to only the three sections still
    pending (max_subscriptions_per_session, max_filters_per_req,
    network.remote_ip_header).
  - PassThroughPolicy base lets each policy declare only the hook(s)
    it actually enforces, keeping call sites readable.

Tests (20 new):
  - PoliciesTest (16) — per-policy unit coverage for each accept/reject
    boundary, including allow + deny precedence, case-insensitive pubkey
    matching, deterministic rate-limit refill via injected clock, and
    composition via IRelayPolicy.plus.
  - PoliciesIntegrationTest (4) — end-to-end through NostrClient →
    RelayHub → Relay; proves OK false comes back over the wire when
    policies reject (kind blacklist, pubkey allow-list, future
    timestamps, oversize events).

Total :quartz-relay tests: 62, 0 failures.

Also: bump Nip40 deleteExpiredEvents wait from 1500ms to 2500ms — the
previous margin was thin enough to flake on busy CI when the SQLite
unixepoch() rounds down across the wait.
This commit is contained in:
Claude
2026-05-07 02:14:42 +00:00
parent cbe5dbe07b
commit 633d0c3c74
12 changed files with 898 additions and 37 deletions
+15 -7
View File
@@ -46,21 +46,29 @@ require_auth = false
# future. Parsed today, enforced once the matching policy lands.
# reject_future_seconds = 1800
# --- Sections below are parsed today but NOT YET ENFORCED. They are
# accepted for forward compatibility — the matching enforcement code is
# tracked separately. The relay logs a warning for each used section. ---
[limits]
# Maximum byte size of an EVENT (canonical NIP-01 JSON form).
# Enforced by MaxEventBytesPolicy.
# max_event_bytes = 131072
# Maximum WebSocket frame size. Frames larger than this are dropped at
# the WS layer. (max_ws_message_bytes maps to the same setting since
# Ktor's WebSockets plugin only exposes per-frame caps.)
# max_ws_message_bytes = 1048576
# max_ws_frame_bytes = 1048576
# Per-session token-bucket caps. Enforced by RateLimitPolicy.
# messages_per_sec = 10
# subscriptions_per_min = 60
# Parsed but NOT YET ENFORCED.
# max_subscriptions_per_session = 32
# max_filters_per_req = 10
[authorization]
# pubkey_whitelist = []
# Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
# pubkey_whitelist = ["abcdef...64hex..."]
# pubkey_blacklist = []
# kind_whitelist = []
# kind_blacklist = []
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
# kind_blacklist = [4]