Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled
from the app instead of only being reported.
- quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the
X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof.
- BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the
account's existing NIP-47 (NWC) wallet and returns the preimage — it never
handles keys or funds itself, only drives the connected wallet. Decodes the
invoice amount for display.
- Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows
the amount and, on confirm, pays and retries the mirror, then continues with the
remaining servers. Cancel leaves the blob unmirrored.
Cashu-only servers and the composer upload path still surface a clear message;
auto-settlement there can reuse this handler next. Not yet validated against a
live paid server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Extends Blossom support toward a full client on both the CLI and the mobile app.
Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
`server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders
Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping
CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
and `report` verbs; auth tokens are scoped to --server
Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
BUD-01 HEAD), delete, mirror-to-missing, and report actions
Tests: quartz URL/auth/descriptor/payment parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
FollowerCrawler is in commonMain but used `Dispatchers.IO` without importing
the multiplatform `kotlinx.coroutines.IO` extension. On JVM `Dispatchers.IO`
resolves to the JVM member (compiled fine locally), but on Kotlin/Native that
member is internal, so `:quartz:compileKotlinIosSimulatorArm64` failed with
"Cannot access 'val IO': it is internal". Add the explicit import — the same
idiom the sibling GrapeRankCrawler already uses across all targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Web-based highlighter clients publish kind:9802 highlights with W3C Web
Annotation selectors (textquoteselector / textpositionselector /
rangeselector) instead of a NIP-84 `context` tag. These were previously
ignored, so the highlight rendered without its surrounding paragraph and
the "jump to page" link couldn't disambiguate repeated quotes.
- Parse the W3C textquoteselector into TextQuoteSelectorTag (exact/prefix/
suffix; a "-" or empty exact is treated as a placeholder since the quote
lives in .content).
- HighlightEvent.contextOrReconstructed() prefers an explicit `context`
tag and otherwise rebuilds the paragraph from prefix + content + suffix,
so the in-context bolding still works.
- Build a disambiguated Text Fragment URL (`#:~:text=prefix-,exact,-suffix`)
from the selector's prefix/suffix so the source link scrolls to the
correct occurrence.
The position/range selectors are left unparsed; they only matter for an
in-app live-page re-highlighter, which we don't have.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Di4UurD9SQGrpScX7uy2Kh
FollowerCrawler set the reverse-lookup filter's `limit` to the page size, but
fetchAllPages treats a filter `limit` as the TOTAL cap across all pages and
stops paging once it's reached — so the crawl silently capped at ~500 followers
per relay (verified live: relay.damus.io returned exactly 500 for a
many-thousand-follower observer).
Leave the filter limit null by default so pagination walks the whole result set
(the same observer now returns 12,823 followers from damus alone); Config gains
`maxPerRelay` and the CLI a `--max N` flag to opt back into a bounded spot check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
NostrSignerRemote extended NostrSigner(signer.pubKey), where `signer` is the
ephemeral NIP-46 transport keypair — so `pubKey` returned the transport key,
not the user's identity. Every self-encryption / self-authorship site keys off
`signer.pubKey`, so for bunker accounts this silently broke:
- private NIP-51 lists (private bookmarks / mute / follows / hashtags) and
NIP-37 drafts — an `if (signer.pubKey != event.pubKey)` guard short-circuits
(desktop: private bookmarks always empty);
- NIP-44 self-encrypted data (Concord list, Cashu) sealed to / read against
the wrong peer key.
Android is unaffected (no bunker path); desktop and CLI were affected.
Make `NostrSigner.pubKey` open and have `NostrSignerRemote` return the
bunker-resolved user key: `getPublicKey()` now caches it, and `bindUserPubkey()`
sets it eagerly for a reloaded account / stored identity. Internal transport
(the response-subscription `p` filter, request addressing) keeps using the
transport keypair explicitly, so it is unchanged. No-op for local/external
signers, where signer.pubKey already equals the account key.
Wired: desktop AccountManager.loadBunkerAccount binds the resolved pubkey; CLI
Context binds identity.pubKeyHex. amy's Concord-list decrypt workaround is
dropped — `newest.decrypt(ctx.signer)` now works for a bunker. Verified live:
`amy concord import` over a bunker account decrypts the kind-13302 list and
recovers Soapbox heldRoots [0,1].
Plan: quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NostrSignerRemote.pubKey returns the ephemeral NIP-46 transport key, not
the verified user identity, so every self-encryption / self-authorship site
that uses signer.pubKey as "myself" breaks for bunker accounts. Verified
impact: Android unaffected (no bunker path); desktop private NIP-51 lists
(private bookmarks/mute/follows) and NIP-37 drafts silently empty, Cashu
self-encryption sealed to the wrong peer; CLI the same incl. `concord list`.
Records the two failure modes, the affected call sites, and the fix
direction (resolve pubKey to the user key via get_public_key while pinning
transport uses to the transport keypair).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outbox model can't find an observer's followers — you don't know a
follower exists until you've seen their kind:3, so you can't route to their
outbox first. FollowerCrawler casts a wide net instead: it asks as many relays
as possible for kind:3 lists that #p-tag the observer, paging each relay past
its per-REQ cap via fetchAllPagesFromPool, verifies with ParallelEventVerifier,
keeps only lists that genuinely tag the observer, dedups by id, and
group-commits to the store.
Each follower's list is a full contact list, so persisting it also enriches the
graph a later `graperank score` builds — every follower becomes a FOLLOW edge
into the observer.
CLI: `amy graperank followers [OBSERVER]` assembles "all possible relays" from
the reachability-cache live set + every kind:10002/30166 relay in the store +
the index/aggregator relays, skipping proven-dead relays. Runs anonymously (no
signing) when given an explicit observer. Tunable via
--relay/--page-limit/--timeout/--relay-concurrency/--insert-batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Each kind:30382 GrapeRank card now carries two more public tags alongside
`rank`:
- `followers` — the number of the target's followers whose own score clears a
threshold (`--followers-threshold`, default 0.02), mirroring Brainstorm's
trusted-follower cutoff.
- `hops` — the shortest follow-graph distance from the observer (1 = a direct
follow), matching the `hops` field on Brainstorm's ScoreCard.
New `HopsTag` (the `followers`/`FollowerCountTag` already existed) is wired
through the ContactCardEvent tag accessors/builders. TrustGraph gains
`hopsFrom` (a follow-only BFS over the compact int-CSR) and
`trustedFollowerCounts`; the out-CSR now packs the relation code so a forward
walk can filter FOLLOW edges. The publisher's `reconcileLocal` takes a richer
`ScoredCard` and diffs the full (rank, followers, hops) triple, so a card
re-signs when any of them moves and older cards migrate onto the new tags once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Relays that can't serve a request (a NIP-50 search-only relay pulled into
the feed, a write-only relay, a relay whose filter shape is rejected) were
being hammered with the same doomed REQs. Observed on a 40s cold start:
search.nos.today CLOSED 13-14x ("error: search filter is required") across
6 subscriptions, plus repeated "restricted: does not accept REQs" and
"unsupported: too many filters". The reconnect path replayed refused REQs on
every reconnect, and many different subscriptions kept hitting the same
capability wall.
Two complementary, purely-quartz mechanisms (so the app and Amy both benefit
with zero wiring):
- Per-subscription refusal memory (RequestSubscriptionState + PoolRequests):
a filter a relay CLOSES is not replayed to that relay across reconnects
until it meaningfully changes or a REQ succeeds (EOSE/event). Never applies
to auth-required (the auth subsystem re-signs and replays) or rate-limited
(the adaptive limiter spaces it out).
- Per-relay capability block (RelayReqRefusals): after 2 refusals, classify a
relay SEARCH_ONLY (suppress only non-search filters; genuine search REQs
still flow) or NO_READS (suppress all), from narrow substring markers that
deliberately avoid auth-conditional messages. A fully-blocked relay is
dropped from PoolRequests.desiredRelays so the pool disconnects it, closing
the idle socket rather than keeping it open with every REQ suppressed. A
SEARCH_ONLY relay stays connected while any subscription carries a search
filter for it, so the separate search path is unaffected.
Adds UNSUPPORTED to MachineReadablePrefix (relays send "unsupported:"; parse()
returned null on it before).
Device-verified: search.nos.today now Connecting -> OnOpen -> 2x Closed ->
Disconnected; sendit.nosflare.com (NO_READS) -> 3x Closed -> Disconnected;
feed event volume unchanged (kind-1 from 17 relays) - no coverage loss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review of this session's changes surfaced four issues:
- Perms re-seeded on every re-pair. connectViaNostrConnect pre-granted the
offer's declared ops outside the first-contact guard, so re-pairing an app
overwrote per-op decisions the user had since changed (e.g. an op set to DENY
came back as ALLOW). Now only on first contact.
- Perms could silently grant sensitive ops. The nostrconnect flow shows no
dialog (scan = consent), so the declared perms are never surfaced — yet seeding
pre-granted everything except decrypt/deletion, which would silently allow
config-overwrite (kinds 0/3) and other sensitive kinds. Tightened to only the
ops REASONABLE already auto-allows, so pairing never exceeds the default policy;
sensitive kinds still prompt on first use.
- Batched-consent deny-all race. SignerConsentActivity.onDestroy denied every
pending request when finishing; a request arriving as the sheet closed is owned
by a freshly-launched instance, so it was wrongly denied. Removed — each
dialog's onDismissRequest already fails closed, and the 120s bridge timeout
backs it up.
- Redundant work: batched-selection state keyed on list size (a new request in a
same-size swap was unselectable) → key on the token set; connected-apps loader
re-read loadPolicy per app when allPolicies() already carried it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Third refinement from the Primal comparison — and the one that needed an
architecture change, not just UI.
Quartz: NostrConnectSignerService now fans each request into a child coroutine
under a Semaphore(maxConcurrentHandles=16) instead of handling them inline, so a
request awaiting a consent prompt no longer blocks other clients' auto-allowed
traffic and several prompts can be pending at once. Intake (dedup, staleness,
rate-limit, seen-id persistence) stays on the single consumer. Two guards keep
it safe: BunkerRequestProcessor serializes the actual crypto with a Mutex
(authorization — the prompt — runs unlocked, only sign/encrypt/decrypt holds the
lock) so an external NIP-55 signer never sees concurrent IPC ops; and
Nip46PermissionAuthorizer serializes first-connect consent so two connects can't
stack dialogs. Covered by BunkerRequestProcessorConcurrencyTest (crypto never
overlaps; a blocked prompt doesn't stall another client's signing).
Amethyst: SignerConsentCoordinator is now a shared pending StateFlow; one
SignerConsentActivity observes it and shows the rich single-request dialog (1
pending) or a batched checkbox list with select-all + a Remember toggle +
Allow/Deny selected (>1). Dismissing the sheet denies every still-open request
(fail closed).
Needs on-device validation (burst batching, no concurrent external-signer IPC,
fail-closed on dismiss) — see the device checklist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Reject sign/encrypt/decrypt when the identity signer is not writeable — the
account was logged out, is read-only, or lost its external (NIP-55) signer —
returning an `account unavailable` error instead of prompting the user or
hanging on a key that can't be used. Checked before authorization, so no
dialog is raised for a key we can't sign with. Public reads (get_public_key,
ping, get_relays) stay ungated.
Test: a non-writeable signer refuses a sign request without invoking the
signer or the authorizer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The 30s window shrank the restart re-sign problem but couldn't close it: a
relay replays stored ephemeral requests on re-subscribe, and the in-memory
dedup set is wiped on restart, so anything within the window came back.
Persist the recently-serviced kind-24133 event ids (bounded to 128) and seed
the service's dedup set from them on start, so a replay after an app restart
is dropped by EXACT event id. Chosen over a created_at high-water mark on
purpose: a global timestamp floor would wrongly drop a second connected app
whose clock lags behind another's, whereas id-matching is immune to client
clock skew. The `since` filter still bounds how far back relays replay.
- AccountSettings.nip46SeenRequestIds (persisted via putStringSet) + host-side
bounded LinkedHashSet, fed to NostrConnectSignerService.initialSeen and
advanced through onHandledId.
- Tests: a fresh request whose id was serviced last session is not repeated;
the serviced id is reported for persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
An app restart re-subscribes with `since = now - window`, so relays replay
(and the signer re-signs) anything created within the window. 120s was wide
enough that a request made a minute before restart still came back; 30s keeps
that replay window small while still tolerating normal NTP clock skew.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Users on multiple relays were being asked to sign the same request repeatedly,
some minutes old. Root cause: kind-24133 is ephemeral, but many relays store
and REPLAY it every time the signer re-subscribes (a relay-set change,
reconnect, toggle, or rotation), and the in-memory dedup set is scoped to one
run() call so it's wiped on restart — the old requests then get signed again.
Gate requests by created_at (default 120s window):
- a `since` on the subscription filter so compliant relays never replay old
stored events, and
- a receive-side staleness drop for relays that ignore `since`.
The window must exceed realistic client/relay clock skew so a genuinely fresh
request is never dropped. Also corrected the seenCap KDoc, which called the
event-id dedup set a "request-id" set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Give the user visibility into what the signer is doing:
- Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced
requests (method + kind + client + ok/denied), fed from the service's
onServiced hook (enriched to pass the full BunkerRequest so the event kind
is available). Survives service restarts; not persisted (it's a live feed).
- The signer screen shows a "Recent activity" card (last 8, friendly labels
like "Signed an event (kind 1)", green/red status dot, relative time) and a
"Signing as npub1…" line so it's clear which account is the bunker.
onServiced now hands callers the BunkerRequest instead of just the method
string (CLI updated to match).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
NIP-46 lets the remote-signer (transport) key differ from the user's identity
key. Previously the bunker advertised and wrapped everything with the identity
key, so anyone watching the inbox relays could see kind-24133 traffic addressed
to the user's real npub and infer "this npub runs a bunker".
Now each account gets a dedicated, persisted transport keypair:
- NostrConnectSignerService wraps/unwraps the kind-24133 envelope with a
`transportSigner`; BunkerRequestProcessor keeps the identity signer for the
actual sign/encrypt/decrypt and answers get_public_key with the real npub
(disclosed only to a connected client, over the encrypted channel).
- The host mints + persists the transport key lazily (accounts that never
enable the signer mint nothing), advertises it in bunker:// and the
nostrconnect ack, and listens p-tagged to it.
- AccountSettings/LocalPreferences persist nip46TransportKey so the advertised
address stays stable across restarts.
Bonus: because the envelope is now wrapped with a LOCAL key, external NIP-55
(Amber) accounts no longer round-trip the external app for envelope crypto —
only the genuine signing request does. A new test asserts get_public_key
returns the identity, never the transport key. Unreleased feature, so no
migration needed. The CLI bunker keeps using the operator's own key (dev tool).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Follow-ups to the audit:
- Abuse protection: the signer service now bounds its event queue
(DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
can no longer force one per event or grow the queue without limit. Fixed
window (default 40 requests / 10s per author, oldest authors evicted). The
limiter is touched only by the single consumer coroutine, so it needs no
locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
ledger grant), so a disconnected app stops being listened for after restart.
Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Findings from an audit of the signer, all verified against the code:
- Data race: NostrConnectSignerService deduped request ids inside onEvent,
which the relay pool invokes CONCURRENTLY from each relay's socket thread
(PoolRequests dispatches listeners outside its lock). Two relays delivering
the same subscription could mutate the LinkedHashSet at once → race / CME.
Move dedup into the single consumer coroutine; onEvent now only does the
thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
processor, the service's decrypt + publish, and connectViaNostrConnect caught
CancellationException too, breaking structured cancellation when the service
restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
file on EVERY authorized request (unthrottled, unlike the relay-auth store).
Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
so a duplicate inbox-relay emission tore the subscription down and re-opened
it on every relay for nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
- NostrConnectSignerService now bounds its request-id dedup set (LinkedHashSet
with an evicting cap) so a long-lived bunker can't leak memory on the ids it
has seen.
- Add NIP-46 `logout`: the processor recognises the method, acks it, and calls
a new Nip46RequestAuthorizer.onLogout hook (default no-op) so a host can
revoke the app's grant when it disconnects.
- New NostrConnectSignerServiceTest drives full request→reply round trips
(connect/sign/logout + drop-if-not-addressed) through the service over a fake
relay client with passthrough signers — headless proof of the subscribe →
decrypt → dispatch → publish wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz
components so Amethyst can act as a remote signer for other apps:
- BunkerRequestProcessor: turns a decrypted BunkerRequest into the
BunkerResponse the client expects, performing the work through whatever
NostrSigner the account uses (local keypair or NIP-55 external app).
Signing/encryption/decryption are gated through a Nip46RequestAuthorizer;
public reads (get_public_key/ping/get_relays) are not.
- Nip46RequestAuthorizer: the permission boundary the host app plugs its
own trust model into (connect validation + per-op authorization).
- NostrConnectSignerService: subscribes to kind-24133 requests on a relay
set, decrypts, dispatches to the processor, and publishes the reply.
- NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect://
pairing URIs (percent-encoded), shared by CLI/desktop/Android.
Unit tests cover the dispatch/authorization matrix with a fake signer (no
crypto) and the URI round-trips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
A live relayop.xyz kind-33301 invite bundle (vsk=6) decrypted correctly but
failed to open: its JSON diverged from quartz's CommunityInvite model on two
CORD-05 wire details, so decodeOrNull returned null and the invite reported
Unreadable ("This invite link can't be opened...") instead of joining.
- InviteChannel.key was required; a public channel (e.g. an unencrypted
`general`) carries no delivered grant key and some reference clients omit
the field. Default it to "" so a keyless channel no longer rejects the
whole bundle.
- icon was modeled strictly as an ImagePointer object; relayop emits a bare
public URL string for an unencrypted icon. Add LenientImagePointerSerializer
(a JsonTransformingSerializer) that lifts a string into ImagePointer(url=...)
on read while still serializing the canonical object form on write.
Adds a regression test driving the real live bundle + its fragment token,
asserting it now classifies as Live. This is a distinct interop case from the
existing vsk=8 mis-posted-registry test: here the sub-kind, token, and crypto
are all correct — only the JSON schema was too strict.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iLfo68yVtG2ALkKB8tpAa
Replaces the two forked cached parsers (amethyst CachedRichTextParser on
android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache
with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext,
built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware
computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey).
- Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path.
- Repoint all amethyst callers (incl. the markdown unit test) and both desktop
callers; delete both forks.
Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Addresses the four findings from the branch audit:
- Make the kotlinx codec (LimitsKSerializer, the iOS/native incoming path)
as lenient as the Jackson path: mistyped fields degrade to null, non-int
array elements are skipped, and a payload-less ["LIMITS"] frame yields an
empty message instead of throwing. Guard the payload access in
MessageKSerializer and MessageDeserializer likewise.
- Make the Jackson reads (LimitsDeserializer) type-checked so an explicit
JSON null or wrong-typed value stays null ("unspecified — keep previous")
instead of coercing to false/0. Both codecs now behave identically.
- LimitsMessage -> data class, so StateFlow.distinctUntilChanged in
RelayLimitsTracker suppresses no-op emissions when a relay re-advertises
identical limits, and tests get value equality.
- Drop the stale "NIP-22" labels (LIMITS is nostr-protocol/nips#1434, not
NIP-22) from LimitsKSerializer, MessageKSerializer and MessageSerializer.
Tests: added mistyped/null-field and payload-less coverage on both the
Jackson and kotlinx paths, plus a value-equality check. quartz:jvmTest
(RelayWireErgonomicsTest 11, KotlinSerializationMapperTest 57,
RelayLimitsTrackerTest 5) and amethyst play compile green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Captures what's done (parse, RelayLimitsTracker, cleanup), the audit fixes
to fold in (kotlinx parse leniency to match Jackson on iOS, drop stale
NIP-22 labels, make LimitsMessage a data class), and the remaining roadmap
(client apply helpers, server-side emit, NIP-11 bridge, amy tooling).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Cleanup after adding LimitsMessage + the client-side cache:
- Remove the unused experimental LIMITS prototype
(experimental/limits/Limits.kt + LimitProcessor.kt). Its @Serializable
model duplicated LimitsMessage (minus auth_for_read/auth_for_write and
the Message wiring); the processor's clamp/reject logic is superseded by
the server-side LimitsPolicy and will be reincarnated as pure helpers on
LimitsMessage. Both were prototypes with no references (preserved in git
history).
- Rename the client accessory RelayLimits -> RelayLimitsTracker so it no
longer collides on simple name with the relay-server-side
nip01Core.relay.server.policies.RelayLimits (the operator-configured
limits a relay enforces and advertises). Updates AppModules and the test.
No behavior change. quartz:jvmTest (RelayLimitsTrackerTest 5/5) and
amethyst play-flavor compile are green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Adds RelayLimits, a passive connection-listener accessory (modeled on
RelayAuthenticator) that caches the latest LIMITS each relay advertises
and publishes it as a Compose-stable StateFlow, so consumers can read a
relay's current rights/limits instead of only observing the raw message.
- RelayLimits: caches LimitsMessage per NormalizedRelayUrl, exposes
limitsFlow (StateFlow), get(url) and snapshot(); connection-scoped
(entry dropped on disconnect so stale limits don't leak).
- LimitsMessage marked @Immutable for Compose stability in the flow map.
- Wired into AppModules next to relayStats (Amethyst.instance.relayLimits).
- Tests: per-relay caching, later-replaces-earlier, independent relays,
drop-on-disconnect, and ignore non-LIMITS messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Adds support for the relay-to-client LIMITS frame, which advertises the
connection's current rights and limits (can_read/can_write, auth
requirements, max_message_length, max_subscriptions, max_filters,
max_limit, POW, rate limits, required tags, etc.). Relays such as
wss://pipe.imwald.eu/ send it on connect and whenever rights change,
and Amethyst previously logged it as an unsupported message.
- New LimitsMessage model in quartz commonMain with every optional field.
- Jackson decode/encode (LimitsDeserializer + MessageSerializer) and the
kotlinx-serialization path (LimitsKSerializer + MessageKSerializer).
- Logs a summary line in RelayLogger.
- Tests: production payload parse, array/tag fields, empty object, and
round-trip parity between the Jackson and kotlinx codecs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Bugs:
- Metadata edit could re-root a subgroup or drop its children on a load race:
the edit ViewModel snapshotted parent/children at prefill and overrode the
Account-level live-read defaults. Children are no longer snapshotted (Account
reads the live child list at save time), and the parent is only overridden
when the user actually re-parents (parentTouched) — a plain rename can't
re-root or orphan children anymore, even if metadata hadn't loaded yet.
- Parent selector card cached a null channel via remember() and never
refreshed, so the parent's name/picture never loaded and the warm-up never
mounted. Now get-or-create + warm + observe the metadata flow.
- previousEventRefs could let a note with an unresolved author slip past the
self-exclusion and reference the sender's own event. Now requires a resolved
author.
- Assigning a relay-defined role replaced the member's whole role set while the
menu implied additive; now keeps existing roles (entry.roles + role.name).
- GroupNAddrInvite now also accepts a bare `invite=<code>` remainder if the `?`
is stripped upstream (+ test).
Performance:
- Subgroups bar mounted a full warm-up (metadata + content) subscription per
child chip — up to ~21 relay subscriptions per open group. Replaced with one
relay-directory subscription; chips read from cache.
- Parent picker recomputed the whole candidate scan on every recomposition
(each search keystroke) via a produceState initial-value argument; the scan
now lives only in the producer with a cheap empty initial.
spotless clean; quartz tests green; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Follows up the subgroup protocol work with four NIP-29 compliance/UX gaps.
previous timeline references (spec §Timeline references):
- RelayGroupChannel.previousEventRefs(): first-8-char id prefixes of the most
recent events seen from the host relay, excluding the sender's own, capped at
the spec's 50-event window. Only draws from events actually received in the
channel so the host relay is known to have them.
- Populate the `previous` tag on all outgoing group events: kind-9 chat and
replies, kind-1111 minichat comments, kind-11 threads, and group replies.
Custom roles (kind 39003):
- Route SupportedRolesEvent onto the channel (LocalCache.consume + a
RelayGroupChannel.supportedRoles field) instead of only storing it.
- Members screen: when the relay advertises a role set, offer those roles when
assigning (admins), and show each member's real relay-assigned role label
instead of collapsing everything to admin/moderator. Falls back to the
built-in admin/moderator shortcuts when no 39003 is published.
Subgroup navigation (spec §Subgroups):
- RelayGroupSubgroupsBar: a self-hiding bar under the pinned bar showing a
breadcrumb up to the parent group and chips for the child subgroups (in the
relay's `child` order), each opening that group on the same host relay.
naddr invite codes (spec §Group identifier):
- GroupNAddrInvite parses the `naddr1…?invite=<code>` suffix; both the tap
handler (ClickableRoute) and the deep-link handler (MainActivity) now feed it
into the kind-9021 join request so a shared invite naddr auto-joins.
Tests for the naddr invite parser. spotless clean; quartz tests green;
amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
GalleryListEvent (kind 10011, @Deprecated "Replaced by NIP-68") was
unreachable: EventFactory routes kind 10011 to ExternalIdentitiesEvent,
and no other code referenced the class. It also shadowed NIP-51's
"Favorite follow sets" kind. Removing the dead class; the live profile
gallery entry (ProfileGalleryEntryEvent) and its builders are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys),
`word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first
three, so `t` hashtag mutes written by other clients were silently
dropped: uncounted, invisible, and never applied to filtering.
Quartz:
- Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and
register it in MuteTag.parse/isTagged so it round-trips like the other
entry types.
- Add mutedHashtags()/mutedHashtagIds() TagArray helpers.
Filtering (commons):
- Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide
notes carrying a muted hashtag in Note.isHiddenFor() (exact, case-
insensitive `t`-tag match — distinct from the existing substring word
scan).
Amethyst:
- Aggregate HashtagTag entries from the mute/block lists in
HiddenUsersState.
- MuteListState.hideHashtag/showHashtag + Account and AccountViewModel
wrappers, and observeUserIsMutingHashtag.
- Surface a Mute/Unmute hashtag action in the hashtag screen's options
overflow menu.
Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and
NoteIsHiddenForTest cases for muted-hashtag hiding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
Implements the NIP-29 Subgroups feature merged upstream: groups can now be
organized into a parent/child tree, scoped per host relay.
Quartz:
- Add `parent`/`child` tag classes and TagArray (builder) helpers.
- GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and
build params.
- EditMetadataEvent (9002): parent()/children() accessors and build params
(a 9002 re-carries the full child list, per spec, or the relay rejects it).
- SubgroupTree: assembles a relay's flat 39000 set into the hierarchy —
structure follows each group's parent tag, sibling order follows the
parent's child-tag order, orphans surface as roots, and malformed cycles
are broken rather than looping.
- NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`,
with a `subgroups()` builder DSL helper.
- Tests for tag round-trips, tree assembly, ordering, orphans and cycles,
plus NIP-11 serialization.
Amethyst:
- RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the
latest metadata.
- Account.editRelayGroupMetadata: preserve the group's current parent and full
children list on a plain metadata edit so an admin renaming a subgroup no
longer detaches it (or gets rejected for dropping children).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Follow-up making the invite redeemer match the CORD-05 §2 spec for the
addressable invite coordinate (33301, link_signer, d=""):
- vsk=6 → live bundle (open with the link token)
- vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries
even a stale, still-openable copy on another relay ("a fetcher finds the
grave instead of keys"). Amethyst previously never checked for this, so a
revoked link failed generically.
- anything else present (e.g. a mis-posted registry vsk=8, the shape of the
relayop.xyz link that hung) → unreadable
- nothing on any relay → absent
New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in
quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`,
and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult`
cases — including a new `Revoked` outcome with its own message and no futile
retry. Crypto is unchanged and already matches the spec
(hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite).
Adds ConcordInviteClassifyTest covering live / revoked (order-independent) /
unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
Makes location channels reachable and manageable natively:
- quartz GeohashChannelLevel: the named Bitchat precision levels
(region2/province4/city5/neighborhood6/block7/building8) with cellFor
truncation, so one location fix yields the whole ladder. Tested.
- LocationState.preciseGeohashStateFlow: an 8-char (building) location flow
alongside the existing 5-char one (untouched, so the "around me" feed is
unchanged); channels truncate it per level.
- NewGeohashChatScreen (Route.NewGeohashChat): join a cell from your current
location (region -> building, with place names via LoadCityName) or by typing
a geohash. Joining adds it to the kind-10081 geohash list (followGeohash) and
opens the chat. Includes a "Your channels" section that lists joined cells with
open + Leave (unfollow) -- the management surface the list previously lacked
(only the per-cell Follow toggle on the notes screen existed).
- Adds a "Location channel" option to the Messages "+" new-conversation chooser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Implements NIP-29 message pinning (nostr-protocol/nips#2379):
Protocol (quartz):
- GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d`
group id + ordered `e` ids.
- UpdatePinListEvent (kind 9010): moderator `update-pin-list` write,
carries the full list so pin/unpin/reorder/clear are one submission.
- Register both in EventFactory; add a pinnedEventIds tag helper.
Model + cache:
- RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned)
with the same createdAt-supersede guard as the roster.
- LocalCache consumes 39005 into the channel and stores the 9010 write;
39005 added to the group's metadata REQ filter so pins load.
Publish path:
- Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers.
UI (non-intrusive, self-hiding):
- Collapsed pinned-message bar under the top bar: shows the current pin,
N-count cycling, tap to jump to the message in-feed (hoisted jump
request threaded through the shared chat feed view). Renders nothing
when the group has no pins.
- Moderator-only Pin/Unpin action under "Show more" in the chat message
bottom drawer, gated on membership.canModerate().
- Small pin glyph on pinned bubbles' footer.
Tests: quartz build/parse round-trip for both kinds; channel pin-fold
supersede/replace/clear semantics.
Geohash chat stays anonymous by default (posting under the real npub would
broadcast the user's location tied to their public identity), but instead of a
random per-device seed the throwaway identity is now derived from the account:
- GeohashKeyDerivation.accountSeed(privKey) = HMAC(privKey, versioned label);
per-geohash keys are HMAC(seed, geohash). The result is stable across the
user's devices and recoverable from the account, yet publicly unlinkable to
their npub and across cells (all one-way).
- GeohashChatIdentity picks the account-derived seed for local-key accounts and
falls back to the random encrypted device seed for NIP-46/NIP-55 signers whose
key we can't reach.
Tests cover determinism, account-specificity, and cross-cell unlinkability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Adds the wire-level protocol for interoperating with Bitchat's Nostr location
channels:
- GeohashChatEvent (kind 20000): plain-text public geohash message with a single
exact ["g", geohash] tag plus optional ["n", nickname] and ["t","teleport"].
- GeohashPresenceEvent (kind 20001): presence heartbeat carrying only the g tag.
- GeohashKeyDerivation: deterministic, unlinkable per-geohash ephemeral identity
(HMAC-SHA256(deviceSeed, geohash||counter) with retry + SHA-256 fallback).
- Registers both kinds in EventFactory; NIP-13 PoW reuses the existing PoWTag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV