f721142c73f5ea5c848efebd65ab59fd3d3b912a
11811 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
1d7f2e7e6d |
Merge pull request #2493 from vitorpamplona/claude/fix-marmot-interop-tests-3ZSSj
Fix MLS commit cryptography and add comprehensive validation |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
0b7b1353e8 |
fix(marmot-interop): broaden skip-retry patch to all MdkCoreError
Narrowing the previous patch to only MlsMessageUnprocessable /
MlsMessagePreviouslyFailed still leaves wn stuck retrying
\`MdkCoreError("Failed to decrypt message with any exporter secret")\`
— mdk surfaces that as an Err variant rather than the Unprocessable
result enum, so it took the full 10-attempt exponential backoff (~17
minutes) before giving up. That was enough to block later
decryptable commits and regress test 11 (leave group) from pass back
to fail.
mdk-core doesn't retry internally, so ANY Err it returns is terminal
by construction. Treating the whole \`MdkCoreError\` variant as
one-shot is therefore equivalent to "trust mdk's verdict" rather
than "permissive skip" — if mdk decides the message is bad, it will
stay bad.
Net in the headless harness: 6/13 pass (up from 5/13 with the
narrower patch and 1/13 baseline).
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
|
||
|
|
e4b5b12a0b |
fix: prevent unnecessary service lifecycle on startup for non-users
- stop() now uses stopService() instead of startService(STOP intent). stopService() is a no-op when the service isn't running and avoids starting a service just to stop it, which could throw on Android 12+. - disableAllLayers() only runs when transitioning from enabled → disabled, not on initial load with false. Users who never enabled the service won't trigger any service/WorkManager/AlarmManager calls on login. https://claude.ai/code/session_01LEPfmgGnwjB9a5SDFw5U8t |
||
|
|
8ba424295c |
fix(marmot-interop): drop retries for provably-unprocessable MLS messages
mdk-core's `MessageProcessingResult::Unprocessable` means the received kind:445 is genuinely outside this member's decrypt horizon — it's from a pre-membership epoch, or an epoch we've retained past, and no amount of retrying will make it decryptable. wn's account event processor was running that down the same exponential retry ladder as a legitimate transient failure (10 attempts, 1s..512s backoff, ~17 minutes to exhaust), which in the interop harness meant every later decryptable commit — add-member, rename, leave — had to wait behind a queue of doomed retries and raced the 30s test timeout. Add a third wn patch, `whitenoise-skip-unprocessable-retry.patch`, that treats `MlsMessageUnprocessable` and `MlsMessagePreviouslyFailed` as terminal: log the warning once, skip the reschedule. Applied in preflight via `setup.sh`. https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP |
||
|
|
66f7753c8b |
Merge pull request #2492 from vitorpamplona/claude/amy-cli-guide-cuLaP
docs: add Amy CLI development guides and skill reference |
||
|
|
f08b010f50 |
fix(marmot,cli,interop): interop-compatible Marmot flows and harness correctness
Five protocol-level fixes and a batch of harness correctness fixes to get
the headless Marmot/Whitenoise interop harness from 1/13 to 5/13 passing
cleanly, with the remaining failures all rooted in wn's per-account
serial event-processor retry backoff (which drops undecryptable
pre-membership commits after several minutes) rather than amy behaviour.
quartz + commons
----------------
* MarmotGroupData: hold CURRENT_VERSION at 2. mdk-core (the Rust MLS
engine used by whitenoise-rs) strict-rejects v3 payloads with
`ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`
— our v3 welcomes and GCE commits never apply, so every cross-client
group flow dies at welcome processing. We still parse v3 happily on
the way in; we just don't emit it until mdk publishes the
forward-compat fix MIP-01 mandates.
* MarmotManager.updateGroupMetadata: MERGE extensions instead of
REPLACING. RFC 9420 §12.1.7 says GCE proposals blow away the old
extension list; callers that pass only [marmot_group_data] dropped
[required_capabilities], which peers then reject. Preserve every slot
except the one we're updating.
* MarmotManager.createGroup: new optional `initialMetadata` parameter
that bakes MarmotGroupData into epoch-0 GroupContext.extensions
directly. Without it, creators had to publish a pre-membership
"bootstrap" commit that no later joiner could decrypt — each such
peer then burned their retry budget on an undecryptable kind:445
before seeing the real state. Threaded through MlsGroup.create /
MlsGroupManager.createGroup.
* MarmotManager.mlsGroupIdHex: new translation helper so any code
juggling the MIP-01 nostr_group_id (what amy indexes on) and the
MLS GroupContext groupId (what mdk indexes on) can cross-reference
them without reaching into MlsGroupManager directly.
amethyst module
---------------
* Account.leaveMarmotGroup: self-demote before SelfRemove per MIP-01,
and promote a surviving member to admin first if the caller is the
sole admin (otherwise we'd throw "admin depletion"). Matches the
cli/GroupMembershipCommands.leave flow.
cli (amy)
---------
* Context.syncIncoming: don't advance `giftWrapSince` on empty polls
(so the first-ever sync doesn't bump the cursor past every
past-timestamped wrap we've ever been sent), subtract 2 days lookback
when filtering (NIP-59 randomWithTwoDays gift wraps can have any
createdAt in the last 48h), and only advance `groupSince` for groups
we actually received events for.
* Context.syncIncoming: after ingest, if any Welcome consumed a
KeyPackage, rotate and publish a fresh one immediately. MIP-00
requires this — a KP can only be welcomed once and leaving the
consumed one on relays just means later senders invite us with a
bundle we no longer have private keys for.
* Context.resolveGroupId: accept either nostr_group_id (amy's primary
key) or the MLS GroupContext groupId (what wn emits) on every verb
that takes a group id. Wired through GroupAdd/Remove/Leave/Metadata
/Read, Message send/list, and all the await* verbs so harness
scripts never have to juggle both forms for a single group.
* GroupCreateCommand: bake initial metadata into epoch 0 (see the
quartz change above). Dropped the now-redundant bootstrap commit
publish and tightened the JSON output to include `mls_group_id`.
* GroupMembershipCommands.leave: self-demote admin before SelfRemove;
promote an heir if we're the only admin, otherwise the GCE would
deplete admins and the leave aborts.
* MarmotIngest.ingestGiftWrap: unwrap the sealed-rumor layer. NIP-59
wrap is gift-wrap(kind:1059) → seal(kind:13) → rumor; the old code
only unwrapped once and then checked `inner.kind == 444`, which is
always false because inner is actually the seal. Unseal once more
before the Welcome check. This single fix is what unsticks every
amy-side Welcome ingestion.
wn harness patches + scripts
----------------------------
* whitenoise-defaults-env.patch: honour $WHITENOISE_DISCOVERY_RELAYS in
`Relay::defaults()` (release builds otherwise bake damus.io / primal
/ nos.lol into every new account's NIP-65 / Inbox / KeyPackage
lists, which breaks publishing and prevents the inbox subscription
plane from ever reaching an operational state in a sandbox).
* setup.sh: sleep 2s after amy's initial kind:30443 publish so
nostr-rs-relay has a chance to fsync before wn's first targeted
discovery query. Without it wn's `keys check` races the relay's
WAL flush and intermittently returns NotFound.
* lib.sh: peel wn's `{"result": …}` wrapper in `jq_group_id`,
`wait_for_invite`, `wait_for_message`, `wait_for_member`. Post-v0.2
wn `--json` output nests everything under `.result` (and
`groups invites[]` nests further under `.group.mls_group_id`) —
these helpers were still pattern-matching on the flat shape, so
they returned empty strings for a perfectly good response.
* tests-{create,manage,extras}.sh: track both group IDs per test
(amy's nostr + wn's MLS), pass each CLI the id it understands, and
bump the post-commit wait timeouts to 90–120s so wn's exponential
retry backoff has time to work through the pre-membership commits
it can't decrypt and get to the ones it can.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
|
||
|
|
9b9084ffaf |
docs(cli): split amy docs by responsibility + add amy-expert skill
Separate the single DEVELOPMENT.md into focused docs per audience: - cli/README.md — trim agent/interop sub-sections; user-facing contract, commands, data-dir, troubleshooting only. - cli/DEVELOPMENT.md — pared down to architecture, how-to-add-a-command, output conventions, testing, housekeeping. - cli/ROADMAP.md (new) — north-star, parity matrix, ordered milestones, non-goals. The live checklist for CLI feature parity. - cli/plans/2026-04-21-cli-distribution.md (new) — packaging strategy (Homebrew / winget / Scoop / deb / rpm / AUR / AppImage). - commons/plans/2026-04-21-event-renderer.md (new) — cross-cutting renderer design owned by commons, consumed by cli + desktop + android. Agentic routing: - .claude/skills/amy-expert/SKILL.md (new) — routing triggers + the five hard rules (thin-layer, JSON contract, non-interactive, data-dir-is-world, extract-before-adding). - references/command-template.md, extraction-recipe.md, output-conventions.md — bundled copy-paste references. Root .claude/CLAUDE.md: - Adds cli/ to the module list with its sharing rule. - Registers amy-expert in the skills table. - Documents per-module plans/ convention; freezes docs/plans/. https://claude.ai/code/session_01BQ5ZHwa8BAgEQ9zeM4CKhW |
||
|
|
b765bbe0a1 |
Merge pull request #2491 from vitorpamplona/claude/nip-53-compliance-OEzbS
Add NIP-53 proof of agreement and event builders |
||
|
|
2f78849845 |
fix(quartz): align NIP-53 Live Activities with spec
- MeetingRoomPresenceEvent (kind 10312) now extends BaseReplaceableEvent
instead of BaseAddressableEvent. NIP-53 states this kind is a regular
replaceable event ("presence can only be indicated in one room at a
time"), and LocalCache already routes it through consumeBaseReplaceable.
createAddress/createAddressATag/createAddressTag now take only pubKey.
- Add TagArrayBuilder extensions for streaming (30311), meeting space
(30312) and meeting room (30313) tags, plus build() helpers that assemble
the required tags per NIP-53.
- Add ProofOfAgreement utility to sign and verify the schnorr proof that
NIP-53 places in the 5th element of a participant p-tag
(SHA256 of kind:pubkey:dTag signed by the participant's private key).
Includes LiveActivitiesEvent.hasValidProof() convenience.
|
||
|
|
35a84ffaf9 |
docs(cli): add user README and development roadmap for amy
Add cli/README.md covering the JSON-output contract, install via installDist, today's command surface (identity / relays / marmot), and use from agents + interop tests. Add cli/DEVELOPMENT.md with the feature-parity matrix vs Amethyst, the extract-from-Android recipe (amethyst/ -> commons/ -> cli/), command template, EventRenderer plan shared with Desktop, testing strategy focused on what quartz and commons don't already cover, distribution matrix across macOS / Windows / Linux / Nix / Zapstore, and an ordered 10-step roadmap. https://claude.ai/code/session_01BQ5ZHwa8BAgEQ9zeM4CKhW |