add NIP-29 LiveKit token and fork detection

This commit is contained in:
2026-09-30 13:08:05 +07:00
parent cd7b459751
commit cfec6dfc85
7 changed files with 369 additions and 19 deletions
+83 -16
View File
@@ -490,18 +490,61 @@ group's relay only, and treat the relay's `OK` as the truth.
"Absent" and "empty" are deliberately the same value here: unlike
`supported_kinds`, the NIP gives the empty list no special meaning, so the
UI reads "no one is live" either way.
2. The `/.well-known/nip29/livekit/<group-id>` token endpoint: a NIP-98
`27235` event in `Authorization`, returning the LiveKit JWT and server URL,
plus the `204` probe at `/.well-known/nip29/livekit` that tells the UI
whether a relay supports AV at all. Not started. This is the only part that
needs an HTTP client in the crate, so it is where the dependency decision
(if any) lands; `livekit` on `39000` is already parsed but currently inert.
3. Fork/migration detection: read the `10009` of the group's admins (pubkeys
from `39001`, cached locally) and compare the relay hint against the relay
we are talking to; a different hint means the group may have moved or been
forked, which is the client's cue to offer fetching from the new relay.
Not started; the periodic — and MUST-when-offline — trigger is what
`Monitor` would feed, so the two decisions go together.
2. The `/.well-known/nip29/livekit/<group-id>` token endpoint and the `204`
probe at `/.well-known/nip29/livekit`. **Done.** The HTTP client is the one
GPUI already owns: `cx.http_client()` returning `Arc<dyn HttpClient>`,
already wired to `reqwest_client::ReqwestClient` on desktop and to `fetch`
on wasm — the same path `state::nip05` uses, which is why no HTTP
dependency lands in this crate. Its `get()` convenience cannot set headers,
so the request goes through `HttpClient::send` with a `Builder`, an
explicit `RedirectPolicy::NoFollow` (the `Authorization` header is signed
for exactly one URL, so a redirect could only ever fail verification) and a
10s `timeout` — both are opt-in extensions, and a request without them has
none. The header itself is NIP-98 from the SDK: `nostr/nip98` is now
enabled on the workspace `nostr` entry and nip29 declares `nostr` itself,
so `HttpData::new(url, HttpMethod::GET).to_authorization(&signer)` builds
`Nostr <base64>` — the `u` + `method` tags that the crate's own
`verify_auth_header` demands, though the NIP's prose mentions only `u`.
`GroupKey::livekit_token_url` / `livekit_probe_url` derive the endpoint
purely: the relay URL is rewritten to http(s) (keeping host and port),
reset to the origin, and the path built with `pop_if_empty().push(..)` so
each segment is escaped — a relay with its own path prefix loses it, since
the NIP puts the endpoint at the absolute path. `Group::livekit_session`
and `Group::livekit_supported` are the two calls, both `&App` +
`background_spawn` like `publish`. The session is returned, not stored:
tokens are per-join artifacts, not group state, so the caller owns it.
Only `livekit_supported`'s unauthenticated GET ignores status beyond
`204`; the token endpoint's response shape is **not specified by the NIP**
(see Decisions) and no LiveKit client library is involved — the backend
stops at the JWT and server URL.
3. Fork/migration detection. **Done.** One standing `nip29-forks`
subscription over `{authors: <every admin of every group>, kinds: [10009]}`
on `ReqTarget::auto` — the admins' own group lists live on their outbox
relays, so this works with the group's relay down, and the admin pubkeys
come from `39001`, which is hydrated from the envelope cache, so it works
after a restart too. Each event is folded into `forks` (author → newest
`GroupList`, guarded on `created_at` because `10009` is replaceable), then
`evaluate_forks` recomputes every group. `Group::elsewhere()` is `Some`
when an admin of that group lists the same group id at a different relay;
the data cannot distinguish a move from a fork, hence the neutral name.
Evaluation is incremental, on each event, rather than batched on EOSE:
`ReqTarget::auto` fans out to several relays, so the first EOSE would
otherwise evaluate — and clear — against one relay's partial view.
Nothing is auto-joined, since the NIP asks the client to *offer*; the
offer is `GroupsRegistry::join_key(elsewhere, None, cx)`, which is exactly
"fetch from the new relay and update our own `10009`". Two triggers,
because the NIP's "periodically" and its "MUST when the relay is offline"
are different things. Reactively, the group observer calls
`sync_forks(false)` on `GroupEvent::Updated`, subscribing as soon as the
first `39001` lands and re-subscribing only when the admin set actually
changes — the guard matters, since `Updated` also fires for metadata,
pins, membership and a finished page. Periodically, a
`background_executor().timer` loop started in `load` pushes
`Signal::Forks` every 15 minutes and `sync_forks(true)` re-queries
regardless: that is the recovery path for a subscription that died or a
client that was offline, not the detection path. `reset` drops the fork
state and unsubscribes the REQ; a `CLOSED` on it is only logged, because
the tick retries and there is nothing to repair.
## UI later — the hooks this backend leaves
@@ -514,6 +557,9 @@ group's relay only, and treat the relay's `OK` as the truth.
| Send / react | `Group::send_message` / `send_reaction` |
| Members, admins, roles, flags | `Group::members/admins/roles/metadata` |
| Who is in the AV room | `Group::participants()` |
| Join AV (get a LiveKit session) | `Group::livekit_session(cx)` → `await` → `LivekitSession { token, server_url }` |
| Is AV available here | `Group::livekit_supported(cx)` after seeing `metadata().livekit` |
| The group may have moved or been forked | `Group::elsewhere()` → offer `GroupsRegistry::join_key(elsewhere, None, cx)` |
| Membership badge (member, pending, refused, removed) | `Group::membership()` |
| Copy an invite link | `GroupMetadata::naddr(relay, invite)` |
| Leave | `GroupsRegistry::leave` |
@@ -533,11 +579,14 @@ join dialog accepts a pasted naddr (with or without `?invite=`). System
notifications can hang off `GroupEvent::Incoming` when the window is inactive.
Nothing new is needed from GPUI to support this backend. The local checkouts
are `gpui-pre` 0.3.5 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui`
styles; the only GPUI APIs the backend uses are the ones `chat` and `community`
are `gpui-pre` 0.3.7 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui`
styles; the GPUI APIs the backend uses are the ones `chat` and `community`
already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`,
`EventEmitter`, `Subscription` — and the panel's components (`ListState`,
`MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the app.
`EventEmitter`, `Subscription` — plus two that `state` already uses:
`App::http_client()` for the AV token fetch and
`App::background_executor().timer(..)` for the fork tick. The panel's components
(`ListState`, `MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the
app.
## Tests
@@ -581,3 +630,21 @@ already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`,
handles the reads.
8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group
whose relay publishes no `39000` cannot be referenced or joined in v1.
9. **LiveKit response shape.** The NIP says the token endpoint returns "the
LiveKit JWT and the URL of the LiveKit server" but names no fields, so
`LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`)
and fails loudly on anything else. This is a guess, not a spec, and it is the
one part of the AV path that a real relay can contradict; it is worth
re-checking against whichever relay is targeted before the UI lands.
Likewise `livekit_supported` treats "not `204`" as "no AV" rather than an
error, because it only feeds a button.
10. **`Monitor`.** Not adopted. It is only reachable through
`ClientBuilder::monitor(Monitor::new(n))`, so nothing can read per-relay
status until `crates/state` opts in, and its `StatusChanged` stream does not
distinguish a permanent outage from a reconnect — so it would need filtering
before it could mean anything. The only thing it buys here is running the
admins' `10009` check the instant a group's relay drops, rather than at the
next 15-minute tick. The NIP frames that as MUST, but the substance is
already covered: the admins come from the cached `39001`, so the check runs
with the relay gone, just later. If that latency ever matters, the hook is
`sync_forks(true, cx)` driven by a `StatusChanged` consumer — one place.