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
This commit is contained in:
Claude
2026-04-22 13:27:18 +00:00
parent 2b44e9b06b
commit ac1e751bec
@@ -0,0 +1,334 @@
# NIP-XX — Interactive Audio Rooms Join Protocol
`draft` `optional`
## 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.
A standard, vendor-neutral profile lets multiple server implementations
interoperate with multiple clients. A client that follows this NIP will
work against any server that advertises compliance, and vice versa.
## 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 `moq-transport`](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/)
and [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)
provide the audio transport substrate.
## 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.
## HTTP control plane
### Base URL
The `service` tag from the kind `30312` event is the base URL. All control-
plane requests are constructed as:
```
GET <service>/<room-d-tag> — room-info / join
```
where `<room-d-tag>` is the `d` tag value of the kind `30312` event,
URL-path-encoded.
The server MAY expose additional paths under the same base (e.g.
`<service>/` for server metadata, `<service>/.well-known/nostr-audio-rooms`
for discovery). This NIP only specifies `<service>/<room-d-tag>`.
### Authentication
Every 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", "GET"]` (or `POST`, `DELETE`, etc. matching the request verb)
- `created_at` within 60 s of the server's clock
- A valid signature
The server SHOULD reject requests older than 60 s with `401 Unauthorized`.
Servers MAY also reject requests whose signer isn't allowed in the room
(e.g. the room is `private` and the signer isn't on the allow-list).
### Join / room-info response
`GET <service>/<room-d-tag>` with a valid NIP-98 header returns a JSON body
with `Content-Type: application/json`:
```json
{
"endpoint": "https://relay.example.com:4443/moq",
"token": "eyJhbGciOi…",
"transport": "webtransport",
"codec": "opus",
"sample_rate": 48000,
"frame_duration_ms": 20,
"moq_version": "draft-17"
}
```
Fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
| `endpoint` | string (https URL) | yes | WebTransport URL the client connects to. |
| `token` | string | yes | Opaque bearer token the client passes to the WebTransport layer (see below). |
| `transport` | string | no, defaults to `"webtransport"` | Reserved for future transports. Clients MUST fail closed on unknown values. |
| `codec` | string | no, defaults to `"opus"` | Audio codec name. Reserved for future codecs. Clients MUST fail closed on unknown values. |
| `sample_rate` | integer | no, defaults to `48000` | Samples per second. |
| `frame_duration_ms` | integer | no, defaults to `20` | Audio frame duration. |
| `moq_version` | string | no, defaults to server's preferred | Identifier of the MoQ-transport draft the server speaks (e.g. `"draft-17"`). Clients MUST include this version (and nothing else) in CLIENT_SETUP. |
Unknown fields MUST be ignored by the client to allow forward-compatible
server extensions.
Error responses are standard HTTP status codes with an optional JSON body
`{"error": "<short-code>", "reason": "<human-readable>"}`:
| Status | When |
|---|---|
| `401` | NIP-98 missing, expired, or signature invalid. |
| `403` | Signer isn't allowed in this room (e.g. room `closed`, signer blocked). |
| `404` | Room `d` tag unknown to this server. |
| `410` | Room has ended. |
| `503` | Server is healthy but audio backend is unavailable. |
## 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)
+ the [WebTransport-HTTP/3 draft](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)).
The HTTP CONNECT request MUST carry:
```
:method: CONNECT
:protocol: webtransport
:scheme: https
:authority: <host[:port] from endpoint>
:path: <path from endpoint>
Authorization: Bearer <token from room-info response>
```
Servers MUST:
- advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1` ([RFC 8441](https://www.rfc-editor.org/rfc/rfc8441)),
- advertise `SETTINGS_ENABLE_WEBTRANSPORT = 1`,
- advertise `SETTINGS_H3_DATAGRAM = 1` ([RFC 9297](https://www.rfc-editor.org/rfc/rfc9297)).
### MoQ session
After the WebTransport session is open, the client opens its first
bidirectional stream as the MoQ control stream and sends `CLIENT_SETUP`
advertising the `moq_version` from the room-info response. The server replies
with `SERVER_SETUP` selecting that version, or closes the session.
### Track namespace + name
A speaker's audio track is published by the server and subscribed-to by
clients under:
```
track_namespace = [ <room-d-tag> ]
track_name = <speaker-pubkey-hex> (64 lowercase hex chars)
```
The namespace is a **one-element tuple** containing the room's `d` tag. It is
intentionally vendor-neutral: there is no `"nests"` or server-brand prefix.
Rationale: the namespace is uniquely keyed by the NIP-53 room id and is
sufficient for a client to subscribe without knowing anything about the
server's brand. Multiple servers hosting rooms with the same `d` tag is
already not a concern — a `d` tag is unique under a host pubkey, and the
room-info response identifies exactly which server's MoQ endpoint the
client must connect to.
### Audio objects
Each OBJECT on a speaker's track carries one Opus packet as its payload:
- Opus encapsulated in **raw packet form** (no Ogg / no TOC-prefix), as
produced by `libopus` / Android `MediaCodec("audio/opus")` encoder output.
- `sample_rate` from the room-info response (default 48 000 Hz).
- `frame_duration_ms` from the room-info response (default 20 ms).
- Mono (single channel), signed 16-bit PCM domain, VBR encoding.
Object delivery MAY use either:
- **OBJECT_DATAGRAM** (lowest latency, lossy) — recommended for live audio.
- **STREAM_HEADER_SUBGROUP** uni-streams (reliable) — MAY be used by servers
that need delivery guarantees; clients MUST support receiving both.
`group_id` on each object is the speaker's monotonic group counter (one
group per speaker session). `object_id` is the zero-based Opus packet index
within the group. `publisher_priority` is `0x80` unless the server has
reason to vary it.
### Server-to-client vs. client-to-server audio
A **listener** SUBSCRIBEs to each speaker's track namespace + track name.
The server MUST accept SUBSCRIBEs from any authenticated client (subject to
its access control).
A **speaker** ANNOUNCEs `[ <room-d-tag> ]` and publishes objects on track
name `<speaker-pubkey-hex>`. The server MUST verify that the announcing
pubkey is listed in the room's `p` tag set with role `host` or `speaker`
**at the current moment** (the room event is replaceable — the set can
change). On role revocation, the server MUST close the publisher's track.
### Leaving
To leave, the client SHOULD:
1. UNSUBSCRIBE every track it had open.
2. If it was a speaker, send UNANNOUNCE + `SubscribeDone` for its own track.
3. Publish a final kind `10312` presence event (optional, improves UX for
other peers) with `["muted","1"]` and without `["hand","1"]`.
4. Close the WebTransport session with capsule type `0x2843`
(`WT_CLOSE_SESSION`, code `0`).
The server SHOULD treat 30 s without a kind `10312` refresh (per NIP-53) as
"left" for UI purposes.
## 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 is in development at
[`amethyst/nestsClient`](../..) — see `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`
for the transport work-in-progress.
## Known divergences from current nostrnests/nests servers
At the time of writing, existing `nostrnests/nests` deployments use:
- MoQ track namespace `[ "nests", <d-tag> ]` (two elements, `"nests"` prefix).
This NIP specifies `[ <d-tag> ]` (one element). Existing nests servers
SHOULD accept both for a transition period; new deployments SHOULD use the
one-element form.
- A `/api/v1/nests/<d-tag>` path convention. This NIP leaves the path
entirely to the `service` tag — servers are free to pick any path.
A "compliance" phase is proposed where a server can advertise
`"nip_xx": true` in its room-info JSON response to signal it implements
this NIP verbatim. Clients MAY use that flag to choose the vendor-neutral
namespace when present, and fall back to the legacy nests convention when
absent.
## 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.