The IEventStore contract now defines the seams a store implementation
needs instead of having the relay layer decide for it:
- NIP-50 search extensions: LiveEventStore no longer strips key:value
tokens before the store sees them. Filter.search reaches every store
verbatim, and each implementation decides which extensions it
supports — the store, not the middleware, is the only component that
knows whether sort:rank is a directive or noise. The built-in SQLite
and filesystem stores strip at their own boundary (their FTS engines
would otherwise error on / literally match the tokens), preserving
the NIP-50 'ignore unsupported extensions' behavior end to end.
Extension-aware stores need no side channel to recover the raw
string anymore. Fs delete now checks emptiness on the stripped
filter so an extensions-only search cannot wipe the store.
- Caller identity: new StoreQueryContext coroutine-context element,
installed by LiveEventStore around every REQ/COUNT store call when
the connection has NIP-42-authenticated pubkeys. Observer-relative
stores (web-of-trust ranking, for-you relevance) read it off the
coroutine context; ranking context only, never match-set changes.
- Negentropy liveness: snapshotIdsForNegentropy gains an optional
onProgress hook so mirrors syncing huge corpora get a running count
through the interface type instead of a concrete-class overload.
SQLite reports from its row loop; the interface default streams and
reports too.
- FtsReindexProgress.cursor documented as opaque and store-defined so
resumable-reindex callers never assume id semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
TagAuthorIndexBenchmark at 1M events settles the flag: the DM-room
shape (kinds + authors + #p, 65 client assembler call sites) drops
14.2 ms -> 0.66 ms (~21x, growing with corpus size) while batch-insert
cost stays inside run noise (49.0 vs 47.4 us/event). Existing relay
DBs build the index on next open via ensureOptionalIndexes.
Also refreshes the docs the numbers made stale: IndexingStrategy KDoc
now records the 200k and 1M measurements instead of a TODO,
MergeQueryExecutor's tag-merge note points at the new relayBench
reactions-watch scenario, FsQueryPlanner/FsDriverSelectionBenchmark
reflect the landed cost-based pick (149 ms -> 4.0 ms at 30k events),
and RELAY.md documents that strategy flag flips materialize indexes on
the next open.
Verified: quartz jvmTest store suites, geode test (126), desktopApp
LocalRelayStore tests (5, incl. reopening a default-strategy DB with
the new pubkey-alone flag), relayBench compiles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
Raw key:value tokens like include:spam reached SQLite FTS MATCH, where
the colon is column-filter syntax — any REQ carrying an extension token
died with CLOSED "no such column: include" instead of matching.
Adds SearchQuery.stripExtensions() plus Filter/List<Filter>
.strippingSearchExtensions() so EventStore users can drop the tokens
before querying, and applies them in LiveEventStore (query, count,
negentropy snapshots). Per NIP-50, unsupported extensions are ignored:
an extensions-only search becomes unconstrained, not match-nothing.
EventSource-backed search relays still receive the raw string since a
real search backend wants the extensions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tujoyfc2kNLZNVgiLZAR7F
Fold the full-text-search on/off switch into `IndexingStrategy` as
`indexFullTextSearch` (default `true`) instead of a separate top-level
`enableFullTextSearch` constructor param on `EventStore`/`SQLiteEventStore`.
`IndexingStrategy` is already the single place that decides which indexes
the store builds — every field is a per-index toggle with a size/speed
tradeoff, and `QueryBuilder` already receives it — so FTS, being just
another index, belongs there rather than split across two config surfaces.
Behaviour is unchanged: the module's no-op path and the QueryBuilder
"search matches nothing" guards now read the flag via the strategy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
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