Lets a clustered burst of mint calls (typical interop test scenario,
also any moq-auth deployment with stricter rate limits) outlast the
60 s rate-limit window instead of cascading into a hard failure.
- Retry up to 7 times on HTTP 429. Worst-case total wait at 1 s
initial + 16 s cap is 1+2+4+8+16+16+16 = 63 s — just past the
reference moq-auth 60 s/IP window.
- Respect the `Retry-After` header (delta-seconds OR HTTP-date)
when present; fall back to capped exponential backoff otherwise.
- Re-sign the NIP-98 auth event on every attempt. The reference
validator accepts a 60 s validity window; without re-signing,
any retry that waits past that window fails 401 "Event too old".
- Suspending `delay` so coroutine cancellation tears the loop down
cleanly.
`./gradlew :nestsClient:jvmTest -DnestsInterop=true -DnestsInteropExternal=true`
now goes green in a single invocation: 157 tests across 33 classes,
0 failures. Previously the same command cascaded 11 failures once
the moq-auth 20/min/IP bucket filled.
Unit tests: 7 new cases covering Retry-After parsing (seconds,
HTTP-date, garbage, missing), exponential cap, the 429-then-200
retry loop, max-retries exhaustion, and that non-429 4xx is NOT
retried. Backed by a tiny ServerSocket-based HTTP fake so no new
test deps.
To make a SubscribeHandle survive a publisher session swap on the same
relay, three things had to change together:
1. Broadcaster emits one Opus frame per moq-lite group
(`publisher.send` + `publisher.endGroup`). moq-lite's "from-latest"
subscribe semantics deliver a new subscriber the NEXT group's
frames; without per-frame rotation a subscriber that attaches
mid-broadcast waits forever for the (single, never-ending) group
to end.
2. MoqLiteSession opens its announce-watch bidi synchronously before
the first subscribe, dispatches subscribe + announce frames over a
single long-running collector (varint type code hoisted outside
`collect`), and FINs the publisher's currentGroup when an inbound
subscribe bidi closes — so the next send opens a fresh group keyed
off the live subscriber instead of the recycled one.
3. ReconnectingNestsListener no longer breaks on terminal=Closed in
the orchestrator; the user-driven stop path goes through
`orchestrator.cancel()`, so any other Closed (peer-driven
transport close, half-broken session, publisher recycle) is a
reconnect trigger.
Round-trip interop test updated to assert `groupId == idx` (one group
per frame) rather than `groupId == 0`. All three reconnecting-listener
interop scenarios now pass against the real moq-rs relay:
happy-path, session-swap, and listener-survives-publisher-recycle.
Two layered fixes for the gap captured in
nestsClient/plans/2026-04-28-listener-survives-publisher-recycle.md.
Session layer (MoqLiteSession.kt):
- Lazy single shared announce-watch pump per session, opened on
first subscribe. moq-lite Lite-03 has no explicit "publisher
gone" message on the subscribe bidi (the relay keeps that
bidi open across publisher cycles in case a fresh publisher
takes over the suffix), so the announce stream's Ended event
is the only reliable signal.
- On Announce(Ended) for a broadcast suffix, close the
matching ListenerSubscription's frames Channel and remove it
from the map. The wrapper-level frames.consumeAsFlow() flow
ends naturally — same shape as a user-driven
handle.unsubscribe() — so the wrapper pump's collect-
completion path drives the re-issue.
Wrapper layer (ReconnectingNestsListener.kt):
- Inner re-subscribe `while (currentCoroutineContext().isActive)`
loop in reissuingSubscribe. When the underlying frames flow
completes (publisher cycled, signalled by the session layer
above), re-issue subscribe against the same listener with a
100 ms backoff. moq-lite supports subscribe-before-announce so
a re-subscribe issued during the gap attaches cleanly when
the next publisher comes up under the same suffix.
Verified against the real moq-rs relay (host build, external
mode): the new
NostrNestsReconnectingListenerInteropTest.subscribe_handle_survives_publisher_recycle
test passes — single SubscribeHandle keeps emitting frames across
multiple speaker JWT-refresh cycles. Speaker reconnect tests
still pass too.
In production, this closes the audio dropout that would have
fired every 9 minutes per speaker JWT refresh on long Nest calls
(see the plan doc for the original diagnosis).
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
The first cut of the JWT-refresh interop test held a single listener-
side SubscribeHandle open across the speaker's session recycle and
asserted both pre- and post-recycle frames arrived on it. That fails
because moq-lite's relay doesn't auto-route a vanilla SubscribeHandle
to a fresh publisher session under the same suffix — the
listener-survival-across-publisher-recycle is a separate concern,
NOT a speaker-reconnect bug. Verified against a real moq-rs relay
(host-built, external mode, --auth-key-dir + per-kid JWK file).
Reworked to validate just what the speaker reconnect should
guarantee:
- Phase 1: pre-refresh frames round-trip on the first session.
- Phase 2: orchestrator recycles (openCount → 2+, wrapper state
reaches Broadcasting on the new session).
- Phase 3: post-refresh frames round-trip on a FRESH listener
subscription against the new session.
- Wrapper invariant: outward state never surfaced
Reconnecting / Failed during the recycle.
Both speaker interop tests pass against the real relay.
The "listener subscription survives publisher recycle" gap is a
real production concern for >9-min stage time but lives outside
this PR — it would need either listener-side announce-watching or
a coupled refresh-on-publisher-cycle hook in the listener wrapper.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
Two ship-readiness items.
1. Status: CLOSED handler. The NestActivityBody never observed kind-30312
status flips, so when a host ended the room every other listener and
speaker stayed connected to the relay until they manually backed out
or the JWT expired — silent room with stale "you're in" UI. New
LeaveOnRoomClosed composable in NestRoomLifecycle.kt watches
event.isLive() and calls onLeave() when the live event flips to
CLOSED (or hits the 8 h auto-close cutoff in MeetingSpaceEvent.
checkStatus). Same teardown path as kick — VM.onCleared() releases
the listener + speaker when the activity finishes.
2. Speaker-reconnect interop test against the real nostrnests stack,
mirroring NostrNestsReconnectingListenerInteropTest. Two cases:
- Happy path: wrapper drives a single real session, frames round-trip.
- Forced JWT refresh (4 s window): orchestrator recycles the
underlying speaker mid-stream; frames pre- AND post-recycle must
all land on the same listener-side SubscribeHandle. Validates the
production 540 s ↔ 600 s JWT-TTL relationship against the real
moq-rs relay. Gated by -DnestsInterop=true.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
Two new opt-in interop cases drive the production
connectReconnectingNestsListener against the real nostrnests
relay (set -DnestsInterop=true to run):
* reconnecting_wrapper_round_trips_frames_via_real_relay —
happy-path validation that the new MutableSharedFlow-backed
pump doesn't drop frames against real moq-lite framing.
* reconnecting_wrapper_keeps_handle_alive_across_session_swap —
custom connector opens two real sessions; we close the first
mid-stream, observe the orchestrator's automatic reconnect,
and confirm that frames published after the swap arrive on
the SAME consumer-facing SubscribeHandle. Real-wire version
of the in-process test added in the previous commit.
Speaker pushes 4 frames, then calls broadcast.close() + speaker.close().
Listener should receive the 4 pushed frames — and currently DOES NOT
auto-terminate its objects flow when the speaker disconnects, because
the relay's `subscribed complete` signal isn't propagated to a
Channel.close() on our listener side. UI code is expected to drive
its own teardown.
The test pins that current behaviour: it asserts the flow stays open
within a 5s window after speaker.close(). If a future change wires
the relay's done-signal through to a clean flow completion (the more
user-friendly behaviour), this test will fail loudly and ask you to
flip the assertion + rename the test —
`speaker_close_terminates_listener_flow_cleanly`.
Documents a known gap rather than asserting an aspirational contract,
so we don't paper over the missing path with a "happy when listener
unilaterally closes" test.
One speaker, two listeners A and B. Push 3 frames (both receive), A
unsubscribes, push 3 more frames, B's stream completes with all 6.
If A's unsubscribe accidentally tore down the speaker's broadcast or
B's subscribe (e.g. an over-eager session-wide cleanup in a future
refactor), B would time out waiting for the second batch — and the
test names that exact failure mode in the message.
Pin moq-lite-03's "from latest" subscribe semantics: a listener that
joins after the speaker has been broadcasting for a while sees only
new frames, not the pre-subscribe history.
Phase 1: speaker pushes 5 early frames (bytes 0..4) with no listener
attached. The relay does not buffer these in moq-lite-03.
Phase 2: late listener subscribes.
Phase 3: speaker pushes 5 more frames (bytes 100..104). The listener
takes the first 5 frames it sees and we assert ALL of them carry
bytes >= 100 — any contamination from phase 1 fails loudly with
the offending bytes named.
This pins the no-replay guarantee against a future regression to
"buffer-and-replay" behaviour, which would change recovery latency
characteristics for users joining mid-stream.
Verifies the broadcaster's mute path through a real moq-relay:
- push two unmuted frames -> listener receives them
- setMuted(true), push two muted frames -> listener never sees them
- setMuted(false), push two more unmuted frames -> listener resumes
Asserts the listener's flow contains exactly [0, 1, 2, 3] in order
(the muted 50, 51 frames must be absent, not silently filled with
zeros). This pins `if (muted) continue` in
AudioRoomMoqLiteBroadcaster against an accidental "send a silent
placeholder while muted" regression.
Also factors the previously-private DriverCapture / StubEncoder
helpers into a shared InteropFrameDriver.kt so the new test can
reuse them without copy-pasting per file.
The previous test
`listener_subscribed_before_announce_receives_late_frames` validated a
contract from older moq-rs versions: the relay would HOLD a SUBSCRIBE
issued before any publisher announced, then resolve it once a publisher
arrived. moq-lite-03 dropped that behaviour — the relay now rejects
immediately with `subscribed error err=not found` and FINs the bidi
without writing a SubscribeDrop body, which our session reader surfaces
as `MoqProtocolException: subscribe stream FIN before reply`.
Rename the test to
`subscribe_before_announce_fails_with_not_found` and flip the
assertion to pin the new contract. Acceptance message check is
permissive: matches the current "FIN before reply" form AND a
hypothetical future "not found" SubscribeDrop, so a relay change to
explicitly Drop wouldn't silently regress this test.
The class-level coverage doc loses the obsolete
"subscribe-before-announce holds" claim and gains a note on why
the test now flips.
Verified end-to-end against moq-relay 0.10.25 + moq-auth running
bare-metal (-DnestsInteropExternal=true): all five interop test
classes (Auth, AuthFailure, AuthEndpoints, RoundTrip, MultiPeer)
now pass — 13/13 cases green.
Without `wt-available-protocols`, moq-relay (`web-transport-quinn`) falls
back to the legacy in-band SETUP exchange (moq-lite-02) instead of
selecting the moq-lite-03 sub-protocol from the ALPN-style negotiation
header. Then the relay tries to decode our first post-CONNECT bytes as a
SETUP_CLIENT message, hits an unknown control type, and closes the QUIC
connection with `connection closed err=invalid value` — surfaced
client-side as a stuck SUBSCRIBE that ends with
`subscribe stream FIN before reply for id=0` (the bidi gets FIN'd because
the whole connection is being torn down).
Pass `wt-available-protocols: "moq-lite-03"` on the Extended CONNECT
request, encoded as an RFC 8941 Structured Field List of strings (the
header format mandated by draft-ietf-webtrans-http3-14 §3.3). With this,
moq-relay logs `negotiated version=moq-lite-03 transport="quic"` and the
SUBSCRIBE makes it into the relay's actual moq-lite session pump.
Mechanism: web-transport-proto's `ConnectRequest::encode` reads
`self.protocols` and writes them as a comma-separated list of bare
strings under `wt-available-protocols`. The server side (web-transport-
quinn) reads the same header into `request.protocols`, and moq-native's
`QuinnRequest::ok()` picks the first match against its supported ALPN
list (`moq-lite-04`, `moq-lite-03`, `moq-00`, `moqt-15`, etc.). On
match, version selection happens via the WT sub-protocol response and
the in-band SETUP is skipped — which is what moq-lite-03 expects.
Default the factory list to `["moq-lite-03"]`. Callers that want a
different version (or to disable sub-protocol negotiation entirely
to talk to a SETUP-based server) override the constructor parameter.
Bare-metal harness: NostrNestsHarness.startExternal() now skips the
TCP probe of the moq-relay port. moq-relay binds UDP only; the Docker
forwarder happens to also open TCP, but a directly-launched binary
doesn't, so the previous `Socket(host, 4443)` probe failed with
ConnectException. The QUIC handshake from the test surfaces a real
transport problem if any.
Adds a "bring your own stack" path to NostrNestsHarness so the interop
tests can run without a Docker daemon. With `-DnestsInteropExternal=true`:
- Skip the `docker compose up` that builds + boots
moq-auth + moq-relay
- Port-probe + /health-check the same 8090 / 4443 endpoints the
Docker path uses
- close() is a no-op — the caller owns the lifecycle
Useful in two situations:
1. Sandboxes / restricted CI without a Docker daemon (just run
`cargo install moq-relay` + `node moq-auth/dist/index.js`
yourself, then run gradle with the flag)
2. Fast iteration — the Docker path takes ~30 s to compile
moq-relay on first run; with this, you keep both processes
alive across many test invocations
Forward `nestsInteropExternal` (and `nestsInteropDebug`,
`nestsInteropMoqRev`) through the Test task so the property reaches
test workers; without that, the gate stays off in the worker JVM.
Also: `assertSpeakerReached` / `assertListenerReached` now log a "✘"
checkpoint with the rich state description before calling fail().
JUnit captures the assertion message in a separate section, but the
"standard output" tab is what most people read first when scanning
for a cause — so the chained-cause string now lands in both places.
When an interop test fails today the report shows a generic
AssertionError pointing at a `runBlocking {` line — useless for
narrowing down which sub-step (mintToken, WT connect, ANNOUNCE,
SUBSCRIBE_OK, frame round-trip) actually exploded.
This adds a small InteropDebug helper that:
- prints a labelled "▶ start" / "✔ ok" / "✘ fail — Class: msg ⟵ Cause: msg"
trail per step (output gated on `-DnestsInterop=true` so the
default test run stays silent)
- walks chained `cause` so the surface message reveals the real
network / protocol error instead of the wrapping string
- pretty-prints NestsSpeakerState / NestsListenerState (Failed in
particular unwraps `cause` for the report)
Each test body in NostrNestsRoundTripInteropTest +
NostrNestsMultiPeerInteropTest now wraps connect / startBroadcasting /
subscribeSpeaker / await-frames in `InteropDebug.stepSuspending(...)`
so a failure points at the exact sub-step rather than the test method.
Harness diagnostics: when `start()` fails, capture
`docker compose ps` + recent logs for moq-auth / moq-relay / strfry
into the IllegalStateException message before tearing the stack down.
Without this, the test report only shows "exited with code 1" —
operators have to re-run by hand to find out which container
crashed.
Output is hidden by default; only surfaces when
`-DnestsInterop=true` (or the explicit `-DnestsInteropDebug=true`)
is set.
The remaining interop failures all root in the same window: a stale
keep-alive pool entry from one test class is reused on the FIRST POST
of the next test class, the connection RSTs as the request body
writes, and OkHttp's built-in retryOnConnectionFailure won't retry a
POST after any byte of the body has gone out. Same situation hits a
phone client whose Wi-Fi hands off mid-mint.
Two fixes, both production-shaped:
- OkHttpNestsClient.mintToken now wraps execute() in
executeWithTransportRetry(): one retry on SocketException /
EOFException / generic IOException. Request builders are
immutable, so the second pass opens a fresh connection cleanly.
HTTP error status codes (4xx / 5xx) and malformed responses are
NOT retried — they go to the caller as before.
- NostrNestsHarness now polls GET /health until it returns 200
after the port-probe succeeds. moq-auth's Node runtime opens its
listen socket before the request handlers are wired, so a POST
that arrives in that window can RST. Waiting for /health proves
the request pipeline is live, eliminating the SocketException
that hit the first test of every test class run after
AuthEndpoints.
Symptoms fixed:
- NostrNestsAuthFailureInteropTest.missing_authorization_header_is_rejected_401
-> SocketException
- NostrNestsRoundTripInteropTest.production_speaker_broadcasts_to_production_listener_via_real_relay
-> "Failed to reach http://127.0.0.1:8090/auth"
- NostrNestsMultiPeerInteropTest.* (3 tests, same root cause)
Each interop test class was running its own NostrNestsHarness.start()
in @BeforeClass and harnessOrNull?.close() in @AfterClass — meaning
the Docker stack tore down + spun back up between every class. That
sequence was both slow (~30 s Cargo build for moq-relay each time)
and unreliable: leftover network state from the prior `down -v` was
racing the next `up -d`, leaving moq-auth either unreachable
(SocketException) or producing truncated 401 responses (EOFException
on body.string()) for tests that ran after the first.
Symptoms before this fix (first class wins; everything else fails):
- NostrNestsAuthEndpointsInteropTest ✅
- NostrNestsAuthInteropTest → SocketException ❌
- NostrNestsAuthFailureInteropTest → EOFException ❌
- NostrNestsRoundTripInteropTest → "Failed to reach" ❌
- NostrNestsMultiPeerInteropTest → "Failed to reach" ❌
Fix: NostrNestsHarness.shared() returns a process-singleton. First
caller does the docker compose up + port-probe; every subsequent
caller reuses the same containers. Teardown is registered once via
Runtime.addShutdownHook so the stack lives for the JVM's lifetime
and dies cleanly at test process exit.
All five test classes now call shared() in @BeforeClass; the
@AfterClass blocks no longer call close() on the singleton (clearing
the local reference is enough — the shutdown hook handles the real
teardown). The original start() entry point is preserved for callers
that want a per-call harness.
The previous --recurse-submodules fix turned out to be moot:
nostrnests's docker-compose-moq.yml references `./moq` as a build
context, but the moq directory is NOT in the nostrnests repo and
is NOT a submodule — each developer is expected to clone
kixelated/moq into ./moq themselves before running compose.
Confirmed against the upstream README + repo listing
(nostrnests/nests has moq-auth/, nests-relay/, NestsUI-v2/ but no
moq/ directory).
Two new harness steps before `docker compose up -d`:
- ensureMoqSource — clone https://github.com/kixelated/moq.git
into <nests-cache>/moq on first run; fetch + checkout
DEFAULT_MOQ_REVISION on every run so the build is reproducible.
Override the pin via -DnestsInteropMoqRev=<sha-or-branch>.
- ensureDevCerts — run dev-config/generate-certs.sh to produce
the self-signed TLS chain moq-relay mounts read-only at
/certs. The script is idempotent (bails when fullchain.pem
exists) so we always invoke it; a chmod +x defensively
handles Windows / odd CI checkouts.
Reverted the spurious --recurse-submodules + submodule update path
since the repo doesn't have submodules at all — the original `git
clone` was correct, just incomplete.
The harness was running plain `git clone`, but
docker-compose-moq.yml references `./moq` (kixelated/moq-rs) and
`./moq-auth` as build contexts via git submodules. Without
--recurse-submodules the directories don't exist and `docker
compose up -d` fails with:
unable to prepare context: path '<cache>/nests/moq' not found
Fix: clone with --recurse-submodules on first run, and sync
submodules after every fetch+checkout so the working tree
matches whatever revision pin the checked-out commit declares.
Tidy items now that the moq-lite swap is complete on both sides:
- Drop the no-op `supportedMoqVersions: List<Long>` parameter from
`connectNestsListener` / `connectNestsSpeaker`. moq-lite negotiates
via ALPN, no caller passed a value, and the project rule forbids
no-op back-compat shims.
- `NostrNestsRoundTripInteropTest` KDoc + comments now describe the
moq-lite framing path (one Subscribe bidi for `audio/data`, group
uni streams with `DataType=0` + GroupHeader + size-prefixed frames)
instead of the stale IETF "OBJECT_DATAGRAMs / SETUP" framing.
- `DefaultNestsListener` / `DefaultNestsSpeaker` KDoc now flags them
as IETF MoQ-transport reference impls — production uses
`MoqLiteNests*`. They stay around for the IETF unit-test suite.
- `audio-rooms-completion.md` Phase M5 / M6 / M7 marked **DONE** —
the plan was written before the moq-lite gap was discovered, so
it described the work as IETF-MoQ-publisher additions; the
moq-lite path lands the same outcome via a different protocol.
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:
1. WebTransport CONNECT path is now /<moqNamespace> (matches the
relay's claims.root prefix check). Previously hardcoded "/anon".
2. JWT travels in the ?jwt=<token> query parameter, not the
Authorization header — moq-rs only reads the query param. The
bearer-token path on QuicWebTransportFactory is now unused for
nests; left in place for non-nests WebTransport servers.
3. Harness `moqEndpoint` is the relay base URL only; the connect
helpers append /<namespace>?jwt=<token> themselves.
Interop test additions (all -DnestsInterop=true gated, default-skipped):
- NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
for the wrong URL, malformed namespace per the strict regex,
publish=true grant for any caller — sidecar does NOT gate by NIP-53
hostlist).
- NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
shape (must contain ES256/P-256), 404 on unknown route.
- NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
speaker isolation, subscribe-before-announce. Code is wired through
production connectNestsSpeaker / connectNestsListener; will pass
once the moq-lite gap (below) is resolved.
Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
Drives connectNestsSpeaker + connectNestsListener end-to-end against the
real nostrnests Docker stack. Speaker announces a track, listener
subscribes by pubkey, speaker pushes deterministic frames through
AudioRoomBroadcaster → MoQ → relay → listener.objects flow, and the
test asserts payload integrity + monotonic object ids.
Validates the wire shapes the Phase-2 refactor committed to:
- QuicWebTransportFactory + PermissiveCertificateValidator can
handshake against the relay's self-signed dev cert
- JWT minting + WebTransport CONNECT + MoQ SETUP all succeed
- The single-segment TrackNamespace `nests/<kind>:<host>:<room>`
matches the relay's `root` JWT claim
Single-keypair design sidesteps host-vs-audience auth policy so the
test stays focused on transport + protocol; a future dual-keypair
test can layer permissions on top.
Skipped by default — set -DnestsInterop=true to enable.
The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.
| Aspect | Before | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method | GET | POST |
| URL | `<base>/<roomId>` | `<base>/auth` |
| Body | none | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response | `{endpoint, token, codec, sample_rate}` | `{token}` only |
| Endpoint | from response | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98 | bound to GET URL | bound to POST URL + body hash |
Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
→ `NestsClient.mintToken(room, publish, signer): String`. The
publish flag drives the JWT claims (`get` for listeners, `put`
for speakers).
Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
NestsRoomConfig` instead of split (serviceBase, roomId), pass
`publish=false` / `publish=true` respectively, and use the room's
`endpoint` (not a server-returned one) for the WebTransport
connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
`Broadcasting` carry the `room: NestsRoomConfig` instead of the old
`roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
bytes are `room.moqNamespace()` — the simplest mapping to the
relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
test will confirm and adjust if the relay expects a multi-segment
tuple.
Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
with `(room: NestsRoomConfig)`. Connector seam interfaces
(NestsListenerConnector, NestsSpeakerConnector) follow the same
shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
`EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
+ roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
`EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
`event.kind` in addition to `event.service()`; rooms missing any
of those are silently un-joinable (the event author didn't host
on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
of (serviceBase, roomId) and threads it down.
Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.
Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.
Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
Brings up the nostrnests reference server (https://github.com/nostrnests/nests)
locally via Docker Compose so we can drive `:nestsClient`'s production
code against the real MoQ relay + NIP-98 auth sidecar.
Mirrors the `:quic` `InteropRunner` pattern (aioquic Docker, opt-in via
`-DinteropHost=…`):
- Set `-DnestsInterop=true` to enable; default `:nestsClient:jvmTest` runs
skip via JUnit `Assume.assumeTrue` (shown as <skipped>, not <failure>).
- Repo cloned + cached at `~/.cache/amethyst-nests-interop/nests/`,
pinned to the `DEFAULT_REVISION` (currently `main`; override via
`-DnestsInteropRev=<sha>` to lock in for reproducibility).
- `docker compose -f docker-compose-moq.yml up -d` brings up moq-relay
(host 4443 TCP+UDP), moq-auth (host 8090), strfry (7777). Port-probes
4443 + 8090 with a 90 s timeout.
- `close()` runs `docker compose down -v --remove-orphans`. Tests use
`@BeforeClass`/`@AfterClass` to amortise the ~30-60 s spin-up across
all cases in one class.
Phase-1 ping test (NostrNestsAuthInteropTest):
- Generates an ephemeral KeyPair / NostrSignerInternal via Quartz.
- POSTs `<authBase>/auth` with `{"namespace":"nests/30312:<pubkey>:<roomId>",
"publish":true}`, NIP-98 Authorization header signed for that exact
(url, method, payload) tuple.
- Asserts 200 + a `"token":"…"` JWT in the response body.
Doesn't yet route through `OkHttpNestsClient` because the production
client's wire shape (GET `<base>/<roomId>` returning `{endpoint, token,
codec, sample_rate}`) does not match nostrnests' actual API (POST
`<base>/auth` with `{namespace, publish}` body, returning just `{token}`
— endpoint comes from the NIP-53 event's `endpoint` tag instead of the
HTTP response). Phase 2 of this audit refactors production to match;
this test documents the divergence on the wire so the refactor has a
clear target.
Verified: harness compiles clean; default `:nestsClient:jvmTest` shows
the test as <skipped> (not <failure>) when `nestsInterop` property is
unset.
Files:
- nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt
- nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.
- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
QuicConnectionDriver + the CONNECT bidi stream id and exposes
open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
are pushed onto each new stream automatically. close() emits a
WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
KwikWebTransportFactory) drives the full open sequence:
1. UDP connect + QuicConnection.start
2. wait until handshake completes (Status.CONNECTED)
3. open H3 control uni-stream with stream-type 0x00 + the
SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
ENABLE_WEBTRANSPORT=1)
4. open the Extended CONNECT bidi: HEADERS frame carrying
:method=CONNECT, :protocol=webtransport, :scheme=https,
:authority, :path, optional Authorization: Bearer
5. wrap the connection + driver + connect stream id in a
WebTransportSession adapter that the existing nestsClient MoQ +
audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
:amethyst, :commons, or the audio pipeline changes — the moment connect()
returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
class name, comment-style cleanup in TlsConstants and LongHeaderPacket.
Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
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
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