Commit Graph
342 Commits
Author SHA1 Message Date
Claude 38023dac45 fix: resolve iOS test-name compile error and LocalContext lint error
- Rename Nip05Test backticked test name to drop parentheses, which are
  illegal identifier characters on Kotlin/Native (iosSimulatorArm64).
- Resolve CLINK budget toast strings at composition time via stringRes
  instead of context.getString inside the async callback, fixing the
  LocalContextGetResourceValueCall lint errors in WalletScreen.

https://claude.ai/code/session_01UgP8ErzBbQYkTDtkJx5nrt
2026-06-11 20:10:20 +00:00
Claude 990c5afe99 Merge remote-tracking branch 'origin/main' into claude/trusting-mayer-6o0yd5
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
2026-06-11 18:15:04 +00:00
Claude 7e7898bf77 refactor(clink): audit follow-ups — consistent error detail, non-null priceType, budget guard
From the audit of this session's changes:

- Error surfacing: the budget (WalletScreen) and offer/invoice card
  (InvoicePaymentDispatcher) paths now use DebitResponse.failureDetail() like the
  zap path, so a GFY code-5/code-4 surfaces its range/retry_after instead of just
  the bare error string.
- NOffer.priceType is now non-null: decode already defaults an absent TLV 3 to
  SPONTANEOUS, so the nullable type was misleading and the '?: SPONTANEOUS'
  fallbacks in ClinkOfferPreview were dead. Drops them and the now-redundant
  always-emit-TLV3 test (covered by the spontaneous round-trip).
- WalletViewModel.requestDebitBudget catches the budget-validation
  IllegalArgumentException so a malformed frequency dismisses the dialog instead
  of hanging the spinner.
- Document why ClinkDebitPayer signs with the persistent account key (stable
  identity for budgets) while ClinkOfferPayer uses an ephemeral key.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 18:55:57 +00:00
davotoula 572f4005e1 test: guard kind-range vs class-hierarchy invariant in EventFactory
Sweeps every typed kind: addressable kinds (30000..39999) must read
their d tag, plain replaceables (10000..19999, 0, 3) must ignore stray
ones — the invariant the kind-34235/34236 fix restores.
2026-06-10 20:33:22 +02:00
davotoula d90574c4e9 refactor: rename ReplaceableVideoEvent to AddressableVideoEvent 2026-06-10 20:32:57 +02:00
Claude f4e0bcf73d fix(clink): spec-conformance hardening (k1 length, frequency, description, GFY detail)
Follow-ups from the line-by-line spec audit, scoped to the consume-only client:

- NDebit.parse rejects a TLV-3 session id that isn't exactly 32 bytes (64 hex),
  per clink-debits: a wrong-length k1 is a malformed session pointer.
- DebitClient.requestBudget validates frequency.unit is one of day/week/month
  (DebitFrequency.VALID_UNITS) instead of sending a unit a node service will GFY.
- OfferClient caps the invoice description at 100 chars per clink-offers.
- DebitResponse.failureDetail() composes the GFY error with its actionable extra
  (allowed range for code 5, retry_after for code 4); the debit zap path now
  surfaces that instead of the bare error string.

Adds regression tests for each (malformed-k1 rejection, invalid-unit throw,
description truncation, failureDetail range/retry_after).

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 16:44:00 +00:00
Claude c78ba27c86 test(clink): correct provenance of the default-offer interop vector
The vector is the canonical @shocknet/clink-sdk example — both its MIT README
usage snippet and clink-demo's public-domain DEFAULT_NOFFER are the same string.
Confirmed the published npm tarball ships only build output (no test vectors), so
this is the one real codec vector the ecosystem exposes.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 16:15:25 +00:00
Claude eb7b3bad07 test(clink): golden wire-shape fixtures from the public-domain specs
Adds ClinkWireShapeTest: the literal decrypted JSON payload bodies documented in
shocknet/CLINK/specs/clink-{offers,debits,manage}.md (public domain) must
deserialize into our DTOs with the right fields. Covers the encrypted-content
half the bech32 pointer vectors don't: offer request + success/error codes 1-5
(incl. code-3 latest, code-5 range) + receipts; debit direct/budget requests,
success, and GFY 1-6 (incl. delta, retry_after, range); manage nested
offer.fields requests and responses — including the single-object 'details'
coercing to a list, which exercises the Manage list/single interop fix.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 15:46:51 +00:00
Claude 5e465410b2 test(clink): add clink-demo DEFAULT_NOFFER interop vector
The clinkme.dev demo (shocknet/clink-demo, public domain) hard-codes a live
default noffer. Adds it as an 8th cross-impl vector — a real-world spontaneous,
relay-bearing, no-price offer with a 64-char-hex offer-id — decoded and
round-tripped through our parser.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 15:26:03 +00:00
davotoula 638486ea1f Compute legacy NIP-71 video addresses with their d tag:
Extend BaseAddressableEvent instead so dTag() reads the real `d` tag.
2026-06-10 17:20:14 +02:00
Claude f9ed2e0ab7 feat(clink): close ecosystem interop gaps (manage list, TLV3, NIP-05, receipts, payer privacy)
Interop review against the shocknet/CLINK ecosystem (Lightning.Pub, clink-sdk,
ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) surfaced five fixes:

1. Manage `details` single-object responses now parse. Lightning.Pub returns a
   bare OfferData object for create/update/get and an array only for list; enable
   Jackson ACCEPT_SINGLE_VALUE_AS_ARRAY so both shapes coerce into the list field.
2. NOffer.encode() always emits the price-type TLV (3), even for spontaneous
   offers — the reference SDK and bridgelet decoders throw on a missing TLV 3, so
   an absent field made our pointers undecodable by every JS consumer. Decode now
   defaults an absent/unknown price-type to SPONTANEOUS, per the spec.
3. Nip05Parser.parseClinkOffer accepts bridgelet's flat top-level
   `"clink_offer":"noffer1…"` string in addition to the spec's per-name map.
4. Offer payment receipts: OfferEvent.createReceipt/decryptReceipt +
   OfferClient.parseReceipt + OfferReceipt.isOk() make the post-settlement receipt
   (the SDK's onReceipt) a parseable primitive instead of a dead DTO.
5. ClinkOfferPayer signs offer requests with an ephemeral key, like the SDK / Zeus
   / Stacker News, so paying an offer no longer reveals the user's Nostr identity
   to the service. Debits keep the persistent account key (budgets need a stable
   app identity).

Adds regression tests for each: always-emit TLV3, flat-string NIP-05 discovery,
and a receipt round-trip.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 15:16:44 +00:00
Claude 2bd18eb50d test(clink): regression tests for the audit fixes
Locks in the protocol-layer fixes that were previously only compile-checked:
- offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a
  positive Long (guards the unsigned-decode fix).
- cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag
  can't be decrypted by anyone (guards the no-self-fallback conversationPeer).
- manageCreateRequestSerializesNested + manageFailureResponseParsesField: the
  Manage request nests under offer.fields, payer_data is a string list, and the
  failure response carries field (guards the 21003 shape fix).

All CLINK tests pass.
2026-06-10 05:10:54 +00:00
Claude 3968790db1 fix(clink): audit fixes — unsigned offer price, Manage shape, NIP-05 cache
From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5):
- NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads
  price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative
  and broke encode/decode idempotency for high-bit prices.
- Manage (21003) messages corrected to the nested spec shape: request nests offer
  data under offer{id,fields}, payer_data is a string list (not a map), and the
  response uses details + field (was offer/offers). Documented the single-object
  details limitation (Manage is consume-unused).
- DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative
  results) so profile visits / kind-0 refreshes don't refetch nostr.json.

Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at
TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments
already noted. CLINK tests pass; app compiles.
2026-06-10 04:20:47 +00:00
Claude 7bac8cfd46 refactor(clink): clink_version as a shared ClinkVersionTag class
Models the protocol-version tag the way other tags are modeled, instead of a
loose helper on the Clink object:
- New ClinkVersionTag (TAG_NAME/CURRENT/assemble/parse) under clink/tags, with
  a clinkVersion() TagArrayBuilder DSL extension, reused by all three events.
- OfferEvent/DebitEvent/ManageEvent read version() via ClinkVersionTag::parse
  and build via clinkVersion() in their templates.
- Retires the now-empty Clink object (its KDoc moved to the tag class).

Behavior-preserving: assemble() emits the identical ["clink_version", "1"]
tag in the same position. All CLINK tests pass.
2026-06-10 01:41:04 +00:00
Claude c1a0e707a0 refactor(clink): clink_offer metadata via ClinkOfferTag + DSL builder
Brings the kind-0 clink_offer field in line with the sibling fields' structure
instead of a raw string constant written to content only:
- New ClinkOfferTag (TAG_NAME/assemble/parse) under nip01Core/metadata/tags.
- clinkOffer() TagArrayBuilder DSL extension in TagArrayBuilderExt.
- MetadataEvent uses ClinkOfferTag.TAG_NAME and dual-writes it as a kind-0 tag
  in updateOrDeleteTagNames (NIP-1770 pattern), like lud16/nip05; drops the
  ad-hoc CLINK_OFFER_PROPERTY constant.

UpdateMetadataTest now also asserts the tag is emitted. quartz tests pass.
2026-06-10 01:10:49 +00:00
Claude 6b9184bf50 feat(clink): read + surface a profile's noffer (kind-0 + NIP-05)
Completes the receive side: a payable CLINK Offer card now appears on a
profile that advertises one, preferring the kind-0 clink_offer and falling
back to the NIP-05 .well-known clink_offer.
- Nip05Parser.parseClinkOffer + INip05Client.loadClinkOffer fetch/parse the
  well-known clink_offer (keyed by local name, mirroring the names map; exact
  shape isn't a finalized spec so a mismatch yields null). JVM-tested.
- DrawAdditionalInfo.DisplayClinkOffer resolves kind-0 first, else fetches
  NIP-05 on IO, parses the noffer, and renders ClinkOfferPreview zapping the
  profile.

quartz tests pass; :amethyst compiles. Network fetch + card render untested
end-to-end.
2026-06-09 23:39:36 +00:00
Claude 50b4ed8c1e feat(clink): kind-0 clink_offer metadata field
Adds the CLINK Offers discovery pointer to profile metadata, mirroring the
NIP-05 `clink_offer` key:
- UserMetadata.clinkOffer (@SerialName clink_offer) + clinkOffer() accessor,
  with trim/blank cleanup alongside the other fields.
- MetadataEvent.createNew/updateFromPast gain a clinkOffer param written into
  kind-0 content via the new CLINK_OFFER_PROPERTY key.

Covered by UpdateMetadataTest (write + parse round-trip) on JVM.
2026-06-09 23:22:36 +00:00
Claude ef7658ae09 test(clink): add cross-impl interop vectors from @shocknet/clink-sdk
Adds ClinkInteropTest with bech32 pointer strings generated by the
reference TypeScript SDK (clink-sdk 1.5.5) for noffer/ndebit/nmanage.
Asserts our parser decodes the SDK's bytes into the expected fields and
that re-encoding round-trips. TLV is order-independent on decode, so
interop is functional (not byte-identical: we emit fields ascending,
the SDK descending); the reverse direction (SDK decoding our output)
was verified out-of-band against decodeBech32.
2026-06-09 20:39:45 +00:00
Claude c619338204 feat(clink): add CLINK client and server facades
Adds the high-level request/response orchestration over the CLINK
pointers and event kinds (experimental/clink):
- OfferClient / DebitClient / ManageClient: build the kind-21001/2/3
  request from a decoded pointer, expose the relays to publish on, the
  response filter (kind + author + #e=requestId), and the response parser
- ClinkServer: per-kind request filters (#p=service), 30s freshness
  check, plus K1Tracker for single-use debit session enforcement

Filter construction, freshness window and k1 single-use covered by
ClinkClientServerTest on JVM; request-building encryption round-trips
will be added under androidDeviceTest (lazysodium constraint).
2026-06-09 20:24:48 +00:00
Claude 8fa06f525f feat(clink): add CLINK request/response event kinds and DTOs
Adds the three CLINK message kinds to quartz (experimental/clink):
- OfferEvent (21001), DebitEvent (21002), ManageEvent (21003), each
  carrying both request and response over one kind, NIP-44 encrypted,
  with p + clink_version tags and an e tag on responses
- Request/response DTOs per spec (offers, debits, manage) plus shared
  SatRange/GfyDelta and GFY/offer error-code constants
- Registers all three kinds in EventFactory

Pure-logic + JSON (de)serialization covered by ClinkEventTest on JVM;
the NIP-44 encrypt/decrypt round-trip will live in androidDeviceTest
(lazysodium is unavailable in JVM unit tests).
2026-06-09 20:19:17 +00:00
Claude 41b1b63482 feat(clink): add CLINK bech32 pointer types and parser
Implements the noffer/ndebit/nmanage pointers (CLINK Offers/Debits/Manage)
as standard-bech32 TLV codes, with a dedicated ClinkPointerParser kept
separate from NIP-19. Wire format (HRPs, TLV indices, single-byte priceType,
4-byte big-endian price) verified against @shocknet/clink-sdk 1.5.5.

Adds round-trip + dispatch + reject tests in commonTest.
2026-06-09 20:04:00 +00:00
Vitor PamplonaandGitHub f05500792c Merge pull request #3144 from davotoula/fix/resilient-profile-metadata
Resilient profile metadata (birthday)
2026-06-07 17:36:11 -04:00
davotoula 0107808ef6 Code review:
- Expose a nullable descriptor
- Log the JSON element kind instead of the raw, network-sourced value.
- drop birthday happy-path tests duplicated by UpdateMetadataTest
2026-06-07 23:18:57 +02:00
davotoula 39531b85fb fix(metadata): tolerate non-spec birthday so it can't drop the profile 2026-06-07 23:05:11 +02:00
davotoula 98ff13b83f feat(birdstar): render Birdex species collections (kind 12473) 2026-06-07 22:18:29 +02:00
nrobi144 8b9875d9cc fix(quartz): NIP-46 bunker double-resume + retry id-reuse races
Two correctness bugs in `RemoteSignerManager` (NIP-46) and its NIP-55
sibling `IntentRequestManager`:

1. **Double-resume crash** — `awaitingRequests.get(id)?.resume(value)`
   was non-atomic. Multi-relay delivery, bunker echo/retry, and
   late-after-timeout responses could call `resume` twice for the same
   continuation, throwing `IllegalStateException: Already resumed` on a
   `Dispatchers.Default` worker.
2. **Retry id-reuse → wrong data** (NIP-46 only) —
   `launchWaitAndParse` built the request and event once, then re-used
   the same `request.id` across retry attempts. A late response from
   attempt N could resume attempt N+1's continuation with stale data.

Replace the cached-`Continuation` map with the in-house Channel-per-request
correlation pattern already used in `quartz/.../accessories/NostrClientPublishExt.kt`
(`LargeCache<id, Channel<Response>(capacity=1)>` + atomic `remove` +
`trySend` + `withTimeoutOrNull { receive() }`). Each retry attempt now
builds a fresh request with a new id; the builder is still called only
once. `finally`-block cleanup removes the cache entry on every path,
incidentally fixing a slow leak on the success path.

Adds three regression tests:
- duplicate responses → no crash + single resume (fails on \`main\`
  with \`IllegalStateException\`)
- late response after timeout → silently discarded
- late attempt-1 response does not corrupt attempt-2 result (fails on
  \`main\`: the two attempts share an id)

Design + review notes: \`quartz/plans/2026-06-03-fix-nip46-bunker-double-resume-plan.md\`
2026-06-07 14:36:29 +03:00
davotoula 7aa04e773b Code review:
- read fundraiser value tags via shared helpers
2026-06-06 12:50:29 +02:00
davotoula be12abcb21 feat(agora): render Agora fundraiser campaigns (kind 33863)
Agora (a crowdfunding client on the Ditto stack) publishes fundraising
campaigns as kind 33863 — an app-specific addressable kind with no NIP.
Amethyst had no parser or renderer, so it hit the "Event Not Supported"
path and was dropped; reposts of one rendered as a permanently blank card.

Add first-class support, modelled on NIP-99 Classifieds (title/image/body)
plus NIP-75 zap goals (goal/deadline/progress).
2026-06-06 12:50:02 +02:00
davotoula 58e4a7c610 feat(feed): hide reposts whose boosted kind is unsupported 2026-06-05 20:46:13 +02:00
Claude 0791ae9d40 test(quartz/relay): assert authenticatedUsers is scoped per connection
The auth set is already a per-RelaySession instance field (one session per
connect(), fresh policy per connection), so two connections never share auth
state. Add a regression test: authenticate different pubkeys on two connections
of the same server and assert each scope holds only its own — no union leak.
2026-06-04 17:42:48 +00:00
Claude 6fb735018c refactor(quartz/relay): drop redundant pubKey param from onAuthenticated/authorize
pubKey was always event.pubKey (the NIP-42 signer is the authenticated
identity), so the parameter was pure redundancy. onAuthenticated(event) and
authorize(event) now read event.pubKey directly; the engine still commits
cmd.event.pubKey. Removes the now-unused HexKey imports from IRelayPolicy and
PolicyStack.
2026-06-04 17:32:24 +00:00
Claude a54d9c92a7 refactor(quartz/relay): move auth identity from policy into connection scope
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.
2026-06-04 16:45:03 +00:00
Claude 3d6acde472 feat(quartz/relay): thread a per-connection RequestContext into EventSource
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.
2026-06-04 15:25:39 +00:00
Claude 1c54a877e2 refactor(quartz): split relay server package into engine / backend / policies
The relay server package had 14 top-level files. Group by concern:

- relay/server/          engine + entry points: NostrServer, EventSourceServer,
                         RelayServerBase, RelaySession, ConnectionRegistry,
                         RelayServerListener, NegSessionRegistry
- relay/server/backend/  data plane: SessionBackend, EventSource,
                         EventSourceBackend, LiveEventStore, IngestQueue
- relay/server/policies/ policy model + impls: IRelayPolicy(+PolicyResult) and
                         RelayLimits move in alongside the existing policies
- relay/server/inprocess/ unchanged

Layering is acyclic: engine -> {backend, policies} -> nip01Core.

This moves three already-published classes (IRelayPolicy, LiveEventStore,
IngestQueue), so external quartz consumers re-import — a source-only change,
no behavior change. In-repo callers (geode) updated. Full quartz + geode
suites green; RELAY.md source map updated.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 21:44:12 +00:00
Claude 66481dac05 refactor(quartz): rename ReqResponder -> EventSource; drop JwtAuthPolicy doc
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
2026-06-03 21:09:24 +00:00
Claude 9a19a0f346 refactor(quartz): remove pendingNewlyAdded — commit auth once, not commit-then-rollback
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
2026-06-03 20:25:12 +00:00
Claude 366d9435d7 fix(quartz): pre-merge review fixes — auth rollback, listener rename
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
2026-06-03 19:41:55 +00:00
Claude 648a00ef63 feat(quartz): add missing NIP-11 banner field to relay info document
Nip11RelayInformation modeled `icon` but not `banner` (NIP-11's wide
promotional image, distinct from the square icon), so relays advertising a
banner couldn't round-trip it. Add the field + a round-trip test.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 19:22:37 +00:00
Claude 37b07057b2 refactor(quartz): enforce session-level limits through policy hooks
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
2026-06-03 18:58:20 +00:00
Claude 70dcaa1b10 feat(quartz): relay limits as single source of truth + NIP-11 serving
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
2026-06-03 18:41:06 +00:00
Claude 952da685d8 feat(quartz): NIP-45 approximate COUNT — HyperLogLog construction + wire support
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
2026-06-03 18:29:01 +00:00
Claude 319ffb729a feat(quartz): relay connection observability + stable connection ids
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
2026-06-03 18:21:05 +00:00
Claude bcb4b2b964 fix(quartz): roll back authentication when the post-auth hook rejects
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
2026-06-03 17:45:52 +00:00
Claude 68e49a97ba feat(quartz): Flow<Event> REQ-responder SPI + storage-free dispatch engine
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
2026-06-03 17:31:53 +00:00
Claude 69aea29954 feat(quartz): server-side relay ergonomics — NIP-50 parser, suspend auth hook, wire helpers
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
2026-06-03 17:15:13 +00:00
Claude 3a43c4f7bb fix: register CommunityRulesEvent in EventFactory
Kind 34551 (CommunityRulesEvent) was missing from EventFactory.create, so
signing a community-rules template produced a generic Event. Returning it
as CommunityRulesEvent in Account.sendCommunityRules threw a
ClassCastException when publishing community rules.

Register the kind in the factory and add a regression test.
2026-06-03 13:30:52 +00:00
Claude 1c2775c6b5 fix(quartz): emit q tags again (inverted guard dropped all quotes)
TagArrayBuilder.addUniqueValueIfNew had an inverted guard:

    if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this

Since has(index) == size > index, `tag.has(1)` is true for every
well-formed tag with a value, so the function returned early and never
added it. addUniqueValueIfNew / addAllUniqueValueIfNew are used only by
the quote() / quotes() builders, so every `q` tag (naddr, nevent, note,
nembed, npub, nprofile) has been silently dropped since this file was
introduced. Restore the missing `!` and add a regression test covering an
addressable (naddr) quote plus the guard's accept/skip/dedupe semantics.

https://claude.ai/code/session_01NMavNzJ7VRLhoD3hboCCC7
2026-06-01 19:15:03 +00:00
Claude 3f4066c4b8 refactor: buildHashtagLabel takes an EventHintBundle
Replace the labeledEventId/relay/author trio with a single
EventHintBundle<out Event>, building the e-tag via the standard
EventHintBundle.toETag() idiom. The Account caller now passes the
note's event hint directly.

https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
2026-05-30 21:23:56 +00:00
Claude d57f8c18c6 feat: first-class NIP-32 hashtag labels on posts and in the hashtag feed
Let users tag any post with a hashtag via a NIP-32 kind 1985 label event
(using the `#t` tag-association namespace), and surface follow-labeled
posts in the hashtag feed.

quartz:
- LabelEvent.buildHashtagLabel() + HASHTAG_NAMESPACE ("#t") and
  hashtagAssociations() to build/extract hashtag-association labels.

commons:
- Note now carries a `labels` reverse-reference map (hashtag -> labeler
  notes) with addLabel/removeLabel and a NoteFlowSet.labels flow,
  mirroring reactions/reports.

amethyst:
- LocalCache consumes LabelEvent, attaching hashtag labels to their
  target notes and re-notifying feed observers for already-cached
  targets.
- Account.createLabelHashtagEvent/labelHashtag/consumeLabelEvent and
  AccountViewModel.labelWithHashtag (tracked + direct broadcast).
- Overflow "⋯" menu gains an "Add hashtag" action backed by a new
  AddHashtagLabelDialog.
- HashtagFeedFilter also accepts posts a followed user labeled with the
  hashtag; a new label sub-assembler subscribes to kind 1985 by `#l`
  and fetches missing label targets.
- Hashtag feed shows an attribution banner ("#tag added by @user") above
  follow-labeled posts via a custom RefresheableFeedView onLoaded.

https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
2026-05-30 20:32:43 +00:00
Claude 30a845a6c1 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-05-29 13:32:49 +00:00