Files
amethyst/nestsClient/specs/EGG-02.md
T
Claude bd12d5ef72 docs(nestsClient/specs): close interop gaps surfaced in spec review
Edits across all 13 EGGs to remove implementer guesswork. After this an
implementer can build a listener and speaker without reading our source.

README:
- Conventions section: hex casing rule (lowercase, 64 chars, no 0x),
  NIP-01 foundations, `a`-tag form, created_at tie-break, JSON / time.
- Joining sequence: numbered end-to-end walkthrough from `kind:30312`
  to first audio frame, referencing each EGG.

EGG-01 (room event):
- `relays` tag is one tag with multiple values (not multi-tag).
- `service` URL trailing-slash normalization.
- `private` status: gate is implementation-defined, not "render as open".
- `d`-tag charset locked to [A-Za-z0-9._-] so it interpolates safely
  into the moq-auth namespace.
- Relay-discovery rule: publish to `relays` tag ∪ NIP-65 outbox.
- Tie-break on identical created_at via smallest event id.

EGG-02 (auth):
- JWT signing pinned: ES256 over P-256, JWKS at /.well-known/jwks.json,
  5-minute relay cache.
- NIP-98 tags pinned: u / method / payload, base64 RFC 4648 standard
  (not base64url).
- Error taxonomy: full HTTP status + `error` slug table for /auth, plus
  WebTransport CONNECT 200/401/403/404 table.

EGG-03 (audio):
- Pin moq-lite Lite-03 to kixelated/moq-rs `rs/moq-lite/src/lite/`.
- One Opus packet per moq Frame (no container, no timestamp).
- Pubkey hex casing reaffirmed at the suffix / broadcast slots.
- AnnouncePlease prefix="" for speaker discovery.
- Mute = stop publishing (not silence frames, not Announce Ended).
- Mid-stream join: discard pre-skip per RFC 7845.

EGG-04 (presence):
- Heartbeat jitter ±5 s required (anti-thundering-herd).
- "0"/"1" are strings, not booleans.
- Single-room rule via replaceable-event semantics.

EGG-05 (chat): 8 KB suggested, 64 KB hard cap, 3 msg/s render rate.
EGG-06 (reactions): 30s window measured against created_at, drop-on-arrival
                    if already stale.
EGG-07 (moderation): replay protection — dedupe kicks by id within 120 s.
EGG-08 (scheduling): planned rooms MUST 403 at the auth sidecar.
EGG-10 (theming): bg image caps (1 MB / 4096 px soft, 8 MB / 8192 px hard).

Deferred (per review): EGG-00 (Conventions as a standalone spec), EGG-13
(capability advertisement), EGG-14 (discovery), test vectors corpus.
The "Conventions" section in README covers EGG-00's most urgent content
inline.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 12:31:19 +00:00

8.1 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 service tag from EGG-01 is the auth sidecar; the endpoint tag is the relay.

Wire format

Step 1 — token request

POST <service>/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 <service> URL is the EGG-01 service tag value with any trailing / stripped, then /auth appended literally.

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",       "<service>/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 <service>/.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 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 `endpoint`>
:path    = /<namespace>?jwt=<token>

The relay reads the JWT from the ?jwt= query string. No Authorization header is used at this step.

Behavior

  1. Listening peers MUST request publish: false. Speaking peers MUST request publish: true AND the JWT's put claim MUST list the speaker's own pubkey hex.
  2. 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.
  3. The auth sidecar SHOULD reject any request whose NIP-98 created_at is more than 60 s in the past or future.
  4. 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/.
  5. 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.
  6. The relay MUST NOT trust the WebTransport authority for authorisation — only the JWT's root claim. A token issued for namespace A MUST NOT be accepted on a session opened against namespace B.
  7. 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 closed (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 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.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.