Commit Graph

11802 Commits

Author SHA1 Message Date
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 5289fa3398 style(cli): import NormalizedRelayUrl + Amethyst defaults instead of inlining FQNs 2026-04-22 22:04:30 +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
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 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
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 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 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
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
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 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
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
Vitor Pamplona cadb9ebec9 Merge pull request #2500 from vitorpamplona/claude/debug-marmot-harness-oCU2g
Improve wnd daemon startup reliability and error diagnostics
2026-04-22 13:00:24 -04:00
Claude 0f032e39d9 fix(marmot-interop): wipe stale wnd state when mock keyring is in use
The headless harness runs wnd with WHITENOISE_MOCK_KEYRING=1, but the
mock keyring is in-memory only — it starts empty on every wnd restart.
Meanwhile the SQLite databases under $data_dir persist across runs and
reference keys that no longer exist, so the second run onward fails at
startup with KeyringEntryMissingForExistingDatabase before wnd can even
open its control socket. Wipe everything under the daemon's data dir
(except logs and pid) before each start so wnd always comes up cold and
consistent with its ephemeral keyring.

Also surface failure diagnostics inline instead of burying them in a log
file path: print the last 40 lines of stderr and 20 of stdout, report
whether wnd is still alive or already exited, and break out of the 30s
wait loop the moment the daemon dies.
2026-04-22 16:59:07 +00:00
Vitor Pamplona 9de2399cb8 Merge pull request #2499 from vitorpamplona/claude/animate-drawer-items-azHFq
Replace newStack with navBottomBar for bottom navigation
2026-04-22 12:37:17 -04:00
Claude f59597c55f refactor: move skip-slide hint to NavBackStackEntry.savedStateHandle
The prior change tracked the "no slide" intent via a mutable flag on Nav
that every navigate method had to reset. Replace it with a per-entry
hint: navBottomBar writes SKIP_SLIDE_ANIMATION_KEY=true to the new
destination's savedStateHandle, and composableFromEnd reads it off
targetState (forward) / initialState (pop).

Benefits:
- No shared mutable state; no reset bookkeeping in nav/newStack/popUpTo.
- No race between interleaved navigate calls.
- Pop animations are automatically consistent with how the entry was
  entered - backing out of a bottom-bar tab now also fades.
2026-04-22 16:24:17 +00:00
Vitor Pamplona 4892eedd80 Merge pull request #2498 from vitorpamplona/claude/fix-marmot-mip-tests-F1mPy
Use processFramedCommit for RFC 9420 §6.1 signature verification
2026-04-22 12:08:34 -04:00
Claude ed68b79938 fix: skip slide animation for user-pinned bottom bar routes
Drawer routes are registered with composableFromEnd (slide-from-right +
scale-out) because that matches the expected drawer push animation.
When a user pins one of those routes onto the bottom navigation bar
(feature added in 376e404c), the bottom-bar tap still replayed the
slide animation because the transition is bound to the Route type at
composable<> registration time, not to the caller.

Introduce a third navigation verb, INav.navBottomBar(route), that sets
a skipSlideAnimation flag on Nav before performing the same newStack-
style stack reset (popUpTo + launchSingleTop). composableFromEnd /
composableFromEndArgs read the flag in their enter/exit/popEnter/
popExit lambdas and return null when set, inheriting the NavHost-level
fade (matching what the five default bottom-bar routes already do).

nav.newStack behaviour and all drawer navigation paths are unchanged,
so non-bottom-bar callers (intent redirects in AppNavigation.kt and
every DrawerContent click) keep their slide.
2026-04-22 16:08:04 +00:00
Claude 64ab67a6fc fix(marmot): align MIP compliance tests with held-back v2 and signed commits
Two drifts surfaced as 7 failing tests on the jvmTest run:

1. MarmotGroupData CURRENT_VERSION is held at 2 for mdk-core interop
   (see the const-doc block), but three tests still assumed v3:
   - marmotGroupData_defaultVersionIsThree asserted the constant directly.
   - marmotGroupData_roundTripWithDisappearingSecs and
     buildGroupEvent_appendsExpirationTagWhenDisappearingConfigured relied
     on the default version emitting the v3-only disappearing_message_secs
     field, which the encoder correctly skips for v2.

   Rename the first to `currentVersionIsHeldAtTwoForMdkInterop` with a
   spec-cross-reference comment and an extra assertion that v3 is still
   in SUPPORTED_VERSIONS. Pin version=3 explicitly in the other two so
   they exercise the v3 path regardless of the interop hold-back.

2. MlsGroup.processCommit now requires a non-empty FramedContentTBS
   signature on non-external commits (per RFC 9420 §6.1, introduced in
   0c970945). Four pipeline tests still called the raw processCommit
   overload with signature=ByteArray(0), so they all failed with
   "FramedContentTBS signature missing on commit from leaf 0".

   Add a `processFramedCommit` wrapper on MlsGroupManager that mirrors
   MlsGroup.processFramedCommit + the retention/persistence bookkeeping
   from processCommit, and switch the four tests to feed framedCommitBytes
   through it. MarmotInboundProcessor is unaffected (it already has the
   decoded PublicMessage fields).

All 7 originally-failing Marmot tests now pass; the 4 unrelated
NostrClient network tests remain independently flaky.

https://claude.ai/code/session_01XQNAmwn1y87GAoK94QWgyn
2026-04-22 16:04:59 +00:00
Vitor Pamplona 491c58dbfb Merge pull request #2497 from vitorpamplona/claude/review-marmot-giftwraps-xdwC2
Fix external join path encryption and add processFramedCommit
2026-04-22 11:31:22 -04:00
Vitor Pamplona 12630d06d3 Merge pull request #2496 from vitorpamplona/claude/fix-amethyst-relay-tests-EqhSz
Improve sanity checks and build resilience in marmot-interop
2026-04-22 11:30:14 -04:00
Vitor Pamplona 57ff68895f Fixes iconography inconsistency 2026-04-22 11:22:01 -04:00
Claude f9c6a4711f fix(quartz/mls): correct MLS commit framing on send and receive
Two bugs in MlsGroup were blocking 12 of 209 jvmTest cases (all RFC 9420
interop vectors already passed; only our higher-level lifecycle code
diverged from the spec).

Bug A — write/read asymmetry on member commits. processCommit requires
signature + confirmation_tag as separate parameters, but CommitResult
only exposed framedCommitBytes (the full PublicMessage envelope). Tests
called processCommit(commitBytes, ..., ByteArray(0)) and immediately
hit "FramedContentTBS signature missing on commit from leaf N".

Add MlsGroup.processFramedCommit(framedCommitBytes) that unpacks the
MlsMessage(PublicMessage(Commit)) envelope, verifies membership_tag for
member senders (RFC 9420 §6.2), and delegates to processCommit. Mirrors
the unwrap MarmotInboundProcessor already does in production. Updates
the 9 affected tests to use the new entry point.

Bug B — externalJoin used the wrong HPKE info bytes. The joiner
encrypted UpdatePath path-secrets against groupContext.toTlsBytes()
(pre-mutation), but receivers HPKE-Open against the post-mutation
context (epoch+1, new tree_hash) per RFC 9420 §7.6. Result:
AEADBadTagException on every external join.

Refactor externalJoin to follow the same staging pattern as commit():
stage public keys, applyUpdatePath, patch parent_hash, replace
placeholder leaf — then compute pathEncContextBytes from the post-
mutation tree, then HPKE-encrypt path-secrets with that context.

To make the framed pipeline available to external commits as well,
introduce ExternalJoinResult exposing both commitBytes and
framedCommitBytes (PublicMessage with sender = new_member_commit, no
membership_tag). processFramedCommit resolves NEW_MEMBER_COMMIT senders
to the receiver-side leaf index using the same first-blank-or-append
algorithm as RatchetTree.addLeaf, so wire decoding doesn't need to
carry the joiner's chosen index in the empty Sender field.

After: 209/209 MLS tests pass.
2026-04-22 15:20:29 +00:00
Claude fc38c4cb5d fix(marmot-interop): repair hunk count in skip-retry patch + harden preflight
Two harness-side bugs that made a broken wn silently look like a working one:

1. whitenoise-skip-unprocessable-retry.patch's hunk header declared
   `@@ -178,7 +178,23 @@` but the new side only has 22 lines. GNU patch
   bails with "malformed patch at line 26" and leaves the target file
   untouched. Fix the count to +178,22 so the patch actually applies.

2. setup.sh's patch-apply loop piped `patch` through `tee`, which
   masked patch's exit code behind tee's. A miscounted hunk therefore
   still touched the `.headless-patched-*` marker and the next run
   skipped the "patch" step entirely, so the resulting wn ran the
   stock 10-retry exponential backoff on every MlsMessageUnprocessable
   — ~17min per event, which pegs the per-account serial event
   processor and makes every later test timeout "just because".
   Check patch's real exit status; fail preflight loudly instead.

Same class of bug was also hiding cargo build failures: the
`cargo build ... | tee` pipeline reported success even when rustup
couldn't fetch the toolchain manifest (503 from static.rust-lang.org)
or cargo couldn't hit the crates.io index (503 from fastly). setup.sh
then `info`-logged the expected binary paths and happily moved on —
until the daemon launch tripped over `nohup: No such file or directory`.

Now each cargo build runs in a 4-attempt retry loop that terminates
only when the binary actually exists on disk, and fails preflight
loudly if it never materialises. Matches the jitpack 503 retry
behaviour already in place for `:cli:installDist`.

https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
2026-04-22 15:14:04 +00:00
Claude d65b4b0e5d fix(marmot-interop): retry gradle :cli:installDist on transient 503s
jitpack.io and dl.google.com both return 503 on a non-trivial fraction
of cold-cache fetches, and Gradle disables the whole repository for the
rest of the build the moment a single 503 lands — so one bad roll
aborts the entire harness before any test gets to run. Wrap the gradle
invocation in a 4-attempt retry loop; each attempt resumes from
Gradle's local cache so only the still-missing artifacts get re-fetched.

In practice the retry completes in seconds when jitpack is the only
flake, and the whole preflight still wall-clocks well under the first
attempt's runtime. Existing success path is unchanged.

https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
2026-04-22 15:06:40 +00:00
Claude d8d094db48 fix(marmot-interop): parse post-v0.2 wn --json shape in interactive harness
The `.result` wrapper fix landed in lib.sh's pollers and the headless tests
back in f08b010f, but the 8 inline `jq` reads in marmot-interop.sh were
missed — which silently returned empty against current `wn` and made every
UI-verification test look like "Amethyst didn't propagate X" when in fact
the underlying wn state was correct and the script just couldn't see it.

All 8 reads now peel `(.result // .)` the same way the lib.sh helpers do,
staying backwards-compatible with the pre-v0.2 flat shape:
  * test 02, 04 member lists
  * test 06 member-removed check
  * test 07 group-name poll
  * test 08 admin list (promote + demote)
  * test 09 anchor message lookup
  * test 11 leave-group member check

Also split the configure_relays sanity check into three relay-level
surfaces so a broken relay set points at the specific kind it can't
handle, instead of 10 later tests all timing out with the same "no
invite" symptom:

  * kind:30443  (KeyPackage)       — both sides publish + symmetric discover
  * kind:10050  (Inbox advertise)  — C reachable as giftwrap target
  * kind:1059   (Gift wrap)        — B->C welcome delivery
  * kind:445    (MLS commit/msg)   — real group-message round-trip

And drop the orphan whitenoise-mdk-plaintext-policy.patch: it lived in
`headless/patches/` but was never referenced by setup.sh's patch array,
and if it ever were applied it would flip mdk-core's MLS wire-format
from MIXED_CIPHERTEXT to MIXED_PLAINTEXT — a protocol-breaking
divergence from every stock whitenoise-rs / White Noise build. Removing
it eliminates the trap of a future contributor "enabling" it.

https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
2026-04-22 14:59:44 +00:00
Vitor Pamplona 4499114ab2 Merge pull request #2495 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-22 09:58:48 -04:00
Crowdin Bot 20cf8fb3bf New Crowdin translations by GitHub Action 2026-04-22 13:56:50 +00:00
Vitor Pamplona c21831bd3e Merge pull request #2481 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-22 09:56:01 -04:00
Vitor Pamplona e5dd70b12c Merge pull request #1983 from vitorpamplona/claude/always-on-notification-service-NuW9I
Add always-on notification service for real-time Nostr relay connections
2026-04-22 09:54:57 -04:00
Crowdin Bot 19ee474ab6 New Crowdin translations by GitHub Action 2026-04-22 13:51:58 +00:00
Vitor Pamplona 03f5f18894 Merge pull request #2494 from vitorpamplona/claude/clubhouse-event-listing-Kta46
Add MoQ-transport client and audio rooms support
2026-04-22 09:50:02 -04:00
Claude 6aa47c2fec chore(audio-rooms): hide drawer entry in release builds
Mirrors the Chess drawer entry pattern — `if (isDebug)` gates the
row. Audio rooms' Nostr surface (presence, hand-raise, chat) works,
but without the WebTransport audio backend the entry would bait
release-build users into a room they can't actually listen to.

One-line filter at the call site in DrawerContent.ListContent:

  val feedsItems = if (isDebug) DrawerFeedsItems
                   else DrawerFeedsItems.filter { it != NavBarItem.AUDIO_ROOMS }
  CatalogSection(..., feedsItems, ...)

Catalog entry + route + screen wiring all stay, so debug builds still
see it and the Nostr feature continues to be exercisable. When
`QuicWebTransportFactory.connect()` stops throwing NotImplemented the
two lines go away.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 13:44:29 +00:00
Vitor Pamplona 1d7f2e7e6d Merge pull request #2493 from vitorpamplona/claude/fix-marmot-interop-tests-3ZSSj
Fix MLS commit cryptography and add comprehensive validation
2026-04-22 09:42:41 -04:00
Claude 3ca613d64b fix(audio-rooms): hide audio-transport UI that has no backend yet
The branch already covers the Nostr-side of audio rooms (drawer
listing, 30312-event parsing, 10312 presence publishing, chat, hand-
raise). The audio-transport pieces (WebTransport + MoQ + Opus play-
back) are blocked on Phase 3b-2 (pure-Kotlin QUIC — see the plan in
`docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`).

The problem: the in-progress AudioRoomConnectionViewModel was
auto-calling connectNestsListener() on stage enter, and the stub
`KwikWebTransportFactory` throws `NotImplemented`. That surfaced as a
persistent red "Audio failed: WebTransport NotImplemented" chip
every time a user opened any audio room — scary, confusing, and
misleading.

Hiding the broken UI until the transport lands:

- Deleted `AudioRoomConnectionViewModel.kt`. The
  AudioRoomConnectionViewModel + ConnectionChip code is recoverable
  from git (commit `64b33674`) when Phase 3b-2 makes the underlying
  transport real.
- Removed the auto-connect LaunchedEffect + ConnectionChip render
  from AudioRoomStage.
- Removed the mute button. Pressing mute when we're not producing a
  mic stream would publish `["muted","0"|"1"]` on 10312 presence
  with no corresponding audio signal — misleading to peers. The
  builder-side support for the muted tag stays in
  MeetingRoomPresenceEvent (Quartz) for future use; the UI will
  re-add the button when audio capture actually runs.
- `publishPresence(..., muted = null)` so the muted tag is elided
  from the broadcast event entirely while the feature is hidden.
- Dropped the unused strings (audio_room_mute, audio_room_unmute,
  audio_room_conn_*) from strings.xml.

What still works on the branch (verified):
- Audio Rooms drawer entry lists kind 30312 rooms.
- Tap a room → live-activity channel screen with the audio-room
  stage overlay showing title, summary, host+speaker avatars,
  audience avatars.
- Kind 1311 chat (read + send) via the existing channel infra.
- Kind 10312 presence published on enter + every 30 s with the
  correct `a`-tag and `["hand","1"|"0"]` reflecting local state.
- Hand-raise button toggles the `hand` tag — browser clients +
  other NIP-53 peers can see the signal and hosts can promote.

What's hidden until Phase 3b-2 lands:
- WebTransport connection attempt, connection chip, mute button.
  Re-enabling them is additive: resurrect AudioRoomConnectionViewModel
  from git, paste three LaunchedEffect / DisposableEffect /
  ConnectionChip blocks back into AudioRoomStage, restore the five
  string resources, restore the mute button + the two-arg muted
  presence call. Zero protocol impact on anything else.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 13:38:35 +00:00
Claude 816d253a65 test(marmot): welcome tree_hash + confirmation_tag + negative processCommit coverage
Quartz:
- processWelcome now enforces hash(ratchet_tree) == GroupContext.tree_hash
  and verifies the joiner-derived confirmation_tag matches GroupInfo's,
  closing the gap where a tampered but-signed GroupInfo could diverge
  the joiner's epoch secrets.
- externalJoin does the equivalent tree_hash check.
- Promoted constantTimeEquals to a file-level helper so the companion
  object (processWelcome) can use it alongside instance methods.

Tests:
- New MlsGroupNegativeTest exercises every authenticity knob on inbound
  commits: tampered confirmation_tag, bit-flipped signature, spoofed
  senderLeafIndex, wrong wire_format, empty confirmation_tag, and
  tampered/missing membership_tag — each must throw and leave the
  receiving group's epoch unchanged (atomic-rollback regression cover).
- Fixed stale jvmAndroidTest references to the removed `selfRemove()`
  helper — use `buildSelfRemoveProposalMessage()` (now Pair-returning).

Interop harness:
- Adds tests 14–16 as inverted-role scaffolding (wn drives, amy receives).
  Currently recorded as SKIP with explicit notes:
    14 / 15 block on filtered-direct-path UpdatePath support in
    quartz's applyUpdatePath (RFC 9420 §7.7 path compression).
    16 blocks on a createdAt-sorted KeyPackage fetch path — the current
    fetchFirst-based fetcher is non-deterministic on relay order.
  Preserves the test functions so they start running the moment those
  gaps are closed.
2026-04-22 13:32:43 +00:00
Claude ac1e751bec docs: draft NIP-XX — Interactive Audio Rooms Join Protocol
Captures the gap between NIP-53's room discovery and what audio-capable
clients/servers must actually agree on to interop. NIP-53 defines the
30312 event but leaves the HTTP control plane + MoQ namespacing +
audio codec params entirely to individual implementations. With Nests
going generic-server, this is the moment to standardize.

What the draft covers:

1. HTTP control plane:
   - Path convention: GET <service>/<d-tag>
   - NIP-98 `Authorization: Nostr <base64>` header required
   - JSON response shape (endpoint, token, transport, codec,
     sample_rate, frame_duration_ms, moq_version)
   - Canonical error-status map (401/403/404/410/503)
2. WebTransport + MoQ handshake requirements (Extended CONNECT,
   required HTTP/3 settings, Bearer token passing).
3. MoQ track naming — vendor-neutral one-element namespace
   `[<d-tag>]` with track-name = speaker-pubkey-hex. Explicit
   rejection of the `["nests", <d-tag>]` prefix for new deployments.
4. Audio object format: raw Opus packets (no Ogg, no TOC), 48 kHz
   mono, 20 ms default, mono PCM 16-bit decode target. Both
   OBJECT_DATAGRAM and STREAM_HEADER_SUBGROUP accepted; listeners
   MUST handle both.
5. Per-track access control: server MUST verify publishing pubkey is
   a host/speaker in the current 30312 event (which is replaceable —
   revocation cascade is spec'd).
6. Leave procedure (UNSUBSCRIBE, UNANNOUNCE, final 10312 presence,
   WT_CLOSE_SESSION capsule).
7. Presence extension: the `["muted", "1"|"0"]` tag we already ship
   in nestsClient's MeetingRoomPresenceEvent overload, promoted from
   Amethyst-specific to NIP-defined.
8. Server + client requirements summaries.
9. Known divergences from current nostrnests/nests servers (two-
   element `["nests", <d-tag>]` namespace + `/api/v1/nests/<d-tag>`
   path) with a transition strategy via a `"nip_xx": true` flag in
   room-info responses.
10. Security considerations (bearer-token handling, NIP-98 `u` tag
    binding, audio-replay attack surface, server-impersonation VU
    meter recommendation).

Deliberately out of scope: E2E-signed audio objects (future NIP),
federation between audio servers, room recording/transcription.

Intended workflow: share with the Nests team while they're designing
the generic server, land changes against their feedback, then open a
PR on nostr-protocol/nips.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 13:27:18 +00:00
Claude 2b44e9b06b docs(quic-plan): lock in new top-level :quic module (Option A)
Updates the Architecture section to reflect the decision to house the
pure-Kotlin QUIC + HTTP/3 + WebTransport code in a new top-level
Gradle module `:quic`, sibling to `:quartz`, `:commons`, `:nestsClient`.

Key changes:

- Module placement: new KMP module `:quic` with commonMain +
  jvmAndroid + jvmMain + androidMain source sets (mirrors Quartz).
  `:quic` takes `api(project(":quartz"))` so all crypto primitives
  are in-scope without re-export.
- settings.gradle delta spelled out.
- Package layout shifts from
  `nestsClient/src/jvmAndroid/...transport/quic/` to
  `:quic/src/commonMain/com/vitorpamplona/quic/`, with UDP
  socket-specific bits in `jvmAndroid/`.
- Varint.kt migration called out: moves from
  `nestsClient/moq/Varint.kt` to `:quic` at
  `com.vitorpamplona.quic.Varint`. Mechanical, one commit.
- Rename KwikWebTransportFactory stub → QuicWebTransportFactory
  (real), living in `:quic` but implementing `:nestsClient`'s
  existing `WebTransportFactory` interface. AudioRoomConnectionViewModel
  changes one ctor call.
- Rationale section documenting why Option A beats putting it in
  `:nestsClient` (single responsibility / reusability / security
  boundary / test isolation / build graph / charter fit).
- Phase A now explicitly includes the module-creation +
  Varint-migration step; test suite must stay green across the move.

Timeline unchanged (17-19 weeks). Dependency story unchanged
(no external libs; everything piggybacks on Quartz).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 13:20:33 +00:00
Claude 0c9709455d fix(marmot): verify inbound commit auth + atomic processCommit
- Verify FramedContentTBS signature on inbound commits using the
  sender's pre-commit leaf signature key (rejects forged commits
  under a compromised exporter key).
- Verify membership_tag on inbound PublicMessage commits before
  advancing state.
- Check LeafNode lifetime bounds (notBefore..notAfter) on UpdatePath.
- Require confirmation_tag to be non-empty.
- Thread wire_format into buildCommitFramedContentTbs so signature
  verification reproduces the bytes the sender signed.
- Snapshot RatchetTree + groupContext + epochSecrets + initSecret +
  secretTree + interimTranscriptHash + pendingProposals + sentKeys
  at the top of processCommit, and restore on any throwable so a
  verification failure mid-apply can't leave the group diverged.
2026-04-22 12:27:52 +00:00
Claude 1ff951dbb4 docs(quic-plan): drop BouncyCastle — Quartz already has every primitive
Audited Quartz's crypto surface and found every primitive the QUIC
plan called out for BouncyCastle is already present in commonMain:

- `utils/ciphers/AESGCM.kt` — AES-GCM with AAD (QUIC-TLS AEAD)
- `nip44Encryption/crypto/ChaCha20Poly1305.kt` — pure-Kotlin AEAD
- `nip44Encryption/crypto/Hkdf.kt` + `utils/mac/MacInstance.kt` — HKDF
- `utils/sha256/Sha256.kt` — SHA-256
- `marmot/mls/crypto/X25519.kt` — X25519 ECDH (TLS 1.3 key exchange)
- `marmot/mls/crypto/Ed25519.kt` — Ed25519 signatures
- `utils/SecureRandom.kt`

Plan update highlights:
- "What we delegate" section rewritten to point at Quartz primitives.
- Phase B renamed from "TLS via BouncyCastle" to "TLS 1.3 client state
  machine on Quartz primitives"; bumped from 2 to 3 weeks because we
  write the state machine ourselves, but eliminates the entire
  BC-adapter integration risk.
- "Dependencies to add" section zeroed out — nothing goes in
  gradle/libs.versions.toml. The only new primitive is a thin
  HKDF-Expand-Label helper on top of existing `MacInstance`, which
  we can upstream to Quartz's `Hkdf` as a general `expand(prk,
  info, length)`.
- Risk table rewritten: removed BC-specific risks, added
  Quartz-specific ones (verify `X25519.dh` against RFC 7748 vector
  on Phase B day-1).
- Cert chain signature verification uses JDK `Signature` for RSA +
  ECDSA plus Quartz `Ed25519` for Ed25519 leaves.

Total timeline moves from 16-18 weeks to 17-19 weeks — same band,
but with zero external dependencies and zero Maven-resolution risk.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 12:25:58 +00:00
Claude 40308dc917 docs: pure-Kotlin QUIC + WebTransport implementation plan
Captures the plan to unblock Phase 3b-2 (the WebTransport handshake)
by writing a Kotlin QUIC client rather than depending on a Java QUIC
library. Triggered by exhausting the off-the-shelf options:

- tech.kwik:* coords don't exist on Maven Central we can resolve.
- Netty incubator HTTP/3 needs an Android quiche-native that isn't
  published.
- Cronet doesn't expose WebTransport.
- WebView JS bridge rejected by product.

The plan delegates TLS 1.3 + crypto primitives to BouncyCastle (bcprov
+ bcpkix already cached, bctls to add) and has us writing the QUIC
packet/frame/state-machine layer + HTTP/3 + WebTransport on top.

Realistic estimate: 16-18 weeks one developer full-time, or 5-6
months at a normal cadence, plus a security review before shipping.

Hard abandonment trigger documented: if the RFC 9001 Appendix A
Initial-packet test vectors don't bit-match by end of Phase D
(~6 weeks in), abandon and wait for an Android-compatible upstream.

The plan is committed as a doc rather than executed in this session
because each phase is multi-week and would block the rest of the
audio-rooms feature in the meantime.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 08:35:09 +00:00