Authentication state (who is logged in on a connection) is connection scope,
not a policy decision. It was stored on FullAuthPolicy, which forced the
AuthScopedPolicy marker, the PolicyStack union, and a downcast in
RequestContext just to route it back out as scope.
Now the engine owns it: RelaySession holds the authenticatedUsers set behind
the (now public) requestContext; the data plane and policies read it through
RequestContext. The policy stays pure decision —
- onConnect(scope, send): a per-connection policy captures the read-only scope
to gate on; shared singletons ignore it.
- onAuthenticated(): Boolean: the policy's vote on whether to record the
verified pubkey (default false, so blind-accept policies never record an
unverified identity). FullAuthPolicy runs authorize() then votes true.
- RelaySession.handleAuth performs the single, engine-side commit after the
whole chain approves and a verifying policy votes to record.
FullAuthPolicy keeps all auth logic (challenge, accept(AuthCmd), gating,
authorize) and gains a protected authenticatedUsers accessor over the scope for
subclasses (restricted content / filter rewrite). Deletes AuthScopedPolicy,
PolicyStack.authenticatedUsers, and the RequestContext downcast.
Non-storage relays answer a REQ purely from filters: EventSource.events()
got no session/auth context, even though the connection's policy already
knows the authenticated pubkey(s). That walled the auth state off from the
code that produces events, forcing a shared mutable holder + hand-wired
RelaySession to build any caller-aware relay (NIP-50 search scored from the
viewer, DM-style restricted content, paid/allow-listed sets, per-connection
tenancy).
Introduce RequestContext (connectionId, authenticatedUsers, policy) and pass
it through the read path: RelaySession -> SessionBackend.query/count/
countResult -> EventSourceBackend -> EventSource. authenticatedUsers is now
exposed on IRelayPolicy (default empty, overridden by FullAuthPolicy and
unioned by PolicyStack) and read live, so a REQ after AUTH sees the freshly
authenticated pubkey(s). For richer per-connection state, downcast ctx.policy.
EventSourceServer.serve { } is now usable for auth-scoped relays without a
side channel. Adds a test proving ctx.authenticatedUsers reaches the source
after a NIP-42 handshake; updates RELAY.md.
The SPI name leaned on "Req" (the NIP-01 command), which isn't self-explanatory.
Rename to read as what it is — a source of events for a query:
- ReqResponder -> EventSource (method respond() -> events())
- ReqResponderBackend -> EventSourceBackend (param responder -> source)
- ReqResponderServer -> EventSourceServer
- *Test + RELAY.md + KDoc references updated to match.
Also drop the JwtAuthPolicy snippet from RELAY.md (it was only an illustrative
doc example, never a real class); the surrounding prose still documents the
`authorize` bridge hook.
Pure rename + doc edit, no behavior change; full suite green.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The pendingNewlyAdded field was a rollback token faked through instance state:
FullAuthPolicy.accept(AuthCmd) committed the pubkey eagerly, so a later
rejection (composed policy or a throwing hook) had to be undone, and the field
existed only to avoid dropping a pubkey that was already authenticated.
Fix the root cause — the eager commit. accept(AuthCmd) now only validates; the
pubkey is recorded in FullAuthPolicy.onAuthenticated (made final), which the
engine calls only after accept AND the whole policy chain approve the AUTH.
External-auth bridges override a new open `authorize` hook that runs before the
commit; throwing rejects the login with nothing committed to undo.
This deletes pendingNewlyAdded, IRelayPolicy.onAuthenticationFailed, its
PolicyStack override, and both rollback call-sites in RelaySession.handleAuth —
and makes the prior compose-after-reject / failed-re-AUTH cases correct by
construction (no rollback to get wrong). Tests updated to override `authorize`;
the two regression tests pass unchanged.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Final-review findings on the relay tooling:
- Auth bypass (security): FullAuthPolicy.accept(AuthCmd) commits the pubkey,
but handleAuth only rolled back on the hook-throw path — a policy composed
AFTER FullAuthPolicy that rejects the AuthCmd left the connection
authenticated behind an OK false. handleAuth now also rolls back on the
reject path, preserving OK-true-iff-authenticated for any composition order.
- Failed re-AUTH no longer drops a prior valid auth: onAuthenticationFailed
removes only the pubkey THIS AUTH newly added (tracked via Set.add's return),
not one already authenticated earlier on the connection.
- Rename the server-side RelayConnectionListener -> RelayServerListener to
avoid colliding with the existing client-side
relay.client.listeners.RelayConnectionListener (published-API clarity).
- RelayLimits.toNip11Limitation clamps created_at bounds to Int range so a
post-2038 epoch second can't wrap negative in the NIP-11 document.
- Add regression tests for both auth cases (reject-after-FullAuth; failed
re-AUTH keeps prior auth).
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Previously max_message_length and max_subscriptions were hard-coded in
RelaySession behind a parallel `limits` param, while the per-command limits
went through LimitsPolicy — two mechanisms, and a custom policy couldn't
influence the session-level ones.
Unify them: add two default-noop hooks to IRelayPolicy —
acceptMessage(raw) (pre-parse) and acceptSubscription(subId, openCount) —
chained through PolicyStack so they compose across multiple policies.
LimitsPolicy now implements all limit checks; RelaySession just invokes the
hooks and no longer takes a `limits` param. Servers compose LimitsPolicy
whenever `limits` is set and keep `limits` only to advertise via NIP-11.
Behaviour is unchanged (oversized -> NOTICE invalid:, sub cap -> CLOSED
rate-limited:); the enforcement now lives in the policy layer. Adds direct
hook unit tests; existing end-to-end limit tests still pass.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
A relay author no longer hand-rolls limit policies or wires NIP-11 twice.
- RelayLimits: one config that is both enforced and advertised. Pass it to
NostrServer/ReqResponderServer and every limit is applied; toNip11Limitation()
renders the same numbers into the NIP-11 limitation block so they can't drift.
- LimitsPolicy: per-command enforcement (max_content_length, max_event_tags,
created_at bounds reject EVENT; max_filters / max_subid_length reject
REQ/COUNT; max_limit clamps, default_limit fills) with invalid: prefixes.
Servers prepend it automatically when limits declares command caps.
- RelaySession: session-level caps that a policy can't see — max_message_length
(NOTICE before parse) and max_subscriptions (rate-limited: CLOSED on new sub).
- NIP-11 serving: Nip11RelayInformation.toJson() + CONTENT_TYPE
(application/nostr+json); the existing model was parse-only.
- Tests for the policy, the session-level caps end-to-end, and the
limits->NIP-11 round trip; RELAY.md Limits + Serving NIP-11 sections.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The HLL aggregation side (estimate/merge/encode) existed but the construction
side did not, and the Jackson wire (de)serializer silently dropped the `hll`
field — so a JVM/Android relay could not actually answer COUNT with HLL.
- HyperLogLog.addPubKey(): the NIP-45 construction (register index = pubkey
byte at the filter offset; value = leading-zero-bits from offset+1, +1),
KMP-safe. Plus HyperLogLog.builderFor(filter) and an HllBuilder that streams
event pubkeys into registers and yields an approximate CountResult.
- Plumb CountResult through the count path: SessionBackend.countResult /
ReqResponder.countResult (default = exact count(); override for approximate/
hll); RelaySession sends the returned CountResult.
- Fix CountResultSerializer/Deserializer (jvmAndroid) to write/read the `hll`
hex field, matching the kotlinx serializer the native targets already use.
- Tests for construction (index/value/merge-idempotence) and the wire path;
RELAY.md Approximate COUNT section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Gives relay operators metrics/logging hooks without patching the engine, and
removes the hashCode()-keyed connection registry the audit flagged.
- RelaySession gains a stable, process-unique `id` (monotonic counter).
- RelayConnectionListener (onConnect/onDisconnect, no-op default) is accepted
by NostrServer and ReqResponderServer; both now key their connection
registry by `id` instead of hashCode() (no more identity-collision hole).
- Both servers expose a live `activeConnections` gauge. Teardown accounting is
idempotent (double close counted once) and onDisconnect fires for any
connections still open at server close.
- Tests + RELAY.md Observability section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Audit finding: FullAuthPolicy.accept(AuthCmd) added the pubkey to the
authenticated set before onAuthenticated ran, so a bridge that threw from
onAuthenticated (its whole point — reject when e.g. a JWT exchange fails)
produced an OK false while the connection stayed authenticated server-side.
Subsequent REQ/EVENT/COUNT were then allowed despite the failed login — an
auth bypass.
- Add IRelayPolicy.onAuthenticationFailed(pubKey) (default no-op), forwarded
by PolicyStack and overridden by FullAuthPolicy to drop the pubkey.
- RelaySession.handleAuth calls it when onAuthenticated throws, restoring the
invariant that a client treated as authenticated is exactly one that got
OK true. The rollback is itself guarded so a misbehaving policy can't also
swallow the failing OK.
- Tests: failed-hook now asserts the connection is NOT authenticated and that
a follow-up REQ is rejected with auth-required.
- RELAY.md: note that throwing from onAuthenticated rolls auth back.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Lets non-storage relays (search, redirector, computed/projected data) answer
REQs without implementing the heavy IEventStore or hand-writing the
readFrame -> parse -> policy -> EVENT/EOSE loop.
- ReqResponder: the public Flow<Event> SPI — respond(filters): Flow<Event>
(+ a count() default). EOSE is sent when the flow completes.
- SessionBackend: the seam RelaySession now depends on (query/count/submit/
negentropy-snapshot). submit + snapshot default to reject / empty so a
responder only implements the read path. LiveEventStore implements it
(storage path unchanged); ReqResponderBackend adapts a ReqResponder.
- ReqResponderServer: storage-free dispatch engine mirroring NostrServer's
connect/serve/close, reusing RelaySession for the full wire protocol.
- RelaySession now frames backend failures as CLOSED error: <msg> (REQ) and
count failures likewise, instead of dropping the coroutine — useful for
responders doing network I/O.
- RELAY.md: Non-Storage Relays section + engine/source-map updates.
Storage path (NostrServer + IEventStore, live tail, negentropy) is unchanged;
existing server/auth/negentropy tests pass alongside the new responder tests.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Addresses the self-contained, low-risk items from the relay-ergonomics
request:
- NIP-50: add SearchQuery to parse Filter.search into free-text terms and
the typed key:value extensions (domain/language/sentiment/nsfw/include),
preserving unknown extensions and offering a canonical toSearchString().
- NIP-42: add a suspend IRelayPolicy.onAuthenticated(pubKey, event) hook
(chained through PolicyStack) so external-auth bridges (e.g. JWT exchange)
can live inside FullAuthPolicy instead of leaking into transport code.
RelaySession invokes it after the AUTH passes; a throw becomes OK false.
- Ergonomics: Command.fromJson/toJson and Message.fromJson/toJson mirroring
Event, plus MachineReadablePrefix + OkMessage/ClosedMessage factories for
standardized OK/CLOSED reason prefixes.
- Docs: RELAY.md sections for the external-auth bridge, NIP-50 search, and
the wire helpers.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Remove architecture diagram, NIP support table, module table,
Live Subscriptions explanation, serve method details, production-ready
example, and excessive code comments. Keep the readable Quick Start,
Store options, and Policy documentation in a more concise form.
https://claude.ai/code/session_01U3iW3eRD7bfLwrM3L9gkDc
- NostrServer and IEventStore implement AutoCloseable for .use {} support
- Add NostrServer.serve() to handle session lifecycle automatically
- IRelayPolicy + operator now returns PolicyStack instead of List
- Deprecate shutdown() in favor of close()
- Update RELAY.md guide to use simplified API patterns
https://claude.ai/code/session_013oL9PkQaFyNQHKVg2vw9qs