Commit Graph
17449 Commits
Author SHA1 Message Date
Claude b06184ac49 feat(cli): add amy git init — bootstrap a repo from the local git checkout
Match `ngit init` / `nak git init`: read the local git repository and publish a
NIP-34 repository announcement, deriving the fields instead of making the user
type them. Shells out to `git` to determine the name (top-level dir), clone URL
(origin remote, ssh→https normalized), earliest-unique-commit (root commit),
and — for the accompanying kind:30618 state — the branch/tag tips and HEAD.
Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in
one shot. Every derived value is overridable with a flag; outside a git repo
the derivation is skipped and `--name`/`--clone` are supplied manually.

This is the one `amy git` verb that shells out to `git`, since it is inherently
about the local working tree — exactly like the tools it mirrors.

Verified against the amethyst checkout itself (derives name=amethyst, the origin
clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD).
The harness gains 4 assertions driving `git init` against its own checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude 45d33c016e feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.

Read-only: pushing git objects back to clone/GRASP servers stays out of scope.

Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude 9922eef9f7 feat(cli): add amy git grasp list|set (NIP-34 GRASP server list, kind 10317)
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.

Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude ad66e6c224 feat(cli): full NIP-34 git collaboration parity for amy git
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.

New sub-verbs (all thin assembly over quartz's `nip34Git` builders):

- `git state`   — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch`   — kind:1617 patch from `git format-patch` (--file or stdin),
                  with --root/--root-revision, --commit, --parent-commit,
                  and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
                  modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
                  (aliases `merged`/`resolved` for applied); applied carries
                  --merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
                  newest authoritative (owner/maintainer/author) status event,
                  with --open/--applied/--closed/--draft/--status filters
- `git thread`  — one item plus its status timeline and comments

Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.

Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Vitor PamplonaandGitHub da451145af Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
2026-07-20 20:10:05 -04:00
Claude a5ac06ad6c Merge branch 'main' into claude/notifications-pagination-n0qahe
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
2026-07-21 00:00:28 +00:00
Claude 77908b2be0 refactor(notifications): extract history paging out of CardFeedView
CardFeedView had grown a ~90-line paging block plus an auto-retry loop,
constants and helpers that aren't about rendering cards. Move all of it into
a dedicated NotificationHistoryPaging.kt:

- rememberNotificationHistoryPaging(): the look-ahead buffer driver (with the
  per-burst cap), the stalled-relay auto-retry loop, cursor building, and the
  per-relay sentinels — returns the List<RelayReachCursor> the feed draws.
- BootstrapNotificationHistoryWhenEmpty(): the empty-feed hunt.
- The five tuning constants and the reachState / relayShortName helpers.

CardFeedView.FeedLoaded now just fetches the pager, calls the helper for the
cursors, and renders the detail dialog; the in-gap RelayReachMarkers stay
inline (they're per-row). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:15:06 +00:00
Claude 05426d8c66 fix(notifications): bound eager fill + only the active feed drives paging
Address the two audit findings on the infinite-scroll driver.

- Bound the eager fill (#1): the buffer targets 100 rows but pages are pulled
  in events and notifications collapse heavily into cards, so on a dense
  account a fill could keep pulling until it downloaded the whole history to
  reach the row target. Cap consecutive pages pulled WITHOUT scrolling
  (NOTIFICATION_MAX_PAGES_PER_BURST); scrolling resets the budget, so paging
  resumes as the buffer is consumed. Appended older cards don't move
  firstVisibleItemIndex, so the from-top preload still fills the full
  look-ahead on open — only a dense whale is bounded.
- Only the active feed drives (#2): add drivesPaging (default true); the split
  screen passes page == pagerState.currentPage so an off-screen tab composed
  during a swipe no longer drives the shared account pager, and its buffer
  driver / auto-retry loop / sentinels stay idle. Single screen and side panel
  keep driving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:07:30 +00:00
Claude c8f43a1bd4 feat(notifications): auto-retry faulty relays + actionable relay detail
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
2026-07-20 20:36:43 +00:00
Claude cc94ef9103 fix(notifications): retry stalled relays via per-relay sentinels
The buffer-only driver stopped paging once every relay was done-or-stalled
(exhausted), so a transient all-relays blip halted history until re-navigation
— bad for faulty relays with different datasets, exactly when we'd miss data.

Restore the per-relay RelayReachSentinels alongside the look-ahead buffer:
- the buffer driver (advanceAll) keeps the runway full from healthy relays;
- the sentinels retry an individual relay when its frontier marker scrolls
  into view — the recovery path once the buffer can't keep the frontier ahead
  (relays stalled/exhausted), naturally rate-limited by scrolling.

The buffer keeps the frontier ~a screen below the fold, so the sentinels stay
quiet during normal scrolling and only fire on stall/end. This also makes the
kept per-relay markers functional again (they drive the retry) instead of
purely decorative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:14:30 +00:00
Claude ad75344c3e feat(notifications): infinite-scroll paging via a look-ahead buffer
Switch the notifications feed from marker-visibility paging (load only when
the bottom marker is on screen) to infinite scroll: keep ~100 already-loaded
rows below the viewport so the user practically never reaches the end.

- Replace RelayReachSentinels with a buffer-depth driver: when fewer than
  NOTIFICATION_LOOKAHEAD_BUFFER (100) rows remain ahead of the last visible
  one, step every not-done relay one older page (advanceAll). It re-fires as
  each page settles until the buffer refills or all relays run dry — the
  wallet's lastVisibleIndex >= totalItems - N pattern with a large N.
- Keep the per-relay BackwardRelayPager engine, cursors and filters unchanged.
- Keep the in-feed per-relay progress markers + tap-through detail dialog;
  they are now purely visual (loading is driven by the buffer, not by them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 19:48:27 +00:00
Vitor PamplonaandGitHub ef9a891974 Merge pull request #3652 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:17:43 -04:00
vitorpamplonaandgithub-actions[bot] b2cfb5e7ee chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 19:14:54 +00:00
Vitor PamplonaandGitHub 0601cfafbc Merge pull request #3651 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:12:11 -04:00
vitorpamplonaandgithub-actions[bot] 0214382d0f chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 18:19:06 +00:00
Vitor PamplonaandClaude Opus 4.8 a0ab3ec66d Merge PR: feat(compose): resolve NIP-05 (incl. Namecoin .bit) in the @-mention popover
Merges nostr proposal b07eb505 into main:
- feat(nip05): add Nip05Id.parseLenient for mention/text rendering
- feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…

Also closes duplicate proposal 4b90b41f, which pointed at the same commits.

Beyond the feature, this replaces the unvalidated `Nip05Id("_", prefix)` raw
constructor in UserSuggestionState with `Nip05Id.parseLenient(prefix)`, closing
a hole where a typed mention such as `evil.com#x.bit` produced a GET to an
arbitrary host via `toUserUrl()`'s bare interpolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:13:17 -04:00
Vitor PamplonaandGitHub 15ce992b61 Merge pull request #3650 from vitorpamplona/fix/napplet-account-isolation-and-consent
fix: v1.13.0 pre-release QA — napplet account isolation, Concord authority, and 30 other fixes
2026-07-20 13:28:41 -04:00
Vitor PamplonaandClaude Opus 4.8 8913af7a79 fix(concord)!: enforce CORD-04 rank gating on the Banlist fold
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>
2026-07-20 12:53:11 -04:00
Vitor PamplonaandClaude Opus 4.8 ea6762b137 docs(concord): Banlist rank gap is a conformance bug, not a spec gap
Correcting the previous commit's reasoning. It concluded from the two
implementations that CORD-04 does not rank-gate the Banlist and that enforcing
it would be a unilateral divergence. Reading the actual spec
(github.com/concord-protocol/concord, not in the Armada repo) shows the
opposite: §3 is normative and 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)" — and §5 step 3 restates it. Banning is the
example the rule itself picks.

Only §4, the section defining the Banlist, states the bit half alone. Both
independent implementations read §4 in isolation and made the same mistake,
which is evidence about the section rather than about the readers.

So the fold fix is spec-mandated. It remains consensus-affecting (we would
ignore bans Armada honors until they ship), so it wants coordination rather
than a race, and the fold is still unchanged here.

Adds docs/concord-banlist-rank-conformance.md to share upstream: verbatim spec
citations, both implementations' gates, a delta-based rule that makes the
per-target requirement expressible against a whole-list entity, and two further
reproduced findings — a banned BAN-holder can lift their own ban (so bans do
not stick against any BAN holder), and a forked ban survives an unban that does
not chain onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:05:21 -04:00
Vitor PamplonaandClaude Opus 4.8 0ae6bc6698 fix(concord): rank-gate the Ban and Remove affordances
Ban/Remove were offered to any BAN holder against any non-owner, ignoring rank
— unlike the role picker, which routes through `canActOn`. Both the Members
roster and the message-level path (`Account.concordBanTarget`, the chokepoint
for the quick-action menu, the note dropdown, the note action sections and the
chat action sheet) now require `canActOn(me, target, BAN)`.

This is NOT the no-op it first looked like. The premise that the fold would
drop such a ban is wrong, and a test proves it: BANLIST is a single whole-list
entity, so `authorizedHeads`/`banGate` gate on the author's BAN bit alone and
never rank-check the list's *contents*. A rank-5 moderator's ban of a rank-1
admin is therefore ACCEPTED by every client, and the admin then loses every
permission, since `hasPermission` is `!isBanned && ..`. It is privilege
escalation, not a silent no-op.

The fold is deliberately left alone. Armada has the identical gap — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while
its role path uses the rank-aware `canActOnPosition` — so rank-gating our fold
would make us ignore bans every other client honors, splitting the banlist
across clients. Closing it needs a spec change, like CORD-05. Refusing to
AUTHOR such a ban restricts only what we write, never what we accept, so it
cannot diverge consensus.

Three `@Ignore`-d tests in AuthorityResolverTest state the fold-level invariant
and currently fail by design; two companions assert the gate does not
over-correct (a moderator still bans a plain member; the owner still bans
anyone). Un-ignore the first three when the spec closes the gap.

The owner short-circuits the check rather than going through `canActOn`, which
begins at `hasPermission` and is false while banned — since a rogue BAN holder
*can* currently banlist the owner, routing them through it would let them be
locked out of moderating their own community.

Device-verified on Amethyst QA Concord as Dr. Edo (QA Lead, rank 2): Bob
(Admin, rank 1) now offers only the disabled "Roles… / You don't outrank this
member" where Ban and Remove used to be enabled, while the Helper (rank 5)
still offers Roles…, Ban and Remove.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:54:28 -04:00
Claude ba8de9ba21 feat(notifications): paginate history by time with in-feed load markers
Notifications were pinned to the recent week with no way to scroll further
back. Mirror the NIP-04 / gift-wrap DM approach: a backward, per-relay
until+limit pager driven by in-feed window-limit markers that pull the next
older page only while visible.

- Add Account.notificationHistory (RelayLoadingCursors) and
  AccountNotificationsHistoryEoseManager, a BackwardRelayPager over the
  inbox + NIP-29 group-host relays, registered always-on in
  AccountFilterAssembler. It parks until a marker advances a relay.
- Add filterNotificationsHistoryToPubkey / filterGroupNotificationsHistoryToPubkey
  and AllNotificationKinds: one combined-kinds filter per relay so the single
  per-relay cursor stays gap-proof (empty page + EOSE = nothing older).
- Wire RelayReachMarkers + RelayReachSentinels into the notifications card
  feed (CardFeedView), with an empty-feed bootstrap, so scrolling a relay's
  marker into view loads more.
- Fix the two live notification loaders to a fixed one-week tail (drop the
  fullness-driven `since` drift), letting the marker-driven pager cleanly own
  everything older — the same live-tail/history split DMs use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 15:53:32 +00:00
Vitor PamplonaandClaude Opus 4.8 c15774e4e1 docs(qa): record the notifications deadlock and close two open findings
Adds the Notifications and Concord role-grant rows to the coverage table,
retires the "grantConcordRole is unreachable" finding, and records two new
ones: the Members roster offering Ban/Remove on members the viewer doesn't
outrank (silently dropped on fold), and notification cards whose target note
isn't cached rendering as placeholders.

Also adds the pattern worth carrying forward: a paging boundary gated on a
full page deadlocks against a narrow query window, and the empty state is
self-sustaining.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:36:39 -04:00
Vitor PamplonaandClaude Opus 4.8 4e7242a295 feat(concord): assign CORD-04 roles from the Members roster
`Account.grantConcordRole` had been implemented with zero callers, so role
grants were unreachable from the app while the changelog claimed they ship.
This adds the missing surface: a "Roles…" item beside "Make admin" opening a
multi-select over the roles the viewer may hand out.

Both rank rules are enforced by delegating to `AuthorityResolver` rather than
reimplementing them:

- assignable roles are `roles().filter { myRank < it.position }` — the fold
  drops a grant whose granter does not strictly outrank every assigned role, so
  offering one at or above our own position would publish an edition that every
  client then silently discards;
- reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which
  already folds the whole rule (hold the bit, not banned, target isn't the
  owner, strictly outrank) and makes self-promotion fall out for free.

Out-of-reach members show the item disabled *with a reason* instead of omitting
it, so there is no silently no-op control.

The grant REPLACES a member's role set rather than merging into it, so the
dialog preselects their current roles. That preselection is provably complete:
a member's rank is the lowest position they hold, and the dialog only opens
when we strictly outrank that rank, so every role they hold sits strictly below
us and is therefore rendered — no held role can be silently stripped.

`amy concord roles` also gained a `grants:` section reading the post-fixpoint
`authority.roleHolders()`. It previously printed role *definitions* but never
the *grants*, which made the fold outcome unverifiable from the CLI; a
rank-violating grant now shows up as visibly absent rather than as if it landed.

Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker
hides roles above the viewer, disables on members who outrank them, preselects
correctly, and a saved grant survived the fold and a fresh relay drain read
back from a second client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:35:48 -04:00
Vitor PamplonaandClaude Opus 4.8 97b861dd5e fix(notifications): remove the 7-day since floor that emptied the tab
The notifications query pinned `since` to `oneWeekAgo()` whenever it had no
EOSE timestamp. Combined with two other facts that produced a deadlock:

- the EOSE `since` map is in-memory only (`SincePerRelayMap = MutableMap<..>`),
  so EVERY cold start re-pinned the window to 7 days; and
- the backward-paging escape hatch (`lastNoteCreatedAtIfFilled()`) only arms
  once the feed holds a FULL page (`notes.size >= localFilter.limit()`).

The feed could not fill a page because the query only asked for a week, and the
query could not widen because the feed never filled. Any account whose last
inbound mention was older than 7 days saw a near-empty Notifications tab
forever — including a fresh install of a long-established account.

Worse, the floor was applied to `filterSummaryNotificationsToPubkey` (kinds
1/7/6/9735 — the overwhelming bulk of notifications), which was not even wired
to the paging fallback that the secondary per-key kinds received.

Home is the precedent and does not do this: `filterHomePostsByAllFollows`
passes `since ?: boundary`, i.e. plain null on a cold start, relying on the
relay-side `limit` to bound the response. Notifications was the outlier. These
filters are `#p`-scoped to the user's own key and carry a `limit`
(2000/500/200/20), so an all-time query is one index scan returning at most
`limit` events newest-first — same cost, strictly more useful.

Measured on the test account before the fix: 163 events p-tagging it exist on
relays and are fetchable (100 kind-1 and 100 kind-7, both hitting the query
cap), 77 of which clear the follow gate — yet the tab rendered ~3, because the
account's most recent inbound activity was a month old. After the fix the tab
scrolls back through Feb 2025.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:34:07 -04:00
Vitor PamplonaandClaude Opus 4.8 4ee0416a90 docs(qa): record v1.13.0 test coverage, open findings and recipes
The 30 fixes from this session each carry their reasoning in their own
commit message, and the regression tests are in the repo. What none of
that captures is the shape of the testing itself: what was actually
exercised, what was not, what we knowingly left broken, and how to
reproduce the setups. That lived only in one conversation.

Records four things:

- **Coverage**, honestly split three ways: exercised on device; fixed but
  only unit-verified (roughly half the session's work, including the
  entire Concord authority chain); and never opened at all (Nests,
  Marmot, the whole Desktop app, payments, notifications). Plus the
  platform gaps — one tablet, one flavour, one ABI, Android 14 only while
  targetSdk is 37, and no real `release` build.

- **Open findings** we chose not to fix, so they are decisions rather
  than oversights: the unbumped versionCode, the Firebase scheduling
  failure that may cost Crashlytics, Tor settings needing a restart, an
  unreachable relay reported as empty, WebView profiles surviving logout,
  control-plane edits still dropping unknown keys, and the CORD-05
  root-binding gap that Armada shares and therefore needs a spec
  conversation.

- **Setup recipes** that took real time to work out: isolating `amy` by
  `$HOME`, which relays actually accept a NIP-29 create, deep-linking
  past a 1000-entry directory, and proving "no network before consent" by
  running a local relay over `adb reverse` and counting events either
  side of the tap.

- **Patterns** that recurred often enough to be process problems: tests
  that assert the bug, implemented-but-unreachable capabilities, Gradle
  serving a stale up-to-date test run and reporting success for a
  deliberately broken build, and four confident diagnoses that
  measurement overturned.

Written for whoever picks this up next, including the parts that reflect
badly on the testing rather than only the parts that reflect well.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:56:21 -04:00
Vitor PamplonaandClaude Opus 4.8 a040aa522c fix(perf): keep favicon and settings disk reads off the main thread
Two StrictMode disk violations on startup and on IPC, fixed differently
because they are different problems.

**Favicon storage was writing on the IPC handler thread**, i.e. the main
looper: `BrowserIconRegistry.record` did `File.writeBytes` inline, and
`init` scanned the directory with `listFiles`. Both now run on an IO
scope. The directory is still published synchronously so `iconModelFor`
and `record` work immediately — only the scan and the write are deferred.
`keys` updates after the bytes are actually on disk, so a reader is never
told an icon exists before the file backing it does, and until the scan
lands an icon renders its placeholder for a frame and then recomposes,
which is what the StateFlow is for.

**The settings read was left synchronous on purpose, and stays that
way.** `notificationServiceEnabled` reads SharedPreferences inside a lazy
initialiser; the surrounding comments record why it is not hydrated
asynchronously — an async hydrate reopens a window where a late disk read
clobbers a user's toggle. Converting it would have looked like a
StrictMode cleanup while resurrecting a settings-corruption bug. Instead
the prefs file is warmed on an IO thread at startup, so the first
synchronous read hits SharedPreferences' in-memory cache. Best-effort by
design: if a main-thread reader wins the race it pays the disk hit once,
exactly as before, and correctness is unchanged either way.

Verified on device: `notificationServiceEnabled` StrictMode hits went
from 9 to 0 on a cold start, no icon storage failures, 884 ms cold start,
no crashes — and both pinned web-app icons (ditto.pub and Brainstorm)
still render, which is the thing making the write async could plausibly
have broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:52:05 -04:00
Vitor PamplonaandClaude Opus 4.8 4255ba3345 fix(browser): capture icons from sites that declare only an SVG favicon
A pinned web app showed a generic placeholder instead of its icon —
ditto.pub failed while brainstorm.nosfabrica.com worked. The
discriminator is what each site declares:

    brainstorm:  favicon.svg  AND  favicon.png   <- the PNG is why it works
    ditto:       logo.svg only  (+ an apple-touch-icon nobody read)

Icons were captured solely through `WebChromeClient.onReceivedIcon`,
which hands back a rasterized Bitmap. Android WebView does not decode SVG
favicons into that callback, so a site offering a raster alternative gets
picked up and an SVG-only site never fires it at all: nothing is
recorded, `iconModelFor()` returns null forever, and it fails silently —
no error, no log line, just a permanently generic icon. SVG-only
declarations are increasingly the norm, so this was set to affect more
apps over time.

Adds a sniffer that, after a main-frame load, ranks the page's declared
icons (raster `rel="icon"` → SVG `rel="icon"` → apple-touch-icon →
/favicon.ico) and fetches the best one IN PAGE CONTEXT, falling through
on failure. The bytes are size-bounded and magic-byte validated before
being relayed down the existing record path; anything unrecognised is
dropped.

Fetching in page context is the point, not an implementation detail. The
registry captures from the WebView deliberately — "an alternative to the
main app fetching host/favicon.ico itself, which would bypass Tor and
leak the request". Ditto's /favicon.ico exists and returns a valid icon,
so fetching it from the app would have been the easy fix and the wrong
one. Every byte still comes through the sandbox WebView's own network
path.

No rasterisation needed: coil3's SVG decoder is registered on the
singleton loader and sniffs by content rather than extension, so stored
SVG bytes decode even under the registry's .png filename. Only a leading
BOM/whitespace trim was required so byte 0 is the '<' the sniffer wants.

Also fixes a second gap found while diagnosing: `NappletBrowserService` —
the EMBEDDED path backing pinned bottom-nav tabs — had no icon capture at
all, so a pinned app's icon depended on having once opened it full
screen. It now gets both the missing raster callback and the sniff.
`NappletHostService` deliberately left alone: it serves verified blobs
over a synthetic internal host, and applet icons already come from the
manifest, so capture there would be dead code.

The sniff runs after a delay and skips when the raster callback already
claimed the host, so sites that worked before are untouched.

Verified on device: ditto.pub now shows its real icon on the favorite
card, the pinned sidebar tab and the recents row, via both the embedded
and full-screen paths; Brainstorm is unchanged; example.com (no usable
icon) degrades to the placeholder with zero record calls and no hang; no
crashes.

Pre-existing, not fixed: `BrowserIconRegistry.record` writes the file on
the IPC handler thread, tripping StrictMode. It did so before this change
— which now simply gives it more occasions. Moving that write off-main is
a cheap follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:09:24 -04:00
Vitor PamplonaandClaude Opus 4.8 08bd5d9b4a fix(napplet): tightening an app's trust level drops its live grants
Changing an app to PARANOID did not stop it signing. `sessionAllows` is
consulted BEFORE `signerLedger.decide()`, so a live "allow for this
session" grant short-circuits exactly the check the stricter policy would
have failed. The user picks "I'm a bit paranoid", the UI updates, and the
app carries on signing on the strength of a grant made under the old
policy — until every applet surface closes.

The trust level is a decision about how an app is treated from now on, so
the policy change now drops what that app is currently holding, the same
way revoking and forgetting already do. It fires on any change rather
than only on tightening: loosening is the user's call too, and a stale
grant surviving a deliberate re-decision is surprising in either
direction.

Completes the revocation work — the three paths that change what an app
may do (forget, per-op revoke, trust level) now all clear its live
session grants.

Not automatically tested: this is a Compose click handler and `amethyst`
has no Robolectric. The underlying `revokeSessionGrants` is covered by
`revokingAnAppDropsItsLiveSessionSignerGrants`, which was verified to
fail before its namespacing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:28:34 -04:00
Vitor PamplonaandClaude Opus 4.8 b0668baeec fix(napplet): make revoking an app actually drop its live grants
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>
2026-07-20 09:13:37 -04:00
Vitor PamplonaandClaude Opus 4.8 62440748a7 fix(concord): stop a rejected edition orphaning the honest ones after it
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>
2026-07-20 09:06:13 -04:00
davotoula 2d29f7ccda no empty methods
use constants instead of repeating strings
2026-07-20 13:58:06 +01:00
Vitor PamplonaandClaude Opus 4.8 5f712b3a8a feat(concord): let a member leave a community
`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>
2026-07-20 08:57:52 -04:00
Vitor PamplonaandClaude Opus 4.8 ab2f4d2da8 fix(nip29): split group-state REQs so relays stop rejecting them
A joined NIP-29 group showed its raw hex id instead of its name — in the
header, the Messages list and the browse directory — offered "Join" to an
account the relay already listed as an ADMIN, and hid the entire admin
surface. Members, Edit group, Invite people, the Threads "+" FAB,
pin/unpin, share and the member count all sit behind the same
`isMember()` gate, so none of them were reachable. Posting was blocked
too.

The cause is not membership modelling, warmup skipping, or the local
kind-10009 list. **The relay rejected the subscription outright.** The app
asked for all five group-state kinds in one filter, captured on the wire:

    SEND ["REQ",…,{"kinds":[39000,39001,39002,39003,39005],"#d":[…]}]
    RECV ["CLOSED",…,"blocked: it's not allowed to mix metadata kinds
                      with others"]

The whole REQ is dropped, so zero 39000/39001/39002 reach LocalCache.
Membership is derived from those events, so it fell back to NONE and
every gated control disappeared. Chat messages arrived on a separate `#h`
REQ, which is why the group looked half-alive rather than broken.

relay29/khatru29 evaluates that rule PER FILTER, confirmed by probe:

    39000-39003 + 39005 in one filter   -> CLOSED, 0 events
    the same kinds as two filters       -> 4 events, EOSE
    39000-39003 alone                   -> 4 events

So the fix is to split the kinds into two filters in the same REQ — no
extra subscription, no new assembler. This repairs every joined group on
any relay29-family relay, not just the one that surfaced it: the always-on
joined-state subscription was hitting the identical rejection everywhere.

Two hypotheses were tested and disproved rather than assumed. The
join-tap/warmup theory was wrong — instrumentation showed the filter was
built correctly with the right scope and only failed on the wire. The
`since`-on-replaceable theory was also wrong here: `since` logged null on
every call, since the map is per-subscription and reset on disconnect
rather than a persisted floor. Both left unchanged.

Regression tests fail on the old single-filter behaviour and pass on the
new one, verified by reverting the behaviour while keeping the constants
so the tests genuinely run rather than fail to compile.

Verified on device: header now reads "Amethyst QA 1.13" with an Admin
badge and member count 2, composer enabled, overflow menu showing
Members / Edit group / Invite people / Leave — all consistent with the
relay's roster.

Not fixed here: a stale "Requested" state is client-side only and is
never reconciled against an arriving roster. With this fix a genuine
pending→member transition now resolves, but a rejected 9021 still shows
"Requested" until restart. Separate change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:18:13 -04:00
Vitor PamplonaandClaude Opus 4.8 a87049e451 fix(chats): stop crashing when a relay's group directory is sorted
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>
2026-07-19 22:43:40 -04:00
Vitor PamplonaandClaude Opus 4.8 894dc7d99b fix(location): don't self-select a location the user never picked
The map picker opened blank, showed "Tessalit, ML" — the middle of the
Sahara — as if it were chosen, and had Confirm/Teleport ENABLED, so a user
could join a geohash cell they never selected. Three of those are one bug
and the fourth follows from it. Notably there is no tile, network or Tor
problem: tiles were downloading and rendering the whole time.

**The defect.** `onCenterChanged` treated any map movement beyond
`SELECT_MOVE_EPS` (0.0005°) as the user's first pick. osmdroid emits a
scroll when the MapView is first laid out, reporting a pixel-quantized
centre — and at the opening zoom one pixel is about 0.4°, roughly 800x the
epsilon. The phantom scroll therefore always cleared the threshold and the
picker selected a location before the user touched anything. No epsilon
can fix that: the quantization error is unbounded at low zoom. Replaced
with a `mapTouched` gate, so only finger-driven movement can become a
selection.

**Why it looked blank.** That phantom selection made the level effect zoom
to 12 over empty desert, and the night tile filter darkened featureless
Sahara tiles to near-black. Tiles were present; there was nothing to see.
Tapping "Region" re-zoomed and a detailed map appeared instantly, which is
what ruled out a loading failure.

**Why Tessalit.** The self-selection encoded the default centre, and
`WORLD_CENTER_LAT/LON = 20.0/0.0` is inland Mali — not "mid-Atlantic" as
the comment claimed. Moved to -30.0 longitude, genuinely unnamed ocean and
off the prime meridian; two nearby hardcoded 20.0/0.0 literals now use the
constants.

**The label mismatch was mostly a misread, with a real bug behind it.**
20N/0E sits exactly on the prime meridian, so a sub-kilometre pan flips the
geohash between the `e…` and `s…` halves of the world — both cells really
were Tessalit, so the name was right. The genuine bug is narrower: the name
came from a 450 ms-debounced cell, so mid-pan the previous cell's name
rendered beside the new geohash. The name now renders only when the
debounced cell matches the live one, and an unresolvable cell shows no
label rather than echoing its geohash.

Side benefit: the seeded composer path no longer has its exact seed
coordinates overwritten by osmdroid's quantized initial scroll.

Verified on device before and after, on both the Teleport screen and the
composer dialog: tiles on open, no bogus place, no selection until a real
touch, Confirm disabled until then; then pan → cell with no label at sea,
pan → "Oriximiná, BR", search → "Lisboa, PT", composer pick → confirmed.

Pre-existing, not fixed: in the modal dialog only, the picker header is
overdrawn by the MapView and invisible. Proven to predate this change by
A/B against an unmodified build. Back-dismiss works; cosmetic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:22:24 -04:00
Vitor PamplonaandClaude Opus 4.8 717461f4d0 fix(chats): draw day headers above the message they introduce
Day separators in group chat did not match the messages under them: a
"Jul 1, 2025" header sat directly above a Sep 23 bubble, and a "Sep 23"
header above a Sep 25 one — while LATER separators in the same
conversation were correct.

The obvious explanation — that a bubble group spanning midnight takes its
header from the group's first message — is wrong. `ChatGroupPosition`
already refuses to group two messages whose formatted dates differ, so a
group cannot cross a day boundary at all.

The real cause is an off-by-one from a false assumption about
`reverseLayout`, stated in the comment that used to sit here: that later
content "draws just above the message". `reverseLayout` reverses the
order of the LIST'S ITEMS, not the content inside one item — each item's
Column still lays out top to bottom. Verified with a throwaway headless
Compose layout probe rather than by argument: with `reverseLayout = true`
and two items each holding a message and a divisor, item 1 (older) sits
above item 0 (newer), but within each item the divisor sits BELOW its own
message.

So the divisor for item i was labelled with date(i) and gated on
date(i+1) != date(i) — the arithmetic was right — but drawn between item
i and item i-1, heading the NEXT, newer message while showing the
previous one's date. That also explains the detail the grouping theory
could not: whenever two consecutive messages fall on the same day the
mislabelled header happens to read correctly, which is why only some
separators looked wrong.

Moving the divisor to the top of the item Column fixes it. Its arguments
were already correct, so nothing else changed — no formatting change, no
grouping change, and one fewer list lookup since it now reuses the
already-computed older note. Subject headers had the same inversion and
are fixed with it.

One call site serves every chat surface — NIP-29 relay groups, NIP-28
public chats, ephemeral chats, live-activity chats, Concord channels,
private DMs, geohash chats and Marmot groups — so all were wrong and all
are fixed.

`markersInGap` immediately below had the identical bug from the identical
assumption: it is handed the bounds for the gap toward the OLDER message
but, composed last, rendered in the gap toward the newer one. Moved for
the same reason.

Not unit-tested, deliberately: the date arithmetic was always correct, so
a pure-function test passes both before and after. Catching this needs a
Compose layout assertion, and `:amethyst` has no Robolectric or JVM
Compose harness — only instrumented tests. The layout probe above is what
established the behaviour; it was deleted afterwards.

Not verified on-device: the visual result is inferred from that probe
plus the reported symptoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:00 -04:00
Vitor PamplonaandClaude Opus 4.8 f2e42105f7 fix(podcast): stop rendering an episode description twice
The episode renderer shows two blocks: the `description` tag via
`episodeDescription()`, and the event content as markdown. Most feeds put
the SAME text in both, and the thread view renders both (`makeItShort` is
false there), so the whole description appeared twice inside one card —
one author header, one player, two identical bodies, each with its own
"Show More" pill.

The existing guard only suppressed the markdown block when the content
was blank. It now also suppresses it when the content merely repeats the
description, compared on collapsed whitespace so a copy differing only in
wrapping still counts as a duplicate. Feeds were unaffected because they
pass `makeItShort = true` and never rendered the second block, which is
why this only showed on the episode screen.

Found by a device sweep of the untested feature areas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 -04:00
Vitor PamplonaandClaude Opus 4.8 c6a6068486 fix(chats): stop showing ciphertext in DM previews; fix stuck npub names
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>
2026-07-19 22:01:19 -04:00
Vitor PamplonaandClaude Opus 4.8 73d59a29bf fix(nip46): gate identity reads on pairing; make decrypt consent informed
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>
2026-07-19 21:28:05 -04:00
Vitor PamplonaandClaude Opus 4.8 7792c42f1d fix(blossom): bound what a paid server can extract on a 402
Four defects in the pay-to-upload path, all exploitable by a hostile or
merely broken media server.

**No amount cap, and the shown amount was not the paid amount.**
`amountSats()` was display-only — `pay()` never read it, so a server
asking 10,000,000 sats was handled exactly like one asking 10. `pay()`
now takes the amount the dialog showed, re-derives it from the invoice,
and refuses on over-cap, on mismatch with the dialog, and on an
amountless invoice (a payee-chosen amount must never be paid
unattended). The cap is 10,000 sats: real BUD-07 per-blob fees are
single-digit to low-hundreds, so this is one to two orders of magnitude
above legitimate use and caps one prompt's damage without blocking
anyone.

**Unbounded re-prompting.** The post-payment retry caught generic
`Exception` — including a second `BlossomPaymentException` — marked the
target missing, then re-entered the mirror path, so a server that
pocketed the preimage and replied 402 again got an indefinite pay-prompt
cycle. A new prompt ledger allows one prompt per (blob, target) per
user-initiated action; the user's own tap resets it, the automatic
continuation does not.

**Double-spend window.** `withTimeoutOrNull(90_000)` abandoned the wait
without cancelling the in-flight NWC request, so a payment settling at
second 91 was reported as failure and could be paid again. NIP-47
`sendZapPaymentRequestFor` is fire-and-forget with no cancellation and
does not return a request id, so cancelling is not reachable. Instead the
invoice is claimed before sending and released only on a definitive
wallet answer — the timeout path deliberately does not release it, so it
can never be sent twice. Known limit: the claim is process-lifetime, so a
timed-out invoice becomes payable again after a restart; persisting it
needs a real store.

**Unsanitised server text.** The server-controlled `X-Reason` header was
rendered verbatim directly above the pay button, letting a hostile server
assert its own amount ("Pay 1 sat") in what looked like Amethyst's voice.
It is now stripped of control characters and Unicode bidi overrides,
whitespace-collapsed, clamped to 200 chars, and rendered in a distinct
style as `<host> says: "…"` so it cannot be mistaken for our wording.

The two policies are small standalone classes so they are testable
without an Account graph. Verified by disabling all four checks: 12 of 15
tests fail, covering every defect; the 3 that stay green are the intended
negative controls.

Not covered: the background mirror sweep does not go through this handler
and cannot prompt, so it was never exposed to the cap or re-prompt
defects, and it inherits the invoice claim and sanitisation for free.
`pay()` itself is still not end-to-end tested — that needs Account
mocking well beyond this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:03:20 -04:00
Vitor PamplonaandClaude Opus 4.8 6045e28830 fix(cashu): validate a token's mint URL before contacting it
`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>
2026-07-19 21:02:59 -04:00
Vitor PamplonaandClaude Opus 4.8 c84acddc0c fix(concord): renaming a private channel no longer makes it public
`renameConcordChannel` built `ChannelEntity(name = ...)` from scratch
without reading the standing definition. `private`, `voice` and every
other flag default to false, so the published edition declared the
channel PUBLIC and TEXT. Renaming a private channel silently reclassified
it; renaming a voice channel silently converted it.

The messages themselves stay encrypted — a private channel is
independently keyed and a rename does not rotate that key — but the
Control Plane, which is what every client reads to decide how to present
and gate a channel, now says the wrong thing about it. A moderator
fixing a typo in a channel name should not change who a client believes
may read it.

`deleteConcordChannel` had the same shape: its tombstone also reset the
flags, so retiring a channel reclassified it on the way out.

Both now carry the standing definition forward and change only what they
mean to change.

Found while auditing which write paths re-serialize a decoded entity
rather than re-wrapping it — the same audit that produced the community
list fix. This one is worse than the unknown-key loss it was found
beside, because it discards KNOWN fields with a privacy meaning rather
than fields we merely failed to model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:59:44 -04:00
Vitor PamplonaandClaude Opus 4.8 5c23f8490d fix(concord): stop destroying other clients' data in the community list
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>
2026-07-19 20:59:19 -04:00
Vitor PamplonaandClaude Opus 4.8 f8d8a2b135 feat(concord): recover a membership stranded by a Refounding
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>
2026-07-19 20:36:52 -04:00
Vitor PamplonaandClaude Opus 4.8 fac1bf5b5d feat(concord): refuse Control-Plane rollbacks with a version floor
`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>
2026-07-19 20:36:25 -04:00
Vitor PamplonaandClaude Opus 4.8 30f4638954 fix(concord): re-key observed members on a Refounding
The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self`
— it never consulted `observedAuthors`, the members seen publishing to a
channel. `ConcordCommunitySession.allMembers()` already unions them in
(and already subtracts the banned); the rotation path just didn't use it.

Amethyst announces a Guestbook Join, but nothing in the protocol requires
one, so in practice most members of a cross-client community have never
sent one. Every member who had only ever *posted* — no role, no Join —
therefore received no rekey blob and was silently expelled by the next
rotation. Removing a single spammer would quietly strand most of the
community, and it fell hardest on Armada members, who are the bulk of the
roster in the communities we interop with.

Now uses `allMembers()`. Still a floor rather than a census, as its KDoc
says: a member who joined silently, holds no role, and has never posted
leaves no trace to re-key, and nothing here can find them. Stranded
recovery is what brings those back.

Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc
said self-certification stops a bundle smuggling "a false owner or a fake
key for a real community". Only the first half is true: `community_id`
commits to (owner, salt) — both public in any invite — and NOT to
`community_root`, so an attacker can mint a bundle carrying a real
community's id, owner and salt beside a root of their own. A joiner
adopts that root, believes they are in the real community, and posts into
planes the attacker can read.

That is a CORD-05 limitation, not an implementation gap: Armada's
`validateBundle` checks exactly the same thing and also leaves the root
unbound, so a stricter unilateral rule would break interop while
protecting nobody. Verifying the adopted root's Control Plane does not
close it either — sealed editions carry the owner's own signature, so an
attacker can re-wrap genuine owner editions into their plane, which is
what a legitimate compaction does. Closing it needs a spec change
(commit the root into the self-certifying id, or require the bundle to be
signed by a roster-authorized member); the KDoc now says so instead of
claiming a guarantee the code does not provide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:35:50 -04:00
Vitor PamplonaandClaude Opus 4.8 0f53eb09a2 fix(concord): gate revokes on target rank; drop the owner rotation refusal
Reviewed the previous commit's authority changes against Armada
(gitlab.com/soapbox-pub/armada), the reference Concord client we interop
with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so
no code was taken from it.

It confirmed the role rank gate (Armada checks both directions too: the
author must outrank the position being minted AND the standing position
being replaced) and the banlist check on rotation. It also showed one of
our rules was wrong and one gate was missing.

**Removes the owner's refusal of foreign rotations.** The previous commit
made the owner ignore any rotation it did not author, reasoning that a
BAN-holder could otherwise carry the owner onto a root of their choosing.
Armada does the opposite on purpose — "authority is the roster, never key
possession" — and it is right: an admin legitimately rotating to remove a
spammer would leave the owner alone on the dead epoch, self-inflicting
the strand the rule was meant to prevent, and diverging from the
reference implementation forks communities across clients. The threat is
better answered by the rank gate: with role editions gated, nobody can
escalate themselves to BAN, so BAN-holders are people the owner
deliberately trusted.

**Adds the missing rank gate on grants.** A grant was authorized if the
granter outranked every role it handed out — but a REVOKE carries no role
ids, and `all {}` over an empty list is vacuously true. So any
MANAGE_ROLES holder could strip anyone's roles, the owner's admins
included: promotion was gated, demotion was free. Armada treats a grant
as an action ON the member and requires outranking the target's standing
rank; this now does the same.

Both new tests were verified to fail with the corresponding check
disabled, and each has a companion asserting the legitimate case still
works (an admin can still revoke a moderator beneath it).

Also records what Armada does about exclusion, since we cannot prevent it
receiver-side: it does not try to. A rotation carries no recipient list
there either, so a stranded member instead re-resolves the invite link
they joined through and merges forward to the higher epoch ("stranded
recovery"). Amethyst has no equivalent, so a stranded member stays
stranded — noted at the call site as follow-up work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:10:36 -04:00
Vitor PamplonaandClaude Opus 4.8 c9c91a8c98 fix(concord): rank-gate role editions and harden rotation authority
Two authority holes, plus the limit that remains.

**Role editions had no rank gate.** Grant editions are correctly gated —
a granter must hold MANAGE_ROLES *and* strictly outrank every role it
hands out. Role editions checked only that the author held MANAGE_ROLES.
Nothing stopped an authorized signer editing a role at or above its own
rank, including the role it holds itself.

So a moderator at position 5 with MANAGE_ROLES could publish one edition
on their own role's chain claiming position 1 and every permission bit,
then a second demoting the real admins beneath them — reaching full
authority over everyone but the owner in two editions. The
rogue-higher-version defence added earlier does not cover this: it drops
editions from *unauthorized* signers, and this signer is authorized.

Role editions are now gated in both directions: an author may not claim a
position at or above its own rank, may not touch a role that already sits
at or above it, and may not hand a role permission bits it does not hold
itself. Deleting keeps only the second rule, so retiring a role beneath
you still works. The owner is unaffected.

**A banned moderator could still rotate the community.** The rekey
receive path authorized the rotator with `effectivePermissions(...)`,
which ignores the banlist, rather than `hasPermission(...)`, which
excludes banned members. Now uses the latter.

**The owner no longer adopts a root someone else minted.** A rotation
replaces the community root, so a rotator who *includes* the owner as a
recipient hands themselves the keys to the owner's own community — the
owner would follow them onto an attacker-chosen epoch. The owner changes
epoch only by rotating themselves.

**Known limit, documented at the call site.** A rotation carries only
(newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot
tell who was omitted. A BAN-holder can therefore still evict the owner by
leaving them out: everyone else adopts, the owner is stranded on the old
epoch. That is not fixable in the receive path; it needs a protocol
change (a recipient commitment the receiver can check, or owner
co-signing). Tracked for CORD-06.

The escalation test was verified to fail with the gate disabled, and a
companion test asserts an admin can still edit a role beneath it, so the
gate is not merely blocking everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:00:50 -04:00
Vitor PamplonaandClaude Opus 4.8 996b800da5 docs(changelog): correct claims the code does not support
An audit of the v1.13.0 notes against the implementation found several
claims that are wrong or overstated. These are user-facing release notes,
so a false claim changes what people believe about their own security.

Corrected:

- **Privacy Lock.** The notes said it gates Messages "behind a password or
  biometric" on "Android and Desktop", with inactivity auto-lock and
  optional preview redaction. It does not exist on Android at all — no
  code, no settings entry — and there is no biometric implementation on
  any platform (`CredentialPrompter` has no implementations). Preview
  redaction persists a setting nothing reads, and the auto-lock is a fixed
  timer, since the idle-reset modifier is never applied. It is also a
  screen gate rather than encryption at rest: the account stays live and
  messages keep syncing while locked. Now described as what ships — a
  password gate on the Desktop Messages and Wallet columns.
- **"Every signature, payment, or data read needs your explicit
  approval."** Only payments require per-use consent; everything else can
  be granted once and reused, and the default trust level auto-signs notes,
  reactions and encryption after a single tap.
- **Web of Trust (GrapeRank)** was listed under app features, but crawling
  and scoring exist only in the `amy` CLI — no app module references it.
  The app consumes NIP-85 cards published by an operator, which is what the
  entry now says.
- **"One-tap trust for your follows' relays"** described bulk-granting
  relays used by people you follow. What exists is category rules evaluated
  per challenge.
- **Pinned web apps "show the app's own icon"** — they render a generic
  placeholder.
- **PoW** contradicted itself: "all cores" in one entry, "half the device's
  cores" in another. The latter matches `PoWPolicy.minerWorkers`.
- **Onion-Location "through every HTTP client"** — the Android app's
  clients only; the desktop, CLI, geode and sandbox blob clients don't
  install it.
- **Relay hardening** claimed REQ refusals stop immediately and failures
  evict "on the first strike"; both take repeated failures, and the
  first-strike eviction applies only to crawls.
- **`bunker://` links cannot be pasted in** — Amethyst only emits them.
- **Git code browser** needs a repository with an http(s) clone URL.
- **Geohash anonymous identity** — the per-area identity is unlinkable, but
  the optional nickname is one global handle, so setting it links your
  posts across areas.
- **Concord ban** is read-time enforcement for everyone else; the banned
  member keeps the keys until a Refounding.

Adds an Upgrading section for the user-visible effects of the per-account
isolation work: sites signed out once, permissions re-asked per account,
and relay logins now prompting under the default remote-signer policy.

Contributor and translator credits are left as they are — several entries
are unresolved npubs and Crowdin-generated usernames, but correcting
attribution is not a call to make from the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:52:52 -04:00
Vitor PamplonaandClaude Opus 4.8 2ccd837f30 fix(napplet): sign as the account a surface was launched as
Requests resolved their signer through `sessionManager.loggedInAccount()`
— whichever account is active *right now* — with no binding to the
surface that asked. A full-screen host is a separate activity that an
account switch does not tear down, so:

- Open a site full-screen as A and log in via NIP-07: the page shows A.
- Switch to B in the main app.
- Return to that still-open surface and request a signature: the broker
  handed it **B's** key.

Confirmed on device before the fix. Worse than a mismatched prompt: B's
session was then written into **A's** WebView storage jar, so afterwards
even the embedded tab — which rebuilds correctly and had been verified
correct — displayed the wrong account. Per-account isolation held only
until a full-screen surface wrote a foreign session into a jar. And it
happened silently, because the ledger is per-account+origin and B had
already granted "always allow" for that origin from an earlier session.

`NappletLaunchRegistry.Session` now carries the account that minted the
token, and the broker resolves *that* account out of the cache. This
needs no new machinery to satisfy both halves of the rule: embedded
surfaces are torn down and re-minted on a switch, so they follow the
active account, while a full-screen surface keeps the account it was
opened with. It also extends an argument the code already made — the
sandbox can only act as the napplet it was launched as, because it holds
only its own token; now the same is true of the account.

Fails closed: if the launch account is no longer loaded, the request is
refused rather than falling back to whoever is signed in now.

The same live-account resolution existed on two adjacent paths, fixed
here too:

- Relay subscriptions took the account from a global supplier, so a
  full-screen surface's REQs would target the newly-active account's
  relays while its signatures came from the old one. The account is now
  passed per-open from the launch token.
- `identity.changed` streamed the app's active account, so a page bound
  to A could be told it had become B while signatures still returned A —
  the same desync inverted. It is now bound to the surface's own account
  and reports only that account going away.

Verified on device: with B active, a fresh identity read from a
full-screen surface launched as A returns **A**; the embedded tab still
follows B; both surfaces ran simultaneously under different accounts with
no cross-writes between jars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:38:52 -04:00