Files
amethyst/docs/plans/2026-04-22-nip-audio-rooms-draft.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

421 lines
16 KiB
Markdown

# NIP-XX — Interactive Audio Rooms Join Protocol
`draft` `optional`
> **Revision 2026-04-26.** Replaces the IETF MoQ-transport / `GET <service>/<room-d-tag>`
> sketch from the original draft. The wire described here matches the
> `nostrnests/nests` + `kixelated/moq` reference deployment running in
> production today, and is what `amethyst/nestsClient` implements.
> See "Reconciliation with previous spec drafts" near the bottom.
## Abstract
This NIP specifies the client/server control plane and real-time audio
transport for joining a NIP-53 Meeting Space (kind `30312`) as a listener
or speaker. It closes the gap between NIP-53's room discovery (which
defines only *what* a room is) and what an audio-capable client must do
to actually hear and speak in it.
The transport is **moq-lite Lite-03** over WebTransport with a thin
NIP-98-authenticated HTTP `/auth` endpoint that mints an ES256 JWT. A
client that follows this NIP will work against any server speaking the
same wire shape.
## Dependencies
- [NIP-53: Live Activities](https://github.com/nostr-protocol/nips/blob/master/53.md)
defines kind `30312` (Meeting Space), kind `10312` (Room Presence),
and kind `1311` (Live Activity Chat).
- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)
defines kind `27235` and the `Authorization: Nostr <base64-event>` header.
- [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)
carries the audio transport.
- **moq-lite Lite-03** (kixelated/moq) — the application-level framing
on top of WebTransport. Selected by ALPN `"moq-lite-03"`. Wire spec:
`kixelated/moq-rs/rs/moq-lite/src/`. NOT to be confused with IETF
`draft-ietf-moq-transport`, which is wire-incompatible.
## Terminology
- **Host** — the pubkey that signed the kind `30312` event.
- **Speaker** — any pubkey listed in the `30312` event's `p` tags with role
`host` or `speaker`.
- **Listener** — any other participant (including un-tagged members of the
audience).
- **MoQ endpoint** — the WebTransport URL a server returns from its room-info
HTTP endpoint; where audio flows once the session is established.
## Event kinds used
### Kind 30312 (already defined by NIP-53)
Clients and servers compliant with this NIP MUST honour these tags:
| Tag | Meaning | Required |
|---|---|---|
| `d` | Room identifier. The "room id" everywhere else in this spec. | yes |
| `room` | Human-readable display name. | recommended |
| `summary` | Short description. | optional |
| `image` | Room cover URL. | optional |
| `status` | `open` \| `private` \| `closed`. | yes |
| `service` | HTTPS base URL of a server implementing the HTTP control plane defined below. | yes |
| `streaming` | Present for non-audio streams (e.g. `wss+livekit://…`, HLS). **MAY be omitted** for pure audio rooms — in that case audio flows via the MoQ endpoint returned by the `service` URL. | optional for audio rooms |
| `p` | `["p", <pubkey>, <relay-hint>?, <role>?, <proof>?]` — role is `host` or `speaker`. | one `host` required |
A client MUST reject a room whose `service` URL is not HTTPS.
### Kind 10312 (Room Presence) — extended
NIP-53 defines this event with an optional `["hand", "1"|"0"]` tag. This NIP
adds a second optional tag:
| Tag | Meaning |
|---|---|
| `["muted", "1"]` or `["muted", "0"]` | The participant's microphone is muted (`1`) or hot (`0`) at the time of publish. Absent means unspecified. |
Servers and peers MUST NOT rely on `muted` for authorization — it's a UI
signal, not an access control. The server still enforces who is allowed to
publish audio via the MoQ session.
### Kind 1311 (Live Activity Chat)
Used per NIP-53 with no changes. Each room's chat is scoped by the `a` tag
pointing at the `30312` event.
### Kind 4312 (Admin Command) — moderation
Ephemeral event used by hosts and admins to issue moderation actions:
```
{ "kind": 4312, "content": "",
"tags": [
["a", "30312:<host-pubkey>:<room-d-tag>"],
["p", "<target-pubkey>"],
["action", "kick" | ...] ] }
```
Recipients matching the `p` tag MUST verify the signer is a `host` or
`admin` per the room's current `30312` event before acting. The
target self-disconnects on `action=kick`. The host SHOULD also
re-publish the `30312` with the target's `p`-tag removed so any
future joiner sees the updated roster.
### Kind 10112 (Audio-room server list)
Replaceable event listing a user's preferred MoQ host servers. Wire
shape mirrors NIP-B7's BlossomServersEvent (kind 10063):
```
{ "kind": 10112,
"tags": [
["alt", "Audio-room (nests) MoQ servers used by the author"],
["server", "https://moq.nostrnests.com"],
["server", "https://moq.example.org"],
... ],
"content": "" }
```
Each `["server", <baseUrl>]` URL is a moq-auth + moq-relay base URL.
Clients SHOULD consume this event when "starting a new space" to
default the `service` / `endpoint` tag fields on the kind-30312
event they're about to publish.
## HTTP control plane
### Base URL
The `service` tag from the kind `30312` event is the base URL of the
auth sidecar (a.k.a. `moq-auth`). It exposes exactly two routes:
```
POST <service>/auth — mint a JWT for one room+role
GET <service>/.well-known/jwks.json — public keys for JWT verification
```
The server SHOULD also expose `GET <service>/health` returning
`{"status":"ok"}` for liveness probes. Anything else SHOULD return 404.
### Authentication
Every `POST /auth` request MUST carry a NIP-98 `Authorization` header:
```
Authorization: Nostr <base64(kind-27235-event)>
```
The kind `27235` event MUST have:
- `["u", "<fully-qualified-URL-being-requested>"]`
- `["method", "POST"]`
- `["payload", "<sha256-hex of the request body>"]` (NIP-98 §2.2)
- `created_at` within 60 s of the server's clock
- A valid signature
The server MUST reject requests older than 60 s with `401 Unauthorized`.
### Join / room-info response
`POST <service>/auth` body:
```json
{ "namespace": "nests/30312:<host-pubkey>:<room-d-tag>", "publish": false }
```
The `namespace` MUST match the regex
`^nests/\d+:[0-9a-f]{64}:[a-zA-Z0-9._-]+$`
(`<event-kind>:<host-pubkey>:<d-tag>`, where `<event-kind>` is `30312`
for now). `publish` is `true` for a host/speaker minting a publish
token, `false` (or omitted) for a listener.
Response on success:
```json
{ "token": "eyJhbGciOi…" }
```
The `token` is an ES256 JWT signed by the `service` server's keypair;
its public key is available at `<service>/.well-known/jwks.json`. The
relay (`moq-relay`) refreshes the JWKS every 30 s.
JWT claims:
| Claim | Meaning |
|---|---|
| `root` | Echoed `namespace` value. The relay matches this against the WT URL path. |
| `get` | `[""]` — listener may subscribe to anything under `root`. |
| `put` | `[<requester's pubkey>]` — publisher may only `ANNOUNCE` under `root/<pubkey>`. Present only when `publish: true`. |
| `iat` / `exp` | Standard. Token lifetime is **600 s**; clients re-mint on expiry. |
Error responses:
| Status | When |
|---|---|
| `400` | Body missing / malformed JSON / `namespace` fails the regex. |
| `401` | Authorization missing, signature invalid, `u`/`method`/`payload` mismatch, or `created_at` outside ±60 s. |
| `429` | Rate-limited (≥ 20 mint requests per IP per 60 s). |
There is **no per-room HTTP info endpoint, no `/permissions`, no
`/recording*`** — the only mutable per-room state lives in Nostr
events (kind 30312 for the room, 10312 for presence, 1311 for chat,
etc.).
## Audio transport
### WebTransport
Clients open a WebTransport session against the `endpoint` URL using
the Extended CONNECT handshake
([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) +
[`webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)):
```
:method: CONNECT
:protocol: webtransport
:scheme: https
:authority: <host[:port] from endpoint>
:path: /<namespace>?jwt=<token>
```
The path component is the **`namespace`** the JWT was minted for —
i.e. exactly the value sent in the `POST /auth` body. The relay
matches it against `claims.root` and rejects any mismatch with HTTP
401 (`IncorrectRoot`). The token is delivered as the **`?jwt=`** query
parameter; the relay does **not** inspect the `Authorization` header.
Servers MUST advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1`,
`SETTINGS_ENABLE_WEBTRANSPORT = 1`, and `SETTINGS_H3_DATAGRAM = 1`.
### moq-lite session
The application-level framing is **moq-lite Lite-03** (kixelated/moq).
The variant is selected by the WebTransport ALPN — clients SHOULD
advertise `"moq-lite-03"` (and MAY include the legacy `"moql"`).
There is **no in-band SETUP message** in Lite-03 — the WebTransport
handshake itself is the handshake. After the WT session opens, both
sides go straight to opening per-purpose streams.
### Per-bidi `ControlType` discriminator
Every client-initiated bidi opens with a single varint
`ControlType` byte that selects the message family:
| Code | Name |
|---|---|
| 1 | Announce (subscriber-of-announces ↔ publisher) |
| 2 | Subscribe (subscriber → publisher) |
| 3 | Fetch (decl. only; not used for live audio) |
| 4 | Probe (bitrate hint) |
Body framing on every bidi/uni stream is `varint(size) + payload bytes`.
Strings are `varint(length) + UTF-8`. Integers are RFC 9000 §16
varints.
### Speaker — publisher path
The relay opens both `Announce` and `Subscribe` bidis **to** the
publisher (publisher accepts inbound bidis). For each:
- `Announce` body: `AnnouncePlease { prefix: string }`. The publisher
replies `Announce { status: u8 (0=Ended,1=Active), suffix: string,
hops: u62 }` with `status=1`, `suffix=<own pubkey>`. Publishers
MUST emit `status=0` on graceful shutdown.
- `Subscribe` body: `Subscribe { id: u62, broadcast: string,
track: string, priority: u8, ordered: u8, maxLatencyMillis: varint,
startGroup: varint, endGroup: varint }`. The publisher replies
`SubscribeOk { priority, ordered, maxLatencyMillis, startGroup,
endGroup }`.
Per-group audio bytes flow on **client-initiated uni streams** the
publisher opens. Each uni stream has the layout:
```
DataType varint = 0 (Group)
GroupHeader (size-prefixed): { subscribe: u62, sequence: u62 }
frames until QUIC FIN: { size: varint, payload: <Opus packet> }
```
### Listener — subscriber path
A listener opens its own client-initiated bidis to the relay:
- `Announce` (ControlType=1) with
`AnnouncePlease { prefix: <empty or namespace prefix> }` to receive
a flow of `Announce` updates from the relay (one per active
publisher).
- `Subscribe` (ControlType=2) per `(broadcast, track)` pair the listener
wants. The relay replies `SubscribeOk` and forwards group uni
streams as the publisher emits them.
For a nests audio room the listener's wire usage per speaker is:
| Field | Value |
|---|---|
| `broadcast` | `<speaker-pubkey-hex>` (single string, no `nests/` prefix) |
| `track` | `"audio/data"` |
| Optional metadata track | `"catalog.json"` (JSON description of the broadcast — clients MAY skip and just subscribe to `audio/data`). |
Path normalisation (strip leading/trailing/duplicate `/`) is
**mandatory** on every wire boundary; `"/foo//bar/"` and `"foo/bar"`
MUST round-trip identically.
### Audio frame format
Each frame payload on the `audio/data` track is one **Opus packet** in
raw form (no Ogg, no TOC prefix), as produced by `libopus` /
Android `MediaCodec("audio/opus")` output:
- 48 000 Hz, mono, signed-16-bit PCM domain, 20 ms frame duration, VBR.
There is **no per-frame envelope** beyond the size varint — no
timestamp, no codec config, no flags. Receivers reconstruct timing
from frame arrival + the group sequence.
### Unsubscribe / close
There is **no UNSUBSCRIBE message**: a subscriber FINs the send side
of the subscribe bidi and the relay tears down. A publisher closes by
emitting `Announce { status: 0=Ended }` on every active announce bidi
and FINing the current group's uni stream.
Errors on any stream are conveyed by `RESET_STREAM` with a u32 error
code; there is no SUBSCRIBE_ERROR / ANNOUNCE_ERROR message.
### Leaving the room (Nostr-side)
In addition to the wire-level cleanup above, a leaving client SHOULD:
1. Publish a final kind `10312` presence event with
`["muted","1"]`, `["onstage","0"]`, no `["hand","1"]` to flush
stale UI on other peers.
2. Close the WebTransport session with capsule type `0x2843`
(`WT_CLOSE_SESSION`, code `0`).
## Chat
Per NIP-53 kind `1311`. No additions.
## Server requirements summary
A server claiming this NIP MUST:
1. Accept NIP-98-signed requests at `<service>/<d-tag>` and return the
JSON shape above on success.
2. Enforce the `status` on the kind `30312` event (reject join when
`closed`).
3. Enforce `p` tag role for publishers (speakers only).
4. Run a WebTransport / MoQ endpoint that speaks the `moq_version`
advertised in its room-info response.
5. Publish each speaker's Opus audio on track
`[<d-tag>] / <speaker-pubkey-hex>` and accept SUBSCRIBEs from any
authenticated listener.
6. Not require any fields beyond those listed in this NIP for basic
interop (but MAY include additional fields in its JSON response for
server-specific features; clients ignore unknown fields).
## Client requirements summary
A client claiming this NIP MUST:
1. Resolve the `service` URL via NIP-98 GET before opening a transport.
2. Establish WebTransport against the returned `endpoint` with the
returned `token` as a Bearer header.
3. Run the MoQ handshake at the returned `moq_version`.
4. SUBSCRIBE to one track per `host`+`speaker` pubkey listed in the
room event.
5. Decode Opus per the returned `sample_rate` / `frame_duration_ms`.
6. Publish kind `10312` presence no less frequently than every 30 s while
joined, with `["muted", …]` reflecting the local user's microphone
state and `["hand", "1"]` when requesting to speak.
7. Fail closed on unknown `transport`, unknown `codec`, or missing
`endpoint` fields.
## Reference implementation
A compliant Kotlin/Android reference client ships in
[`amethyst/nestsClient`](../..). The reference server is the
nostrnests stack:
[`nostrnests/nests`](https://github.com/nostrnests/nests) (auth
sidecar) +
[`kixelated/moq`](https://github.com/kixelated/moq) (relay).
This NIP describes the wire as the reference server speaks it — there
is no "vendor-neutral" alternative being trialled. Any server claiming
compliance MUST speak the moq-lite Lite-03 framing detailed above and
the `POST /auth` HTTP shape verbatim.
## Reconciliation with previous spec drafts
Earlier drafts of this NIP described the audio transport in terms of
IETF `draft-ietf-moq-transport` (CLIENT_SETUP / SERVER_SETUP, namespace
tuples, OBJECT_DATAGRAM) and a `GET <service>/<room-d-tag>` HTTP
control plane returning `{endpoint, token, codec, sample_rate, …}`.
Both have been **superseded**. nostrnests's reference deployment uses
`POST /auth` with a `{namespace, publish}` body returning `{token}`,
and runs moq-lite Lite-03 (single-string broadcast/track names, group-
per-uni-stream framing, no in-band SETUP). IETF MoQ-transport remains
a possible future ALPN, but no audio-rooms server implements it today
and this NIP no longer specifies it.
## Security considerations
- The `token` returned by the server is a bearer token. Clients MUST NOT log
it. Servers MUST scope it to a single room + pubkey and SHOULD expire it
within the room's lifetime.
- The server MUST validate the NIP-98 event's `u` tag matches the exact
request URL, preventing token reuse across rooms.
- Audio published by a pubkey `P` on this server can be replayed by a
malicious peer claiming to be `P` on a different server — the listener
has no signature on the Opus packets themselves. If end-to-end authenticity
matters, a future NIP can extend this by signing each object with the
speaker's Nostr key; this NIP leaves that out of scope.
- A malicious server can serve silent audio while presence indicators show
a speaker talking. Clients SHOULD expose signal-level metering (VU meter)
so users can spot this.
## Discussion
Send feedback to the author via Nostr or GitHub issues on the reference
client repository.