Commit Graph

11770 Commits

Author SHA1 Message Date
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
Claude 320784baf0 chore(debug): remove MarmotInboundProcessor diag prints
13/13 passing; the System.err.println trace in
processPrivateMessage/applyCommit that we added to diagnose the
wire_format / admin / SelfRemove-proposal bugs is no longer needed.
Logging via the existing Log.d("MarmotDbg") calls is preserved.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 08:32:28 +00:00
Claude 4dde5d6342 test(marmot-interop): promote B before B's rename in test 07
Test 07 second half exercises a rename from the wn side, but
GROUP_02 is created by amy, so amy is the sole admin. wn's
ensure_account_is_group_admin silently refuses B's rename and
never publishes anything. The test design assumed a symmetric
admin set; the MIP-01 admin check makes that explicit.

Add an `amy marmot group promote <B>` step between amy's rename
and B's rename so B is admin when it tries, matching the real
round-trip intent. Also sleep 3s to let wn apply the promote
commit before firing the rename.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 08:26:48 +00:00
Claude 72d1793a25 fix(marmot): send SelfRemove as standalone PublicMessage proposal
Reverts the SelfRemove-as-commit approach. openmls rejects a
commit whose only proposal is a SelfRemove from the committer with
RequiredPathNotFound — the spec model is that the removing member
publishes a STANDALONE proposal, and an admin receiver
(wn's mdk auto-commit path) folds it into their next commit.

Add `MlsGroup.buildSelfRemoveProposalMessage()` which frames
Proposal.SelfRemove as `MlsMessage(PublicMessage{proposal})` with
the proper FramedContent / signature / membership_tag and returns
the ready-to-publish bytes + the pre-commit exporter key for outer
encryption. Rewire `MlsGroupManager.leaveGroup` and
`MarmotManager.leaveGroup` to use it.

Target: test 11 (amy leave → B auto-commits SelfRemove →
removes amy from member list).

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 08:21:23 +00:00
Claude d68c6a2fe7 fix(marmot): SelfRemove uses IANA proposal type 0x000A (not 0xF001)
SelfRemove is standardized in draft-ietf-mls-extensions with
IANA-registered proposal type 0x000A. openmls and mdk encode it
that way on the wire. Quartz was writing 0xF001 (Marmot private-use
range), so mdk's strict tls_codec read 0x000A for a known proposal
type it didn't recognize and the decoder drifted past the variant
body, surfacing later as
    Tls(DecodingError("Trying to decode Option<T> with 64 for option..."))
when it reached `Commit.path` with misaligned bytes.

Fixes test 11 (amy leave → B still sees A as member).

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 08:09:31 +00:00
Claude b01ee5336a fix(marmot): leaveGroup sends a SelfRemove-only commit, not a standalone proposal
Quartz's leaveGroup was returning `proposal.toTlsBytes()` and
publishing that raw on kind:445. That's not a valid MLS message
— it's just the proposal struct with no FramedContent / MlsMessage
framing — and even if it parsed, mdk/openmls would only STAGE the
proposal. The target never actually leaves the tree until someone
commits with that proposal.

MIP-03 explicitly allows non-admins to commit a SelfRemove-only
commit, so amy can produce a proper committable kind:445 herself
after the self-demote. Replace `selfRemove()` (returned raw bytes)
with `selfRemoveCommit()` which adds the proposal to the pending
set and runs the standard `commit()` path. Plumb the resulting
CommitResult through MarmotManager.leaveGroup so the outer
encryption uses `preCommitExporterSecret` (the epoch we're
leaving, which is the one every existing member still has).

This finally propagates amy's leave to B in test 11.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 07:46:05 +00:00
Claude c07f7baa14 docs(nestsClient): Phase 3b-2 Kwik integration plan in stub
Phase 3b-2 attempt. The honest version: I do not have network access
to verify Kwik's current Maven coordinates or test against a live
nests server, so committing speculative QUIC + Extended CONNECT code
risks breaking the build for everyone else without giving us
audible-audio-on-device confidence in return.

What I did instead: expanded the docstring on KwikWebTransportFactory
into a full integration playbook that the next person picking this
up can execute directly. It covers:

- Maven coordinates to verify (`tech.kwik:kwik-core` + `tech.kwik:flupke`,
  with `net.luminis.quic:kwik` as the legacy fallback group).
- The exact `gradle/libs.versions.toml` + `nestsClient/build.gradle.kts`
  edits to drop in once coords are confirmed.
- Step-by-step handshake sequence with the right HTTP/3 setting IDs:
  * SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441, 0x08)
  * SETTINGS_ENABLE_WEBTRANSPORT=1 (WT-H3 draft, 0x2b603742)
  * SETTINGS_H3_DATAGRAM=1 (RFC 9297, 0x33)
- Extended CONNECT pseudo-header set, including the legacy
  `sec-webtransport-http3-draft02 = 1` for older server compat.
- WebTransport stream-type prefix bytes (0x41 for client bidi, 0x54
  for client uni) + WT capsule type for graceful close (0x2843).
- Test strategy: validate against local nests-rs first, then the
  production `nostrnests.com`.
- Open questions (Flupke Extended CONNECT maturity, draft churn,
  dev-only self-signed-cert support, Android API surface).

Behavior unchanged: connect() still throws WebTransportException
with Kind.NotImplemented and a message pointing at this docstring.

When the real implementation lands, no upstream caller needs to
change — the AudioRoomConnectionViewModel from Phase 3d-3 will
auto-light up the connection chip from "Failed: NotImplemented" to
"Connected" and start producing audio through the Phase 3d-1
pipeline that's already wired.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:41:56 +00:00
Claude b62e3dd0ec feat(nestsClient): MoQ ANNOUNCE family + Opus encoder + AudioRecord capture
Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ
control messages and the Opus encode + microphone capture pieces a
speaker needs. The host-grants-speaker UI flow is deferred — that's
multi-screen UX that should be designed before being implemented.

commonMain (nestsclient.moq):
- 5 new MoqMessageType entries with codec round-trip + tests:
  * Announce (0x06): publisher offers a track namespace.
  * AnnounceOk (0x07): subscriber acknowledges.
  * AnnounceError (0x08): subscriber rejects with error code +
    reason phrase.
  * Unannounce (0x09): publisher withdraws a previously-announced
    namespace.
  * SubscribeDone (0x0B): publisher tells the subscriber no more
    objects are coming for this subscription, with stream count and
    reason.

commonMain (nestsclient.audio):
- New `OpusEncoder` interface — symmetric to `OpusDecoder`, one
  instance per outgoing track since Opus state is per-stream.
- New `AudioCapture` interface — `start()`, `readFrame()` returns a
  PCM frame or null when stopped, `stop()` releases the mic.

androidMain (nestsclient.audio):
- `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder
  variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms
  frames. Drains output queue per encode call.
- `AudioRecordCapture` — wraps `AudioRecord` from
  `MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's
  echo-cancellation + noise-suppression filters apply when available.
  Reads exactly one PCM frame per readFrame() call, retries on
  underrun, throws AudioException(DeviceUnavailable) on permission/
  resource failures.

commonTest:
- `AnnounceCodecTest` — 7 cases covering each message round-trip,
  concatenated decode in sequence, and a guard against accidentally
  reordering MoqMessageType enum codes.

Permission already declared:
- `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no
  manifest change needed.

What this does NOT include:
- A publisher loop class (analogous to AudioRoomPlayer for publish
  direction) — can be added when the speak-button wiring lands.
- The host-grants-speaker UI — needs design input.
- STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams
  cover the listener happy-path; streams add reliability that nests
  may or may not require.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:33:55 +00:00
Claude d0fb8ac788 fix(marmot): persist group state after inline PrivateMessage commit
PrivateMessage commits apply their Commit body inline inside
`MlsGroup.decrypt`, so the epoch advance + extension replace
happen in `MlsGroupManager.decrypt` — not in `processCommit`.
The old `decrypt` path never called `persistGroup`, so amy's
in-memory promotion (e.g. test 08 "B promotes A") looked correct
for the current CLI invocation but the NEXT `amy marmot …` command
reloaded the pre-commit extensions from disk and refused the
follow-up rename with "MIP-01: only admins may update group
extensions".

Detect epoch advance + COMMIT result inside `decrypt`, then push
the pre-commit exporter into the retained-epoch window and persist
the group so CLI reloads see the post-commit state.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 07:31:20 +00:00
Claude 64b3367472 feat: AudioRoomConnectionViewModel + connection chip in stage
Phase 3d-3 of the Clubhouse/nests integration. Wires the
nestsClient.connectNestsListener() facade into AudioRoomStage via a
dedicated Android ViewModel, and surfaces the listener's StateFlow
through a small assist chip so the user sees connection progress and
failure modes.

amethyst/audiorooms/room:
- New `AudioRoomConnectionViewModel` (Android `ViewModel`):
  * Owns one `NestsListener` + one `AudioRoomPlayer` per host/speaker.
  * `connect(event, signer)` resolves the room HTTP-side, opens
    WebTransport, runs MoQ SETUP, then subscribes to every host +
    speaker pubkey from the 30312 event and wires their Opus stream
    through `MediaCodecOpusDecoder` -> `AudioTrackPlayer`.
  * `disconnect()` is idempotent, also called from `onCleared()`.
  * Mirrors the underlying NestsListener.state into its own
    StateFlow so callers observe one source.
  * Audience members are skipped — they don't publish audio.
- `AudioRoomStage` now mounts the ViewModel keyed by the room
  address, auto-calls `connect()` in a LaunchedEffect, and
  disconnects in a DisposableEffect.
- New `ConnectionChip` composable renders the listener state with
  retry-on-tap for Idle / Failed / Closed, and color-codes Connected
  (primary) and Failed (error) states.

amethyst:
- Added `:nestsClient` to the project's dependencies.

res:
- New strings for the five connection-chip states.

Behavior today: on opening an audio-room, the chip will report
`Connecting (OpeningTransport)` then immediately
`Failed: WebTransport NotImplemented: ...` because the Kwik handshake
in `KwikWebTransportFactory` is the next phase. Tapping the chip
retries — same outcome until 3b-2 ships. Everything else (presence,
chat, hand-raise, mute) is unaffected.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:29:42 +00:00
Claude 0f3d4e04f2 fix(marmot): thread actual wire_format into ConfirmedTranscriptHashInput
RFC 9420 §8.2 says ConfirmedTranscriptHashInput carries the
wire_format of the AuthenticatedContent being committed, not a
hard-coded value. openmls/mdk pass mls_content.wire_format()
through, so B's PrivateMessage commits use wire_format=2 in their
transcript-hash input. Quartz always wrote wire_format=1
(PublicMessage), so amy's receiver-side new_confirmed_transcript_hash
diverged from B's — and every tag derived from it
(confirmation_key, confirmation_tag, epoch secrets) diverged too,
surfacing as "Confirmation tag verification failed" once my
error-surfacing fix stopped hiding it.

Plumb the real wire format through processCommit and its decrypt()
dispatch so PrivateMessage commits from B are hashed as
PrivateMessage on amy's side.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 07:23:06 +00:00
Claude c1355f1dd8 feat(nestsClient): NestsListener facade + connect orchestrator
Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.

commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
  `subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
  speaker's track as `["nests", <roomId>]` with the speaker's pubkey
  hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
  * Idle
  * Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
  * Connected(roomInfo, negotiatedMoqVersion)
  * Failed(reason, cause?)
  * Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
  connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
  roomId, signer, supportedMoqVersions)` — walks the three handshake
  steps. Each failure short-circuits to Failed with a clear reason
  string and the underlying cause attached; transport is torn down on
  partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
  free of a URL-library dependency. Handles default-port stripping,
  rejects userinfo, preserves query strings.

commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
  * Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
    asserts state lands on Connected with the right roomInfo +
    negotiated version, and verifies the transport saw the parsed
    authority/path/bearer.
  * resolveRoom failure short-circuits without touching transport.
  * Transport handshake failure short-circuits with the right kind in
    the reason.
  * Malformed endpoint URL short-circuits.
  * parseEndpoint covers default-port stripping, explicit port,
    pathless URL, query string preservation.

This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:

  state = MutableStateFlow(NestsListenerState.Idle)
  listener = connectNestsListener(...)
  for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
      AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)

…and audio plays.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:21:38 +00:00
Claude 6e6fc4cabb feat(nestsClient): listener audio pipeline (Opus decode + AudioTrack)
Phase 3d-1 of the Clubhouse/nests integration. Adds the listener-side
audio pipeline that turns a SubscribeHandle's Flow<MoqObject> into
audible PCM through Android's MediaCodec + AudioTrack. Encoder /
AudioRecord (speaker publishing) lands in Phase 4.

commonMain (nestsclient.audio):
- `AudioFormat` constants — 48 kHz mono signed-16-bit, 20 ms frames
  (960 samples), matching the nests Opus profile.
- `OpusDecoder` interface — stateful per-track decoder.
- `AudioPlayer` interface — start/enqueue/stop, suspend on backpressure.
- `AudioRoomPlayer` — wires a Flow<MoqObject> through OpusDecoder into
  AudioPlayer. Decoder errors are surfaced via an `onError` callback
  but don't tear down the loop (one bad packet shouldn't kill the
  room); player errors are fatal. play()/stop() are single-shot per
  instance, with stop() idempotent and double-release-safe.
- `AudioException` with three canonical kinds (DecoderError,
  DeviceUnavailable, PlaybackFailed).

androidMain:
- `MediaCodecOpusDecoder` — wraps `MediaCodec("audio/opus")` with the
  RFC 7845 §5.1 Opus identification header in csd-0 and zeroed
  pre-skip / seek-pre-roll in csd-1 / csd-2. Drains the output queue
  per-packet, handles INFO_OUTPUT_FORMAT_CHANGED gracefully.
- `AudioTrackPlayer` — wraps `AudioTrack` in MODE_STREAM with USAGE_
  VOICE_COMMUNICATION / CONTENT_TYPE_SPEECH so the OS treats audio-
  room playback like a phone call (call-volume rocker, ducks
  notifications). Buffer = 4× minimum so the producer can fall behind
  ~80 ms (roughly the WebTransport datagram jitter on mobile).

Tests (commonTest, fake-based):
- `AudioRoomPlayerTest` — 7 cases covering happy-path decode +
  enqueue, decoder failure with continued loop, stop idempotency,
  double-play rejection, play-after-stop rejection, empty-PCM
  skip-enqueue, and live channel-backed flow.

The real MediaCodec / AudioTrack code is thin glue against Android
APIs and is validated manually on device — no Robolectric here, the
seams are the Fake* doubles.

Next: Phase 3d-2 wires NestsClient end-to-end (HTTP auth → MoqSession
listener subscribe → AudioRoomPlayer) plus the Amethyst-side
AudioRoomViewModel. After that the only remaining pure-MoQ work is
the Kwik handshake (Phase 3b-2).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:12:25 +00:00
Claude 0e23f2cde5 fix(marmot): surface real decrypt exception instead of stale epoch echo
`MlsGroupManager.decrypt` used to swallow the current-epoch exception
via `decryptOrNull`, try retained epochs, then retry `group.decrypt`.
After our inline-processCommit change this means a mid-commit throw
leaves the group half-advanced, and the retry then reports a
misleading "Message epoch N doesn't match current epoch N+1" —
masking the real cause (unable-to-decrypt path, path-mismatch, etc.).

Swap the order: call `group.decrypt` directly first, capture any
exception, fall through to retained epochs, and re-raise the original
exception if every epoch fails. Retains same semantics for messages
that DO decrypt on the first try.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 07:12:22 +00:00
Claude 4e00df7344 chore(debug): route MarmotInboundProcessor diag prints to stderr
`amy_json` captures stdout for JSON output; stdout prints were
swallowed by command substitution. Switching to System.err.println
routes the diag trace to the harness log.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 07:00:04 +00:00
Claude b091b50fe6 feat(nestsClient): MoQ session pump + subscribe API
Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession
from a one-shot SETUP runner to a real concurrent session: the
control-stream and datagram pumps run on a caller-supplied scope
after the handshake, and a public subscribe()/unsubscribe() API
delivers per-track OBJECT_DATAGRAMs as a Flow<MoqObject>.

commonMain (nestsclient.moq):
- MoqSession now takes a `pumpScope: CoroutineScope` so callers can
  bind the pumps to their lifecycle (test backgroundScope, viewmodel
  scope, etc.).
- After setup() succeeds, two background coroutines start:
  * Control-stream pump — buffers chunks across multiple writes,
    decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR
    to the matching CompletableDeferred, drops malformed frames
    without killing the session.
  * Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the
    matching per-track sink keyed by track_alias.
- `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE,
  awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR
  with the publisher's reason), and returns a SubscribeHandle whose
  `objects` flow emits inbound OBJECT_DATAGRAMs.
- `unsubscribe(subscribeId)` is idempotent; second call is a no-op.
  close() cancels pumps + tears down all in-flight subscribes cleanly.
- Per-track sink uses a bounded Channel with DROP_OLDEST overflow
  rather than MutableSharedFlow, so objects buffered between
  subscribe() returning and the consumer attaching are preserved
  (SharedFlow with replay=0 silently drops pre-subscription emissions).

transport (FakeWebTransport):
- Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()`
  during setup followed by a long-running pump `.collect{}` works on
  the same channel without the first call closing it.

Tests:
- Updated existing setup tests for the new `pumpScope` parameter.
- New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order`
  end-to-end: client subscribes, raw server peer (no MoqSession to
  avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams,
  client's flow yields all three in order with correct payloads.
- `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`.
- `datagrams_for_unknown_track_alias_are_dropped_silently` ensures
  the pump doesn't crash on orphan datagrams and the session stays
  closeable.

This completes the pure-Kotlin MoQ work. Remaining 3.x phases all
touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the
real WebTransport handshake (Phase 3b-2 with Kwik).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 06:58:52 +00:00
Claude c222a5d50b chore(debug): temporary prints in MarmotInboundProcessor
Tracing which path B→A commits take (processPrivateMessage vs
applyCommit) and why past-epoch PrivateMessage events surface
as Failure instead of Duplicate in tests 07/08/11/12. Will be
reverted once diagnosis is complete.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 06:55:58 +00:00
Claude 647acb5909 fix(marmot): short-circuit past/future PrivateMessage commits
Extend the past-epoch dedup check to PrivateMessage commits too.
Previously only the PublicMessage branch returned
`GroupEventResult.Duplicate` for a stale commit echo; the PrivateMessage
branch called `groupManager.decrypt()` directly, which consumed a
generation on the sender's ratchet and then failed with
`Message epoch X doesn't match current epoch Y` — polluting the harness
log and (worse) burning the real commit's generation slot.

Peek the epoch from the parsed PrivateMessage before touching the
secret tree: past-epoch → Duplicate, future-epoch → Error, same-epoch
falls through to the existing decrypt-and-dispatch path.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:59:04 +00:00
Claude e9df0155c1 feat(marmot): accept PrivateMessage commits (handshake ratchet + dispatch)
MDK-core (openmls) defaults group configuration to
`MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY` — outgoing commits / proposals
ship as PrivateMessage. Quartz only handled PrivateMessage for
ContentType.APPLICATION; any handshake message came in via the
application ratchet, consumed the first application generation on
whatever sender sent an app message next, and then died with
`Generation 0 already consumed` the moment the receiver saw the real
PrivateMessage commit. That's why every A-side processing of B's
rename / promote / remove / leave commit timed out.

Fix:
  1. SecretTree grows a parallel `handshakeKeyNonceForGeneration`
     path, using the per-sender `handshakeSecret` / `handshakeGeneration`
     the struct already tracked — with its own skipped-keys cache and
     replay-detection set so handshake and application generations
     never clash.
  2. `MlsGroup.decrypt()` dispatches on the PrivateMessage's
     `content_type` BEFORE consuming any ratchet — COMMIT and PROPOSAL
     take the handshake path; APPLICATION stays on the existing
     application path.
  3. COMMIT bodies decode as `Commit || signature<V> || confirmation_tag<V>
     || padding` (RFC 9420 §6.3.1) and route into `processCommit` inline,
     so the caller just sees the epoch advance — no new public API.
     PROPOSAL still rejects (standalone proposals aren't used by
     Marmot flows yet) with a clear message instead of a cryptic
     generation-consumed error.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:52:23 +00:00
Claude 94c5e0e833 chore(cli): surface ingest Failure message in harness log
The interop harness prints `[cli] ingest <kind>/<id> via <relay> -> Failure`
for any commit/proposal/app message that quartz can't process. Without
the error string it's impossible to tell `confirmation_tag mismatch` from
`parent_hash invalid` from `commit references unknown proposal` without
reading the Kotlin stack. Append `result.message` for Failure outcomes so
the harness log is self-contained when diagnosing quartz→quartz errors
(the openmls side already has `{e:?}` via the mdk-core patch).

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:40:14 +00:00
Claude 9ae07894c7 fix(marmot): sign commits under pre-proposal extensions (fix InvalidMembershipTag)
For a GroupContextExtensions commit, openmls signs the
`FramedContentTBS` over `group.public_group.group_context()` — the
UNMUTATED context, before the `diff` applies the GCE proposal. The
post-proposal extensions only make it into the HPKE path-encryption
context and the subsequent key schedule, not into the TBS / signature /
membership_tag.

Quartz's `applyProposal` handler for `GroupContextExtensions` mutates
`groupContext.extensions` inline — by the time `commit()` captured
`preCommitContextBytes`, the extensions were already updated. The
signature and membership_tag were then minted over the post-GCE context,
and openmls's `verify_membership` recomputed the MAC over the pre-GCE
context and reported `ValidationError(InvalidMembershipTag)` on every
cross-impl rename / promote / demote / leave.

Fix: snapshot `groupContext.extensions` before any proposal is applied,
and rebuild `preCommitContextBytes` with those original extensions. The
path-encryption context and post-commit key schedule still use the
updated extensions, matching openmls.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:35:09 +00:00
Claude 8313c4a584 fix(marmot): trim trailing blank leaves after removeLeaf
RFC 9420 §7.8: the ratchet tree has no trailing blank leaves — when a
Remove blanks the rightmost leaf, everyone MUST shrink `leaf_count` and
drop the (now-orphaned) parent nodes so future `direct_path`,
`resolution`, and `treeHash` computations see the new shape.

Quartz was blanking the leaf and its direct path but leaving
`_leafCount` untouched. Openmls / mdk shrink, so after
e.g. `amy removes C (leaf 2)` quartz still had `leafCount = 3` while
B's openmls had `leafCount = 2`. Amy's `direct_path` from leaf 0 then
had two entries (to the 3-leaf-tree root), but B's tree layout expected
one — and openmls rejected the commit with
`UpdatePathError(PathLengthMismatch)`.

Fix: after blanking the leaf + direct path, walk `leafCount` down past
any trailing blanks and truncate the `nodes` list to `nodeCount(leafCount)`.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:28:32 +00:00
Claude d65ab7b616 feat(nestsClient): MoQ SUBSCRIBE family + OBJECT_DATAGRAM codecs
Phase 3c-2 of the Clubhouse/nests integration. Extends the MoQ
control-plane codec with the subscribe lifecycle messages (SUBSCRIBE,
SUBSCRIBE_OK, SUBSCRIBE_ERROR, UNSUBSCRIBE) and adds the
OBJECT_DATAGRAM wire format the listener uses to receive low-latency
Opus audio. Pure codec layer — no session-pump or network changes;
the subscribe/object delivery Flow arrives in Phase 3c-3.

commonMain (nestsclient.moq):
- New message types in `MoqMessageType`: Subscribe (0x03),
  SubscribeOk (0x04), SubscribeError (0x05), Unsubscribe (0x0A).
- New data classes in MoqMessage.kt:
  * `TrackNamespace` — tuple of byte strings, with `of(vararg String)`
    helper for the common UTF-8 case.
  * `SubscribeFilter` enum. Phase 3c-2 supports LatestGroup /
    LatestObject; AbsoluteStart/AbsoluteRange throw at construction
    (they need extra wire fields we'll add when nests needs them).
  * `Subscribe`, `SubscribeOk`, `SubscribeError`, `Unsubscribe` data
    classes. `SubscribeOk` validates contentExists + largest-id
    coupling at construction.
- New codecs in `MoqCodec` (namespace tuple + all four messages).
  Unknown filter codes on the wire are rejected.
- `MoqObject` data class + `MoqObjectDatagram` encode/decode for the
  OBJECT_DATAGRAM wire format (track_alias, group_id, object_id,
  publisher_priority, status, payload).

Tests:
- `SubscribeCodecTest` — round-trips for each message with multi-
  segment namespaces + parameters, SUBSCRIBE_OK with and without
  contentExists, concatenated decode in sequence, unknown-filter
  rejection, construction-time validation.
- `MoqObjectDatagramTest` — Opus-sized payload round-trip, zero-
  length payload with OBJECT_DOES_NOT_EXIST status, 8-byte-varint
  boundary coverage, truncated datagram rejection, out-of-range
  priority rejection.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:26:53 +00:00
Claude c50fd18cb5 fix(marmot): derive commit_secret one step past the root path_secret
RFC 9420 §9.2: `commit_secret = DeriveSecret(path_secret_at_root, "path")`.
Openmls / mdk implement this exactly — after deriving the ratchet-tree's
own path_secrets up to (and including) the root, they advance one more
step and use THAT as the commit_secret contribution to the new epoch's
key schedule.

Quartz was using the root's own path_secret as commit_secret on both
the encryption side (MlsGroup.commit) and the decryption side
(MlsGroup.processCommit), plus in externalJoin's commit construction.
Internally consistent, so quartz↔quartz worked; but every cross-impl
commit (quartz→openmls or openmls→quartz) derived a different
epoch_secret, cascaded into a different confirmation_key, and failed at
`InvalidCommit(ConfirmationTagMismatch)` — the blocker behind every
test that actually needed an openmls peer to accept a quartz-produced
commit or vice-versa once the UpdatePath-layer bugs were out of the way.

Fix: add one `DeriveSecret(pathSecrets.last().pathSecret, "path")` step
on the sender side, and one extra `DeriveSecret(..., "path")` past the
root on the receiver side, mirroring openmls's `ParentNode::derive_path`
loop post-condition.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:26:09 +00:00
Claude 37e24ce4f3 feat(nestsClient): MoQ varint + SETUP handshake codec
Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport
control-plane codec (draft-ietf-moq-transport) and a session wrapper
that runs the SETUP handshake over any WebTransportSession. Purely
commonMain + fully tested end-to-end against FakeWebTransport — no
network or Kwik dependency required.

commonMain (nestsclient.moq):
- `Varint` — QUIC variable-length integer codec per RFC 9000 §16.
  Encode/decode/size, with typed truncation handling (decode returns
  null so callers can buffer more and retry).
- `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked
  reader used by the message codec.
- `MoqCodecException` — typed error for malformed frames.
- `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`.
  Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types
  0x40 / 0x41).
- `MoqCodec.encode/decode` — wraps payload with `type (varint) +
  length (varint)`. Rejects unknown types and trailing bytes inside a
  declared payload window.
- `MoqSession.client/server` — attaches to a WebTransportSession and
  runs the CLIENT_SETUP / SERVER_SETUP handshake with version
  negotiation + configurable timeout.
- `MoqVersion` constants for draft-11 and draft-17.

Tests:
- `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte),
  boundary round-trips, negative/overflow rejection, short-buffer
  returns null, bytesConsumed accuracy.
- `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and
  multi-param), multi-version negotiation, exact wire layout for
  ServerSetup(1L), truncated-frame returns null, concatenated frames
  decode with offset, unknown type rejected, trailing bytes rejected,
  zero-length parameter values allowed.
- `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both
  sides happy path, client falling back to older version, server
  rejecting when no version overlap + client timing out cleanly).

SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in
Phase 3c-2 on top of the same MoqSession.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:21:15 +00:00
Claude 0163d7e75a fix(marmot): fold real signature into ConfirmedTranscriptHashInput
RFC 9420 §8.2: the confirmed_transcript_hash for a Commit is taken over
`wire_format || FramedContent || signature` — where `signature` is the
real `FramedContentAuthData.signature`, not an empty placeholder.

Quartz was hashing over a zero-length signature, so the committer and
every receiver baked DIFFERENT `confirmed_transcript_hash` bytes into
their new GroupContext. The committer's confirmation_tag — MAC'd over
that value — never matched the receiver's recomputation, and openmls
rejected the commit with `InvalidCommit(ConfirmationTagMismatch)`.

Fix restructures the commit flow so the FramedContentTBS is built and
signed BEFORE the transcript hash update:
  1. extract `buildCommitFramedContentTbs()` as a shared helper
  2. in `MlsGroup.commit()`, sign the TBS immediately after building
     the commit, then pass that signature to
     `buildConfirmedTranscriptHashInput(..., signature)`
  3. thread the signature through `framePublicMessageCommit` as a
     parameter (callers already have it) instead of re-signing inside

`processCommit` also takes the signature and forwards it to
`buildConfirmedTranscriptHashInput`; `MarmotInboundProcessor.applyCommit`
pulls `pubMsg.signature` out of the `PublicMessage` envelope and passes
it through `MlsGroupManager.processCommit`.

Also switches `RatchetTree.derivePathSecrets` from a custom
`expandWithLabel(nodeSecret, "hpke", ...)` derivation to HPKE's standard
`DeriveKeyPair(node_secret)` (RFC 9180 §7.1.3). Without this, the
receiver derives parent-node public keys that don't match the ones
quartz baked into `UpdatePathNode.encryption_key`, which manifested as
`UpdatePathError(PathMismatch)` once the HPKE context fix landed.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:16:03 +00:00
Claude 4ead4ccd5c feat(nestsClient): WebTransport abstraction + fake + Kwik stub
Phase 3b-1 of the Clubhouse/nests integration. Lands the
[WebTransportSession] abstraction the MoQ layer (Phase 3c) will code
against, so MoQ framing + tests can develop in parallel with the real
Kwik-based transport integration (deferred to Phase 3b-2).

commonMain:
- `WebTransportSession` — bidi/uni stream access, datagrams, close.
- `WebTransportBidiStream` / `WebTransportReadStream` /
  `WebTransportWriteStream` — minimal read/write surface.
- `WebTransportFactory.connect(authority, path, bearerToken)` for
  opening sessions.
- `WebTransportException(kind, ...)` with four canonical failure modes
  (HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI
  code doesn't need to know about library-specific exceptions.
- `FakeWebTransport.pair()` — in-memory, fully-wired client/server
  pair for unit-testing MoQ framing without touching a real QUIC stack.

jvmAndroid:
- `KwikWebTransportFactory` stub that throws
  `WebTransportException(NotImplemented)` at `connect()`. Doc spells
  out the handshake sequence (QUIC dial → H3 SETTINGS →
  `:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so
  Phase 3b-2 can drop the real implementation in without touching
  callers.

Tests:
- `FakeWebTransportTest` — datagram round-trip both directions,
  bidi-stream write visible on peer side, close() flips isOpen.
- `WebTransportExceptionTest` — kind + message + cause preserved.
- `KwikWebTransportFactoryTest` — `connect()` currently fails with the
  NotImplemented sentinel, so Phase 3b-2 can replace this test when
  the real handshake lands.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:10:28 +00:00
Claude dff22d7a55 fix(marmot): encrypt UpdatePath secrets under post-tree-mutation context
RFC 9420 §7.6 / openmls `compute_path`: after applying the Commit's
proposals and the committer's UpdatePath locally, the committer must
bump the epoch and recompute tree_hash, THEN serialize the GroupContext
(with the still-pre-commit confirmed_transcript_hash) and use those
bytes as the HPKE info for encrypting every path secret. Receivers
perform the exact same recomputation on their copy of the tree before
decrypting, so the AEAD keys line up.

Quartz was using the fully pre-commit GroupContext on both sides. Self-
consistent (quartz→quartz passed), but openmls/mdk re-derives the AEAD
key from the post-mutation context and the tag check fails, which
surfaced as `InvalidCommit(UpdatePathError(UnableToDecrypt))` on every
commit-produced-by-quartz that an openmls peer had to process (add,
rename, remove, leave, promote, catchup).

Fix restructures `MlsGroup.commit()` into stage→apply→encrypt:
  1. derive path-secret keypairs, stage UpdatePath entries with public
     keys only
  2. apply the staged path, patch parent_hashes, swap in the new leaf
  3. serialize an intermediate GroupContext (new epoch + new tree_hash,
     old confirmed_transcript_hash) and HPKE-encrypt each copath
     resolution under that info

`processCommit()` mirrors the change on the decryption side, and also
splits the proposal loop so Add proposals apply after non-Adds (matching
the committer's order and unblocking the new-leaf-exclusion filter for
the decryption resolution lookup).

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 03:05:18 +00:00
Claude 933b522273 feat(nestsClient): NIP-98 auth + nests room-info client
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.

Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
  Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
  `Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
  fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
  `<serviceBase>/<roomId>` with the signed NIP-98 header and returns
  the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
  shared between Android and desktop JVM.

Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
  unknown-field tolerance, missing-endpoint rejection, and URL
  construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
  base64 back through Quartz's JacksonMapper, and asserts the
  signature verifies and the url+method tags bind correctly.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 02:58:24 +00:00
Claude a853ac66b0 fix(marmot): exclude newly-added leaves from UpdatePath resolution
RFC 9420 §12.4.1: when a Commit contains Add proposals, the committer
MUST exclude the new leaves from the copath-resolution used for path-
secret encryption. Joiners get the group state via the Welcome at epoch
N+1; they neither need nor expect a path secret for epoch N.

Quartz was including them. openmls (via mdk-core) then runs its own
resolution-minus-exclusion-list when picking which ciphertext to
decrypt, so its `resolution_position` landed one slot off from where
quartz had put the ciphertext for the existing member. The AEAD tag
naturally mismatched and the commit died with
`InvalidCommit(UpdatePathError(UnableToDecrypt))` — the blocker behind
every test that needs an existing openmls member to process a commit
produced by quartz (add, rename, remove, leave, promote, catchup).

Fix: in `MlsGroup.commit()`, capture `newLeafIndices` from the Add
proposals applied in this epoch and `filterNot` them out of every
copath node's resolution before HPKE-encrypting the path secret.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 02:41:09 +00:00
Claude 0db80c66b8 feat: audio room stage with NIP-53 presence (kind 10312)
Phase 2 of the Clubhouse/nests integration: when the live-activity
channel screen is opened on a kind 30312 MeetingSpaceEvent, render an
audio-room "stage" above the chat that shows host/speaker/audience
avatars and exposes hand-raise + mute toggles backed by NIP-53 kind
10312 presence events.

Quartz:
- New MutedTag (`["muted","1"|"0"]`) and `muted()` builder helper for
  MeetingRoomPresenceEvent.
- New MeetingRoomPresenceEvent.build() overload that accepts a 30312
  MeetingSpaceEvent root (matching the existing 30313 overload) and
  optionally encodes hand-raise + mute flags in one call.

Amethyst:
- AudioRoomStage composable: filters participants from the 30312 event
  into hosts / speakers / audience, renders avatars, and publishes
  presence on enter + every 30 s while composed (on dispose it pushes
  one final lowered-hand presence so peers drop us before timeout).
- ChannelView wires AudioRoomStage in next to ShowVideoStreaming; the
  latter is a no-op for non-30311 events so non-audio rooms are
  unaffected.

No audio is captured yet — the mic toggle is a Nostr-only signal
until Phase 3 brings the MoQ/WebTransport transport.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 02:25:06 +00:00
Claude a07e22d7ab fix(marmot): sign + membership-tag PublicMessage commits
openmls (via mdk-core) strict-verifies both the FramedContentAuthData
signature and the membership_tag on every inbound PublicMessage from a
Member sender (RFC 9420 §6.1, §6.2). Quartz was framing commits with
empty placeholders for both fields, so any existing member processing a
quartz-originated commit tripped `openmls::ciphersuite: Incompatible
values` (constant-time compare of a zero-length tag against the 32-byte
expected HMAC). That's the reason every interop test that requires B to
process a commit from amy (add-member, rename, remove, leave, promote,
catchup) was failing at step one.

Fix computes both fields correctly:
  * capture pre-commit groupContext bytes, membership_key, and
    signingPrivateKey before the key schedule / epoch advance
  * build FramedContentTBS = version || wire_format || FramedContent ||
    serialized_context and SignWithLabel("FramedContentTBS", ..) with
    the committer's pre-commit leaf signing key
  * build AuthenticatedContentTBM = TBS || signature<V> ||
    confirmation_tag<V> and HMAC-SHA256 it with the pre-commit
    membership_key

No change to quartz's own processCommit path (it doesn't re-verify these
fields on inbound), so this is a one-way fix for outbound to openmls
peers.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 02:22:09 +00:00
Claude 1fd89b23e5 feat: audio rooms drawer listing (NIP-53 kind 30312)
Adds a new "Audio Rooms" entry in the drawer feeds section that lists
NIP-53 kind 30312 (Interactive Rooms / audio spaces) events, mirroring
the existing Live Streams architecture.

- AudioRoomsFeedFilter narrows LocalCache.liveChatChannels to
  MeetingSpaceEvent (30312) and MeetingRoomEvent (30313), sorting by
  OPEN > PRIVATE > CLOSED then by follow participation.
- AudioRoomsFilterAssembler/SubAssembler reuse makeLiveActivitiesFilter
  so the wire-level REQs are shared with the Live Streams screen.
- AudioRoomsScreen/TopBar/FeedLoaded follow the Live Streams layout and
  render via ChannelCardCompose.

This is phase 1 of the Clubhouse/nests integration plan: it only adds
the discovery surface. Joining, presence, audio transport (MoQ) and
room creation will arrive in subsequent PRs.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 01:57:16 +00:00