Commit Graph

11831 Commits

Author SHA1 Message Date
Claude c88923a3d1 fix(mls): decrypt retained-epoch messages correctly (Test 12 offline catchup)
The `tryDecryptWithRetainedEpoch` fallback in `MlsGroupManager.decrypt`
exists precisely to cover the interop harness's Test 12 scenario —
application messages encrypted under epoch N arriving at a receiver
that has already processed a commit advancing to N+1. The primary
path throws "Message epoch X doesn't match current epoch Y", then the
manager iterates the retained epoch window and tries each.

Two bugs kept the fallback from ever succeeding:

1. sender-data ciphertext sample length. The primary decrypt path uses
   `MlsCryptoProvider.HASH_OUTPUT_LENGTH` (32 bytes, = KDF.Nh for
   HKDF-SHA256, per RFC 9420 §6.3.2); the retained path was using
   `AEAD_KEY_LENGTH` (16). The comment on the primary path — "Using
   AEAD.Nk here made sender-data decryption fail against every
   spec-compliant sender" — was the same fix just never propagated
   to this branch. sender-data AEAD always failed, the try/catch
   swallowed it, and the fallback returned null.

2. content plaintext was returned raw. AEAD output is a
   PrivateMessageContent struct (§6.3.1):
     `applicationData<V> || signature<V> || padding`.
   The primary decrypt path parses this with `pmcReader.readOpaqueVarInt()`
   to extract applicationData; the retained path returned the whole
   struct. Callers saw a length-prefixed blob with signature + zero
   padding glued on — obvious in a raw diff, invisible in a pass/fail
   test that only checked "decrypted is not null".

Fix both in `tryDecryptWithRetainedEpoch`. Existing quartz↔quartz
tests still pass because they don't exercise out-of-order decrypt —
this branch was effectively dead code before.

Add testDecryptRetainedEpoch_ApplicationMessageAfterCommit as a
regression: Bob encrypts at epoch N, Alice advances to N+1 via
add-member, Bob's epoch-N message then arrives at Alice — must round-
trip through the retained-epoch fallback and match the original
plaintext exactly.
2026-04-23 00:30:57 +00:00
Claude d05742a8b6 debug(marmot): log publishMarmotKeyPackages path for Test 13 diagnosis
Test 13 (KeyPackage rotation) fails with "no new KP event_id observed"
— the interop log shows three `needsKeyPackageRotation=true` triggers
(once per group join), each of which invokes
`account.publishMarmotKeyPackages()` from `processMarmotWelcomeFlow`,
but no subsequent `kind:30443` publish ever appears in the logcat.

`publishMarmotKeyPackages` was completely silent: no-op guards, the
needsRotation check, the rotated-events count, and the per-event
publish all ran without a log line. We can't tell whether the rotation
produces events that never reach a relay, produces zero events,
short-circuits on needsRotation=false, or is never called at all.

Mirror the addMarmotGroupMember log pattern: entry guards (manager
null / not writeable), the needsRotation + relay count at decision
time, the rotateConsumedKeyPackages produce-count, and a per-event
publish line with id + target relay list. Next rerun of Test 13 will
tell us which branch is silently dropping the rotation.
2026-04-23 00:18:56 +00:00
Claude a4e031353a Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs 2026-04-22 23:41:40 +00:00
Vitor Pamplona 95f189338a Merge pull request #2513 from vitorpamplona/claude/fix-whitenoise-reactions-EWq2j
Filter non-displayable messages from Marmot group chat feed
2026-04-22 19:39:42 -04:00
Vitor Pamplona ca4459ca1d Merge pull request #2512 from vitorpamplona/claude/fix-marmot-relay-routing-VuVUp
Implement per-recipient relay routing for Marmot protocol
2026-04-22 19:38:58 -04:00
Claude 3279c2463a fix(mls): use filtered direct path in UpdatePath per RFC 9420 §7.9
Interop Test 06 (wn removes a member from a group Amethyst is in) was
failing with:

  GroupEventHandler.add: ERROR Failed to apply commit:
    UpdatePath node count (1) doesn't match direct path length (2)

Repro scenario: 3-member group [B=leaf 0, C=leaf 1, A=leaf 2]; B (admin,
committer) removes C. After the Remove proposal applies, leaf 1 is
blank but leaf 2 (A) is still occupied — so leaf_count stays at 3 per
RFC 9420 §7.8 and the sender's direct path has length 2 ([node 1,
root]).

Per RFC 9420 §4.1.2 and §7.9 the **filtered direct path** drops any
parent node whose child on the copath has an empty resolution:
encrypting to such a parent is equivalent to encrypting to its only
non-blank child, which is already on the direct path. In our scenario
the parent at level 1 (parent of leaves 0, 1) has a blank leaf 1 on
the copath — empty resolution — so it's filtered out. Filtered
direct path length is 1, and that's what openmls / whitenoise-rs emit
in `UpdatePath.nodes<V>`.

Quartz was always using the unfiltered direct path on both sides
(send + receive + parent-hash chain + path-secret decrypt). That
worked for quartz↔quartz (both sides agreed), but broke the moment a
spec-correct sender (wn) sent a filtered UpdatePath into a quartz
receiver. Parent-hash chain is further affected because §7.9.2 walks
the filtered path — even if the size check passed, the hashes would
not match.

Fix it throughout:

- RatchetTree.filteredDirectPath: new helper returning parallel
  (filteredDirectPath, filteredCopath) with the empty-resolution
  entries dropped. Uses the existing `resolution()` helper, which
  already handles unmerged_leaves per §4.1.2.

- RatchetTree.applyUpdatePath: size-check + target the FILTERED path.

- MlsGroup.commit (sender): emit one staged path node per filtered
  level; encrypt path secrets using the filtered copath; patch parent
  hashes into the filtered positions only.

- MlsGroup.processCommitInner (receiver): the UpdatePath node index
  aligns to the filtered direct path, so the common-ancestor lookup
  must find the filtered index to pick the right ciphertext + copath
  resolution. Use the unfiltered index only for counting KDF steps
  to the root (path_secret chain advances one step per unfiltered
  level regardless of filtering — the chain is continuous; filtering
  only decides which levels emit a UpdatePathNode, not how many KDF
  steps separate them).

- MlsGroup.computeSenderParentHashes: walk the filtered direct path.
  Map each filtered node back to its unfiltered level so the
  preUpdateSiblingHashes lookup (indexed by unfiltered level) still
  resolves. This makes the parent_hash chain agree with §7.9.2 and
  with what openmls computes on the other side.

- MlsGroup.verifyParentHash: short-circuit on empty filtered path
  rather than empty unfiltered path.

New regression test: testRemoveMiddleLeaf_ReceiverAcceptsCommit
reproduces the 3-member remove-middle scenario end-to-end within
quartz. It passed before this patch (because both sides were
unfiltered and agreed with each other), and it passes after this
patch (because both sides are now filtered and still agree) — the
compatibility win is that quartz now also agrees with
openmls/wn-produced commits of the same shape.

Also fix an unrelated script bug in marmot-interop.sh: the
discover_a_relays SQL probe was falsely reporting "wn has NO cached
relay entries for A" even when welcome delivery was working, because
the query assumed users.pubkey stored as BLOB but wn stores it as
TEXT (hex) in the current schema. Accept either encoding.
2026-04-22 22:58:21 +00:00
Claude 0657f6e1c7 fix(cli): apply Marmot relay-routing rules across every marmot command
The first pass only fixed `group add`. Auditing the rest of the CLI
against MIP-00..03 turned up three more spots where `amy` either
queried the wrong relays or silently skipped a required advertisement:

1. `marmot key-package check <npub>` used `anyRelays()` — i.e. the
   inviter's configured relays — so checking for a KeyPackage on a
   user who advertises `kind:10051` somewhere we don't know about
   always returned `not_found`. Now runs RecipientRelayFetcher against
   bootstrap seeds and fetches from the union of (target kind:10051,
   target kind:10002 write, bootstrap). Emits `found_on` so callers
   can see which relay served the hit.

2. `await key-package <npub>` had the same bug inside the poll loop.
   Resolved once up front, then the loop fetches from the target's
   advertised relays every tick. Throws an `AwaitTimeout` early if no
   relays can be discovered at all, instead of silently polling void.

3. `relay publish-lists` published kind:10002 + kind:10050 but never
   kind:10051. Per MIP-00 the KeyPackage Relay List is how other
   Marmot clients discover where our KPs live — without it the
   `key_package` bucket on disk is invisible to anyone else. Now also
   publishes kind:10051; falls back to the NIP-65 set if the bucket
   is empty so we never advertise an empty list. (`amy create`
   already publishes it via AccountBootstrapEvents.)

Docs: cli/README adds a "Relay routing" section that lists the exact
relay set used for publish vs fetch of every Marmot event kind, plus
the bootstrap-pool definition, so agents + interop-test authors can
reason about cross-user reachability without reading the code.
2026-04-22 22:15:10 +00:00
Claude c4e7e0d9b4 fix: don't render Marmot reactions/deletions as chat bubbles
WhiteNoise threads its kind:445 payloads across three inner kinds:
kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The
Amethyst ingest pipeline was routing every inner event into the group
chatroom feed, so a kind:7 reaction rendered as a chat bubble whose
only content was the emoji, quoting the liked message via the target
`e` tag. That looked identical to a threaded reply. The actual kind:9
reply from `wn messages send --reply-to` never arrived, which made the
two look swapped in the UI.

Three changes to untangle this:

- MarmotGroupList.addMessage/restoreMessage now skip inner events with
  kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`.
  The reaction is still consumed by LocalCache so it attaches to the
  target note's reaction row, and the deletion still revokes that
  reaction — they just don't appear as standalone bubbles.
- LocalCache.computeReplyTo learned to derive thread parents for
  `ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the
  existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies;
  without this the reply bubble had no quote context in the feed.
- tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes
  its `reply_to` field through clap v4, which renames snake_case to
  kebab-case by default. The script was passing `--reply_to`, which
  clap rejected; the `|| true` + redirected stderr hid the error and
  no reply was ever published. Use `--reply-to`.

https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32
2026-04-22 22:07:15 +00:00
Claude 5289fa3398 style(cli): import NormalizedRelayUrl + Amethyst defaults instead of inlining FQNs 2026-04-22 22:04:30 +00:00
Vitor Pamplona 7a932c03e3 Merge pull request #2511 from vitorpamplona/claude/fix-marmot-group-tabs-zLq2N
Implement known/new group classification based on follow set
2026-04-22 18:02:58 -04:00
Claude 6f3abac3b2 fix(marmot-interop): peel .result wrapper when reporting Test 10's B view
Post-v0.2 wn wraps `--json groups show` output in {"result": {...}},
so `jq '{name, epoch}'` was reading the top level where those keys
don't exist — Test 10 always logged "B state: {name: null, epoch: null}"
and the operator couldn't tell whether the race produced the expected
converged state on wn's side.

Peel the .result wrapper before extracting the fields; same pattern
already used everywhere else in the script that parses wn JSON.
2026-04-22 21:54:18 +00:00
Claude f6f2a34353 fix(cli): route Marmot welcome + KeyPackage fetch to the invitee's relays
`amy marmot group add` previously sent the Welcome gift wrap to the
inviter's own kind:10050 (ctx.inboxRelays()) and fetched the invitee's
KeyPackage from only the inviter's configured relays. Two users with
disjoint relay configurations could never successfully marmot each
other — the welcome landed somewhere the invitee never polled.

Mirror the Android Account.addMarmotGroupMember flow in the CLI:

- New RecipientRelayFetcher in quartz/marmot one-shot-drains a target
  user's kind:10050, kind:10051 and kind:10002 from a seed relay set.
  Replaceable-event semantics: newest created_at per kind wins. Guards
  against relays echoing events authored by someone else.
- Context.bootstrapRelays() unions the inviter's configured relays with
  AmethystDefaults (DefaultNIP65RelaySet + DefaultDMRelayList) so we can
  discover strangers even when nothing overlaps with our own config.
- GroupAddMemberCommand now per-invitee: looks up their relay lists;
  passes kind:10051 + kind:10002 write + bootstrap to KeyPackageFetcher;
  publishes the welcome to kind:10050 (fallback to NIP-65 read, then
  DefaultDMRelayList, then our outbox as belt-and-braces).

Group commits/messages (kind:445) continue to use the group's MIP-01
relay set — those were already correct. Exposes welcome_targets and
key_package_relays in the JSON output so callers can verify routing.
2026-04-22 21:45:14 +00:00
Claude 11ba03334c fix: classify Marmot groups in Messages Known/New tabs by admin follow
Before, every Marmot group landed unconditionally under the Known tab of the
Messages screen while the New Requests tab ignored groups entirely, so an
invite from a stranger appeared next to real conversations. The dedicated
Marmot group list screen split by ownerSentMessage only, so unreplied groups
where an admin was followed still showed as New Requests.

Replace both splits with a shared MarmotGroupChatroom.isKnown(followingKeySet):
- user has replied -> Known
- no known members and no messages -> New Requests
- otherwise Known iff any admin is in the follow set

Wire the combined Messages feeds (ChatroomListKnownFeedFilter and
ChatroomListNewFeedFilter) through the helper, invalidate dmNew on group list
changes so empty groups flow into New Requests, and have
MarmotGroupListScreen observe kind3FollowList so newly followed admins move
groups between tabs immediately.
2026-04-22 21:40:23 +00:00
Claude 7295c12c82 Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs 2026-04-22 21:37:27 +00:00
Claude 2cdd89558b fix(marmot-interop): split Test 07 into admin-authored halves
The reverse-rename leg issued `wn_b groups rename` on Interop-02, but
Amethyst created Interop-02 — A is the admin, B is a plain member.
MIP-03's `enforceAuthorizedProposalSet` rejects any GroupContextExtensions
commit from a non-admin, so wn's rename was being dropped client-side,
the metadata commit never reached any relay, and Amethyst's UI (correctly
waiting for a kind:445 on Interop-02) never saw "Interop-02-reverse".

Split Test 07 into two separately-recorded legs so each uses a group
where the renamer is actually the admin:

  07a — A (admin of Interop-02) renames to "Interop-02-renamed" via
        the Amethyst UI; wn-side poll observes the new name.

  07b — B (admin of Interop-03) renames to "Interop-03-renamed" via
        `wn_b groups rename`; operator confirms Amethyst picked it up.

Each leg individually records pass/fail/skip so a missing GROUP_03
(Test 03 skipped) doesn't take out the whole test.
2026-04-22 21:31:53 +00:00
Claude 1e181eb305 fix(marmot-interop): drive Test 06 removal from the admin (B), not A
Test 06 was asking the operator to remove C from Interop-05 via the
Amethyst UI — but Interop-05 was created by B (wn), so B is the sole
admin and A is a plain member. MIP-03's authorization gate
(`enforceAuthorizedProposalSet` in `MlsGroup.kt:1842`) rejects any
Remove proposal from a non-admin, so the UI correctly fires "not an
admin" and the test fails against its own author's assumption — the
test was misaligned with the admin model, not the code.

Flip the flow: B (admin) issues the removal via `wn groups
remove-members <gid> <C_NPUB>`, and A / C observe the effect. That
still exercises the same intended invariants:

  - B + A remain in the group, commit + epoch advance propagate.
  - A's Amethyst UI shows C gone (visible confirm).
  - A sends "after removing C" → B decrypts (expected).
  - C cannot decrypt the new message (forward secrecy at the new
    epoch — C's retained keys only open the pre-remove epoch).

The "Amethyst admin issues remove" path is already separately
covered by Test 02 (A creates the group → A is admin → A removes)
and Test 08 (promote A, then A issues an admin-only action). Test
06's focus is forward secrecy after remove, not who may initiate —
so having B drive it is the spec-correct shape.
2026-04-22 21:28:56 +00:00
Vitor Pamplona 6c214b8c13 Merge pull request #2510 from vitorpamplona/claude/add-user-selection-button-L4Ndb
Refactor MarmotGroupInfoScreen layout for better UX
2026-04-22 17:28:15 -04:00
Claude 119959e942 feat: pin add-member field to bottom of Marmots group info with add button
Move the add-member search to a fixed position at the bottom of the screen
(matching the New Short Note pattern) so it stays visible regardless of how
far the user has scrolled through a large member list. Suggestions now render
above the input, and each suggestion row shows an explicit PersonAdd icon
button so the add action has a clear visual affordance.

Adds an optional trailingContent slot to ShowUserSuggestionList / UserLine
so callers can attach per-row actions without forking the component.

https://claude.ai/code/session_01JbRycm4g3LrqatnCVWVdGh
2026-04-22 21:16:25 +00:00
Claude a6388a6751 feat(marmot-interop): discover A's relays instead of forcing a shared set
The interop harness was forcing specific relays on the Amethyst account —
"configure Amethyst with these five relays, also add them to your DM
Inbox list, also to your Key Package Relays, then republish" — which
made the test artificial:

  - It masked the real failure modes (wn publishing to an A-advertised
    relay wn can't reach, A's 10050 pointing at auth-required relays,
    etc.) because everyone was always on the same happy-path set.
  - It mutated the operator's real Amethyst account config — those
    five relays persist after the run.
  - It short-circuited wn's discovery chain (kind:10002 → 10050 →
    10051 resolution), so the one path most likely to hold subtle bugs
    was never exercised under divergent configs.

Real-world interop is: both clients have their own independently-chosen
relay sets and use the advertised kind:10002/10050/10051 to find each
other. Make the harness match that:

  - Keep the five-relay set as the wn B/C bootstrap (they still need
    somewhere to publish their own identity). Drop the "configure
    Amethyst to match" steps from `instruct_amethyst_setup`;
    confirmation only now ("is A logged in as <npub>, and has A
    published its four lists to any public relay?").

  - Add `discover_a_relays` step between configure + the operator
    prompt. Calls `wn keys check $A_NPUB` on both daemons to trigger
    wn's targeted fetch for kinds [0, 10002, 10050, 10051] (the
    only side-effect available to populate wn's user_relays cache).
    Then reads the cache directly via sqlite3 against wn's DB and
    prints what wn actually learned, grouped by relay_type. Warns
    loudly if A's 10050 shares zero relays with wn's bootstrap — the
    exact failure mode that caused the prior round of "Test 03
    silently times out" reports.

  - `--local-relays` path keeps the old behavior: offline/sandbox
    mode, the harness owns the relay so forcing Amethyst onto it is
    correct.

Scope is the harness only. No Amethyst / quartz / commons changes.
2026-04-22 21:13:16 +00:00
Vitor Pamplona c82f4342c4 Merge pull request #2509 from vitorpamplona/claude/fix-group-nav-stack-QioA1
Fix navigation stack handling when creating marmot groups
2026-04-22 16:56:28 -04:00
Claude 34f6be225f fix(marmot-interop): prompt operator to set DM Inbox relays too
Test 03 (wn creates a group, invites Amethyst) was failing at the
operator-visible level — "I don't see Interop-03 on Amethyst" — with
no clear signal in the Amethyst logcat because no kind:1059 welcome
gift wrap ever arrived.

Root cause: when wn invites Amethyst it fetches A's kind:10050 inbox
list and publishes the welcome gift wrap there. Amethyst's built-in
DM inbox defaults advertise auth.nostr1.com + relay.0xchat.com, which
wn (connected only to the five harness public relays) cannot reach.
Unless the operator manually adds the harness relays to Amethyst's
DM Inbox list, the gift wrap lands on relays the reader isn't
subscribed to, and the invite is silently dropped.

Confirmed empirically by adding wss://nostr.bitcoiner.social (one of
the harness relays) to Amethyst's DM Inbox list and watching
Interop-03 appear on the next wn create.

Update `instruct_amethyst_setup` to make the DM Inbox step explicit
and call out the specific failure mode it prevents. Also add a nudge
to verify NIP-65 + kind:10050 were actually republished after the
relay list edits.
2026-04-22 20:42:44 +00:00
Claude 7cc53dc8ba fix: pop Create Group screen when navigating to new Marmot group
After creating a Marmot group, the Create Group screen stays on the
back stack, so pressing back from the new group chat returns to the
empty creation form instead of the Messages screen. Swap `nav.nav`
for `nav.popUpTo(..., Route.CreateMarmotGroup::class)` so the creation
screen is removed inclusively as we navigate into the group.
2026-04-22 20:40:53 +00:00
Claude f0eaff638e Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs 2026-04-22 20:20:13 +00:00
Claude 5a18dc41c8 fix(marmot): inner events are unsigned rumors per MIP-03
MIP-03 ("Application Messages" → Security Requirements) is explicit:

  > "Inner events MUST remain unsigned (no `sig` field)
  >  This ensures leaked events cannot be published to public relays."

Authentication comes from (a) MLS framing — the sender's LeafNode
credential signs the outer MLS Application message — and (b) the
mandatory check in MarmotInboundProcessor.processPrivateMessage that
the inner event's `pubkey` equals the MLS sender's credential identity.
The Nostr Schnorr signature is redundant, and leaving it populated
means a leaked plaintext can be replayed as a valid public kind:9.

whitenoise-rs is spec-compliant (builds via `UnsignedEvent::new()` +
`ensure_id()`, never calls sign). Amethyst was the non-conforming side
on both directions:

1. Send path (`MarmotManager.buildTextMessage`,
   `AccountViewModel.sendMarmotGroupMediaMessage`) called
   `signer.sign(template)`, producing a signed rumor that shipped a
   valid Schnorr signature through the encrypted channel. Switch both
   to `RumorAssembler.assembleRumor` — same id derivation (SHA-256
   over canonicalized [0, pubkey, createdAt, kind, tags, content]),
   but `sig = ""`.

2. Restore path (`Account.kt` on startup reload of persisted inner
   events) called `cache.justConsume(innerEvent, null, false)`, which
   routed through `consumeRegularEvent` → `justVerify` → `event.verify()`
   → FAIL on empty sig → Note registered with `event = null`, message
   never rendered. Pass `wasVerified = true`, matching what the live
   receive path already does after the previous commit (573c5c2b).

Existing on-disk persisted messages from older signed-rumor builds
still load — `wasVerified=true` skips sig verify entirely, so both
legacy signed and spec-correct unsigned rumors deserialize cleanly.
2026-04-22 20:14:07 +00:00
Vitor Pamplona fdcc86cedb Merge pull request #2508 from vitorpamplona/claude/inline-member-removal-wnenb
Inline member management in Marmot group info screen
2026-04-22 16:09:28 -04:00
Claude 573c5c2b06 fix(marmot): trust MLS authentication, skip Nostr verify on inner events
The previous commit (6ad522b5) shielded the chatroom from the mistaken
"duplicate" drop but was treating the symptom. The real cause of the
"inner event already in cache" log — reported while Test 02's chat
stayed completely empty — is that `cache.justConsume(innerEvent, null,
wasVerified=false)` runs `event.verify()` on a wn-signed inner kind:9,
that verify fails (likely JSON-canonicalization skew or a rumor-style
empty signature), so `consumeRegularEvent` bails at line 633 before
ever setting `note.event`. LocalCache then always had the empty Note
registered by id but with no event content — subsequent arrivals re-hit
line 615 (`note.event != null` is still false, not the "duplicate"
path), retried verify, failed again, and the chatroom never got a
populated message to render.

The inner event has already been cryptographically authenticated by
MLS — `MarmotInboundProcessor.processPrivateMessage` rejects any inner
event whose `pubkey` doesn't match the MLS sender's credential
identity (MIP-03, line 430). Running Nostr's `event.verify()` again
is a redundant gate. Pass `wasVerified=true`: `consumeRegularEvent`
skips `justVerify` and calls `note.loadEvent(...)` on first arrival,
chatroom-add works immediately, and the test passes.

Also tightens the "already in cache" comment to reflect what that
branch actually means now (a legitimate hydrated-by-startup-restore
duplicate, not a silent verify failure masquerading as one).
2026-04-22 20:03:54 +00:00
Claude 32b0f41be9 test: move re-add-after-remove regression to Quartz MlsGroup test
The bug (re-added user rejoins without a usable own_leaf, so they
can neither decrypt new group messages nor send) reproduces entirely
with two in-process MlsGroup instances — no relay or second Nostr
client needed. A pure Quartz unit test runs in seconds and catches
the regression deterministically, where the bash interop version
depended on whitenoise-rs, wnd daemons and a local relay.

- quartz: add MlsGroupLifecycleTest.testReaddAfterRemove_RejoinerCanEncryptAndDecrypt
  covering create → add → round-trip → remove → re-add with a fresh
  KeyPackage → round-trip again, asserting leafIndex and both
  encrypt/decrypt directions on the rejoined instance.
- interop: drop test_17_readd_after_remove from the headless suite and
  its registration, since the unit test supersedes it.

https://claude.ai/code/session_01JaeqZwVNLKvUUvXWRzPmRP
2026-04-22 19:52:30 +00:00
Claude 6ad522b52b fix(marmot): surface inbound app messages even when LocalCache says duplicate
The B->Amethyst kind:445 reply was decrypting fine — the harness's
new outbound-side diagnostics + the Amethyst logcat showed a clean
ApplicationMessage path right up to the chatroom-add step:

    GroupEventHandler.add: kind:445 id=988221d0… groupId=ee27bc48…
    GroupEventHandler.add: ApplicationMessage decrypted innerKind=9
                            innerId=ebf35373… author=170df8d1…
    GroupEventHandler.add: inner event already in cache (duplicate)

`cache.justConsume` returned false because the inner event id was
already in LocalCache (a prior session persisted the same plaintext
via persistDecryptedMessage and the startup restore loop in
Account.kt:3034-3044 reloaded it into the cache before this kind:445
arrived). The live-receive branch in DecryptAndIndexProcessor was
gated on `isNew` and silently dropped the message — never calling
marmotGroupList.addMessage — so the chatroom never saw B's reply
even though decryption succeeded.

Mirror what the restore path does: always surface the inner note
in the chatroom. `MarmotGroupChatroom.addMessageSync` is itself
idempotent (dedupes by Note identity), so the call is safe even
when the message was already added through another path.
`persistDecryptedMessage` stays guarded on `isNew` because the
message store is append-only and a re-persist would grow the
on-disk log unboundedly across decrypt retries.
2026-04-22 19:50:50 +00:00
Claude bca829df30 Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs 2026-04-22 19:37:25 +00:00
Claude a10fdb934a fix: route Leave Group back to the Messages screen
After leaving a Marmot group the user was dropped on the group list;
send them to the main Messages tab instead, which is the correct
starting point for picking another conversation.

https://claude.ai/code/session_01JaeqZwVNLKvUUvXWRzPmRP
2026-04-22 19:36:44 +00:00
Claude 65c56d0035 feat: inline member management on Marmot group info screen
- Fold the Remove Member screen into the info screen: each member row
  now has an inline remove icon that opens a confirmation dialog.
- Fold the Add Member screen into the info screen: a search field with
  a user-suggestion list lives directly above the member list, so
  adding a member never requires leaving the screen.
- Move Leave Group out of the scrolling body into a top-bar action so
  it is reachable regardless of member count.
- Shrink the relay strip (35dp tiles with a small activity dot) and
  tuck it next to the group header; drop the "Relays" label and the
  MLS epoch readout, which were visual clutter.
- Route the chat screen's Add Member button to the info screen and
  delete the two standalone screens and their routes.
- Add headless interop test 17: A creates a group, adds B, removes B,
  re-adds B, and verifies B can both receive and send messages. Guards
  the reported regression where a re-added member came back without a
  usable leaf and silently lost the ability to post.

https://claude.ai/code/session_01JaeqZwVNLKvUUvXWRzPmRP
2026-04-22 19:28:25 +00:00
Claude 8416afd965 debug(marmot): log removeMarmotGroupMember + syncMetadataTo transitions
The bug report: after tapping Remove on the Marmot Group Info screen,
the target npub stays in the member list, and the only log line is
the round-tripped kind:445 hitting the "Duplicate" branch in
GroupEventHandler.add. The author-side path (Account.removeMarmotGroupMember
→ MarmotManager.removeMember → syncMetadataTo → publish) has no logging,
so there's no way to tell which step broke — the MLS tree mutation,
the local member-list push to chatroom.members, or the UI recomposition.

Mirror the addMarmotGroupMember logging style on removeMarmotGroupMember
(entry, no-op guards, built commit id, publish) so we can see whether
the author path even runs end-to-end, and have syncMetadataTo log the
group id and the previous→new member count so the StateFlow update
becomes observable. No behavior change.
2026-04-22 19:12:19 +00:00
Claude d1ea9c39e8 fix(marmot-interop): guard empty-array expansion in snapshot_invites
bash 3.2 (stock on macOS) raises "unbound variable" on "${gids[*]}"
when the array is empty under `set -u`. The happy-path sanity check
still worked (the array was populated), but the shell error aborted
execution before the actual test cases ran on a fresh state/ directory.

Only expand when the array has at least one entry.
2026-04-22 18:53:17 +00:00
Vitor Pamplona 2d6f626ee3 Merge pull request #2502 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-22 14:44:42 -04:00
Vitor Pamplona 21c367302b Merge pull request #2506 from vitorpamplona/claude/fix-scaffold-spacing-NDAkw
Fix disappearing bars hiding on pure overscroll for non-scrollable lists
2026-04-22 14:44:35 -04:00
Crowdin Bot 642aa6fe1f New Crowdin translations by GitHub Action 2026-04-22 18:44:32 +00:00
Claude 1cc29d605a feat(marmot-interop): add nostr.bitcoiner.social + nostr.mom to defaults
Broaden the default public relay set for the interactive harness so
kind:1059 gift wraps and kind:10050 inbox lists have more landing
spots that don't pre-filter Marmot traffic out. Both relays are
known to accept kinds outside the core "note / metadata / contacts"
trio that damus/primal tend to restrict.

The Amethyst-side setup instructions are rendered from the same list,
so operators who configure Amethyst per the harness prompt get the
same five URLs on the app side too.
2026-04-22 18:43:43 +00:00
Claude 76f23c0798 fix(marmot-interop): filter stale welcomes in wait_for_invite
wnd persists pending group invites across daemon restarts, so on any
rerun C (and B) already have unaccepted welcomes from previous runs
lying around. `wait_for_invite` was pulling `.[0]` out of the invite
list, which is the oldest pending — i.e. the stalest — and treating
it as the arrival the caller just triggered. In the sanity probe this
caused the kind:445 step to send to B's NEW group id while C was
accepting a DIFFERENT (old) group, producing a bogus "kind:445 failed
— relays may be dropping group messages" with no actual relay issue.

Add `snapshot_invites <B|C>` which returns the current gids as CSV,
and teach `wait_for_invite` to accept that CSV as an ignore list so
it walks past stale welcomes and only returns a freshly-arrived one.
Use the new snapshot→wait pattern at every call site: sanity check,
Test 02 (B), Test 04 (C), Test 05 (C), Test 08 (C), Test 12 (C).
Also log a gid-mismatch warning in the sanity check when B's created
group and C's accepted welcome disagree, as an extra guard against
the filter being bypassed.
2026-04-22 18:43:28 +00:00
Vitor Pamplona 74aa940098 Merge pull request #2504 from vitorpamplona/claude/republish-relay-events-mo64n
Auto-republish account settings when relay lists change
2026-04-22 14:42:52 -04:00
Vitor Pamplona 4b52b2702d Merge pull request #2505 from vitorpamplona/claude/fix-marmot-account-isolation-4Ysv5
Isolate MLS and Marmot stores per account
2026-04-22 14:42:10 -04:00
Claude 414fd31bf9 fix(marmot-interop): publish kind:0 for B and C before sanity probes
Several public relays (damus.io in particular) silently drop kind:1059 /
10050 / 30443 from accounts they've never seen before — the sanity
check in the interactive harness reports exactly this failure mode
when the three-relay default set is in use, because wn's identities are
brand-new and have nothing on file.

Before the kind:30443 + 10050/1059/445 probes, publish a kind:0
profile event for each wn identity via `wn profile update --name
--about`. That marks both accounts as "known" on the relay and lets
the subsequent gift-wrap and KeyPackage publishes land. Runs inside
configure_relays (after relays are added, before the first sanity
probe) so the sequencing is: add relay -> advertise profile -> publish
KPs -> start exchanging welcomes.

Does not help when a relay outright refuses the kind (some relays
whitelist only kind:1 / 3 / 7); those still need --local-relays.
2026-04-22 18:32:31 +00:00
Claude 36eda1cdea feat(relays): republish account settings when AllRelay lists change
When the user updates their relay configuration on the AllRelay screen,
the newly-selected relays often have no copy of the account's existing
settings (profile, follow list, mute list, other relay lists, etc.), so
the user appears to "lose" their account to anyone reading from those
relays until the next time each list is edited.

After saving each relay list, re-publish the relevant existing events
to the newly-selected relays (only when the set actually differs):

- Outbox/inbox (NIP-65) or private-storage or broadcast changes
  republish every known account-settings event to the new relay set.
- KeyPackage relay-list changes republish all active kind:30443
  KeyPackage events authored by this account.
- Local relay-list changes republish account-settings events locally.
- Indexer relay-list changes publish kind:0 and kind:3.
- Search relay-list changes publish kind:0.

Local relays now flow through a new Account.saveLocalRelayList so the
republish hook lives alongside every other relay-list save.

https://claude.ai/code/session_01PcvDaNyzT6Tk4D7jn75Jbm
2026-04-22 18:32:28 +00:00
Claude bd2fae9ca5 fix(marmot): scope Marmot storage per-account to prevent chat leakage
The three Marmot stores (MLS group state, messages, KeyPackage bundles)
were all initialized with the global app filesDir, so chats and MLS
state from one account were visible to any other logged-in account.

Pass each store a per-account subdirectory (accounts/<pubkey>) derived
from the signer pubkey, so paths become:

  files/accounts/<pubkey>/mls_groups/<groupId>/{state,retained,messages}
  files/accounts/<pubkey>/marmot_keypackages/state

Existing in-memory state was already scoped per-Account; this closes the
last remaining cross-account leak on disk.
2026-04-22 18:30:24 +00:00
Claude b5b01f6eda fix(scaffold): keep bars visible on non-scrollable lists
The DisappearingScaffold hid its top/bottom bars purely from overscroll
gestures, so a feed with too few items to scroll would end up with two
empty bands where the chrome used to be. Pure overscroll (consumed.y == 0)
from the fully-visible state is now ignored; once the bars have started
moving we know the list is scrollable and edge overscroll keeps feeding
bar motion as before.
2026-04-22 18:28:41 +00:00
Claude fda58f4413 fix(marmot-interop): diagnose B->Amethyst kind:445 drops in Test 02
When `wn_b messages send` fires but Amethyst never displays the reply,
the harness used to swallow the send output and only ask the operator a
blind yes/no — giving no signal about which side of the pipe failed.
Amethyst subscribes to kind:445 on the MLS GroupContext `relays`
extension (falling back to `Account.homeRelays`), so the two
actionable failure modes are:

  - wn published to a relay Amethyst isn't subscribed to (group-relay
    mismatch between the MLS extension and Amethyst's subscription)
  - Amethyst received the event but `GroupEventHandler.add` dropped it
    (no `h` tag, `isMember=false`, decrypt error)

Teach the harness to dump both sides automatically on the reply step:

  - Capture wn's `messages send` stdout/stderr into the run log.
  - `dump_outbound_diagnostics` prints wn's local view of the group
    messages (did the send persist to wn's own DB?), the MLS
    GroupContext relay list (what Amethyst should be subscribed to),
    and live relay connection status.
  - On operator-reported fail, prompt for an Amethyst-side logcat
    grep targeted at the exact log points — MarmotGroupEventsEoseManager
    filter updates, GroupEventHandler.add arrivals, and the membership
    drop branch — naming the current group id so the operator can grep
    for the right hex prefix.
2026-04-22 18:21:22 +00:00
Claude 13741be695 fix(marmot-interop): decode npub locally so member-list checks use hex
`wn users show <npub>` only resolves identities the local daemon has
cached, so A's npub (the Amethyst account, which B's daemon has never
interacted with directly) falls through to the "return input unchanged"
branch. That leaks the raw npub into the Test 02 member-list assertion,
where it's compared against hex pubkeys pulled from `wn groups members`
and fails with "member list missing npub1...".

Add a pure-awk bech32 decoder and use it as the final fallback in
`npub_to_hex`, so unknown npubs still convert to the 32-byte hex form
the daemon emits. Also harden the JSON path to peel a `.result` wrapper
in case a future `wn --json users show` ships one.
2026-04-22 18:01:05 +00:00
Vitor Pamplona e452cf8abd Merge pull request #2501 from vitorpamplona/claude/debug-marmot-harness-oCU2g
Claude/debug marmot harness o cu2g
2026-04-22 13:39:15 -04:00
Claude c85848968c fix(marmot-interop): recover from stale MLS DB in interactive harness
The interactive harness was failing with the same
KeyringEntryMissingForExistingDatabase error as the headless one —
the MLS SQLite DB on disk references a keyring entry that no longer
exists (keychain entry pruned, data dir restored out of band, or a
previous run used the mock keyring). wnd can't open the DB in that
state, so the daemon exits before its socket appears.

Unlike the headless harness (which wipes unconditionally because its
mock keyring is always ephemeral), the interactive harness uses the
real OS keyring and benefits from preserving B/C identities across
runs. So: try once, and only wipe + retry when stderr actually shows
the keyring-missing error. Also dump stderr/stdout tails inline on
final failure so the operator doesn't have to chase a log path.
2026-04-22 17:38:08 +00:00
Claude ac72262acc fix(marmot-interop): clean up daemons on Ctrl+C in interactive harness
Apply the same SIGINT/SIGTERM/SIGHUP trap hardening to the interactive
harness that just landed on the headless one. Without it, killing the
script mid-run leaves the nohup'd wn daemons alive and the next run
fails in preflight because the sockets are still held.
2026-04-22 17:33:27 +00:00
Claude b21f8677ce fix(marmot-interop): clean up daemons on Ctrl+C / SIGTERM / SIGHUP
The previous trap only fired on the EXIT pseudo-signal, which doesn't
always run cleanly when the script is killed mid-flight — leaving the
nohup'd wnd daemons (and the local relay) alive and still holding the
port. Route SIGINT/SIGTERM/SIGHUP through an explicit `exit`, use a
single idempotent cleanup handler, and preserve the exit code so the
harness still reports failure when killed.
2026-04-22 17:30:33 +00:00