Adds full NIP-88 poll support to Amethyst Desktop and a search content-type
filter for polls.
Polls (DesktopPollCard):
- Render kind-1068 polls in feed + thread (and reposted/boosted polls) as an
interactive card via NoteCard's bottomContent slot.
- Vote (single-choice radio / multi-choice checkbox), re-vote ("Change vote")
seeded with the prior selection; hide-until-voted with a "View results" opt-in.
- Tallies reuse commons PollResponsesCache; responses are fetched from the
poll's OWN declared relays (NIP-88 relay tags) unioned with connected relays,
so the full tally loads regardless of the viewer's relay set. Votes are
likewise published to the poll's relays (not just broadcastToAll).
- Result row marks the viewer's own choice (border + check), tap a row to see
its voters, footer shows distinct-voter count + deadline/ended state, and the
voter gallery draws the viewer front-most with a ring.
- Create polls from the composer (options, single/multi, optional deadline);
the dialog content scrolls with a pinned Cancel/Publish row; a poll requires
a question and >=2 options.
Wiring:
- DesktopLocalCache.consume for kind 1068/1018 (response links into pollState).
- DesktopFeedFilters + FilterBuilders surface polls; feed/thread interaction
subscriptions fetch kind-1018 responses.
- Thread + profile pass myPubKeyHex so the viewer's vote-state renders.
Search "Polls" facet:
- KindRegistry preset + alias for kind 1068 (auto-renders the filter chip and a
NIP-50 kind filter); SearchResultsList renders poll results interactively and
SearchScreen fetches their responses.
Also:
- Read-only accounts see results instead of dead vote controls.
- Cold-start: the response subscription re-evaluates as relays connect.
- Pull the upstream fix for the pre-existing RelayLatencyTracker.sweep
ConcurrentModificationException (synchronized(pending)) so relay-health
reclassify no longer crashes the UI during search.
Ripple/shaping: clickable elements clip to their shape for bounded ripple.
Tests: commons PollResponsesCache (dedup/tally/WoT sort) + DesktopLocalCache
response-linking.
Deferred (noted in review): wall-clock re-check of a poll expiring mid-view;
mention-dropdown now inside the composer scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The additive feed path re-filters the existing list whenever an incoming batch
contains a kind-5, dropping notes whose event has been deleted. Event-less
notes fell into the else branch and returned false, so they were dropped too.
An event-less row is a placeholder the filter synthesizes for a room with no
message yet — a just-joined Concord channel, NIP-29 group, Marmot group or
geohash cell. It carries no event, so it cannot have been deleted. Dropping it
removed every such row from Messages the moment ANY unrelated deletion landed,
and because this is the additive path the rows stayed gone until the next full
rebuild. A community whose channels are all quiet looked like it had never
loaded at all.
Verified on device: surviving Concord placeholders in sort() went 0 -> 14, and
a community that had been absent from Messages entirely now renders all of its
channels. Not Concord-specific — the same placeholderNote() pattern backs
NIP-29, Marmot and geohash rooms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold boot bumped the session revision ~292 times for 3 communities, driving 22
Messages rebuilds and re-deriving every plane subscription each time. Three
compounding causes, all measured on device:
1. Every refold republished state even when the fold was identical.
ConcordCommunityState and its components were plain classes, so StateFlow
conflation never applied and a prior-epoch wrap that didn't move the
anti-rollback floor still counted as a change. Make the fold result compare
by value (AuthorityResolver holds only immutable value fields; a data class
with a private constructor is fine).
2. A control wrap bumped twice — once from ingest() returning STRUCTURAL and
once from the per-session state watcher reacting to the same refold. Add
ConcordIngestOutcome.STRUCTURAL_FOLD for the two control-plane branches so
the manager leaves those to the watcher, which (given 1) now fires only on
genuine change. Guestbook and base-rekey keep STRUCTURAL: they mutate
members/the rekey buffer, not state, so no watcher covers them.
3. refold() and controlFloorsLocked() re-opened the WHOLE wrap buffer on every
control wrap, and opening a wrap is a NIP-44 decrypt + parse — making a
backfill quadratic in decryptions (~8.6k opens to ingest 93 wraps for one
community). Memoize editions by wrap id: one open per wrap, ingest() stays
synchronous and results are unchanged.
Measured over one cold boot: revision bumps 292 -> 87, Messages rebuilds
22 -> 7, and time from first fold to all 17 channels 43s -> 7.5s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the files are now pure-ASCII and visually unambiguous:
- BlossomPaymentSafetyTest: raw U+202E/U+202C test payload -> escapes
- BlossomPaymentRequired: BIDI_OVERRIDES char array -> escapes
- Sanitizer: RTL_OVERRIDES and ZERO_WIDTH regex classes -> escapes
Verified by mutation: with BIDI_OVERRIDES stripping disabled,
reasonBidiOverridesAreRemoved fails, proving the escaped payload still
carries a real U+202E. Emoji ZWJ sequences in RichTextParserTest are
intentionally untouched (functional joiners, not bidi controls).
Bring the notifications-pagination feature up to date with main.
Conflict resolution — the two live-notification managers:
main independently fixed the "notifications capped at a week" bug by a
different route: it dropped the oneWeekAgo() floor and now runs an all-time
`#p`+`limit` query gated by the lastNoteCreatedAtIfFilled() paging boundary
(kept together with its lastNoteCreatedAtWhenFullyLoaded collector job). That
updateFilter + newSub pair is one self-consistent unit, so this merge takes
main's complete version of AccountNotificationsEoseFrom{Inbox,Random}
RelaysManager and keeps the branch's dedicated `until`+`limit` history pager as
an additive layer on top (Account.notificationHistory, the history manager,
NotificationHistoryPaging.kt, the markers/retry UI, filter builders, tests).
Net: the feed gets main's all-time live query plus the branch's unbounded
backward pager. Note the two now overlap for users under the relay limit — the
pager's remaining unique value is scrolling past that limit; worth a review
pass, not a merge blocker.
Verified: :amethyst:compilePlayDebugKotlin, spotlessApply (clean), and the
notification unit tests (FilterNotificationsHistoryTest,
NotificationKindsContractTest) all green on the merged tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
Improve the notifications history UX around slow/unreachable relays, keeping
the per-relay markers (they let users notice their own bad relays) but making
recovery automatic and the tap-through actionable.
- Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver
stops (every relay done-or-stalled) but some are merely stalled, keep
re-advancing them so recovery no longer depends on the user scrolling to the
marker or reopening the screen. One non-restarting effect so the backoff
survives the transient in-flight blips each retry causes; cancels on leave.
- Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller
passes onRetry and a relay is stalled, the tapped marker's detail popup
offers an active retry and drops the now-inaccurate "retries on reopen" hint.
Notifications wire it to advanceAll; DM callers pass nothing (unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
Closes the privilege escalation: any BAN holder could ban the authorities above
them — including the owner — because the Banlist gate checked only the BAN bit.
Once banned, a member loses all authority (`hasPermission` is `!isBanned && ..`)
and honest clients drop their events, so a single edition from the most junior
moderator permanently silenced every admin above them.
CORD-04 §3 requires the rank half: "One hard rule binds every action: the actor
must hold the required bit and strictly outrank its target — equal cannot act on
equal (an admin cannot ban a peer admin)", restated as §5 step 3. Only §4, which
defines the Banlist, states the bit half alone — which is why both this client
and Armada shipped the same rank-blind gate.
§3 is stated per TARGET while the Banlist is one whole-list document, so it is
enforced as a DELTA rule: an edition may only add or remove npubs its signer
strictly outranks, judged against the roster settled behind it; the owner is
never a valid target (position 0 is "supreme and unremovable"); and entries the
signer may not act on are IGNORED rather than rejecting the edition, so one bad
entry cannot discard the bulk-ban §4 recommends as the collision remedy, and a
rogue cannot grief the list by forcing rejections.
ConcordModeration.currentBanned now reads the honored banlist through the
resolver instead of decoding the raw head. Besides picking up the fork healing
it was missing, this closes a laundering path: our own next ban/unban would
otherwise re-publish an entry our fold refuses, under our signature.
BREAKING (consensus): Armada has not shipped this rule, so banlists can differ
between clients until it does — we now ignore a ban Armada honors whenever the
signer did not outrank the target. Shipping the spec-conformant behaviour was
judged better than continuing to honor an escalation. Write-up to send upstream
is docs/concord-banlist-rank-conformance.md.
The three tests added in 0ae6bc6698 as @Ignore-d documentation now pass and are
un-ignored; two companions (a moderator still bans a plain member, the owner
still bans anyone) passed throughout and pin what the fix had to preserve.
Full :quartz:jvmTest and :commons:jvmTest suites green.
Still open and documented, not addressed here: a banned BAN holder can lift
their own ban (a fixpoint-ordering question that needs a spec ruling), and a
forked ban survives an unban that does not chain onto it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three defects, each of which made revocation look like it worked.
**`revokeSessionGrants` had no callers.** It was added with the KDoc "so
revoking an app takes effect immediately instead of lingering until this
broker instance dies" and then never wired, so revoking an app in
Connected Apps left its in-memory session grants active. The user
revokes; the app keeps signing.
**And it was broken as written.** `sessionAllows` keys are the
account-namespaced `napplet:<signer>:<coordinate>|<op>`, but every revoke
call site holds the BARE coordinate, so the prefix match found nothing.
Wiring it naively would have looked correct and silently done nothing. It
now namespaces before matching, and also clears the post-Cancel re-prompt
cooldown so a revoked app prompts on next use instead of being quietly
dropped.
**Worse: there were three ledgers.** `NappletBrokerService`,
`ConnectedAppsScreen` and `ConnectedAppDetailScreen` each constructed
their own `NappletPermissionLedger`, while ALLOW_SESSION grants are
per-instance in-memory state. So "Forget" cleared the screen's own
always-empty session map while the grants the broker actually consults
lived on. The KDoc described a process-wide singleton; it wasn't one.
Promoted to a real singleton in AppModules alongside the existing
permission store, and shared by all three.
The screens are plain composables with no binder to the broker service,
so rather than invent an IPC path the cached broker moved to the
service's companion under a lock — matching the sibling main-process
registries in that package. Both revoke paths call it: the Forget button
and the per-op revoke.
Also gives `NappletPermissionLedger.endSession()` its first caller, which
promoting the ledger made necessary: it used to die with the service, so
session grants had a natural bound. Now that it outlives the service,
`onDestroy` restores exactly the lifetime ALLOW_SESSION already implied.
The boundary is safe — the service is bind-only and is destroyed only
once every applet and browser surface has unbound, so switching between
two open applets never drops grants mid-use. Deliberately NOT wired to
account switch (already handled by account-keying) or to backgrounding
(would re-prompt mid-use).
Test notes, kept honest: the revoke test was verified to fail before the
namespacing fix. The `endSession` test PASSES without the change —
`endSession` itself was always correct, the bug was that nobody called
it — so it is characterization for the new lifetime contract, not a
regression test. The `onDestroy` wiring and the composable click handlers
have no automated coverage; `amethyst` has no Robolectric and no harness
was invented for them.
Known gap, left alone deliberately: changing an app's trust level to
PARANOID does not drop its live session grants, because `sessionAllows`
is consulted before the signer ledger. That is a revoke-shaped action and
belongs in the same fix, but it is a behaviour change and was out of
scope tonight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An unauthorized control edition in the middle of an entity's chain
permanently froze that entity. Observed on device for a member's GRANT:
v0 owner (grant mods)
v1 owner (grant admins) <- fold stopped here, forever
v2 MIDTIER (escalation, correctly rejected)
v3,v4,v5 owner orphaned, unreachable
`AuthorityResolver` filtered unauthorized editions out BEFORE calling
`EditionFold.foldEntity`, and the walk only advances when the next
version cites the current head's hash. Removing v2 severed the chain, so
every honest edition above it was lost. Any member could permanently
freeze any member's role assignment — including the owner's ability to
change it — with a single event, recoverable only by a Refounding. It
predates the recent rank gates (verified with a zero-role identity); the
gates only widen which editions can poison.
Armada does not have this bug, and its approach settles the design.
Reading its control-plane fold (read for semantics only — Armada is
AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the
UNFILTERED set, producing an ordered candidate list — chain-verified head
first, then every remaining edition version-descending — and authority is
applied AFTERWARDS, per candidate, picking the first admissible one. A
rejected edition is skipped during the ascending admissibility walk
without truncating it. For the chain above, Armada picks v5.
So the fix is not to filter later but to gate later: `EditionFold` gains
candidate-based gated folding, and the resolver and community state now
gate per candidate instead of pre-filtering the pool. Authority checks
themselves are unchanged — only WHEN they run moved. Applied to ROLE,
GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map.
The writer had to be fixed too, for a sharper reason than expected. With
an ungated `headOf`, a rogue banlist edition at the tip is read as
current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT
UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered
into an owner-signed one the moment the owner bans anyone else. Tolerant
reading cannot heal that, because the resulting edition is genuinely
authorized. `ConcordModeration.headOf` now folds the authority-gated
heads, and `owner` is a REQUIRED parameter rather than defaulted, since a
silently-wrong default here is a consensus footgun.
Banlist healing is preserved with one necessary change: the ancestry walk
now runs over the full pool rather than the authorized subset. Ancestry is
structural — walking only authorized editions stops at the rejected one
and misreads genuine ancestors as concurrent forks, resurrecting bans an
unban had cleared.
Six regression tests, each verified to fail without the fix. Two process
notes worth recording: the first "without the fix" run reported BUILD
SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting
it would have meant concluding the tests were worthless. And the
forged-edition test initially passed both ways because the forgery's
content coincided with the honest outcome; it was rewritten so the
mid-chain arm genuinely discriminates.
The rank-gate, rogue-higher-version, floor and rollback tests all pass
unchanged.
Known gap: `headOf` gates through the per-kind permission map, which is
coarser than the resolver's rank gates, so the writer can still pick a
head the reader rejects when an in-permission but out-of-rank edition
sits at the tip. Tolerant reading makes that benign, but it is not an
exact reader/writer match; tightening it needs the resolver to expose
per-entity heads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Account.leaveConcordCommunity` has existed since the feature landed and
had ZERO callers anywhere in the repo — so joining a Concord community
was one-way. It stayed in the account's kind-13302 list and in Messages
permanently, with no affordance on the community screen, the Members
screen, or Messages.
Found because a test account joined a probe community whose only relay
then went away: stuck in the list, channels unrecoverable, nothing to
tap. Third capability found this session that is fully implemented and
unreachable, after `ConcordInviteBundle.isExpired` (called only from a
test, so invite expiry was decorative) and
`NappletPermissionLedger.endSession` (never called, so session grants
outlived every revoke). Each made a feature look complete to anyone
reading the model.
Adds "Leave community" to the community screen's top-bar overflow,
mirroring how NIP-29 relay groups already place membership-destroying
actions, behind a confirmation. It renders whether or not the Control
Plane ever folded, which is the case that matters — a dead-relay
community never folds.
The copy is deliberately narrow about what leaving does: it removes the
community from THIS account's list and stops syncing, it does NOT notify
the community or remove anyone from a roster, and returning needs a new
invite.
Owner leaving is allowed, with an extra warning. Blocking it would make
the actual stuck case unfixable, since the motivating community was one
the account created; and it is the user's own private list to edit.
But it is irreversible in a way worth stating: `ownerSalt` lives only in
that entry, so discarding it retires the community rather than
transferring it. Ownership is read from the stored entry rather than the
folded authority, because a dead-relay community has no folded authority.
Works offline by construction: the underlying call rewrites the local
list (falling back to the on-disk backup when nothing folded) and
publishes fire-and-forget to the user's OWN outbox — never the
community's relays — so the UI does not wait on a relay that cannot
answer. Both paths that could resurrect a left entry were checked:
stranded recovery iterates live communities only, and the list import
takes the newest 13302, which is ours.
Tests cover the real logic behind the button — `unfollow` was previously
untested — driven through the offline-backup path with no cached relay
event: drops only the named community, preserves other memberships'
secrets, empties cleanly on the last one, no publish for a community
never joined, and the rewritten list stays self-encrypted.
Not verified on device: building an APK would have replaced the build a
concurrent Concord authority test was running against. The composable
itself has no automated coverage — `amethyst` has no Robolectric.
Two follow-ups noted, not fixed: leaving does not unpin a community from
the bottom bar, so a pinned one leaves a dead tab; and
`grantConcordRole` is another zero-caller capability — the general
CORD-04 role-grant path is unreachable, with only the narrower
make/remove-admin wired up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browsing a relay's groups (Relay Groups → Find groups → pick a relay)
crashed the app to the launcher:
java.lang.IllegalArgumentException: Comparison method violates its
general contract!
at java.util.TimSort.mergeLo
...
at RelayGroupChannelListScreen$allChannels$3$1$1.emit
`sortedBy { it.toBestDisplayName().lowercase() }` re-evaluates its key on
every comparison, and the key comes from mutable shared state — the
channel's display name, which a kind-39000 directory event can change
while the sort is running. TimSort detects the inconsistency and throws.
The failure scales with directory size and needs no user action: a relay
hosting 1237 groups tripped it on the first browse, while a relay with 18
never did — which is why an earlier sweep of this same screen missed it.
v1.12.6 already fixed this class elsewhere ("snapshot live-stream status
order before sorting"); these sites were not covered.
Adds `sortedBySnapshot`, which computes each key ONCE before any
comparison runs, and applies it to all four sites that sorted live
objects by their display name: the relay group directory (both the
initial value and the observer), the parent-group picker, and the
name-ordered search results. The picker's site also had to materialise
its Sequence first, since sorting lazily would have re-introduced the
same window.
Verified on device: the exact tap that crashed now loads the screen with
zero FATAL EXCEPTIONs and the app stays in the foreground.
Found while setting up a NIP-29 group to test the admin surface, which is
also how the directory got large enough to expose it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs found by a device sweep of the Messages list.
**Raw NIP-04 ciphertext rendered as the message preview.** Rows in New
Requests showed base64 blobs like `0tyoSVovKSK9uDKLUVMs137TD0b+vz…`.
Cause: every decryption branch in `Account.cachedDecryptContent` and
`decryptContent` is gated on `isWriteable()`, and the non-writeable path
fell through to `event.content` verbatim — which for a kind:4 IS the
NIP-04 blob. A read-only (npub-only) login therefore hit this on every
legacy DM room.
Both functions now return null instead of the ciphertext, which closes
the leak on every surface reading them — including the open chatroom
body, which had the identical fallthrough. A new pure classifier backs
the preview and never reads `event.content` for an encrypted kind, so a
future raw fallback cannot resurface there.
Pending and undecryptable are now distinguished on facts the UI actually
has, rather than collapsed into one message: no key at all, or a kind:4
between two other people, is "could not decrypt"; encrypted with our key
a party but plaintext not yet back is "Decrypting…", which resolves
itself when the signer answers. The old code showed the not-found string
for the pending case.
**Group DM titles stuck on npubs while the facepile beside them showed
real names** — and this one is not a display bug at all. Both already
observe metadata through the same flow; the fault is in `User`:
fun metadata() = metadata ?: UserMetadataCache().also { metadata = it }
Non-atomic lazy init on a plain field, called from BOTH the Compose main
thread (every `observeUserInfo` composition) and the relay/IO threads
(`updateUserInfo`). Two threads can each read null, each allocate, and
one instance is orphaned. A composable collecting the ORPHANED cache
never receives the metadata, so it sits on its pubkey fallback forever
while a sibling that got the surviving instance renders the name — which
is exactly "npubs in the title, names in the facepile, same row", and
why it never recovers.
All six per-user lazy caches are now `@Volatile` with double-checked
locking under one process-wide lock, held only for the allocation. The
store holds tens of thousands of users, so a lock per user would be
worse than the bug.
This likely explains a broader class of "some names resolve and others
never do" symptoms, not just the row that surfaced it.
Not fixed: for a read-only account the open chatroom body now renders
nothing for a kind:4 rather than ciphertext — better, but it deserves the
same "could not decrypt" placeholder the preview row got.
Unverified: that the account which showed the ciphertext was in fact
read-only. Every other route to ciphertext was traced and returns null,
so the non-writeable fallthrough is the only reachable source, but the
device state itself was not captured.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems in the remote signer, both about a client getting something
without the user meaningfully agreeing to it.
**`get_public_key` and `get_relays` answered anyone.** Every other method
runs through `ifAuthorized`; these did not, and nothing required a prior
successful `connect`. The service decrypts and dispatches any well-formed
kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a
malicious app, posted for support, leaked in a screenshot — could ask it
which account it belongs to, without the secret and without connecting.
`get_relays` additionally handed over the inbox relay set. That defeated
the transport/identity split, which otherwise works: the relay-visible
traffic really is anonymous, since the p-tag and author are a transport
key and the payload is NIP-44.
Both now require the client to be paired. The authorizer interface gains
`isPaired` with NO default, so a future authorizer has to state its own
rule rather than silently inheriting "everyone is paired".
`ping` is deliberately left open. It reveals nothing the caller does not
already have — a signer is alive at a pubkey they hold — and first-party
behaviour could be confirmed but third-party clients that ping before
connecting could not be ruled out. Breaking a legitimate handshake to
close a minor oracle is a bad trade. The choice is pinned by a test that
also asserts the pairing check is never consulted, so it stays deliberate
rather than drifting back by accident.
**Decrypt consent showed nothing at all.** The bridge populated the
content preview and raw data only for signing requests, so a decrypt
request produced an empty preview block — no ciphertext, no counterparty,
not even the "Show event" toggle — leaving "AppName wants to read your
private messages" with *Allow always* as the primary button. Meanwhile
the coordinator documented the opposite: "Amethyst decrypts first, then
asks permission to expose." That was never implemented.
Now:
- The counterparty is resolved and shown, so the prompt reads "…read your
private messages **with Alice**". It never degrades to nothing —
cached name, else a shortened npub. Knowing *whose* messages is a
categorically different decision.
- The message is decrypted BEFORE prompting and the plaintext is the
preview, as documented. It is a local operation and nothing is exposed
until approval. Failure, blank and hang all collapse to an explanatory
string under a timeout, so the dialog is never empty and cannot stall.
- A narrower grant is offered ALONGSIDE the broad one, not instead of it:
`DecryptFrom(counterparty)` keyed `decrypt:<hex>` next to `Decrypt`.
The dialog's primary button becomes "Always allow for Alice" with the
broad option demoted. Because the ledger stores an opaque op key, no
persisted decision migrates and the storage format is untouched.
Scoping decrypt per counterparty *instead* would have been worse than
the bug: a DM client would prompt once per conversation, training users
to approve everything. A narrow option beside the broad one gives
granularity without the prompt explosion.
Also fixes a latent bug found on the way: `AllowForSession` recorded the
*requested* op rather than the *granted* one, which would have widened a
narrow session grant back to broad.
Verified by three sabotage passes; the tests that stayed green under them
are the intended negative guards. One existing test asserted the buggy
behaviour outright ("public reads are never gated") and was rewritten.
Not done: the batched consent sheet still records the broad op for
"remember" — offering the narrow choice per row there is a UX design
question, not a mechanical change.
Needs a device check before release: the decrypt preview runs the account
signer before consent. That is free for a local key, but an account backed
by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of
Amethyst's.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`MintHttpClient` only trimmed trailing slashes — no scheme check, no host
check — and `token.mint` comes verbatim from any pasted or posted Cashu
token. Tapping Redeem on a token in someone's note therefore made the
device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:<port>`,
LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all —
plus it disclosed the user's IP to whoever controlled the URL.
Validation now runs in the constructor, so no caller can issue a request
before it. The rule: `https://` to a public host, or `http://` to a
`.onion` host, and nothing else. Onion mints matter — a blanket
"https only" rule would have silently broken every Tor mint.
Rejected hosts cover the private/loopback/link-local/unique-local ranges
plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host,
so allowing them buys nothing and leaks reachability.
The bypasses are what make this worth care, and each has a test:
IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`),
the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`,
`127.1`), trailing-dot hosts, and userinfo disguise
(`https://mint.example.com@127.0.0.1/`) — handled by splitting on the
LAST `@`. The host parse is hand-rolled rather than delegated to
`java.net.URI`/`HttpUrl` precisely because those normalise these forms
inconsistently.
A mint the user added to their own wallet is exempt from the host and
https rules — a self-hosted mint on a LAN is a legitimate setup, and the
threat here is a *pasted, untrusted* token pointing inward, not a mint
the user chose. The exemption never relaxes the scheme check. It is
threaded properly rather than TODO'd: the melt path passes the wallet's
known mints and marks the mint user-configured only on a match; the
wallet ops and CLI pass it directly, since those URLs are the user's own.
Refusal gets its own message rather than reusing the mint-error string,
whose wording would have misattributed our own refusal to the mint.
DNS rebinding is out of scope and noted in a comment — the check runs
pre-resolution and cannot defend against a host that resolves differently
on the second lookup.
Verified by disabling the scheme and host checks: 11 of 20 tests fail,
every rejection case among them, and every allow case still passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The private community list (kind 13302) is documented as wire-compatible
with Armada's `communityList.ts`, whose entry type ends in
`[k: string]: unknown` — unknown keys are part of the contract, and
`ConcordJson`'s own KDoc says shapes are "deliberately client-extensible
(CORD-03/04)". But `ignoreUnknownKeys = true` plus closed `@Serializable`
DTOs meant decode dropped every unmodelled key and encode never restored
it, so **every Amethyst write of a user's list silently stripped fields
another client had written**, across every community in it.
Already proven, not hypothetical: `JoinMaterialWire` declared a
`refounder` field that nothing in the repo reads, so it was parsed and
destroyed on the first write. We only avoided destroying Armada's
`invite_ref`/`excluded_at_epoch` because those were modelled hours ago,
for stranded recovery — the anchor recovery depends on would otherwise
have been deleted on every write.
Each wire DTO's compiler-generated serializer is now wrapped in a shared
`JsonTransformingSerializer` that lifts unknown keys into a bag on
decode and merges them back on encode, with declared fields winning on
conflict. The known-key set is read from the descriptor rather than
hand-listed, so it cannot drift from the DTO. Preserved at the document
root, each entry, the `current` join material, each channel, each
held_root, each tombstone, and everything nested inside `seed`.
`refounder`'s typed field is removed so it round-trips generically.
Two further data-loss bugs surfaced while doing it, both fixed here:
- **`seed` was overwritten with `current` on every write**, destroying
the immutable join anchor. It is now kept and re-emitted verbatim as a
raw JsonObject — we never hydrate from it while `current` exists, so we
have no business rewriting it, and keeping it raw preserves everything
nested inside for free.
- **`tombstones` were re-encoded as an empty list**, which did not just
lose their unknown keys: it RESURRECTED communities another client had
deliberately removed. They are now carried verbatim.
Verified by four separate sabotage passes (no-op the transform,
re-mint `seed`, restore the empty-tombstone write, flip the merge order);
each new test fails under at least one, and every mechanism is covered.
Control-plane re-serialization was audited too and is NOT fixed here:
`compactControlPlane` is safe (it re-wraps the original seal verbatim),
but the user-facing *edit* paths — `editConcordMetadata`, `grant`, and
the channel edits — construct fresh typed entities and re-encode, so they
drop extensions the same way. Fixing those means merging into the head
edition's raw JsonObject on each edit path, which is a larger change than
this should carry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A rotation carries only (newRoot, newEpoch, rotator) — no recipient list
— so a member simply left out of the recipient set receives nothing and
is stranded on the dead epoch forever while everyone else moves on. It
applies to any member, the owner included, and cannot be prevented on the
receive side: there is nothing to check.
Armada does not prevent it either; it recovers, and this follows the same
approach. The invite link a membership was joined through is stored as
the anchor, and when that link later resolves to a HIGHER epoch, the
membership merges forward.
Uses Armada's exact wire names so the kind-13302 list stays compatible in
both directions: `invite_ref` (the link in bare `<naddr>#<fragment>`
form, host-stripped so a link minted by a different front end reduces to
the same anchor) and `excluded_at_epoch`, both at the entry level.
Details that decide whether this works at all:
- `merge()` lets a higher-epoch winner inherit the loser's `invite_ref`
when it has none. Without it a two-device merge silently discards the
only anchor recovery has, disarming it permanently.
- `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a
rotation; it rebuilt the entry field-by-field and would have dropped
them at exactly the moment they matter.
- Merging forward preserves `heldRoots`, so prior-epoch history the
member legitimately holds is not lost by recovering.
- Recovery requires a strictly higher epoch and a matching community id,
so it is monotonic and cannot be steered by an unrelated bundle.
Hooked onto the existing Concord revision tick immediately after
`drainConcordRekeys`, because the two are halves of one problem: a
rotation you were included in arrives as a rekey to drain, one you were
excluded from produces no message at all and can only be found by polling
the link. Rate-limited to 15 minutes per community; an idle tick costs a
map lookup. Only a Live bundle recovers — an expired or revoked link is
not a missed rotation.
Verified by mutating the production code eight ways (dropping the anchor,
dropping heldRoots, dropping the epoch comparison, renaming the wire
keys, removing the merge inheritance, breaking bare-form parsing) and
confirming each produced exactly the expected failures.
Two things this surfaced, both left for their own change:
- Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys`
plus closed DTOs — while Armada's format ends in `[k: string]:
unknown`. So every Amethyst write of the community list silently strips
fields Armada added that we do not model; it already discards the
`refounder` field we parse but never re-emit. That is live interop data
loss, caused by us, independent of this work.
- `mergeForward` keeps the entry's existing private-channel grants rather
than adopting the bundle's, matching what `joinConcordViaInvite`
already does. If a recovered member should pick up new-epoch grants,
both paths need it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ConcordRefounding.compactControlPlane` re-wraps one edition per entity
when a community rotates epoch, and the ROTATOR chooses which one
survives. The receiving side had no memory: `refold()` folds only the
wraps at the current epoch's Control-Plane address and discards the prior
epoch's buffer, and `EditionFold` accepts whatever it is handed (its
no-genesis fallback anchors at the lowest version present).
So a rotator could publish only version 1 of a chain and omit version 2 —
restoring a revoked role, clearing a banlist, reverting metadata. Every
signature is genuine; this is rollback by omission, not forgery.
Adds a per-entity floor: the version AND hash last successfully folded.
- **No floor (fresh joiner)** — unchanged: genesis anchor, else the
lowest-version edition as the legitimate compaction bootstrap.
- **With a floor** — the walk is anchored AT the floor: the offered set
must contain the exact edition already folded (version and hash; a
same-version sibling is a fork, not our chain), then walks up. A head
below the floor is structurally unreachable.
- **Gap** (the floor edition is absent) — refuse, and keep the known
head. Refusing by *retaining* matters here: this fold is recomputed
from scratch each time, so letting an entity vanish would itself be a
rollback — a dropped banlist is an unban.
The floor needs no new persistence. It is derived from `heldRoots`, the
rotated-out access roots already persisted in the NIP-44 self-encrypted
kind-13302 list: the session derives each prior epoch's Control-Plane
address from them, folds oldest-first, and takes the resulting heads as
the floor. That survives both a process restart and the session rebuild
`ConcordSessionRegistry.sync` performs at exactly the moment of a
Refounding — which would have destroyed any in-session floor. If the old
planes are not served, there is no floor and behaviour is as before.
Floors are built from AUTHORITY-GATED heads, not raw ones. Without that,
any ex-member still holding a rotated-out root could mint a high-version
edition on the old plane and freeze the entity for every honest client —
a denial of service this change would otherwise have introduced. Covered
by a test.
Verified by disabling both enforcement points: 7 of 12 quartz tests and
the end-to-end commons test fail, and the ones that still pass are
exactly the non-regression cases (fresh joiner, honest compaction,
pass-through without floors).
Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the
edition pool and does not carry floors itself; gating happens at the pool
level before the resolver sees anything. Sound, but connectivity checked
on the full set is a weaker precondition than on each subset — passing
floors into the resolver's three folds is worth a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes to the Concord invite and moderation paths.
**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.
The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.
**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.
**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.
Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`REASONABLE` — the default policy on connect — auto-approved kind 22242
(NIP-42 relay auth). The in-code justification was that the event is
ephemeral and bound to one relay and challenge, so it cannot be replayed
elsewhere. That is true and beside the point: the requesting app supplies
the `relay` and `challenge` tags verbatim, so it never needs to replay —
it just asks for a FRESH signature naming any relay it likes.
A paired app could therefore, with no prompt, open its own socket to any
NIP-42 relay, take the challenge, get 22242 signed, and authenticate to
that relay AS THE USER. That yields read access to whatever the relay
gates behind AUTH — notably the kind-1059 giftwrap inbox and its full DM
metadata (who, when, how many) — and burns quota on paid relays, which
bill whoever authenticates.
Amethyst auto-signing AUTH for relays the USER configured is not the same
as letting a third party name the relay; the comment conflated them.
22242 now falls through to ASK. The existing test asserted the vulnerable
behaviour with the same flawed reasoning, so it is inverted here rather
than merely extended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accepting the "Connect to Nostr" dialog bulk-grants every declared
non-payment capability as ALLOW_ALWAYS — RELAY, IDENTITY, STORAGE,
RESOURCE, UPLOAD, NOTIFY, KEYS — unless the user picks PARANOID. The
dialog never showed that: `buildConnectInfo` did not receive `declared`
at all, so the user approved a set on the strength of a title and an
icon.
`SignerConnectInfo.requestedPermissions` already existed for exactly this
("shown so the user gives INFORMED consent before those ops are
pre-granted") and is populated by the NIP-46 nostrconnect path. The
napplet path simply never filled it in. This threads `declared` through
`NostrConnectPrompt` to `buildConnectInfo`, which lists the capabilities
that actually get pre-granted — SHELL/THEME never prompt and VALUE is
per-use, so listing those would overstate what accepting hands over.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every napplet/web-app grant was keyed by applet coordinate alone
(`<appAuthor>:<identifier>`), which carries no account. The stores and
ledgers are process-wide singletons shared by all accounts, so grants
leaked in two directions:
- **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key`
("sign:1"), and the check ran *before* the per-app ledger lookup. One
app's "Allow for this session" therefore authorized that op for every
other applet and browser origin, silently, for the broker's lifetime.
- **Across accounts.** A grant made under one npub authorized the same
applet under every other npub on the device. For a user keeping a
pseudonymous account separate from a real one, an app authorized by
one could sign as the other with no prompt — defeating the point of
separate accounts.
NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer`
namespaces by account (`nip46:<signer>:<client>`) and keys session grants
by `(coordinate, op)`. Its comment even claims it "mirrors the napplet
broker's sessionAllows" — the mirror was backwards. This adopts the same
pattern on the napplet side:
- `sessionAllows` and `NappletPermissionLedger.session` are keyed by
account + coordinate + op.
- Napplet storage and the capability store namespace keys by account,
read at call time so a switch moves reads/writes with no rebuild.
- The signer ledger is deliberately NOT account-scoped at the store: it
is shared with NIP-46, whose sessions run for a specific account rather
than the active one, so scoping it there would break a background
bunker. The napplet path namespaces its own coordinate instead.
Also here, found while scoping:
- `identity.watch` consulted only the manifest declaration and never the
ledger, bypassing a standing DENY — it short-circuits before
`NappletBroker.handle`, where the "a standing denial always wins" rule
lives. It now applies that rule itself.
- `DataStoreNappletStorage.keys()` filtered on a space separator while
keys are written with NUL, so it silently matched nothing and always
returned an empty list.
Existing grants live under the old un-namespaced keys and are not
migrated: users are re-prompted once. Migrating would attribute grants
made under the broken model to whichever account is active, preserving
the bug.
Regression tests cover both leak directions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Context.drain/drainAllPages/requestResponse re-implemented the
subscription state machine quartz already ships in
relay/client/accessories — the CLI-specific needs (per-event
verify-and-store hook, dead-relay collection, pending-on-auth) now live
in an option-rich fetchAll variant there, and Context keeps thin
adapters. The per-domain sections bolted onto Context (Cashu seed
warming/snapshot/restore counters; the Concord stream-key AUTH
registry) move to CashuContext/ConcordAuth, with the NUT-09 restore
counter rule shared via commons CashuWalletOps so the CLI and Android
can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged
(cli tests green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The NIP-29 group create/edit form asked for the group's location as a raw
geohash text field (placeholder "u0nd") — unusable, since nobody knows their
geohash, so the discovery geo-filter it feeds stayed empty.
Replace it with a first-class, map-first location experience, reusing the
pieces that already power the Geohash-chat Teleport screen:
- New GeohashLocationPickerDialog: a full-screen picker where the user pans a
map under a fixed center pin, searches for a place by name, or taps "use my
location" (device GPS). Precision chips (GeohashChannelLevel) control the
area size; the resolved place name is shown via LoadCityName. Never surfaces
a raw geohash.
- New ForwardGeolocation service (Geocoder.getFromLocationName) powering search,
mirroring the existing ReverseGeolocation.
- Extend LocationPickerMap with backward-compatible recenter/recenterZoom and
onCenterChanged hooks for the center-pin interaction; extend LocationPreviewMap
with a configurable aspectRatio for the form's map thumbnail.
- Rework the form's location field: an inviting empty-state card, a filled card
with a themed map thumbnail + place name + geohash + clear, and a collapsed
"enter manually" field so power users can still paste a known geohash.
The ViewModel's geohash state stays the single source of truth, so the publish
path (parseGeohashes -> kind-9002 EditMetadataEvent) is unchanged. Adds the
MyLocation glyph and regenerates the Material Symbols subset font.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Context.syncIncoming carried real protocol policy — the NIP-59 gift-wrap
since-cursor rule (2-day lookback, advance only when events arrive),
per-group cursor bookkeeping, and the MIP-00 consumed-KeyPackage
rotation — that the Android app implements separately. Divergence there
silently drops DMs, so the policy now lives once in
commons/marmot/MarmotSyncPolicy with Cursors/Relays/drain/publish
injected, and the CLI Context wires itself in as a thin adapter.
Behavior is unchanged (the body moved verbatim, comments included).
Also resolves aliases from the per-account aliases.json in
Context.requireUserHex, so 'amy dm send bob ...' works with a local
alias — previously aliases.json was written but never read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The Threads tab loaded kind-11/1111 with a single #h since-filter and no
limit, so a group with more threads than the relay's default result cap
silently lost the older ones. Mirror the chat history stack for threads:
- RelayGroupChannel.threadsHistory: separate RelayLoadingCursors so paging
the forum doesn't move the chat's cursor.
- buildRelayGroupThreadsHistoryFilters: per-armed-relay #h + kind-11/1111
until+limit page, the forum analog of buildRelayGroupHistoryFilters.
- RelayGroupOpenThreadsHistoryFilterAssembler: the on-demand BackwardRelayPager
("relayGroup.threads.history"), bound to the open group's threadsHistory
cursors, landing on the normal ingest path (kind-11 -> addThread).
- Threads screen: mount the history subscription, eagerly backfill to a
window on open, page older content as the list nears its end, and show a
quiet loading/caught-up footer.
Tests: RelayGroupFilterBuildersTest gains the threads-history filter shape;
RelayGroupFilterServingRelayTest gains a geode backward #h + thread-kinds
walk proving every thread is covered exactly once and the walk terminates.
The screen wiring is device-untested (flagged with the other Tier-D items).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
A group-scoped content event (kind-9 chat, poll, kind-11 thread) is keyed
to its RelayGroupChannel by the relay that served it, because a NIP-29
event doesn't carry its host relay. That's correct for the group's own
host-pinned subscriptions, but a message resolved from a NON-host relay --
e.g. a quoted kind-9 fetched by id during missing-event resolution -- was
filed under GroupId(groupId, strangerRelay), a channel the group's screens
never read, so the message silently vanished (the serving-relay hazard).
LocalCache.attachToRelayGroupIfScoped / attachThreadToRelayGroupIfScoped
now, when no channel is keyed to the serving relay, redirect the stray to
the group's single confirmed host channel via redirectStrayRelayGroupContent,
keyed off RelayGroupChannel.hasRelaySignedState(). A phantom channel never
has relay-signed state, so the redirect can only ever land on a real host,
never on another phantom -- the fix is strictly safe and the common
host-pinned arrival stays an untouched O(1) fast path (the scan runs only
on the rare no-channel-for-serving-relay miss).
Also add the cache-prune gap-fill can't-miss test: drive the production
RelayLoadingCursors down a real relay, rewindTo below the window, and
confirm the pruned band re-loads with no gap.
Tests: RelayGroupContentRoutingTest (pure router + the channel signal);
RelayGroupHistoryPagingRelayTest gains the rewind reload case. The
LocalCache wiring is unit-covered at the router level but still device-
untested end-to-end (flagged in the test plan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Primal iOS writes a bare subtype in the imeta MIME tag (`m jpeg`) instead of a
full type (`m image/jpeg`). `createMediaContent` classified media only by the
MIME `startsWith` prefixes when a MIME was present, so a bare subtype matched
neither image/video/pdf and the whole imeta was dropped: the URL rendered as a
plain link. That path is doubly bad — it discards the imeta `dim`/blurhash, so
the loading placeholder cannot reserve the image's height and the feed jumps
when the bitmap finally arrives, and it forces a URL-preview network round-trip
just to rediscover the type the imeta already declared.
Fall back to file-extension detection whenever the type is still unknown after
the MIME/data: checks. This recovers the `.jpg` (or `.mp4`, …) classification
and keeps the imeta metadata, so the image renders through the fast media path
with its dimensions reserved up front. `data:` URIs keep their type in the
prefix and are left unprobed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQ9F7xnRvRgBmVNbo8hRKc
Two clarity cleanups on the group-chat subscription split:
1. RelayGroupCardWarmup (ex-Warmup) now skips a group the user has already joined.
Joined groups are kept fully warm app-wide by the always-on state + chat-tail
subs; warmup only needs to cover groups shown as cards that those don't — above
all NON-joined groups (discovery, a relay's channel list, member/metadata/parent
screens). Removes the last joined-group double-fetch. (A joined group appearing
in the discovery "My Groups" tab now draws its card activity from the recent-tail
cache instead of a fixed newest-50 fetch.)
2. Rename the assemblers so their scope and role read at a glance, side by side:
RelayGroupState -> RelayGroupJoinedState (joined · state, always-on)
RelayGroupPreviewTail -> RelayGroupJoinedChatTail (joined · recent chat, always-on)
RelayGroupChatTail -> RelayGroupOpenChatTail (open group · recent chat)
RelayGroupChatHistory -> RelayGroupOpenChatHistory (open group · older chat)
RelayGroupWarmup -> RelayGroupCardWarmup (on-screen card, non-joined)
The family now reads Joined{State,ChatTail} · Open{ChatTail,ChatHistory} · CardWarmup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Audit fixes for the Blossom client:
- BlossomBlobManagerViewModel: use StateFlow.update{} for the presence
matrix so the Main-thread sync collector and IO-thread delete/mirror
actions can't lose each other's writes; add refreshJob de-dup so two
quick refreshes can't interleave; rethrow CancellationException; bound
the /list HEAD-probe backfill with a Semaphore(8).
- BlossomClient.has(): rethrow CancellationException instead of
swallowing it as 'not found'.
- BlossomSyncForegroundService: drop the stale 'running' de-dup guard so
a fresh sweep always gets foreground protection.
- CLI mirror: strip query/fragment before extracting the sha256.
- DisplayBlossomSyncProgress: retain the last state so the slide-out exit
animation still has content to draw after the state clears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Consolidates the six overlapping NIP-29 group-chat REQ assemblers into the
state-vs-content shape the DM and Concord chat stacks already use, reusing the
BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker framework. See
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
State (always-on, account-keyed), mounted at LoggedInPage:
- RelayGroupStateFilterAssembler — one #d filter per host relay carrying every
joined group's 39000/39001/39002/39003/39005. Keeps name/roster/roles/pins
current app-wide, so no screen re-queries metadata. Promotes (and replaces) the
old RelayGroupMyJoinedGroups roster path from "while a groups screen is up" to
genuinely always-on.
- RelayGroupPreviewTailFilterAssembler — one #h filter per host relay across all
joined group ids, since=recentBoundary(), NO per-group limit (a time floor
batches it and makes it reconnect-safe). Drives the Messages-list previews.
Replaces the old fixed-window content path whose shared per-relay `since` gated
a newly-joined group's backfill (the reported slow-first-load bug).
Content (per open group), mounted on the chat screen:
- RelayGroupChatTailFilterAssembler — the open group's recent chat live (covers a
non-joined group opened by link, which the joined-only preview tail misses).
- RelayGroupChatHistoryFilterAssembler — on-demand backward pager (until+limit on
the host relay, all authors), cursors on RelayGroupChannel.history, driven by
the feed's viewport markers with an eager backfill-to-window on open. Gap-proof
via RelayLoadingCursors.rewindTo, so deep scroll never misses older messages —
and being all-authors it re-materializes my own history too.
Retires the duplicated paths:
- RelayGroupMyJoinedGroups{FilterAssembler,Subscription} deleted (state + preview
subs cover it); unmounted from Messages panes, discovery, bottom-bar preloader.
- ChannelPublicFilterSubAssembler RelayGroupChannel branch drops the content
window, keeps only metadata + pinned-id backfill (for a non-joined open group).
- ChannelFromUserFilterSubAssembler RelayGroupChannel branch removed (redundant).
- FilterMessagesToRelayGroup / FilterMyMessagesToRelayGroup deleted (orphaned).
Warmup (non-joined cards), OnRelay (directory), Discovery, ThreadFeed and the
always-on notifications path are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
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
Audit follow-up. These renderers sit in scrolling feeds; each recomputed
event parses / string builds directly in the composable body instead of
caching them against the (immutable) event — the CLAUDE.md rule-#4 pattern.
The cards are already skippable (immutable event params + strong skipping),
so this is work redone per composition (each item scrolling into view), not
per frame; still worth removing. These patterns pre-existed in the Android
originals and were carried over faithfully — this cleans them up now that the
code is shared.
- RelayDiscoveryCard: the heaviest — 6 `joinToString` + a `.sorted()` ran in
the body. Now each display string (network / relay-type / requirements /
supported-NIPs / accepted-kinds / geohashes, the requirements lock flag, and
the relay-URL displayUrl()) is `remember`ed off its parsed list; the row
visibility checks still key off the original lists so behavior is identical.
- CalendarRsvpCard: `status` / `calendarEventAddress` / `freebusy` parses now
`remember(event)` instead of re-scanning tags every composition.
- CalendarCollectionCard: `title()` and `calendarEventAddresses().size` (which
allocated a whole List just to read size) now `remember(event)`.
- PodcastValueSplits: the `recipients.filter{}` + `totalSplit()` now
`remember(value)`.
Behavior is unchanged (same keys, conditions, and outputs). Verified:
:commons:compileKotlinJvm + :amethyst:compileFdroidDebugKotlin pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
commons ColorScheme.isLight tested `primary.luminance() < 0.5f`. With the
default purple accent the primary is a deep purple in the light theme (lum
0.09) AND a light purple in the dark theme (lum 0.35) — both < 0.5 — so it
reported "light" in BOTH modes. The Android app decides the same thing from
the background (`background != Color.Black`); `background.luminance() > 0.5f`
is the multiplatform-safe equivalent (light bg ≈ 0.98, dark bg = 0.0).
Surfaced while reviewing the note-ui extraction: the new commons theme helpers
that branch on isLight — subtleBorder / replyModifier (card hairline borders)
and allGoodColor / warningColor (RelayDiscovery latency chips) — were rendering
their light-theme variant in dark mode, a regression vs the Android originals
which use the app's background-based isLight. This restores parity.
Also corrects two pre-existing consumers that had the same latent bug and now
behave correctly in dark mode (worth a dark-mode glance in review):
- UserAvatar → CachedRobohash light/dark variant selection;
- ChatTheme.chatBubbleBackground alpha.
Verified: :commons:compileKotlinJvm passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Amends commons/plans/2026-07-16-note-ui-commons-extraction.md with a §0.1
"Progress & findings" section after landing 13 event kinds:
- a batch table (what moved, which seam mechanic each proved) and the shared
commons theme now in place;
- Finding A: commons i18n was an unscoped prerequisite — wired commons into
Crowdin so migrated strings/plurals keep every locale;
- Finding B: gate #1 (amethyst reads commons Res) is the real Tier-1 unblock,
since most renderers share strings with a still-native screen;
- Finding C: "unused seam" (declares accountViewModel/nav but never calls
them) is the cleanest Tier-1 signal;
- Finding D: commonMain bans Jackson (blocks MedicalData/MiniFhir);
- Finding E: platform leaves become opaque or typed @Composable slots (PS1
bitmap icon, Roadstr map) and it works cleanly.
Also flips front-matter status to in-progress and annotates the Tier 0 / Tier
1 lists in §5 with what's done and what's left. The design (§1–§8) is
unchanged; §0.1 is the amendment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Twelfth batch of the note-ui-commons extraction — the calendar family, and
a fuller exercise of gate #1: strings shared with three native call sites
move to commons with no duplication.
- commons/ui/note/CalendarCollectionCard.kt: the calendar collection card
(kind 31924) — title, description, event count.
- commons/ui/note/CalendarRsvpCard.kt: the RSVP card (kind 31925) — the
going/maybe/not-going status, note, and target address.
Both are pure value-in; the entries' unused accountViewModel/nav are dropped.
- Strings/plural migrated to commons: calendar_rsvp_going/maybe/not_going and
the calendar_collection_count plural. Their native co-users now read them
from commons Res instead of the Android res tree — CalendarRsvpRow (the
interactive RSVP row), CalendarEventDetailScreen, and CalendarCollectionsView
— so each key lives in exactly one place.
- amethyst keeps the thin RenderCalendarCollectionEvent / RenderCalendarRSVPEvent
dispatcher entries.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Eleventh batch of the note-ui-commons extraction, and the first to exercise
gate #1 (app-side reads of commons Res) for a string shared with a native
screen — no duplication.
- commons/ui/note/RelayDiscoveryCard.kt: the NIP-66 relay discovery/monitor
card (relay URL, latency health chips, network/relay-type/requirements/
NIPs/kinds/topics/geohashes). Pure value-in — takes the quartz event; the
entry's unused accountViewModel/nav are dropped.
- commons/ui/theme: adds ColorScheme.allGoodColor / warningColor (the
green/amber status colors), mirroring the Android values.
- Migrated the 10 relay_monitor_* / relay_discovery_* strings the card uses
into commons. Seven of them are shared with the native RelayInformationScreen,
which now reads them from commons Res instead — so the keys live in exactly
one place. relay_monitor_reports (screen-only) stays app-side.
- amethyst keeps the thin RenderRelayDiscovery(Note, …) dispatcher entry.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Ninth batch of the note-ui-commons extraction, completing the podcast
display family (badge/link/soundbite atoms landed earlier).
- commons/ui/note/PodcastValueSplits.kt: the Podcasting-2.0 value-for-value
split breakdown card (header, split hint, one row per recipient with its
percentage). Pure value-in — takes a quartz PodcastValue.
- commons/ui/theme: adds ColorScheme.grayText (onSurface @52%); reuses the
Size5dp added with the ActivityCard batch.
- Consumers (PodcastEpisode, PodcastMetadata) re-point to commons.
Strings: podcast_value_zap_split_hint is renderer-only and moves fully to
commons. podcast_value_for_value and podcast_value_split_percent are also
used by the native V4V split editor — for_value via an Android-int
ResourceToastMsg toast that has no Compose-resource equivalent, and the
editor can't reference commons' generated Res until amethyst gains the
compose-resources dependency. So those two keys are intentionally duplicated
(kept in amethyst for the editor, copied to commons for the shared card).
The duplication collapses once amethyst can read commons Res or the editor
itself moves; both sources stay in Crowdin meanwhile.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr