The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
emits a different on-the-wire schema than the previous EGG-01 / EGG-09
drafts and Quartz writers. Verified by reading the production
NestsUI bundle. The deployed schema is now canonical:
kind:30312 (room event)
- relay URL → ["streaming", url] (was ["endpoint", url])
- auth URL → ["auth", url] (was ["service", url])
- live status → ["status", "live"] (was "open")
- ended status → ["status", "ended"] (was "closed")
- room name → ["title", name] (was ["room", name])
kind:10112 (user MoQ-server list)
- 3-element entries: ["server", relay, auth]
- first-element name "relay" accepted as a synonym (legacy)
- 2-element entries: derive auth host by replacing leading "moq."
with "moq-auth.", or prepending "moq-auth." otherwise
Implementation
- ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service"
- EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint"
- StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended";
legacy "open"/"closed" still parsed on read
- NestsServersEvent: emit/read 3-element [server, relay, auth] tags
with the legacy-shape tolerances above; expose a NestsServer pair
- Account.nestsServers.flow now produces List<NestsServer> pairs
- NestsServersScreen: rewritten edit-field asks for both URLs,
recommended row shows the nostrnests pair, list rows show both URLs
- NestsScreen first-time setup writes the nostrnests pair, not a
single URL; gate the create FAB on both URLs being parseable
- CreateNestViewModel: drop the resolveServerPair hardcoded mapping —
the saved pair is now authoritative; defaults stay correct for an
empty kind-10112 list
Specs
- EGG-01 rewritten to canonical streaming/auth/live/ended with a
legacy-spelling table; example uses the real nostrnests pair
- EGG-02 references "auth" tag throughout; error taxonomy says "ended"
- EGG-09 rewritten to 3-element server tag with derivation fallback
- New nestsClient/specs/nip-53-proposed.md — a single self-contained
proposed update to upstream NIP-53 covering kind 30312 + kind 10112
with the deployed schema and the JWT-mint flow
All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest,
and nestsClient:jvmTest pass.
8.5 KiB
EGG-02: Auth & WebTransport handshake
status: draft
requires: EGG-01, NIP-98
category: required
Summary
To open the audio plane (EGG-03), a peer first proves identity to a per-room auth sidecar (moq-auth) using a NIP-98 HTTP signature, receives a short-lived JWT, then opens a WebTransport session against the moq-relay carrying the JWT in the URL.
Two separate URLs are involved: the auth tag from EGG-01 is the auth
sidecar (HTTP/1.1 or HTTP/2 over TCP); the streaming tag is the moq-relay
(WebTransport over QUIC). The deployed nostrnests reference puts these on
different hosts — see EGG-01 §4 for why they cannot be collapsed.
Wire format
Step 1 — token request
POST <auth>/auth
Authorization: Nostr <base64(NIP-98 kind:27235 event JSON)>
Content-Type: application/json; charset=utf-8
{ "namespace": "nests/<kind>:<host_pubkey_hex>:<room_d>",
"publish": <boolean> }
<kind> is 30312 for nests audio rooms (EGG-01).
The <auth> URL is the EGG-01 auth tag value with any trailing /
stripped, then /auth appended literally. (The same path component is
used regardless of how the host is named — <auth> here is just the
sidecar base URL, so the full request line is POST <auth-base>/auth.)
The body is a single-line UTF-8 JSON object — the server hashes the exact
bytes sent to compare against NIP-98's payload tag, so producers MUST
NOT pretty-print or re-order keys after signing.
The <host_pubkey_hex> is the host's pubkey, lowercase hex. The <room_d>
is the EGG-01 d tag (which by EGG-01 rule 9 cannot contain :, so the
namespace is unambiguous without escaping).
NIP-98 event shape
The signed kind 27235 event MUST carry exactly these tags (NIP-98 §1):
{
"kind": 27235,
"pubkey": "<requester pubkey hex>",
"created_at": <unix seconds>,
"tags": [
["u", "<auth>/auth"], // exact request URL, scheme included
["method", "POST"],
["payload", "<sha256 of request body, lowercase hex>"]
],
"content": "",
"id": "<...>",
"sig": "<...>"
}
The event JSON is then serialized (NIP-01 canonical form), encoded as
RFC 4648 standard Base64 (NOT base64url; pad with =), prefixed with
Nostr (literal, including the trailing space), and placed in the
Authorization header.
Step 2 — token response
HTTP/1.1 200 OK
Content-Type: application/json
{ "token": "<jwt>" }
The JWT carries (at minimum):
| claim | meaning |
|---|---|
root |
The exact namespace echoed back. Authorisation is scoped here. |
get |
Read-allowed sub-paths; for nests this is [""] (any). |
put |
Publish-allowed sub-paths. For listeners: []. For speakers: [<own pubkey hex>]. |
iat |
Unix seconds; issuance. |
exp |
Unix seconds; expiry. MUST be iat + 600 for nests today. |
JWT signing
The JWT MUST be signed with alg: "ES256" (ECDSA on the NIST P-256 curve,
RFC 7518 §3.4). The auth sidecar MUST publish its public verification keys
as a JWKS at:
GET <auth>/.well-known/jwks.json
The response is an application/json body shaped per RFC 7517:
{
"keys": [
{
"kty": "EC", "crv": "P-256", "alg": "ES256",
"use": "sig", "kid": "<key id>",
"x": "<base64url x>", "y": "<base64url y>"
}
]
}
The relay (EGG-03 streaming endpoint) MUST verify inbound JWTs against this JWKS. Relays SHOULD cache the JWKS for at most 5 minutes so a key rotation propagates without requiring a relay restart. A relay that cannot reach the JWKS endpoint MUST refuse new sessions rather than fall through to "trust the unverified token".
Step 3 — WebTransport CONNECT
:method = CONNECT
:protocol = webtransport
:scheme = https
:authority = <host:port from `streaming`>
:path = /<namespace>?jwt=<token>
The relay reads the JWT from the ?jwt= query string. No Authorization
header is used at this step.
Behavior
- Listening peers MUST request
publish: false. Speaking peers MUST requestpublish: trueAND the JWT'sputclaim MUST list the speaker's own pubkey hex. - The auth sidecar's TLS certificate MUST be a publicly-trusted chain. The relay's TLS chain MAY be self-signed in development; production deployments MUST use a public chain.
- The auth sidecar SHOULD reject any request whose NIP-98
created_atis more than 60 s in the past or future. - The JWT lifetime is fixed at 600 s. There is no refresh endpoint. A client
that needs a longer session MUST mint a fresh token and open a new
WebTransport session before the old token expires; see the deployment-side
commentary in
nestsClient/plans/. - The relay MUST close the WebTransport session within 30 s of
exp. A peer receiving an unexpected close MUST be prepared to mint a fresh token and reconnect. - The relay MUST NOT trust the WebTransport authority for authorisation —
only the JWT's
rootclaim. A token issued for namespace A MUST NOT be accepted on a session opened against namespace B. - A peer MUST NOT log the JWT or include it in error reports. The token is a bearer credential.
Error taxonomy
The auth sidecar MUST use the following HTTP status codes for POST /auth.
Bodies are application/json and follow the shape
{ "error": "<machine slug>", "reason": "<human string>" }. Receivers
MAY ignore reason but MUST surface error to user-facing error toasts.
| status | error slug |
when |
|---|---|---|
| 200 | — | success; body is { "token": "<jwt>" } |
| 400 | bad_request |
malformed JSON body, missing namespace, unknown content-type |
| 400 | bad_namespace |
namespace does not match nests/<kind>:<hexpubkey>:<d> |
| 401 | bad_nip98 |
Authorization header missing, malformed, or id/sig invalid |
| 401 | wrong_url |
NIP-98 u tag does not match the actual request URL |
| 401 | wrong_method |
NIP-98 method tag is not POST |
| 401 | wrong_payload |
NIP-98 payload tag does not match sha256 of the body bytes |
| 401 | stale |
NIP-98 created_at outside the ±60 s tolerance |
| 403 | room_closed |
room status is ended (EGG-01) or planned (EGG-08) |
| 403 | not_invited |
room status is private and requester is not on the allowlist |
| 403 | publish_forbidden |
publish: true requested but caller is not a speaker per EGG-07 |
| 410 | unknown_room |
no kind:30312 known to the sidecar for (host, d) |
| 429 | rate_limited |
per-pubkey or per-IP rate limit; Retry-After header SHOULD be set |
| 5xx | internal |
sidecar internal error |
Receivers MUST treat unknown 4xx slugs as "fatal, do not retry" and unknown 5xx slugs as "transient, retry with exponential backoff".
The relay (EGG-03 streaming endpoint) signals authorization failures through WebTransport CONNECT response codes, NOT through the auth-sidecar table:
| WT status | meaning |
|---|---|
| 200 | session established |
| 401 | JWT signature invalid, expired, or fails the JWKS check |
| 403 | JWT root does not match the path namespace, or put claim missing for a publishing peer |
| 404 | path namespace unknown to the relay |
Example
> POST https://moq-auth.nostrnests.com/auth
> Authorization: Nostr eyJ...kind27235...
> Content-Type: application/json
>
> {"namespace":"nests/30312:abchost:office-hours-2026-04","publish":false}
< HTTP/1.1 200 OK
< Content-Type: application/json
<
< {"token":"eyJhbGc..."}
> CONNECT :path=/nests/30312:abchost:office-hours-2026-04?jwt=eyJhbGc...
< 200 OK (WebTransport session established)
Compatibility
EGG-03 (audio plane) operates inside the WebTransport session this EGG opens. EGG-12 (catalog) shares the same session.