Files
amethyst/nestsClient/plans/2026-04-26-moq-lite-gap.md
T
Claude 8b5af5d496 docs(audio-rooms): refresh against shipped state + nostrnests gap audit
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.

  - 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
    layout: implementation table for every protocol/transport/UI
    surface, "pending" table for the remaining items (reconnect,
    level meters, Desktop / iOS, Nests parity), pointers section
    refreshed.
  - 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
    that landed it (fb47a4c71cf99d015b0d7); "When picking up"
    section now points at the shipped surface first, raw protocol
    references second.
  - 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
    today's nostrnests reality:
      * status banner up top calling out the revision
      * dependencies dropped IETF MoQ-transport, added moq-lite
        Lite-03 + ALPN "moq-lite-03"
      * HTTP control plane: GET <service>/<room-d-tag> → POST /auth
        with {namespace, publish}, returning {token}; documented the
        JWT claim shape (root, get, put), 600 s lifetime, regex
        on `namespace`, JWKS endpoint, error matrix
      * Audio transport: replaced IETF SETUP / TrackNamespace tuples
        / OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
        per-bidi message types, group uni streams, audio/data track,
        no in-band SETUP, FIN-as-unsubscribe semantics)
      * New event-kind sections: kind 4312 (admin command / kick),
        kind 10112 (audio-room server list)
      * Reconciliation section explaining what changed from the
        original draft and why
  - NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
    every nostrnests/NestsUI feature we don't yet ship, sourced from
    a code-walk of the React app + moq-auth + API.md (which is
    LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
    reactions, role parsing + promotion, hand-raise queue, kick
    (kind 4312), edit/close room, scheduled rooms, listener counter.
    Tier 2: participant grid, augmented presence tags
    (publishing/onstage), per-avatar context menu + zap, share via
    naddr. Tier 3: room theming. Tier 4: token-refresh +
    Connection.Reload sanity checks.

Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
2026-04-26 19:23:41 +00:00

321 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: bridge the moq-lite protocol gap
**Status:****DONE** — listener + speaker on moq-lite Lite-03 against
the real nostrnests stack. Phases 5a → 5d landed (`fb47a4c`
`71cf99d`); follow-up cleanup + harness fixes shipped through `015b0d7`.
Default `:nestsClient:jvmTest` passes; integration tests gated by
`-DnestsInterop=true` work end-to-end against a Docker'd nostrnests
deployment.
**Origin:** discovered while writing the nostrnests interop test suite (phases 14).
## Discovery
`:nestsClient` implements **IETF `draft-ietf-moq-transport-17`**
(`TrackNamespace` tuples, `CLIENT_SETUP` / `SERVER_SETUP`,
`OBJECT_DATAGRAM` with `track_alias`, two-message ANNOUNCE / SUBSCRIBE
shape).
The actual nostrnests stack runs on **moq-lite** — kixelated's own MoQ
flavour, wire-incompatible with IETF MoQ-transport:
- JS client `NestsUI-v2/package.json` depends on `@moq/lite`,
`@moq/publish`, `@moq/watch`. None implement IETF MoQ-transport.
- Rust relay `kixelated/moq-rs` is built on `rs/moq-lite/` types
throughout.
The phase-4 wire fixes (path = `/<namespace>`, JWT in `?jwt=` query) are
on the moq-rs wire shape and let the WebTransport CONNECT succeed. But
the first MoQ-framing message we send afterwards (IETF `ClientSetup`)
is unintelligible to moq-rs's moq-lite framing.
## Wire spec — moq-lite (Lite-03)
Fully extracted from `kixelated/moq-rs/rs/moq-lite/src/` and
`@moq/lite` v0.1.7.
### Connection setup
- **ALPN advertised on the WebTransport upgrade:** prefer
`"moq-lite-03"` (`Lite.ALPN_03`); fall back to `"moql"` (legacy
combined ALPN that requires a SETUP exchange).
Source: `rs/moq-lite/src/version.rs:21-26`,
`@moq/lite/connection/connect.js:277`.
- **Lite-03 has NO setup or control message inside the WT session.**
The WebTransport handshake itself is the handshake. Both sides go
straight to opening per-purpose streams. `client.rs:86-101`,
`connect.js:113-115`.
- Legacy Lite-01/02 (`"moql"`) DO open a bidi setup stream with byte
`0x20``ClientSetup` (Draft-14 IETF-format), reply `0x21`
`ServerSetup`, then exchange `SessionInfo {bitrate: u62}` forever.
We are targeting Lite-03 only.
- Version is **chosen entirely by ALPN.** No version, role, or setup
parameter is exchanged inside the WT session.
### Streams + datagrams
- **No persistent control stream.** Each control "request" is a fresh
client-initiated **bidi**, whose first byte is a varint
`ControlType` discriminator and whose body is a size-prefixed message.
The bidi stays open for the duration of the request/response/stream.
- `ControlType` codes (`lite/stream.rs:7-15`): `Session=0` (unused in
Lite-03), `Announce=1`, `Subscribe=2`, `Fetch=3`, `Probe=4`. All
encoded as QUIC varints.
- **Media flows on uni streams**, one stream per *group*. Uni-stream
type byte: `DataType::Group = 0` (varint, in practice a literal
`0` byte). `lite/stream.rs:32-36`, `publisher.js:209`.
- **No QUIC datagrams used for media in Lite-03.** Each group is its
own uni stream; no datagram path.
### Announce
Two-step, on a single bidi opened by the **subscriber**:
1. **Subscriber → publisher** (or relay). Bidi opens with control byte
`varint(1)` = `Announce`, then size-prefixed `AnnouncePlease`:
prefix: string (varint length + UTF-8; broadcast-name prefix
the subscriber cares about; empty = "everything")
Source: `subscriber.rs:82-89`, `subscriber.js:46-47`,
`announce.rs:64-81`.
2. **Publisher → subscriber.** Server emits one size-prefixed
`Announce` message per active broadcast, then keeps the bidi open
for live updates:
status: u8 (0 = Ended, 1 = Active)
suffix: string (broadcast path with `prefix` stripped;
normalised — `path.rs:78-99`)
hops: u62 (Lite-03 only; varint, relay routing depth)
Source: `announce.rs:84-90`, `announce.rs:28-31`. `active=true` is
sent on first publish; `active=false` on explicit unannounce.
Disconnect is **not** an explicit Ended (see Cleanup).
### Subscribe
Subscriber opens a **fresh bidi**, control byte `varint(2)` = `Subscribe`,
then size-prefixed body:
id u62 varint (subscriber-chosen, monotonic)
broadcast string (varint length + UTF-8 path)
track string (opaque app string —
"audio/data" or "catalog.json")
priority u8 (raw byte 0..255; NOT a varint)
ordered u8 (Lite-03 only; 0 / 1)
maxLatency varint (Lite-03; **milliseconds**, 0 = unlimited)
startGroup varint (Lite-03; 0 = "from latest",
else group_seq + 1)
endGroup varint (Lite-03; 0 = "no end",
else group_seq + 1)
Source: `subscribe.rs:25-72`, `subscribe.js:87-104`,
`encode.rs:99-185`.
Reply: size-prefixed `SubscribeResponse` on the same bidi. Lite-03
prefixes a varint type: `0 = Ok`, `1 = Drop`. `SubscribeOk` body is
`(priority, ordered, maxLatency, startGroup, endGroup)` — same
five fields as Subscribe minus id/broadcast/track.
There is **no SUBSCRIBE_ERROR**. Failure = stream RESET with an
`Error::to_code()` u32. Track names are arbitrary opaque UTF-8.
### Frame / group / object delivery
For each group, the publisher opens a fresh uni stream:
DataType (varint) = 0 (Group)
Group header (size-prefixed):
subscribe u62 varint (echoes Subscribe.id)
sequence u62 varint (group sequence number)
then a sequence of frames until the stream's FIN:
frame_size varint (length in bytes)
payload frame_size raw bytes
Source: `publisher.rs:330-393`, `publisher.js:205-232`,
`subscriber.js:152-161`.
**No per-frame envelope beyond size.** No timestamp, no codec config,
no flags. All semantic structure (Opus packet boundaries, JSON
document) is the track's app-layer convention. End-of-group is QUIC
stream FIN.
### Cleanup / unsubscribe / unannounce
- Per-group end / track ended: QUIC **FIN** the uni stream
(`publisher.rs:387-388`).
- Broadcast ended: send `Announce {status=0=Ended, suffix, hops}` on
the announce bidi (`publisher.rs:206`, `publisher.js:118`).
- Unsubscribe: **FIN the subscribe bidi's send side**
(`subscriber.rs:230`). No UNSUBSCRIBE message exists.
- Cancel a single group from the receiver: QUIC `STOP_SENDING` on the
uni stream.
- Mid-broadcast publisher disconnect: relay either FINs/resets the
announce bidi or emits `Announce::Ended` if graceful. Consumers
detect via the bidi/QUIC close — there is no "publisher gone"
message.
- Errors on any stream: `RESET_STREAM` with `Error::to_code()` (u32).
### Varint encoding
**RFC 9000 §16 QUIC varints** (2-bit length tag, 1/2/4/8 byte forms,
max value `2^62 1`). `coding/varint.rs:172-239`,
`stream.js:147-170`. The `stream.js` `u53` reader caps at the JS-safe
range. `priority` is a plain byte. Strings = `varint length + UTF-8`.
`bool` = 1 byte 0/1.
### Notable + non-obvious
- **Same WT session is bidirectional w.r.t. roles.** Either side can
open an Announce or Subscribe bidi; the role is per-bidi via the
`ControlType` byte. There is no role announcement.
- **`Path` normalisation is mandatory** on both sides — leading,
trailing, and duplicate `/` are stripped before encoding. A wire
path `"/foo//bar/"` is identical to `"foo/bar"`. An interop client
that doesn't normalise will see broadcast lookups silently fail.
- **`startGroup`/`endGroup` use the off-by-one trick:**
`0 = None, n = Some(n 1)`. Easy to get wrong.
- **`maxLatency` is in milliseconds.** Not seconds, not microseconds.
- **No head-of-line blocking across groups** (one uni stream per
group), but no in-order guarantee across them either. The receiver
uses `sequence` to reorder.
- **`hops`** is the only relay-routing metadata on the wire (Lite-03).
- **Probe stream (ControlType=4)** is opened by the *subscriber* but
the publisher writes `Probe { bitrate: u62 }` size-prefixed messages
on it — opposite direction from a normal request/response.
## Concrete wire shapes nests-side
| Wire field | JS reference value |
| ---------------------- | ---------------------------------------- |
| WT URL path | `/nests/30312:<host>:<roomId>` |
| `?jwt=` query | JWT (`claims.root` = the same path) |
| `claims.put` (publish) | `[<myPubkey>]` |
| `claims.get` | `[""]` |
| ANNOUNCE.suffix | `<myPubkey>` (single string) |
| SUBSCRIBE.broadcast | `<speakerPubkey>` (single string) |
| SUBSCRIBE.track | `"catalog.json"` then `"audio/data"` |
## Implementation status (2026-04-26 PM)
**Landed (listener path complete end-to-end through `:nestsClient`):**
| Phase | Commit | Surface |
| ----- | --------- | ---------------------------------------------------------------------------------------------------- |
| 5a | `fb47a4c` | `MoqLitePath` (mandatory wire-boundary normalisation), `MoqLitePathTest` |
| 5b | `fb47a4c` | `MoqLiteCodec` + every Lite-03 message type + `MoqLiteCodecTest` (round-trip + negative paths) |
| 5c | `4e136ca` | `MoqLiteSession.client(...)` (no SETUP), `announce`, `subscribe`, group uni-stream demux, framing helpers, `MoqLiteSessionTest` |
| 5d | `41f4dcd` | `connectNestsListener` swap — `MoqLiteNestsListener` adapts `MoqLiteFrame``MoqObject` for downstream `AudioRoomPlayer` / `AudioRoomViewModel`. WT URL path = `/<namespace>?jwt=<token>`. |
**Speaker path landed (phase 5c-speaker):**
| Layer | Surface |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WebTransportSession` | `incomingBidiStreams()` (relay → us) + `openUniStream()` (group push) added. |
| `:quic` `WtPeerStreamDemux` | `StrippedWtStream` carries optional `send` + `finish` closures (driver-aware) so peer-initiated bidis can write back; demux now takes the `QuicConnectionDriver` so wakeups fire after each app-level write. |
| `FakeWebTransport` | now exposes `incomingBidiStreams` + `openUniStream` directly, so production callers and tests use the same API. |
| `MoqLiteSession` | New `publish(suffix)` returns a `MoqLitePublisherHandle`. The session lazily launches a relay→us bidi pump that dispatches by `ControlType`: `Announce` → reply `Active(suffix=ours)`; `Subscribe` → reply `SubscribeOk` and register an inbound subscription. `MoqLitePublisherHandle.send` opens a uni stream per group with `DataType=0` + group header + `varint(size)+payload` frames; `endGroup` FINs the current uni; `close` emits `Announce(Ended)` on every active announce bidi. |
| `MoqLiteNestsSpeaker` | NestsSpeaker adapter that wraps a `MoqLiteSession` + `AudioRoomMoqLiteBroadcaster` (Opus encode + push loop, mirror of the IETF `AudioRoomBroadcaster` but driving the moq-lite publisher). |
| `connectNestsSpeaker` | now opens a `MoqLiteSession` (no SETUP) and returns `MoqLiteNestsSpeaker`. |
The existing integration tests (round-trip, multi-peer, fan-out,
subscribe-before-announce) drive the end-to-end public API — they
should now pass against the real Docker'd nostrnests stack when the
host runs them with `-DnestsInterop=true`.
## Implementation plan (original spec — kept for reference)
### Phase 5a — codec primitives (1 day)
- `MoqLiteVarint` — RFC 9000 varint reader/writer (we already have
`Varint` in `:quic`; reuse).
- `MoqLitePath` — string with `normalize()` (strip leading/trailing/
duplicate `/`) + `join(prefix, suffix)`.
- `MoqLiteWriter` / `MoqLiteReader` — varint, length-prefixed string,
size-prefixed message envelope.
### Phase 5b — message codec (12 days)
- `MoqLiteAnnouncePlease(prefix: String)`
- `MoqLiteAnnounce(status: AnnounceStatus, suffix: String, hops: Long)`
- `MoqLiteSubscribe(id, broadcast, track, priority, ordered,
maxLatency, startGroup, endGroup)`
- `MoqLiteSubscribeOk(priority, ordered, maxLatency, startGroup,
endGroup)`
- `MoqLiteSubscribeDrop(...)` (decode-only)
- `MoqLiteGroupHeader(subscribeId, sequence)`
- `MoqLiteFrame(payload)`
- Round-trip tests against hand-rolled byte sequences — exactly the
pattern `MoqCodecTest` uses today.
### Phase 5c — session layer (35 days)
- `MoqLiteSession` parallel to `MoqSession`:
- `client(transport, scope)` — no SETUP step; just spawn pumps.
- `announce(suffix)` — subscriber-side: opens bidi with
ControlType=Announce + AnnouncePlease(prefix=""), returns a flow
of incoming `Announce` updates.
- `publish(suffix)` — publisher-side: opens an uni-stream-per-group
pump under our broadcast.
- `subscribe(broadcast, track)` — opens bidi with
ControlType=Subscribe + body, awaits `SubscribeOk`, returns a
`MoqLiteSubscribeHandle` whose `frames` flow yields each frame
grouped by sequence.
- `close()` — close all streams.
- Path normalisation applied automatically at every wire boundary.
- ALPN wired through `:quic`'s WT factory: `"moq-lite-03"`.
### Phase 5d — production wiring (1 day)
- Replace `MoqSession.client(...)` calls in `connectNestsListener` /
`connectNestsSpeaker` with `MoqLiteSession.client(...)`.
- `subscribeSpeaker(pubkey)` becomes:
- subscribe `(broadcast=pubkey, track="catalog.json")` — discover
metadata
- subscribe `(broadcast=pubkey, track="audio/data")` — receive
Opus frames
- Speaker side: announce `(suffix=ourPubkey)`, publish `audio/data`
frames as one group per session (or rotate groups periodically).
- Drop the `TrackNamespace` plumbing on the nests side — it doesn't
apply to moq-lite.
### Phase 5e — integration tests (existing)
- The `-DnestsInterop=true` round-trip + multi-peer tests should now
pass against the real Docker'd moq-rs. No test rewrites needed; the
tests drive `connectNestsSpeaker` / `connectNestsListener` and
`listener.subscribeSpeaker(pubkey)` — purely through the public API.
## Decision points still open
- **Keep IETF MoQ-transport code (option A) vs delete (option B)?**
Recommend **A** — keeps `MoqSession` reachable for any future IETF
target, and the unit-test suite is genuinely useful as a reference
implementation. The IETF code is ~1.5k LOC of well-tested codec
that costs ~nothing to keep around.
- **Where does `MoqLiteSession` live?** Same package
(`com.vitorpamplona.nestsclient.moq`) under a `lite/` subpackage —
matches the upstream `rs/moq-lite/` layout.
## When picking up
This doc captures the wire spec used during implementation. For the
shipped surface, start from:
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/`
— `MoqLiteCodec`, `MoqLiteSession`, `MoqLitePath`, `MoqLiteFraming`.
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/`
`MoqLiteNestsListener.kt` / `MoqLiteNestsSpeaker.kt`.
- `nestsClient/src/jvmTest/.../interop/` — Docker-driven interop tests
(auth, round-trip, multi-peer, endpoint smoke).
For raw protocol reference:
- `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/`
— JS reference once the harness has run once.
- `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,path}.rs`
— canonical Rust implementation.
- `moq-auth/src/index.ts:160-166` — `claims.put = [pubkey]` rule.