Operator APIs & the SDK
How the operator control surfaces connect to what a viewer app built on the SDK actually observes. The wire-level reference for every endpoint — request/response bodies, auth, schemas — is Reference. This page is the integration map: which surface changes what, and when the SDK sees it.
1. The three control surfaces
| Surface | Transport | Default bind | Auth | Who uses it |
|---|---|---|---|---|
| Panel admin API + dashboard | HTTP/JSON | 127.0.0.1:3210 (ADMIN_ENABLED=1) |
Admin login → panel-signed Bearer token (Argon2id verifiers in panel-private secrets/admins.json) |
Operators: accounts, grants, catalog curation, publishers, sources, categories. Plus unauthenticated GET /healthz + /metrics for monitoring |
| Broadcaster control API + UI | HTTP/JSON | 127.0.0.1:3310 (CONTROL_ENABLED=1) |
Control login → broadcaster-local Bearer token | Operators: channels, ingest, transcode, start/stop/rotate, logs, incidents. Plus unauthenticated GET /healthz + /metrics for monitoring |
| Library control API + UI | HTTP/JSON | 127.0.0.1:3320 (CONTROL_ENABLED=1) |
Control login → library-local Bearer token | Operators: VOD titles — add/ingest/re-ingest/delete, progress, logs. Plus unauthenticated GET /healthz + /metrics |
| Panel RPC | DHT (Hyperswarm), not TCP/HTTP | reachable by key, worldwide | Proof-of-work + per-method crypto (OPRF login; Ed25519 for register) |
The SDK (hello/login/session), and broadcasters and the library (register — the library registers titles as type:'vod') |
Two deliberate consequences:
- Both HTTP surfaces bind loopback. Exposing one is a TLS-fronting decision (public dashboards). Neither is ever needed by a viewer — the SDK has no HTTP dependency on your infrastructure at all.
- The SDK's only inbound path is the DHT RPC, plus replication of two signed data structures (next section). Everything an operator does reaches viewers through those, never directly.
2. The data plane the SDK replicates
- The signed catalog DB (Hyperbee) — accounts (verifiers, sealed
keys, devices), catalog records (
catalog/<streamId>), category presentation (catmeta/), and assets metadata. The panel is the single writer. Every connected SDK replicates it and watches thecatalog/range — this is the push channel that makes admin edits live. - The assets drive (Hyperdrive) — poster/backdrop/logo bytes. These
are served to the app from the SDK's localhost server (
assetUrl()), or passed through verbatim when the catalog stores an absolutehttpsURL (hybrid art).
Stream media itself travels on per-channel encrypted Hyperdrives (feeds),
whose current feedKey lives in the catalog record. The per-user
encryption keys are sealed to each user at grant time, and unsealed
only inside login().
3. The three latencies
Every operator action lands in exactly one of these buckets. Knowing which one is the whole integration model:
| Bucket | Mechanism | Typical delay |
|---|---|---|
| Live push | catalog watch → streams event re-emit (or feed-changed for the active stream) |
~seconds — no polling, no re-login, no re-tune |
| Next tune | value is read at resolve() time |
whenever the viewer next zaps to the channel |
| Next login | value is baked into the login reply (entitlements, sealed keys) | next login() call |
4. Panel admin API → SDK effects
Endpoint inventory is authoritative in Reference. Here it is mapped to viewer-visible effect:
Accounts & entitlements
| Operator action | Endpoint(s) | SDK effect | Bucket |
|---|---|---|---|
| Create user | POST /api/users |
Account can login() |
— |
| Grant a stream | POST /api/users/:u/grants |
Stream appears in the display list with its sealed key | Next login |
| Revoke a grant | DELETE /api/users/:u/grants/:streamId |
Stream gone from the next login's list. An already-running session still holds the unsealed key in memory — see §8 | Next login |
| Disable / delete user | POST /api/users/:u/status, DELETE /api/users/:u |
Next login() rejected; existing offline tokens age out (checkSession expiry) |
Next login |
| Password / logout-all / max-devices | POST /api/users/:u/password · /logout-all · /max-devices |
tokenVersion bump. The SDK's online check (sessionLive) then fails, and a well-behaved app drops to login. |
Live-ish (next online check) |
| Revoke ONE device | DELETE /api/users/:u/devices/:deviceId |
Deliberately does not bump tokenVersion. Only sessionLive on that device notices, so that device re-logins; others stay untouched. |
Live-ish (next online check) |
Catalog & curation
| Operator action | Endpoint(s) | SDK effect | Bucket |
|---|---|---|---|
| Edit title / description / category | PATCH /api/streams/:id |
streams re-emits with new metadata |
Live push |
Curation: order, featured |
PATCH /api/streams/:id |
streams re-emits; rails/hero re-sort (order also defines the zap ring and prewarm priority) |
Live push |
| Upload art | POST /api/streams/:id/art/:kind |
streams re-emits with new localhost art URLs (P2P) or the record's absolute URL (hybrid art passthrough) |
Live push |
Flip isLive |
PATCH /api/streams/:id (usually done by the broadcaster for you) |
streams re-emits — live badge |
Live push |
| Set/clear a redirect URL | PATCH /api/streams/:id {url} |
resolve() returns the URL verbatim (source:'cdn'). An edit reaches viewers on their next tune; entitlement is unchanged |
Next tune |
Rotate feedKey (usually the broadcaster) |
PATCH /api/streams/:id {feedKey} |
Non-watched channels: picked up at next tune. The actively-watched channel gets feed-changed — the engine re-resolves behind the same localhost URL, and the host just reloads the player |
Live push (active) / next tune |
| Delete a stream | DELETE /api/streams/:id |
Full purge (catalog+secret+grants+art): vanishes from the display list | Live push |
| Category presentation | GET/POST/PATCH/DELETE /api/categories (rename/merge/order/hide) |
Rails re-label/re-order live — membership stays on the records | Live push |
Sources (provider-imported lineups)
| Operator action | Endpoint(s) | SDK effect | Bucket |
|---|---|---|---|
| Add/sync a source | POST /api/sources, POST /api/sources/:name/sync |
Imported channels appear as redirect channels (the records: live push). With autoGrant, grants reconcile at sync and at user-create, but the keys still ride the next login |
Live push / next login |
| Deselect channels | PATCH /api/sources/:name {exclude} |
Excluded channels purge from the catalog | Live push |
| Remove source | DELETE /api/sources/:name (?keepChannels=1 detaches) |
Its channels purge (or detach and stay) | Live push |
| EPG pointers | carried per-record (epgUrl/epgId) from the source feed |
The app fetches the schedule directly over https — never through the panel (guide) | Next fetch |
Publishers (multi-broadcaster security)
| Operator action | Endpoint(s) | SDK effect |
|---|---|---|
| Enroll / scope / revoke a publisher | POST /api/publishers, POST /api/publishers/:name/scopes, /status |
None directly. This gates which broadcaster can register which streamId. A rejected register (out-of-scope/revoked) means the catalog stops updating for that channel — viewers simply keep seeing the last-accepted state. Attribution (origin) rides the record; the SDK ignores fields it doesn't know. |
5. Broadcaster control API → SDK effects
The broadcaster never talks to viewers. Every effect flows broadcaster →
register RPC → panel catalog → replication:
| Operator action | Endpoint(s) | What happens | SDK effect |
|---|---|---|---|
| Start a channel | POST /api/channels/:id/start |
ffmpeg ingest → encrypted feed → register (feedKey, isLive:true) |
streams live-push (badge); tune-able ≈ seconds later |
| Stop a channel | POST /api/channels/:id/stop |
isLive:false via the panel link |
streams live-push; a tune now yields "not broadcasting" |
| Change input / transcode | PATCH /api/channels/:id (+ restart) |
A restart with a changed input mints a fresh feed generation → new feedKey registered |
Watching viewers: feed-changed, auto-follow. Others: next tune |
| Rotate | POST /api/channels/:id/rotate |
Fresh feed generation on demand (bounds merkle growth) | Same as above — watching viewers follow live |
| Watchdog / offline slate | (automatic) | Dead source → profile-matched "SOURCE OFFLINE" loop keeps the feed flowing; auto-returns on recovery | Viewers keep playing — they see the slate. Status reads state:'up' with slate.slated:true, so monitoring must check the flag, not the state |
| Registration outcome | GET /api/channels (registered, registerError) |
Surfaces panel rejects (out-of-scope, revoked, …) verbatim |
If rejected, the catalog is frozen for that channel — viewers keep the last state |
| Diagnostics | GET /api/status, /api/capabilities, /api/channels/:id/logs, GET /api/incidents (fleet-wide correlated respawn bursts) |
Ops-only | None |
| Liveness | GET /healthz (unauthenticated) — {up, resuming, resumed, total, …} |
Point uptime checks here | None. But during a boot resume, channels come live in waves, and viewers see isLive flips as each one registers |
5a. Library control API → SDK effects (VOD)
Same shape as the broadcaster's: the library never talks to viewers.
Every effect flows library → register RPC (type:'vod' +
durationSec) → panel catalog → replication. Run it as its own
enrolled publisher, scoped to its title ids.
| Operator action | Endpoint(s) | What happens | SDK effect |
|---|---|---|---|
| Add + ingest a title | POST /api/titles {id, input, …} |
One-shot ffmpeg job (probe → copy/transcode → encrypted drive) → seeds → registers type:'vod', durationSec, status:'available' |
streams live-push once registered. resolve() returns type:'vod' + durationSec — full seek, no live machinery |
| Watch ingest progress | GET /api/titles/:id (ingest: {phase, pct}), /logs |
Probe → convert → import phases | None until ready |
| Re-ingest (replace the file) | POST /api/titles/:id/ingest |
Next feed generation — new feedKey registered, old cores purged |
Next tune (deliberately NOT feed-changed — a mid-film hot-swap would yank the playhead) |
| Delete a title | DELETE /api/titles/:id |
Stops seeding, purges the title's cores + key from the library box, registers status:'unavailable' |
streams live-push — status flip, gray it out. A tune now stalls or errors. Finish cleanup in the panel: remove the record and grants |
| Liveness | GET /healthz (unauthenticated) — {ok, titles, ready, ingesting, queued, error, panelLink} |
Point uptime checks here | None |
Descriptive metadata follows the same rule as channels: the library seeds title, description, and category at creation. After that, they are edited in the panel.
6. Panel RPC → the SDK's own calls
What the SDK does under the hood — you never call these directly;
login() does:
hello→ proof-of-work challenge (rate-limits account probing).login(username, blinded, powNonce)→ blinded OPRF evaluation. The panel cannot see the password; the client cannot learn the OPRF key.session(username, deviceId, signature, …)→ device enrollment — evicting the oldest device pastmaxDevices— plus the panel-signed session token.- Entitlements are then read from the replicated DB: sealed stream keys unseal client-side only.
Broadcasters use the same RPC surface for register. That's the entire
coupling between the two server components
(details).
7. End-to-end flows
Onboard a viewer app. Operator side: init (panel key printed) →
add-user → grants. App side: createPlayer({ panelPubKey }) →
connect() → login() → resolve(). Nothing else crosses the
boundary — no URLs, no API keys in the app.
Broadcaster box restarts. Boot resume re-registers channels (watch
/healthz). Each register updates feedKey/isLive, so watching
viewers get feed-changed and auto-follow, and idle viewers see badges
flip. No client action, no re-login.
Provider lineup import. POST /api/sources (plus scheduled sync)
makes redirect channels materialize in a category, and autoGrant
reconciles grants. Users see the category after their next login;
record edits or removals afterward are live-push.
8. What revocation really means
Layered, weakest to strongest:
- Device revoke (
DELETE …/devices/:id) — cooperative. That device'ssessionLivefails, and a well-behaved client logs out. A hostile client keeps its cached token and keys. - tokenVersion bump (password / logout-all) — all devices drop at their next online check. Same caveat.
- Grant revoke — gone from the next login. The current session still holds unsealed keys in memory.
- The real boundary: rotate the stream's encryption key (re-key the channel). Old keys stop decrypting new segments for everyone. Entitled users re-login and unseal the new key. This is the only content-protection-grade cut — the others are session hygiene. (A feedKey rotation alone is not revocation: the encryption key is unchanged by design, so grants survive it.)
9. Security boundaries worth knowing when integrating
- Viewer apps hold no secrets. The panel key is public. Accounts are the only credential, and the OPRF keeps passwords off the wire. Ship nothing else.
- Stream encryption keys never leave the engine. The display list
carries metadata plus localhost/absolute art URLs only.
resolve()returns URLs, not keys. - Admin/control tokens are operator-local (loopback HTTP) and unrelated to viewer sessions — never embed them in an app.
- The SDK's HTTP server binds 127.0.0.1 with a per-session random port. Media is reachable only on-device.
- The catalog is panel-signed end to end — a viewer can't be fed a
forged lineup by a peer. Broadcasters are authenticated writers via
registeronly, and enrolling publishers scopes them further.