Removes config keys + policies that don't earn their complexity:
- [limits].max_event_bytes / MaxEventBytesPolicy — duplicates
[limits].max_ws_frame_bytes which is the right layer (Ktor frame
cap at the wire), and the policy-level check ran AFTER the event
was already parsed and bound for the store.
- [limits].messages_per_sec, [limits].subscriptions_per_min /
RateLimitPolicy — per-session token buckets without the matching
per-IP / global-EPS infrastructure are mostly cosmetic; an
operator who needs real rate limits will use a reverse proxy.
Also flips [options].verify_signatures default from `false` to `true`.
A relay accepting traffic from real clients should verify Schnorr
signatures, and verify-by-default closes the footgun of forgetting
the flag. The CLI gains `--no-verify` for explicit opt-out
(test fixtures, mirror replays); the old `--verify` is dropped (a
no-op now anyway).
Removed:
- quartz-relay/.../policies/MaxEventBytesPolicy.kt
- quartz-relay/.../policies/RateLimitPolicy.kt
- 7 obsolete tests in PoliciesTest + 1 in PoliciesIntegrationTest
- The matching config fields in LimitsSection
- Sample config entries in config.example.toml
- Wiring in Main.composePolicy
Added:
- Test verifySignaturesCanBeExplicitlyDisabled covering the
new explicit-opt-out path.
Total :quartz-relay tests: 59, 0 failures.
Closes the "100ms grace can drop in-flight EVENTs" gap from the
audit.
Behavioural change:
- stop() default grace is now 5 s (was 100 ms) and total timeout 10 s
(was 1 s). A SQLite write + OK reply round-trip easily fits.
- stop() first sends a NOTICE("closing: relay is shutting down —
please reconnect later") to every connected client, so well-
behaved clients can reconnect rather than hammering a dead socket.
- Active client sessions are tracked in a ConcurrentHashMap-backed
set populated by the WebSocket handler's connect/finally pair.
Exposed read-only via [activeSessionCount] so operators (and
tests) can observe lifecycle.
- stop() is now idempotent.
Tests (4 new in GracefulShutdownTest):
- activeSessionCountTracksConnectAndDisconnect — counter goes
0 → 1 on subscribe, → 0 on disconnect.
- stopSendsShutdownNoticeToActiveClients — connected client
receives a NOTICE whose message starts with "closing:".
- stopIsIdempotent — second stop() is a safe no-op.
- rawWsClientObservesNoticeBeforeServerCloses — bare OkHttp ws
client sees the NOTICE frame before the server closes the socket
(proves the order: NOTICE first, then engine.stop).
Total :quartz-relay tests: 66, 0 failures.
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.
Closes the gaps from the test audit. Adds 24 new tests covering
features we advertise in NIP-11 but previously had zero coverage for,
plus regression tests for the wire-format bugs fixed earlier on this
branch.
New files:
- Nip09DeletionTest (4 tests) — deletion removes targeted events,
deletion event itself is queryable, reinsert is blocked, cross-
author deletion is ignored.
- Nip40ExpirationTest (3 tests) — expired-on-arrival events
rejected, future-expiration events stored and retrievable,
deleteExpiredEvents() purges past-expiration entries.
- Nip62VanishTest (3 tests) — vanish cascades prior events,
blocks re-insertion of older events, doesn't affect other authors.
Extended LocalRelayServerTest (+4 tests, now 9):
- nip42_successfulAuthUnlocksPublishing — full AUTH dance over
real WebSocket: relay challenge → RelayAuthenticator signs →
OK true → publishAndConfirm now succeeds.
- nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage —
regression test for the OkMessage serializer bug fixed earlier.
- nip11_servesConfigDrivenInfoDoc — custom RelayInfo flows
through to the NIP-11 GET response.
- nip01_closeStopsLiveSubscription — CLOSE over the wire
actually terminates a live subscription.
Extended Nip01ComplianceTest (+5 tests, now 18):
- reqFiltersByPTag, reqFiltersByGenericSingleLetterTag (#t),
reqTagFilterValuesAreOred, reqMultipleFiltersAreOred,
multipleSubscriptionsOnOneConnectionAreIndependent.
Total: 42 tests in :quartz-relay (was 23), 0 failures.
Adds operator-facing TOML configuration to :quartz-relay, with the
section layout deliberately mirroring nostr-rs-relay's config.toml so
existing operators can port across with little churn.
Sections parsed AND enforced today:
[info] — NIP-11 doc fields (name, description, contact, pubkey,
software, supported_nips, …); replaces the previous
hardcoded RelayInfo.default()
[network] — host, port, path
[database] — in_memory toggle + file path
[options] — verify_signatures, require_auth (compose to the right
IRelayPolicy stack)
Sections parsed today but NOT YET ENFORCED (forward-compat for the
upcoming rate-limit / authorization work — relay logs a warning when
they're set):
[limits] — max_event_bytes, messages_per_sec, …
[authorization] — pubkey_whitelist/blacklist, kind_whitelist/blacklist
[options].reject_future_seconds
[network].remote_ip_header
CLI flag precedence over the config file is preserved: --host, --port,
--path, --info, --db, --auth, --verify all override the matching field.
Adds:
- cc.ekblad:4koma 1.2.0 for TOML parsing
- quartz-relay/config.example.toml as the canonical operator reference
- 5 unit tests (defaults, full parse, NIP-11 mapping, bundled example
file, optional sections)
LiveEventStore.query had a race window between emitting EOSE and
registering as a SharedFlow collector — any event emitted in that
window was lost because newEventStream has replay=0. The race was
usually masked by SQLite write latency for persisted kinds, but it
fired reliably for ephemeral kinds (20000-29999) where store.insert
is a no-op.
Per NIP-01 ephemeral events are not persisted but MUST still reach
matching active subscriptions. Fixed by registering the live
collector before signalling EOSE via Flow.onSubscription.
Adds two tests: one proves an ephemeral event reaches an active
subscriber, the other proves it isn't persisted (a follow-up REQ
returns zero events).
Renames :quartz-test-relay to :quartz-relay and promotes it from a
test-only fixture to a real, runnable Nostr relay that just happens
to also be used in tests.
Two transports now share the same `Relay` core:
- `RelayHub` + `InProcessWebSocket` — no socket, fastest path; ideal
for unit tests inside one JVM.
- `LocalRelayServer` — Ktor `embeddedServer` (CIO engine) listening
on a real `ws://` port. Use for `cli` interop tests, Android
instrumented tests, or running the relay standalone.
NIPs implemented (all driven through production `NostrClient` in the
new `LocalRelayServerTest`):
- NIP-01 wire protocol (REQ/EVENT/EOSE/CLOSE) over real WebSockets
- NIP-09 deletion (via existing `DeletionRequestModule`)
- NIP-11 relay info doc — `RelayInfo` wraps the existing
`Nip11RelayInformation` model and is served on HTTP GET when
`Accept: application/nostr+json` is requested. Loadable from a
JSON config file via `RelayInfo.fromFile(...)`.
- NIP-40 expiration (existing `ExpirationModule`)
- NIP-42 AUTH (existing `FullAuthPolicy`, opted-in via the
`--auth` flag in the standalone runner)
- NIP-45 COUNT
- NIP-50 search via the existing FTS index
- NIP-62 right-to-vanish (existing module)
Adds a standalone runner: `./gradlew :quartz-relay:run --args="--port 7447 --verify"`
binds the relay to a real port. Flags: --host, --port, --path,
--info <file>, --db <file>, --auth, --verify.
Test-only event generators moved to a clearly-named
`com.vitorpamplona.quartz.relay.fixtures` package so they're still
shareable with consumer tests without polluting the production API.
Adds `LocalRelayServerTest` covering NIP-01/11/42/45/50 over a real
loopback WebSocket. Existing 11-test `Nip01ComplianceTest` (in-process
transport) continues to pass.