openvibers/openvibe.live Commits

Agent streams S1: the desk stream, its tickets, the curtain and the two pages (#622)

OP OpenVibers committed 7 days ago · f93ba8fe3a · 37 files, +3955 −18

* Agent streams S1: the desk stream, its tickets, the curtain and the two pages

An agent's screen as a stream of its own kind: a managed_streams row with kind 'agent' and a public id
ags_<ULID>, beside an agent_streams row for the agent-only facts. It never has a stream key, never takes a
normal slot (one live at a time; five for Live's owner), and keeps its card — the last frame, the worked
minutes, the VOD once Media has it — after it ends.

- POST /api/streams/agent creates it and answers the publish part (whip_url, ticket, expires_at). The caller
  is the person's session or a first-party service token for audience openvibe.live holding
  live.agent_stream.publish that names the person; never a developer app, never another person's stream.
- POST /whip/agent/<ags> publishes with the wpt_ ticket (sha256 at rest, four hours, bound to one session);
  a wrong, expired, revoked or other-stream ticket is 401 invalid_ticket, a second session 409
  already_publishing. The normal stream-key paths now refuse an agent row outright.
- State, frame (JPEG/WebP, 200 KB), curtain, end; the housekeeping loop ends a stream whose ticket expired.
- View tickets (wvt_, ten minutes, renewing up to four hours) for private streams — the owner, a person the
  sharing system allows (openvibe-sdk/share, service live, kind agent-stream), or the unlisted link's secret.
  The watch socket enforces it a second time.
- The curtain pauses every viewer's consumer and resumes exactly those, and every player shows "Private screen".
- /@person/agents/<ags> for the owner (player, task, visibility, Share sheet, curtain, end) and
  /embed/agent/<ags> for the task page and the Dock, frame-ancestors first-party only.

Pins openvibe-sdk v0.89.0 for openvibe-sdk/share; Contracts stays at 0.294.0 until agent-stream-contracts
releases the schemas. Tests: test/agent-streams.test.js and test/agent-streams-whip.test.js, plus the
account-data, skeleton, staff-capability and SSRF guards they touch.

* WIP: WHEP for agent streams and the owner page review (saved from a crashed session; not reviewed yet)

* Agent streams: WHEP under the curtain, the owner's page and the released pins

- WHEP: a consumer born while the curtain is up starts paused. The row is the truth (a restart empties the in-memory set), so the curtain is raised from it before the consumer is paused.
- the owner's page: the Reset link hint says the old links stop working, in both states of the link.
- pin openvibe-contracts v0.298.0 and openvibe-sdk v0.91.0 where Live pins them; no branch pins.

* Merge remote-tracking branch 'origin/main' into live-agent-streams-s1

# Conflicts:
#	package-lock.json
#	package.json

* Agent streams: guards for the owner's page and the embed

The review items read in the source, so they cannot slip: one status line, the AI agent label on the player, the Private screen and the button that lifts it, an End that asks in the page (never a confirm()), Public disabled with its note, the Reset link hint; and on the embed a picture that fills the frame with the label, the curtain and a small viewer count on it.

* Perf: the home page budgets for the agent stream's route

A new route costs the home page its placeholder in the HTML and its asset manifest entries (htmlRawKB 128.3) plus the route code in app.js and its shape in ov-skeleton.js (jsBrotliKB 301.7). The agent's own scripts stay route-loaded: the home page still loads 26 files.

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 9dbd0f4b..9bf66e1e 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -81,6 +81,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/go-live-in-your-browser.md](docs/go-live-in-your-browser.md) — Going live from a browser with no OBS and no follower minimum (the headline feature: keep it on the home page, in /llms.txt and in search metadata)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
++- [docs/agent-streams.md](docs/agent-streams.md) — Agent streams: the `ags_` resource, publish and view tickets, the curtain, the owner page and the embed
- [docs/streams-api.md](docs/streams-api.md) — Stream slots API: everything the Go live screen does, as calls (slots, settings, key, title ideas, history, going live and ending)
- [docs/rundown.md](docs/rundown.md) — The Rundown (`/rundown`, was the Arena): what streamers talked about, written up from their speech — topics, takes, episodes, crossovers
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
ddiff --git a/docs/agent-streams.md b/docs/agent-streams.md
nnew file mode 100644
iindex 00000000..dd682cf3
---- /dev/null
++++ b/docs/agent-streams.md
@@@ -0,0 +1,119 @@
++# Agent streams
++
++An agent stream is an AI agent's screen on OpenVibe.Live: the desk on the person's machine publishes it with WHIP, Live
++relays it with mediasoup, and a viewer watches it on the person's channel, in a task page or in the Dock. This page is the
++S1 API (the Agent Streams design, 2026-11-06): the resource, the tickets, the curtain and the embed. There is no chat on
++an agent stream in S1.
++
++```
++POST /api/streams/agent create it; answers the stream and its publish ticket
++POST /whip/agent/<ags_id> the desk publishes with the ticket (WHIP)
++GET /api/streams/agent/<ags_id> the stream (the view rule decides who may read it)
++POST /whep/agent/<ags_id> a player that is not ours: its offer in, an SDP answer out
++GET /embed/agent/<ags_id>?vt=wvt_… the player, for a task page or the Dock
++```
++
++## The stream
++
++`live.agent-stream@1` is a `managed_streams` row of kind `agent` with a public id `ags_<ULID>`. It never has a stream key
++and it never takes one of the person's normal stream slots: a person may have one agent stream live at a time (the Live
++owner, five), and any number of offline ones — the cards stay with the last frame and the worked time.
++
++| Field | What it is |
++|---|---|
++| `owner`, `channel` | the person (`usr_…`) and their Live channel |
++| `task` | `{ source: codes\|actor\|desk\|node, ref, title, url? }` — what the agent is doing, and where |
++| `visibility` | `private` (the owner and the people it is shared with), `unlisted` (anyone with the link), `public` |
++| `mode` | `watch` in S1 (the field exists for narrated, co-pilot and chat plays) |
++| `state` | `idle`, `live` or `ended` |
++| `curtain` | `{ up, since? }` — the desk's privacy screen |
++| `label` | always `AI agent` |
++
++`POST /api/streams/agent` answers `201 { stream, publish: { whip_url, ticket, expires_at } }`. The ticket is shown once;
++Live keeps only its SHA-256. The caller is the person's own session, or a first-party service token for audience
++`openvibe.live` that holds `live.agent_stream.publish` and names the person (the token's `sub`, or `X-OV-Subject` when a
++`svc:`/`node:` principal acts for them). Never a developer app, never another person's stream.
++
++## The publish ticket
++
++```
++POST /api/streams/agent/<ags_id>/ticket a new ticket (the desk renews at three hours)
++DELETE /api/streams/agent/<ags_id>/ticket revoke it: the kill switch, the WHIP session ends at once
++POST /whip/agent/<ags_id> Authorization: Bearer wpt_…
++```
++
++A ticket lives four hours. A `wpt_` that does not verify, has expired, was revoked or names another stream answers `401
++invalid_ticket`; a second WHIP session while one is up answers `409 already_publishing`; a person already at their live
++limit answers `409 live.agent_stream_limit`. A ticket is bound to one session when the WHIP offer is accepted, and the
++`Location` it answers with is `/whip/agent/<ags_id>/<resource>`.
++
++## What the desk reads and sends
++
++```
++GET /api/streams/agent/<ags_id>/state { viewers, encode, curtain, ends_at } every 10 s, with the ticket
++PUT /api/streams/agent/<ags_id>/frame JPEG or WebP, at most 200 KB, with the ticket
++POST /api/streams/agent/<ags_id>/curtain { up, reason }
++POST /api/streams/agent/<ags_id>/end
++```
++
++`encode` is true while anyone watches or the stream is public. While it is false the desk stops encoding and sends a
++snapshot every five seconds; that frame is the card's picture and the task page's still. `ends_at` is the current
++ticket's expiry — a stream whose ticket expires with no renewal ends.
++
++## Watching
++
++Live's own player joins `/ws/broadcast` as usual, and a private or unlisted stream carries a **view ticket**
++(`?vt=wvt_…`). A third-party player (Node's latency probe, a WHEP-compatible player) uses the WHEP endpoint below.
++
++```
++POST /api/streams/agent/<ags_id>/view-ticket { k? , renew? }
++```
++
++The owner's session gets one. So does a person the sharing system allows (`openvibe-sdk/share`, service `live`, kind
++`agent-stream`) — the same sheet as everywhere else on the network. For an unlisted stream the secret is the link:
++`/@person/agents/<ags_id>?k=<22 chars>`, stored hashed, and `POST /api/streams/agent/<ags_id>/rotate-link` closes the old
++links. A view ticket lives ten minutes and renews itself while the page is open, at most four hours from the first mint.
++
++Without a ticket a private stream's page answers `404` and the socket refuses the join (`4003 View ticket required`).
++There is no other way in.
++
++## WHEP (a player that is not ours)
++
++```
++POST /whep/agent/<ags_id> Authorization: Bearer wvt_… (a public stream needs none)
++DELETE /whep/agent/<ags_id>/<resource> the viewer leaves; the stream keeps going
++```
++
++The offer goes in, an SDP answer comes out — the same shape as the WHIP side, `sendonly` from Live. The server
++consumes the stream's audio and video for that viewer with the codecs the offer named, and its ICE/DTLS setup mirrors
++`/whip` (Live is ice-lite and answers `setup:passive`). A private or unlisted stream needs a view ticket; a stream that
++is not publishing answers `409 not_live`; an offer with no codec Live can send answers `406 no_compatible_codecs`.
++CORS is the WHIP endpoint's (every origin, because the player is usually on someone else's page). The consumer is an
++ordinary room consumer, so the curtain pauses it like any other viewer's, and a WHEP viewer counts as a viewer: the desk
++keeps encoding while one is connected.
++
++## The curtain
++
++The desk blanks its own picture before this runs; Live is the second, independent enforcement. While the curtain is up,
++every viewer's media consumer of that stream is paused — a desk that failed to blank still shows nothing — and every
++player is told to show "Private screen". The reason (`password_field`, `secret_file`, `env_print`, `manual`, `site`,
++`detector`) is kept for the owner and never reaches a viewer. The curtain comes down only from the desk, after its
++detectors report clear for two seconds, or from the owner's own button.
++
++## The pages
++
++- **The owner's page:** `/@person/agents/<ags_id>` — the player with the `AI agent` label on it, one status line
++ (Live · N watching · worked X min, or Waiting for the desk, or Ended), the task and its link, the visibility switch
++ (Public is disabled: public agent streams come with the Agents tab), the Share sheet, Reset link (the old links stop
++ working), Curtain now (Lift the curtain while it is up) and End (which asks in the page, never a browser dialog).
++ Others see the player and the label.
++- **The embed:** `/embed/agent/<ags_id>?vt=wvt_…` — the picture fills the frame (contained, centred), with the
++ `AI agent` label, the curtain state and a small viewer count on it. Its
++ CSP `frame-ancestors` is the first-party origins only (Live, Network, Codes, Actor, Node, Tools), never `*`. A
++ third-party iframe has no Live cookies, so Codes, Actor or the desk mints the view ticket server-side with the person's
++ own token and puts it in the address.
++
++## In S1
++
++There is no chat, no voice, no Agents tab and no directory yet (S2 and later), and public streams do not appear anywhere
++but their own page. The visibility switch offers Public, and the page disables it until S2.
ddiff --git a/migrations/0027_agent_streams.sql b/migrations/0027_agent_streams.sql
nnew file mode 100644
iindex 00000000..098f28c2
---- /dev/null
++++ b/migrations/0027_agent_streams.sql
@@@ -0,0 +1,133 @@
++-- phase: expand
++-- Agent streams, S1 (design 2026-11-06, plan-state-2026-10-22/AGENT-STREAMS-S1-DESIGN-2026-11-06.md): an agent's
++-- own desk screen, published by WHIP with a ticket and watched on Live. An agent stream is a managed_streams row of
++-- kind 'agent', so the existing slot machinery, the live session row, the recorder and the player all apply; it never
++-- has a stream key (the desk publishes with a wpt_ ticket) and it never takes one of the person's normal slots.
++--
++-- The public id is ags_<ULID>. The agent-only facts live beside the slot rather than on managed_streams, whose
++-- SELECT * rows go to channel pages and through publicManagedStream(); a separate table keeps them out of every
++-- response that was not written for them.
++--
++-- Tickets are stored as a sha256 only: a database copy cannot publish or watch with them. A publish ticket is bound to
++-- one agent stream and, once a WHIP session attaches, to that session; revoking it is the kill switch. A view ticket
++-- lives ten minutes and is minted for the owner, for someone the sharing system allows, or for the holder of the
++-- unlisted link's secret (also stored hashed).
++--
++-- Expand only: the kind default makes every existing row a normal slot; an older release ignores all of it.
++ALTER TABLE managed_streams ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'normal';
++DO $$ BEGIN
++ ALTER TABLE managed_streams ADD CONSTRAINT managed_streams_kind_allowed CHECK (kind IN ('normal', 'agent'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE managed_streams ADD COLUMN IF NOT EXISTS public_id text;
++-- An agent stream has no stream key and no API may ever answer one.
++ALTER TABLE managed_streams ALTER COLUMN stream_key DROP NOT NULL;
++CREATE UNIQUE INDEX IF NOT EXISTS managed_streams_public_id ON managed_streams (public_id) WHERE public_id IS NOT NULL;
++CREATE INDEX IF NOT EXISTS managed_streams_kind_user ON managed_streams (user_id, kind);
++
++CREATE TABLE IF NOT EXISTS agent_streams (
++ managed_stream_id bigint PRIMARY KEY REFERENCES managed_streams(id) ON DELETE CASCADE,
++ task jsonb NOT NULL,
++ visibility text NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'unlisted', 'public')),
++ mode text NOT NULL DEFAULT 'watch' CHECK (mode IN ('watch', 'narrated', 'copilot', 'chatplays')),
++ state text NOT NULL DEFAULT 'idle' CHECK (state IN ('idle', 'live', 'ended')),
++ curtain_up bigint NOT NULL DEFAULT 0,
++ curtain_since timestamptz,
++ curtain_reason text,
++ started_at timestamptz,
++ ended_at timestamptz,
++ worked_seconds bigint NOT NULL DEFAULT 0,
++ last_frame_path text,
++ last_frame_at timestamptz,
++ vod_id text,
++ allow_sites jsonb NOT NULL DEFAULT '[]'::jsonb,
++ unlisted_secret_hash text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX IF NOT EXISTS agent_streams_state ON agent_streams (state);
++
++CREATE TABLE IF NOT EXISTS agent_publish_tickets (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ managed_stream_id bigint NOT NULL REFERENCES managed_streams(id) ON DELETE CASCADE,
++ token_hash text NOT NULL UNIQUE,
++ expires_at timestamptz NOT NULL,
++ revoked_at timestamptz,
++ session_id text,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX IF NOT EXISTS agent_publish_tickets_stream ON agent_publish_tickets (managed_stream_id, expires_at DESC);
++
++CREATE TABLE IF NOT EXISTS agent_view_tickets (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ managed_stream_id bigint NOT NULL REFERENCES managed_streams(id) ON DELETE CASCADE,
++ token_hash text NOT NULL UNIQUE,
++ subject text,
++ via text NOT NULL CHECK (via IN ('owner', 'share', 'link')),
++ expires_at timestamptz NOT NULL,
++ -- The chain of renewals that began with one mint stops here: a page open all day tops up every ten minutes, but
++ -- never past four hours from when it first asked (the design's "renews while the page is open, at most 4 h").
++ renews_until timestamptz NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX IF NOT EXISTS agent_view_tickets_stream ON agent_view_tickets (managed_stream_id, expires_at DESC);
++
++-- ── The sharing system's tables (openvibe-sdk/share) ──────────────────────────────────────────────────────────────
++-- A private agent stream's guests are the sharing system's (server/agent-streams/sharing.js). These are its own
++-- expand-only tables, kept here because a table is only ever created by a migration. The SDK owns their shape; the
++-- service column is 'live' and the kind is 'agent-stream'.
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
ddiff --git a/package-lock.json b/package-lock.json
iindex 077786f9..f0fbcb4e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.1",
@@@ -2840,9 +2840,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.297.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
-- "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",
++ "version": "0.298.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
++ "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex ba6724a8..8e8ff1a4 100644
---- a/package.json
++++ b/package.json
@@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.1",
ddiff --git a/public/css/features/agent-stream.css b/public/css/features/agent-stream.css
nnew file mode 100644
iindex 00000000..196bc598
---- /dev/null
++++ b/public/css/features/agent-stream.css
@@@ -0,0 +1,56 @@
++/* Agent streams (Agent Streams S1): the owner's page (/@person/agents/<id>) and the embed (/embed/agent/<id>).
++ Theme tokens only, so both follow the person's chosen look. */
++.agent-page { max-width: 1180px; margin: 0 auto; padding: 20px 16px 56px; }
++.agent-head { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; }
++.agent-label { align-self: flex-start; padding: 2px 10px; border: 1px solid var(--accent, #7c5cff); border-radius: 999px; color: var(--accent, #7c5cff); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
++.agent-title { margin: 0; font-size: clamp(1.4rem, 1.1rem + 1.2vw, 2rem); font-weight: 800; letter-spacing: -0.02em; overflow-wrap: anywhere; }
++.agent-task { margin: 0; color: var(--text-secondary); font-size: 0.92rem; overflow-wrap: anywhere; }
++.agent-task a { color: var(--accent); }
++.agent-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
++@media (min-width: 900px) { .agent-layout { grid-template-columns: minmax(0, 1fr) 300px; align-items: start; } }
++.agent-main { min-width: 0; }
++.agent-stage { position: relative; aspect-ratio: 16 / 9; width: 100%; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: #0b0d10; }
++.agent-stage video, .agent-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #0b0d10; }
++.agent-stage video { display: block; }
++.agent-stage img[hidden], .agent-stage video[hidden] { display: none; }
++/* The overlay always says whose stream this is, in the player's corner (the design's rule). */
++.agent-player-label { position: absolute; top: 10px; left: 10px; z-index: 5; background: rgba(7, 8, 10, 0.82); border-color: rgba(255, 255, 255, 0.26); color: #cfc7ff; }
++.agent-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px; color: #e8eaed; font-size: 0.95rem; text-align: center; background: rgba(11, 13, 16, 0.72); z-index: 2; }
++.agent-state[hidden] { display: none; }
++.agent-curtain { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #07080a; color: #cfd3d8; font-size: clamp(1rem, 0.9rem + 0.6vw, 1.3rem); font-weight: 600; letter-spacing: 0.02em; z-index: 4; }
++.agent-curtain[hidden] { display: none; }
++.agent-status-line { margin: 10px 0 0; color: var(--text-secondary); font-size: 0.88rem; }
++.agent-status-line.is-live { color: var(--live-red-text, var(--live-red, #e0245e)); font-weight: 700; }
++.agent-side { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); display: flex; flex-direction: column; gap: 12px; }
++.agent-side[hidden] { display: none; }
++.agent-side-title { margin: 0; font-size: 0.95rem; font-weight: 700; }
++.agent-field { display: flex; flex-direction: column; gap: 6px; }
++.agent-field-label { font-size: 0.8rem; color: var(--text-muted); }
++.agent-note { margin: 0; color: var(--text-muted); font-size: 0.78rem; }
++.agent-select { width: 100%; padding: 9px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); color: var(--text-primary); font: inherit; }
++.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; }
++.agent-actions .btn { flex: 1 1 auto; }
++.agent-actions .btn[hidden] { display: none; }
++.agent-hint { margin: 0; color: var(--text-muted); font-size: 0.8rem; overflow-wrap: anywhere; }
++.agent-confirm { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); }
++.agent-confirm[hidden] { display: none; }
++.agent-confirm-text { margin: 0; font-size: 0.85rem; color: var(--text-primary); }
++
++/* The embed: the player is the page. The picture is contained in its own 16:9 tile, centred; the tile is the only
++ thing painted, so there are no wide empty sides — the host page shows through around it. The label sits on the
++ picture's corner, the viewer count in the other, the curtain over both. */
++.agent-embed { margin: 0; background: transparent; color: #e8eaed; font: 13px system-ui, -apple-system, "Segoe UI", sans-serif; }
++.agent-embed[hidden] { display: none; }
++.agent-embed-frame { display: flex; flex-direction: column; height: 100vh; min-height: 120px; }
++.agent-embed-stage { position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
++.agent-embed-media { position: relative; width: min(100%, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; background: #0b0d10; border-radius: 10px; overflow: hidden; }
++.agent-embed-media video, .agent-embed-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #0b0d10; }
++.agent-embed-media img[hidden], .agent-embed-media video[hidden] { display: none; }
++.agent-embed-label { position: absolute; top: 8px; left: 8px; z-index: 5; padding: 1px 8px; font-size: 0.64rem; color: #cfc7ff; border-color: rgba(255, 255, 255, 0.26); background: rgba(7, 8, 10, 0.82); }
++.agent-embed-title { position: absolute; top: 10px; left: 78px; right: 8px; z-index: 2; font-weight: 600; font-size: 0.78rem; color: #eef1f5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); }
++.agent-embed-viewers { position: absolute; right: 8px; bottom: 8px; z-index: 3; padding: 1px 7px; border-radius: 999px; background: rgba(7, 8, 10, 0.78); color: #aeb4bb; font-size: 0.68rem; white-space: nowrap; }
++.agent-embed-viewers[hidden] { display: none; }
++.agent-embed .agent-embed-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #cfd3d8; background: rgba(11, 13, 16, 0.7); text-align: center; padding: 12px; z-index: 2; }
++.agent-embed .agent-embed-state[hidden] { display: none; }
++.agent-embed .agent-embed-curtain { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #07080a; color: #cfd3d8; font-weight: 600; z-index: 4; }
++.agent-embed .agent-embed-curtain[hidden] { display: none; }
ddiff --git a/public/features.json b/public/features.json
iindex 75173b5d..98fbac06 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -309,6 +309,18 @@
"js": [
"/js/app-search.js"
]
++ },
++ "agent-stream": {
++ "fragment": "agent-stream",
++ "css": [
++ "/css/features/agent-stream.css",
++ "/shared/ov-share.css"
++ ],
++ "js": [
++ "/js/agent-player.js",
++ "/js/agent-stream.js",
++ "/shared/ov-share.js"
++ ]
}
},
"routes": [
@@@ -380,6 +392,12 @@
"channel"
]
},
++ {
++ "path": "^/@[A-Za-z0-9_]+/agents/",
++ "features": [
++ "agent-stream"
++ ]
++ },
{
"path": "^/@",
"features": [
ddiff --git a/public/fragments/agent-stream.html b/public/fragments/agent-stream.html
nnew file mode 100644
iindex 00000000..5b295d98
---- /dev/null
++++ b/public/fragments/agent-stream.html
@@@ -0,0 +1,47 @@
++<div class="agent-page">
++ <header class="agent-head">
++ <span class="agent-label">AI agent</span>
++ <h1 id="agent-title" class="agent-title">Agent stream</h1>
++ <p id="agent-task" class="agent-task"></p>
++ </header>
++ <div class="agent-layout">
++ <div class="agent-main">
++ <div class="agent-stage" id="agent-stage">
++ <video id="agent-video" playsinline muted autoplay></video>
++ <img id="agent-still" alt="" hidden>
++ <span class="agent-label agent-player-label">AI agent</span>
++ <div class="agent-state" id="agent-state">Loading…</div>
++ <div class="agent-curtain" id="agent-curtain" hidden>Private screen</div>
++ </div>
++ <p class="agent-status-line" id="agent-status-line">Loading…</p>
++ </div>
++ <aside class="agent-side" id="agent-owner" hidden>
++ <h2 class="agent-side-title">This stream</h2>
++ <div class="agent-field">
++ <label class="agent-field-label" for="agent-visibility">Who can watch</label>
++ <select id="agent-visibility" class="agent-select">
++ <option value="private">Only me and people I share it with</option>
++ <option value="unlisted">Anyone with the link</option>
++ <option value="public" disabled>Everyone (coming soon)</option>
++ </select>
++ <p class="agent-note">Public agent streams come with the Agents tab.</p>
++ </div>
++ <div class="agent-actions">
++ <button type="button" class="btn btn-outline" data-ov-share-sheet id="agent-share" data-title="An AI agent's screen">Share</button>
++ <button type="button" class="btn btn-outline" id="agent-rotate" hidden>Reset link</button>
++ </div>
++ <div class="agent-actions">
++ <button type="button" class="btn btn-outline" id="agent-curtain-btn">Curtain now</button>
++ <button type="button" class="btn btn-danger" id="agent-end">End</button>
++ </div>
++ <div class="agent-confirm" id="agent-end-confirm" hidden>
++ <p class="agent-confirm-text">End this agent stream? The card keeps its last frame and the recording.</p>
++ <div class="agent-actions">
++ <button type="button" class="btn btn-danger" id="agent-end-yes">End it</button>
++ <button type="button" class="btn btn-outline" id="agent-end-no">Keep going</button>
++ </div>
++ </div>
++ <p class="agent-hint" id="agent-hint"></p>
++ </aside>
++ </div>
++</div>
ddiff --git a/public/index.html b/public/index.html
iindex 6ee4b3f4..fa5b5deb 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -184,6 +184,9 @@
<!-- ── CHANNEL PAGE (/:username) ─────────────────────── -->
<section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
++ <!-- ── AN AGENT'S STREAM (/@person/agents/<id>) ──────── -->
++ <section id="page-agent-stream" class="page" data-fragment="agent-stream"><!--ov:fragment:agent-stream--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
++
<!-- ── VOD PLAYER ────────────────────────────────────── -->
<!-- ── CLIP PLAYER ───────────────────────────────────── -->
ddiff --git a/public/js/agent-embed.js b/public/js/agent-embed.js
nnew file mode 100644
iindex 00000000..73d94932
---- /dev/null
++++ b/public/js/agent-embed.js
@@@ -0,0 +1,84 @@
++/* The agent stream embed (Agent Streams S1): /embed/agent/<ags_id>?vt=wvt_….
++ *
++ * Codes, Actor or the desk mints the view ticket server-side with the person's token (a third-party iframe has no Live
++ * cookies), and this page joins the stream with it. It shows the player, the "AI agent" label, the curtain state and the
++ * viewer count; no chat in S1. It renews its own ticket while it stays open, up to the server's four-hour ceiling.
++ */
++/* global window, document, location, URLSearchParams, setTimeout, AgentPlayer */
++(function () {
++ 'use strict';
++ const body = document.body;
++ const id = body && body.dataset ? body.dataset.agentId : '';
++ if (!id) return;
++ const $ = (name) => document.getElementById(name);
++ let ticket = new URLSearchParams(location.search).get('vt') || '';
++ let player = null;
++ let renewTimer = null;
++
++ async function json(path, options) {
++ const res = await fetch(path, { credentials: 'omit', headers: { 'Content-Type': 'application/json' }, ...options });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) { const err = new Error(data.error || 'request failed'); err.status = res.status; throw err; }
++ return data;
++ }
++
++ function renewSoon() {
++ if (renewTimer) clearTimeout(renewTimer);
++ if (!ticket) return;
++ renewTimer = setTimeout(async () => {
++ try {
++ const next = await json(`/api/streams/agent/${encodeURIComponent(id)}/view-ticket`, { method: 'POST', body: JSON.stringify({ renew: ticket }) });
++ ticket = next.ticket || ticket;
++ renewSoon();
++ } catch { /* the existing ticket lasts until it stops working */ }
++ }, 8 * 60 * 1000);
++ }
++
++ async function load() {
++ const query = ticket ? `?vt=${encodeURIComponent(ticket)}` : '';
++ const data = await json(`/api/streams/agent/${encodeURIComponent(id)}${query}`);
++ const stream = data.stream;
++ ticket = data.view_ticket || ticket;
++
++ const title = $('agent-embed-title');
++ if (title) title.textContent = stream.title || '';
++ document.title = `${stream.title || 'AI agent'} — OpenVibe.Live`;
++
++ const video = $('agent-embed-video');
++ const still = $('agent-embed-still');
++ const stateEl = $('agent-embed-state');
++ const curtain = $('agent-embed-curtain');
++ const viewers = $('agent-embed-viewers');
++
++ curtain.hidden = !(stream.curtain && stream.curtain.up);
++
++ if (stream.state === 'live' && data.live_stream_id && video) {
++ still.hidden = true;
++ stateEl.hidden = false;
++ player = AgentPlayer.create({ streamId: data.live_stream_id, viewTicket: ticket, video, state: stateEl, curtain, viewers });
++ } else {
++ if (video) video.hidden = true;
++ if (stream.last_frame_url) {
++ still.src = `${stream.last_frame_url}${ticket ? `?vt=${encodeURIComponent(ticket)}` : ''}`;
++ still.hidden = false;
++ stateEl.hidden = true;
++ } else {
++ still.hidden = true;
++ stateEl.hidden = false;
++ stateEl.textContent = stream.state === 'ended' ? 'This agent’s work has ended.' : "This agent isn't streaming right now.";
++ }
++ }
++ renewSoon();
++ // The state changes on the server (the desk starts publishing, or the work ends); look again in a minute.
++ setTimeout(() => { if (!player && document.visibilityState !== 'hidden') load().catch(() => {}); }, 60000);
++ }
++
++ if (!$('agent-embed-stage')) return;
++ load().catch((err) => {
++ const stateEl = $('agent-embed-state');
++ if (stateEl) {
++ stateEl.hidden = false;
++ stateEl.textContent = err && err.status === 404 ? 'This agent stream is private or gone.' : 'The stream could not be loaded.';
++ }
++ });
++})();
ddiff --git a/public/js/agent-player.js b/public/js/agent-player.js
nnew file mode 100644
iindex 00000000..814fa9a2
---- /dev/null
++++ b/public/js/agent-player.js
@@@ -0,0 +1,185 @@
++/* Agent stream viewer (Agent Streams S1): the small player the owner's page and the embed share.
++ *
++ * It speaks the same mediasoup signaling as the site's full player (/js/stream-player.js): connect to /ws/broadcast as
++ * a viewer, send `watch`, then Device → recv transport → consume each producer. It is deliberately small — no DVR, no
++ * clips, no chat — because an agent stream has none of those in S1, and the embed must stay light enough for a
++ * third-party iframe.
++ *
++ * A private or unlisted agent stream is joined with a view ticket (`?vt=wvt_…`); the socket refuses the join without
++ * one, and this player only ever has what the page was allowed to mint.
++ */
++/* global window, document, WebSocket, MediaStream */
++(function () {
++ 'use strict';
++ let clientPromise = null;
++ function loadClient() {
++ if (!clientPromise) {
++ clientPromise = import('https://esm.sh/[email protected]');
++ clientPromise.catch(() => { clientPromise = null; });
++ }
++ return clientPromise;
++ }
++
++ function create(options) {
++ const { streamId, viewTicket = '', video, state: stateEl, curtain: curtainEl, viewers: viewersEl, onViewers } = options;
++ const setState = (text) => { if (stateEl) { stateEl.textContent = text; stateEl.hidden = !text; } };
++ let ws = null;
++ let closed = false;
++ let retries = 0;
++ let device = null;
++ let transport = null;
++ let pending = [];
++ let waiters = new Map();
++
++ function waitFor(type, timeoutMs = 12000) {
++ return new Promise((resolve, reject) => {
++ const list = waiters.get(type) || [];
++ const timer = setTimeout(() => {
++ waiters.set(type, (waiters.get(type) || []).filter((w) => w.resolve !== resolve));
++ reject(new Error(`${type} timed out`));
++ }, timeoutMs);
++ list.push({ resolve: (msg) => { clearTimeout(timer); resolve(msg); }, reject });
++ waiters.set(type, list);
++ });
++ }
++ function settle(type, msg) {
++ const list = waiters.get(type) || [];
++ if (!list.length) return false;
++ const [first, ...rest] = list;
++ waiters.set(type, rest);
++ first.resolve(msg);
++ return true;
++ }
++
++ function send(message) {
++ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
++ }
++
++ async function setup(rtpCapabilities, producers) {
++ setState('Starting playback…');
++ const mod = await loadClient();
++ const Device = mod.Device;
++ if (!Device) throw new Error('mediasoup-client unavailable');
++ device = new Device();
++ await device.load({ routerRtpCapabilities: rtpCapabilities });
++
++ const asked = waitFor('sfu-viewer-transport-created');
++ send({ type: 'sfu-viewer-create-transport' });
++ const params = await asked;
++ const iceServers = Array.isArray(params.iceServers) && params.iceServers.length
++ ? params.iceServers
++ : [{ urls: 'stun:stun.l.google.com:19302' }];
++ transport = device.createRecvTransport({
++ id: params.id,
++ iceParameters: params.iceParameters,
++ iceCandidates: params.iceCandidates,
++ dtlsParameters: params.dtlsParameters,
++ iceServers,
++ });
++ transport.on('connect', ({ dtlsParameters }, callback, errback) => {
++ const connected = waitFor('sfu-viewer-transport-connected');
++ send({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters });
++ connected.then(() => callback()).catch(errback);
++ });
++
++ const media = new MediaStream();
++ for (const producer of producers) {
++ const consumed = waitFor('sfu-viewer-consumed');
++ send({ type: 'sfu-viewer-consume', transportId: transport.id, producerId: producer.id, rtpCapabilities: device.rtpCapabilities });
++ const info = await consumed;
++ const consumer = await transport.consume({
++ id: info.id,
++ producerId: info.producerId,
++ kind: info.kind,
++ rtpParameters: info.rtpParameters,
++ });
++ media.addTrack(consumer.track);
++ if (info.kind === 'video') {
++ consumer.track.addEventListener('unmute', () => { if (video && video.paused) video.play().catch(() => {}); });
++ }
++ }
++ if (video) {
++ video.srcObject = media;
++ video.autoplay = true;
++ video.muted = true;
++ video.playsInline = true;
++ video.play().then(() => setState('')).catch(() => setState('Tap to play'));
++ video.addEventListener('click', () => { if (video.paused) video.play().catch(() => {}); });
++ }
++ }
++
++ function connect() {
++ if (closed) return;
++ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
++ const vt = viewTicket ? `&vt=${encodeURIComponent(viewTicket)}` : '';
++ ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?streamId=${encodeURIComponent(streamId)}&role=viewer${vt}`);
++ ws.addEventListener('open', () => { setState('Connecting…'); send({ type: 'watch' }); });
++ ws.addEventListener('message', (event) => {
++ let msg;
++ try { msg = JSON.parse(event.data); } catch { return; }
++ if (msg.type && settle(msg.type, msg)) return; // an answer the setup is awaiting
++ switch (msg.type) {
++ case 'sfu-viewer-ready':
++ retries = 0;
++ if (msg.producers && msg.producers.length) {
++ setup(msg.rtpCapabilities, msg.producers).catch((err) => {
++ console.warn('[AgentPlayer] setup failed:', err && err.message);
++ setState('Reconnecting…');
++ setTimeout(() => { send({ type: 'watch' }); }, 2500);
++ });
++ } else {
++ setState('Waiting for video…');
++ setTimeout(() => send({ type: 'watch' }), 3000);
++ }
++ break;
++ case 'watch-queued':
++ setState('Waiting for video…');
++ setTimeout(() => send({ type: 'watch' }), 3000);
++ break;
++ case 'sfu-source-unavailable':
++ setState('Waiting for video…');
++ break;
++ case 'curtain':
++ if (curtainEl) curtainEl.hidden = !msg.up;
++ break;
++ case 'viewer-count':
++ if (typeof onViewers === 'function') { try { onViewers(msg.count); } catch { /* the page's own problem */ } }
++ if (viewersEl) {
++ viewersEl.textContent = msg.count === 1 ? '1 watching' : `${msg.count} watching`;
++ viewersEl.hidden = false;
++ }
++ break;
++ case 'stream-ended':
++ setState('Stream ended');
++ if (curtainEl) curtainEl.hidden = true;
++ break;
++ case 'sfu-viewer-error':
++ console.warn('[AgentPlayer]', msg.error);
++ setState('Reconnecting…');
++ break;
++ default:
++ break;
++ }
++ });
++ ws.addEventListener('close', () => {
++ if (closed) return;
++ if (retries >= 8) { setState('Stream not available'); return; }
++ retries++;
++ setState('Reconnecting…');
++ setTimeout(connect, Math.min(1000 * retries, 8000));
++ });
++ ws.addEventListener('error', () => { /* close follows */ });
++ }
++
++ connect();
++ return {
++ close() {
++ closed = true;
++ try { if (ws) ws.close(); } catch { /* */ }
++ try { if (transport) transport.close(); } catch { /* */ }
++ },
++ };
++ }
++
++ window.AgentPlayer = { create };
++})();
ddiff --git a/public/js/agent-stream.js b/public/js/agent-stream.js
nnew file mode 100644
iindex 00000000..80edfee3
---- /dev/null
++++ b/public/js/agent-stream.js
@@@ -0,0 +1,333 @@
++/* The agent stream page (Agent Streams S1): /@person/agents/<ags_id>.
++ *
++ * The owner sees the player, the task, the visibility switch, the Share sheet, the curtain and End; anyone else sees the
++ * player and the "AI agent" label. The view ticket the API mints is what the socket joins with, and it is renewed while
++ * the page stays open (at most four hours, the server's rule).
++ *
++ * One status line carries the whole state — Live, N watching, worked 42 min; or Waiting for the desk; or Ended, worked
++ * 42 min. The player is only rebuilt when the stream or its live session changes, so the 20-second poll never interrupts
++ * playback.
++ */
++/* global window, document, location, setTimeout, clearTimeout, URLSearchParams, api, AgentPlayer, navigator */
++(function () {
++ 'use strict';
++ let player = null;
++ let mountedStreamId = null;
++ let state = null;
++ let ticket = null;
++ let liveViewers = null;
++ let renewTimer = null;
++ let pollTimer = null;
++ let generation = 0; // bumped on every page leave, so a slow request from the last page changes nothing
++
++ function $(id) { return document.getElementById(id); }
++
++ function stop() {
++ generation++;
++ if (player) { try { player.close(); } catch { /* */ } player = null; }
++ mountedStreamId = null;
++ liveViewers = null;
++ if (renewTimer) { clearTimeout(renewTimer); renewTimer = null; }
++ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
++ state = null;
++ ticket = null;
++ }
++
++ function minutes(seconds) {
++ const m = Math.max(0, Math.round(Number(seconds) || 0) / 60);
++ return m < 1 ? 'under a minute' : `${Math.round(m)} min`;
++ }
++
++ function taskText(task) {
++ if (!task) return '';
++ const where = { codes: 'Codes', actor: 'Actor', desk: 'Desk', node: 'Node' }[task.source] || task.source;
++ return `${where} task: ${task.title}`;
++ }
++
++ /** The one status line: what the stream is doing, and for how long. */
++ function renderStatus(stream, viewers = liveViewers) {
++ const line = $('agent-status-line');
++ if (!line) return;
++ if (stream.state === 'live') {
++ const count = Number(viewers == null ? stream.viewers : viewers) || 0;
++ line.textContent = `Live · ${count} watching · worked ${minutes(stream.worked_seconds)}`;
++ line.classList.add('is-live');
++ } else if (stream.state === 'ended') {
++ line.textContent = stream.worked_seconds ? `Ended · worked ${minutes(stream.worked_seconds)}` : 'Ended';
++ line.classList.remove('is-live');
++ } else {
++ line.textContent = 'Waiting for the desk';
++ line.classList.remove('is-live');
++ }
++ }
++
++ function renderCurtain(up) {
++ const curtain = $('agent-curtain');
++ if (curtain) curtain.hidden = !up;
++ const btn = $('agent-curtain-btn');
++ if (btn) btn.textContent = up ? 'Lift the curtain' : 'Curtain now';
++ }
++
++ function mountPlays(data) {
++ const stream = data.stream;
++ const video = $('agent-video');
++ const still = $('agent-still');
++ const stateEl = $('agent-state');
++ const wanted = stream.state === 'live' && data.live_stream_id ? data.live_stream_id : null;
++
++ // Already playing this very session: keep it (a poll must never interrupt the picture).
++ if (wanted && mountedStreamId === wanted && player) return;
++
++ if (player) { try { player.close(); } catch { /* */ } player = null; }
++ mountedStreamId = null;
++
++ if (wanted) {
++ still.hidden = true;
++ video.hidden = false;
++ stateEl.hidden = false;
++ stateEl.textContent = 'Connecting…';
++ player = AgentPlayer.create({
++ streamId: wanted,
++ viewTicket: ticket || '',
++ video,
++ state: stateEl,
++ curtain: $('agent-curtain'),
++ onViewers: (count) => {
++ liveViewers = count;
++ if (state && state.stream) renderStatus(state.stream, count);
++ },
++ });
++ mountedStreamId = wanted;
++ return;
++ }
++
++ video.hidden = true;
++ stateEl.hidden = false;
++ stateEl.textContent = stream.state === 'ended'
++ ? `This agent's work ended after ${minutes(stream.worked_seconds)}.`
++ : "This agent isn't streaming right now.";
++ if (stream.last_frame_url) {
++ still.src = `${stream.last_frame_url}${ticket ? `?vt=${encodeURIComponent(ticket)}` : ''}`;
++ still.hidden = false;
++ stateEl.hidden = true;
++ } else {
++ still.hidden = true;
++ }
++ }
++
++ function render(data) {
++ state = data;
++ ticket = data.view_ticket || ticket;
++ bindControls();
++ const stream = data.stream;
++ if (document.getElementById('agent-title')) document.title = `${stream.title} — AI agent on OpenVibe.Live`;
++ $('agent-title').textContent = stream.title;
++
++ const task = $('agent-task');
++ task.replaceChildren();
++ const label = document.createElement('span');
++ label.textContent = taskText(stream.task);
++ task.appendChild(label);
++ if (stream.task && stream.task.url) {
++ const a = document.createElement('a');
++ a.href = stream.task.url;
++ a.target = '_blank';
++ a.rel = 'noopener noreferrer';
++ a.textContent = 'Open the task';
++ a.style.marginLeft = '8px';
++ task.appendChild(a);
++ }
++
++ renderCurtain(!!(stream.curtain && stream.curtain.up));
++ renderStatus(stream, liveViewers);
++ mountPlays(data);
++ renderOwner(data);
++ scheduleRenew(data);
++ schedulePoll(stream.id);
++ }
++
++ function renderOwner(data) {
++ const panel = $('agent-owner');
++ if (!panel) return;
++ panel.hidden = !data.can_manage;
++ if (!data.can_manage) return;
++ const stream = data.stream;
++ const select = $('agent-visibility');
++ select.value = stream.visibility;
++ select.disabled = stream.state === 'ended';
++
++ const share = $('agent-share');
++ if (share) {
++ share.dataset.kind = 'agent-stream';
++ share.dataset.id = stream.id;
++ share.dataset.url = stream.page_url;
++ share.dataset.title = stream.title;
++ share.dataset.base = location.origin;
++ share.dataset.path = `/api/v1/agent-stream/${encodeURIComponent(stream.id)}/share`;
++ }
++ const rotate = $('agent-rotate');
++ if (rotate) rotate.hidden = stream.visibility !== 'unlisted';
++ const hint = $('agent-hint');
++ if (hint) {
++ if (stream.visibility === 'unlisted') {
++ hint.textContent = stream.page_url.includes('?k=')
++ ? 'Anyone with this link can watch. Reset the link and the old links stop working.'
++ : 'Anyone with the link can watch. Press "Reset link" to get a link to send — old links stop working.';
++ } else {
++ hint.textContent = 'Only you and the people you share it with can watch.';
++ }
++ }
++ renderCurtain(!!(stream.curtain && stream.curtain.up));
++ const endBtn = $('agent-end');
++ if (endBtn) endBtn.hidden = stream.state === 'ended';
++ const confirm = $('agent-end-confirm');
++ if (confirm && stream.state === 'ended') confirm.hidden = true;
++ }
++
++ function scheduleRenew(data) {
++ if (renewTimer) { clearTimeout(renewTimer); renewTimer = null; }
++ if (!ticket || data.stream.visibility === 'public') return;
++ const gen = generation;
++ renewTimer = setTimeout(async () => {
++ if (gen !== generation) return;
++ try {
++ const next = await api(`/streams/agent/${encodeURIComponent(data.stream.id)}/view-ticket`, { method: 'POST', body: { renew: ticket } });
++ if (gen === generation) ticket = next.ticket;
++ } catch { /* the page keeps the ticket it has until it stops working */ }
++ }, 8 * 60 * 1000);
++ }
++
++ /** Look again for a state the socket cannot tell us (the desk started, the work ended), without remounting a player. */
++ function schedulePoll(id) {
++ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
++ const gen = generation;
++ pollTimer = setTimeout(async () => {
++ try {
++ const k = new URLSearchParams(location.search).get('k');
++ const next = await api(`/streams/agent/${encodeURIComponent(id)}${k ? `?k=${encodeURIComponent(k)}` : ''}`);
++ if (gen !== generation) return; // the page is gone
++ const previous = state && state.stream;
++ const structural = !previous
++ || previous.state !== next.stream.state
++ || previous.visibility !== next.stream.visibility
++ || !!previous.curtain.up !== !!next.stream.curtain.up
++ || (state && state.live_stream_id) !== next.live_stream_id;
++ if (structural) { render(next); return; }
++ state = next;
++ ticket = next.view_ticket || ticket;
++ renderStatus(next.stream, liveViewers);
++ renderOwner(next);
++ schedulePoll(id);
++ } catch { if (gen === generation) schedulePoll(id); }
++ }, 20000);
++ }
++
++ /** The owner's controls: bound once, acting on the stream the page currently shows. */
++ let controlsBound = false;
++ function bindControls() {
++ if (controlsBound) return;
++ controlsBound = true;
++ const idOf = () => (state && state.stream ? state.stream.id : null);
++
++ $('agent-visibility').addEventListener('change', async (event) => {
++ const id = idOf();
++ if (!id) return;
++ const select = event.currentTarget;
++ try {
++ const next = await api(`/streams/agent/${encodeURIComponent(id)}/visibility`, { method: 'PUT', body: { visibility: select.value } });
++ if (next && next.stream && state) {
++ state.stream = next.stream;
++ renderOwner(state);
++ }
++ } catch (err) {
++ select.value = state ? state.stream.visibility : 'private';
++ const hint = $('agent-hint');
++ if (hint) hint.textContent = (err && err.message) || 'That did not save.';
++ }
++ });
++
++ $('agent-curtain-btn').addEventListener('click', async () => {
++ const id = idOf();
++ if (!id) return;
++ const up = !(state && state.stream.curtain && state.stream.curtain.up);
++ try {
++ await api(`/streams/agent/${encodeURIComponent(id)}/curtain`, { method: 'POST', body: { up, reason: 'manual' } });
++ if (state) state.stream.curtain = { up };
++ renderCurtain(up);
++ renderOwner(state);
++ } catch { /* the desk still owns the curtain */ }
++ });
++
++ $('agent-end').addEventListener('click', () => {
++ const confirm = $('agent-end-confirm');
++ const end = $('agent-end');
++ if (confirm) confirm.hidden = false;
++ if (end) end.hidden = true;
++ });
++
++ $('agent-end-no').addEventListener('click', () => {
++ const confirm = $('agent-end-confirm');
++ const end = $('agent-end');
++ if (confirm) confirm.hidden = true;
++ if (end) end.hidden = !!(state && state.stream && state.stream.state === 'ended');
++ });
++
++ $('agent-end-yes').addEventListener('click', async () => {
++ const id = idOf();
++ if (!id) return;
++ try {
++ await api(`/streams/agent/${encodeURIComponent(id)}/end`, { method: 'POST', body: {} });
++ const k = new URLSearchParams(location.search).get('k');
++ const next = await api(`/streams/agent/${encodeURIComponent(id)}${k ? `?k=${encodeURIComponent(k)}` : ''}`);
++ render(next);
++ } catch {
++ const confirm = $('agent-end-confirm');
++ if (confirm) confirm.hidden = true;
++ }
++ });
++
++ $('agent-rotate').addEventListener('click', async () => {
++ const id = idOf();
++ if (!id) return;
++ try {
++ const next = await api(`/streams/agent/${encodeURIComponent(id)}/rotate-link`, { method: 'POST', body: {} });
++ if (state && next && next.stream) {
++ state.stream = next.stream;
++ renderOwner(state);
++ }
++ if (next && next.stream && next.stream.page_url && navigator.clipboard) {
++ await navigator.clipboard.writeText(next.stream.page_url).catch(() => {});
++ }
++ } catch { /* shown by the hint below */ }
++ });
++ }
++
++ async function loadAgentStreamPage(username, publicId) {
++ stop();
++ const owner = $('agent-owner');
++ if (owner) owner.hidden = true;
++ const title = $('agent-title');
++ if (title) title.textContent = 'Agent stream';
++ const stateEl = $('agent-state');
++ if (stateEl) { stateEl.hidden = false; stateEl.textContent = 'Loading…'; }
++ const line = $('agent-status-line');
++ if (line) line.textContent = 'Loading…';
++ const k = new URLSearchParams(location.search).get('k');
++ try {
++ const data = await api(`/streams/agent/${encodeURIComponent(publicId)}${k ? `?k=${encodeURIComponent(k)}` : ''}`);
++ if (username && data.stream.channel && data.stream.channel.toLowerCase() !== String(username).toLowerCase()) {
++ // The link named another channel: the stream's own address is the right page.
++ window.location.replace(`/@${encodeURIComponent(data.stream.channel)}/agents/${encodeURIComponent(data.stream.id)}${k ? `?k=${encodeURIComponent(k)}` : ''}`);
++ return;
++ }
++ render(data);
++ } catch (err) {
++ if (stateEl) stateEl.textContent = err && err.status === 404 ? 'This agent stream is private or gone.' : 'The stream could not be loaded.';
++ if (title) title.textContent = 'Agent stream';
++ if (line) line.textContent = '';
++ }
++ }
++
++ window.loadAgentStreamPage = loadAgentStreamPage;
++ window.stopAgentStreamPage = stop;
++})();
ddiff --git a/public/js/app.js b/public/js/app.js
iindex e357dc5a..25e09e06 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -936,6 +936,7 @@ function teardownRoute(nextPath) {
if (typeof _goalCycleTimer !== 'undefined' && _goalCycleTimer) { clearInterval(_goalCycleTimer); _goalCycleTimer = null; }
stop('stopAiViewerActivity');
stop('stopPlayerStatsPoll');
++ stop('stopAgentStreamPage');
// The broadcast desk's polls only matter while you are on it. Leaving them running elsewhere
// costs roughly a request a second for the rest of the session.
const goingToBroadcast = typeof nextPath === 'string' && nextPath.startsWith('/broadcast');
@@@ -1269,6 +1270,15 @@ function routeFromURL() {
const mine = !!(currentUser && String(currentUser.username || '').toLowerCase() === username.toLowerCase());
navigate(mine ? `/broadcast${location.search}` : `/@${username}`, true);
return;
++ } else if (segments.length === 3 && segments[0].startsWith('@') && segments[1] === 'agents' && segments[2]) {
++ // An agent's own stream (Agent Streams S1): /@person/agents/<ags_id> (public/js/agent-stream.js).
++ const username = normalizeChannelUsername(segments[0]);
++ if (CHANNEL_USERNAME_RE.test(username)) {
++ showPage('agent-stream');
++ whenRouteReady('agent-stream', () => { if (typeof loadAgentStreamPage === 'function') loadAgentStreamPage(username, segments[2]); });
++ } else {
++ showNotFound();
++ }
} else if (segments.length >= 1 && segments[0].startsWith('@')) {
// Channel page: /@username or /@username/:managedStreamIdOrSlug
// Backward compat: /@username?stream=sessionId
ddiff --git a/public/js/ov-skeleton.js b/public/js/ov-skeleton.js
iindex 1267a887..db07464c 100644
---- a/public/js/ov-skeleton.js
++++ b/public/js/ov-skeleton.js
@@@ -116,7 +116,7 @@
// the wait is long enough to see (120 ms): a route already loaded never flashes one.
const ROUTE_SHAPE = {
'page-home': 'cards', 'page-content': 'cards', 'page-moments': 'cards', 'page-search': 'rows',
-- 'page-channel': 'player', 'page-stream': 'player', 'page-dashboard': 'dash', 'page-broadcast': 'panel',
++ 'page-channel': 'player', 'page-stream': 'player', 'page-agent-stream': 'player', 'page-dashboard': 'dash', 'page-broadcast': 'panel',
'page-chat': 'chat', 'page-updates': 'rows', 'page-recap': 'lines', 'page-rundown': 'rows',
'page-documentation': 'lines', 'page-paste-viewer': 'lines', 'page-profile': 'rows',
};
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 7def0439..16d30ea1 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
const BUDGETS = {
-- htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
++ htmlRawKB: 129, // 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsRawKB: 1290, // 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
-- jsBrotliKB: 301.6, // 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
++ jsBrotliKB: 302, // 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
ddiff --git a/server/agent-streams/auth.js b/server/agent-streams/auth.js
nnew file mode 100644
iindex 00000000..6b98a264
---- /dev/null
++++ b/server/agent-streams/auth.js
@@@ -0,0 +1,129 @@
++'use strict';
++/**
++ * Who may publish an agent stream, and who is asking to watch one.
++ *
++ * Publishing (create, renew/revoke a ticket, state, frame, curtain, end) is the person's own call:
++ *
++ * - their Live session (the cookie their browser already has); or
++ * - a first-party service token for audience openvibe.live that holds the capability
++ * `live.agent_stream.publish` and names the person — as the token's `sub` (`usr_…`, the design's form, when Network
++ * issues a person token to a Node principal or to Codes/Actor) or in `X-OV-Subject` (`usr_…`, the form Actor
++ * established for a first-party service acting for somebody). A developer-app token and another person's stream are
++ * always refused.
++ *
++ * Watching is the visitor's session when there is one; the view rule itself is service.mayView.
++ */
++const contracts = require('openvibe-contracts');
++const { verifyServiceToken } = require('openvibe-sdk/auth');
++const { optionalAuth, verifyToken, resolveNetworkUser, networkVerifyOptions, getNetworkIssuer } = require('../auth/auth');
++const db = require('../db/database');
++
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const AUDIENCE = 'openvibe.live';
++// The registry's id (a capability id carries no hyphen); the design's spelling is accepted too, so a token minted
++// before the registry entry is pinned still works.
++const CAPABILITIES = ['live.agent_stream.publish', 'live.agent-stream.publish'];
++const CAPABILITY = CAPABILITIES[0];
++const FIRST_PARTY_SUB = /^(svc|node):/;
++
++/** The local account linked to a Network subject, or null. */
++async function userForSubject(subject) {
++ if (!SUBJECT_RE.test(String(subject || ''))) return null;
++ const row = await db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? LIMIT 1").get(subject);
++ return row ? await db.getUserById(row.user_id) : null;
++}
++
++/** The capability on a service token, with the pinned registry's check when it knows the id. */
++function holdsCapability(claims) {
++ const c = claims || {};
++ if (!Array.isArray(c.cap)) return false;
++ return CAPABILITIES.some((capability) => {
++ if (!c.cap.includes(capability)) return false;
++ if (contracts.capabilities.get(capability)) return contracts.capabilities.check(c, capability).allowed;
++ return contracts.capabilities.grants(c.cap, capability);
++ });
++}
++
++/**
++ * The person a first-party service token acts for, or an error code. Only a `svc:`/`node:` token may name somebody;
++ * an app or an agent acts as itself, never for a person.
++ */
++async function personFromServiceToken(req) {
++ const auth = String(req.headers.authorization || '');
++ if (!auth.startsWith('Bearer ')) return { code: 'token.missing' };
++ const token = auth.slice(7).trim();
++ if (!token.includes('.')) return { code: 'token.invalid' };
++ let payload;
++ try { payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch { return { code: 'token.invalid' }; }
++ // A service token is the only thing with one of these subjects; a person's token is handled by optionalAuth.
++ const looksService = payload && typeof payload.sub === 'string' && (FIRST_PARTY_SUB.test(payload.sub) || String(payload.sub).startsWith('app:') || String(payload.sub).startsWith('agent:'));
++ if (!looksService) return { code: 'token.not_service' };
++
++ const r = await verifyServiceToken(token, { ...networkVerifyOptions(), issuer: getNetworkIssuer(), audience: AUDIENCE, contracts });
++ if (!r.ok) return { code: r.code === 'token.unavailable' ? 'identity.unavailable' : r.code || 'token.invalid' };
++ if (!holdsCapability(r.claims)) return { code: 'capability.denied' };
++
++ const sub = String(r.claims.sub);
++ let subject = null;
++ if (SUBJECT_RE.test(sub)) subject = sub; // the design's person token
++ else if (FIRST_PARTY_SUB.test(sub)) { // a first-party service acting for a person
++ const named = req.get ? req.get('x-ov-subject') : req.headers['x-ov-subject'];
++ if (named === undefined || named === '') return { code: 'subject.missing' };
++ if (!SUBJECT_RE.test(String(named))) return { code: 'subject.invalid' };
++ subject = String(named);
++ } else {
++ return { code: 'subject.not_delegated' };
++ }
++ const user = await userForSubject(subject);
++ if (!user) return { code: 'subject.unknown' };
++ if (user.is_banned) return { code: 'user_banned' };
++ return { user, subject, via: `service:${sub}` };
++}
++
++/** The person publishing: their session, or the first-party service acting for them. */
++async function publisher(req, res, next) {
++ try {
++ await optionalAuth(req, res, () => {});
++ if (req.user && !req.user.is_banned) {
++ req.publisher = { user: req.user, via: req.authSource || 'session', subject: null };
++ return next();
++ }
++ if (req.user && req.user.is_banned) return res.status(403).json({ error: 'Account is banned', code: 'user_banned' });
++ const service = await personFromServiceToken(req);
++ if (service.user) {
++ req.publisher = { user: service.user, subject: service.subject, via: service.via };
++ return next();
++ }
++ if (service.code === 'identity.unavailable') return res.status(503).json({ error: 'Identity is unavailable', code: service.code });
++ if (service.code === 'token.not_service' || service.code === 'token.missing') {
++ return res.status(401).json({ error: 'Sign in, or present a service token that holds ' + CAPABILITY, code: 'authentication_required' });
++ }
++ return res.status(403).json({ error: 'This token may not publish an agent stream', code: service.code });
++ } catch (err) {
++ next(err);
++ }
++}
++
++/** The visitor, if any (never refuses). `req.viewer` is a Live user row or null. */
++async function viewer(req, res, next) {
++ try {
++ await optionalAuth(req, res, () => {});
++ req.viewer = req.user || null;
++ } catch { req.viewer = null; }
++ next();
++}
++
++/** The caller's Network subject when Live knows it (for the sharing system). */
++async function viewerSubject(user) {
++ if (!user) return null;
++ const row = await db.getDb().prepare("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ? LIMIT 1").get(user.id);
++ return (row && SUBJECT_RE.test(String(row.subject_id || ''))) ? row.subject_id : null;
++}
++
++/** A person's own Live identity from a raw person token (the embed's server-side minting uses it). */
++async function userFromToken(token) {
++ const decoded = await verifyToken(String(token || ''));
++ return decoded ? await resolveNetworkUser(decoded) : null;
++}
++
++module.exports = { publisher, viewer, viewerSubject, userForSubject, userFromToken, holdsCapability, AUDIENCE, CAPABILITY, CAPABILITIES };
ddiff --git a/server/agent-streams/frames.js b/server/agent-streams/frames.js
nnew file mode 100644
iindex 00000000..4687af4a
---- /dev/null
++++ b/server/agent-streams/frames.js
@@@ -0,0 +1,43 @@
++'use strict';
++/**
++ * The desk's last frame: one JPEG or WebP per agent stream, under DATA_DIR/agent-frames (server/paths.js, never a
++ * literal directory). It is the card's picture and the task page's still while the desk is not encoding.
++ *
++ * Writes are atomic (a temporary sibling then a rename), so a viewer never reads half a picture; reads never create a
++ * file. The file name is the stream's own public id, which the route has already matched against a row — nothing a
++ * caller sends becomes a path.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const DIR = require('../paths').dir('AGENT_FRAMES_PATH', 'agent-frames');
++
++function fileFor(name) {
++ // Public ids only (ags_<ULID>.<jpg|webp>); anything else is not a frame this service wrote.
++ if (!/^ags_[0-9A-HJKMNP-TV-Z]{26}\.(jpg|webp)$/.test(String(name || ''))) return null;
++ return { path: path.join(DIR, name), type: name.endsWith('.webp') ? 'image/webp' : 'image/jpeg' };
++}
++
++async function write(name, bytes) {
++ fs.mkdirSync(DIR, { recursive: true });
++ const file = fileFor(name);
++ if (!file) throw new TypeError('not a frame name');
++ const tmp = `${file.path}.${process.pid}.${Date.now()}.tmp`;
++ await fs.promises.writeFile(tmp, bytes);
++ await fs.promises.rename(tmp, file.path);
++ return file;
++}
++
++/** The stored picture for a row's last_frame_path, or null when there is none. */
++async function read(name) {
++ const file = fileFor(name);
++ if (!file) return null;
++ try {
++ const bytes = await fs.promises.readFile(file.path);
++ return { bytes, type: file.type };
++ } catch {
++ return null;
++ }
++}
++
++module.exports = { DIR, fileFor, write, read };
ddiff --git a/server/agent-streams/pages.js b/server/agent-streams/pages.js
nnew file mode 100644
iindex 00000000..bd308e60
---- /dev/null
++++ b/server/agent-streams/pages.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * /embed/agent/<ags_id> — the agent stream as a frame for the task page and the Dock (Agent Streams S1).
++ *
++ * A standalone page, not the SPA: a third-party iframe has no Live cookies and must not load the shell or chat. It is
++ * the player, the "AI agent" label, the curtain state and the viewer count (the design's embed; no chat in S1).
++ *
++ * Who may frame it: the first-party origins only. That is the page's own CSP `frame-ancestors`, and the reason it is a
++ * short allow-list rather than `*` — a private agent stream is a person's screen.
++ *
++ * The ticket travels in the URL (`?vt=wvt_…`), minted by Codes, Actor or the desk with the person's own token, because
++ * an iframe on someone else's page has no session with us. Anyone without a valid ticket is answered by the API with
++ * 404, and the socket refuses the join as well.
++ */
++const SERVICE_SITES = [
++ 'https://openvibe.live',
++ 'https://openvibe.network',
++ 'https://openvibe.codes',
++ 'https://openvibe.actor',
++ 'https://openvibe.node',
++ 'https://openvibe.tools',
++];
++
++/** The CSP frame-ancestors list: first-party origins, plus a deployment's extra ones from the environment. */
++function frameAncestors() {
++ const extra = String(process.env.AGENT_EMBED_FRAME_ANCESTORS || '').split(/[\s,]+/).filter(Boolean);
++ return ["'self'", ...SERVICE_SITES, ...extra].join(' ');
++}
++
++function embedCsp() {
++ return [
++ "default-src 'none'",
++ "script-src 'self' 'unsafe-inline' https://esm.sh",
++ "style-src 'self' 'unsafe-inline'",
++ "img-src 'self' data: blob:",
++ "media-src 'self' blob:",
++ "connect-src 'self' wss: https://esm.sh",
++ "font-src 'self' data:",
++ `frame-ancestors ${frameAncestors()}`,
++ 'base-uri none',
++ 'form-action none',
++ ].join('; ');
++}
++
++function html(publicId) {
++ const id = String(publicId || '').replace(/[^A-Za-z0-9_]/g, '');
++ return `<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width,initial-scale=1">
++<meta name="robots" content="noindex">
++<title>AI agent stream</title>
++<link rel="stylesheet" href="/css/features/agent-stream.css">
++</head>
++<body class="agent-embed" data-agent-id="${id}">
++ <div class="agent-embed-frame">
++ <div class="agent-embed-stage" id="agent-embed-stage">
++ <div class="agent-embed-media" id="agent-embed-media">
++ <video id="agent-embed-video" playsinline muted autoplay></video>
++ <img id="agent-embed-still" alt="" hidden>
++ <span class="agent-label agent-embed-label">AI agent</span>
++ <span class="agent-embed-title" id="agent-embed-title"></span>
++ <span class="agent-embed-viewers" id="agent-embed-viewers" hidden></span>
++ <div class="agent-embed-state" id="agent-embed-state">Connecting…</div>
++ <div class="agent-embed-curtain" id="agent-embed-curtain" hidden>Private screen</div>
++ </div>
++ </div>
++ </div>
++ <script src="/js/agent-player.js" defer></script>
++ <script src="/js/agent-embed.js" defer></script>
++</body>
++</html>`;
++}
++
++/** GET /embed/agent/:id */
++async function embed(req, res) {
++ const id = String(req.params.id || '');
++ if (!/^ags_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) return res.status(404).type('text/plain').send('No such agent stream');
++ // The page itself says nothing about the stream; it 404s for an id that names nothing, and a private stream's
++ // script shows "private or gone" (the API, not this page, is the judge of who may watch).
++ const row = await require('./service').getByPublicId(id).catch(() => null);
++ if (!row) return res.status(404).type('text/plain').send('No such agent stream');
++ res.set('Content-Security-Policy', embedCsp());
++ res.set('Cache-Control', 'no-store');
++ res.set('X-Robots-Tag', 'noindex');
++ res.type('html').send(html(id));
++}
++
++module.exports = { embed, embedCsp, frameAncestors, SERVICE_SITES };
ddiff --git a/server/agent-streams/routes.js b/server/agent-streams/routes.js
nnew file mode 100644
iindex 00000000..fbb626c7
---- /dev/null
++++ b/server/agent-streams/routes.js
@@@ -0,0 +1,336 @@
++'use strict';
++/**
++ * /api/streams/agent — the agent streams API (live.agent-stream@1 and the publish/view routes around it).
++ *
++ * POST / create the stream and its publish ticket { stream, publish } 201
++ * GET /:id the stream (view rule), plus what its page needs
++ * PUT /:id/visibility the owner's switch
++ * POST /:id/rotate-link a fresh unlisted link secret (the old links stop working)
++ * POST /:id/ticket renew the publish ticket (the desk renews at three hours)
++ * DELETE /:id/ticket revoke it — the kill switch; Live ends the WHIP session at once
++ * GET /:id/state what the desk polls every 10 s (with the ticket)
++ * PUT /:id/frame the desk's last frame (JPEG or WebP, ≤200 KB, with the ticket)
++ * GET /:id/frame that frame, for a viewer allowed to see the stream
++ * POST /:id/curtain the desk's privacy screen up or down
++ * POST /:id/end end the stream (the person or the desk)
++ * POST /:id/view-ticket mint a view ticket for a player (owner, guest, unlisted link)
++ *
++ * The desk endpoints (state, frame, curtain, end) take the publish ticket as `Authorization: Bearer wpt_…`; the
++ * person's own browser uses their session instead. Nothing here ever answers a stream key: an agent row has none.
++ */
++const express = require('express');
++const auth = require('./auth');
++const service = require('./service');
++const tickets = require('./tickets');
++const frames = require('./frames');
++
++const router = express.Router();
++
++function fail(res, status, code, detail) {
++ return res.status(status).json({ error: detail || code, code });
++}
++
++function stringOf(value, max) {
++ if (typeof value !== 'string') return null;
++ const s = value.trim();
++ return s && s.length <= max ? s : null;
++}
++
++function cleanTask(task) {
++ if (!task || typeof task !== 'object') return null;
++ const source = service.TASK_SOURCES.includes(task.source) ? task.source : null;
++ const ref = stringOf(task.ref, 200);
++ const title = stringOf(task.title, 200);
++ if (!source || !ref || !title) return null;
++ const url = task.url == null ? null : (typeof task.url === 'string' && /^https?:\/\//i.test(task.url) && task.url.length <= 2000 ? task.url : undefined);
++ if (url === undefined) return null;
++ return { source, ref, title, url };
++}
++
++function cleanSites(value) {
++ if (value === undefined) return [];
++ if (!Array.isArray(value) || value.length > 50) return null;
++ const out = [];
++ for (const site of value) {
++ if (typeof site !== 'string' || !/^https?:\/\//i.test(site) || site.length > 500) return null;
++ out.push(site);
++ }
++ return out;
++}
++
++/** The stream's row for this request's :id, or a 404 already sent. */
++async function load(req, res) {
++ const row = await service.getByPublicId(req.params.id);
++ if (!row) { fail(res, 404, 'agent_stream_not_found', 'No such agent stream'); return null; }
++ return row;
++}
++
++/**
++ * The desk, or the person: a publish ticket for this stream (state, frame, curtain, end), else the owner's session.
++ * A ticket names its stream, so a ticket for another stream is the same as no ticket.
++ */
++async function deskOrOwner(req, res, next) {
++ try {
++ const header = String(req.headers.authorization || '');
++ const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
++ if (bearer.startsWith('wpt_')) {
++ const ticket = await tickets.findPublish(bearer);
++ const row = ticket ? await service.getByManagedStreamId(ticket.managed_stream_id) : null;
++ if (!ticket || !row || row.public_id !== req.params.id) return fail(res, 401, 'invalid_ticket', 'The publish ticket is not valid for this stream');
++ req.ticket = ticket;
++ req.agent = row;
++ return next();
++ }
++ return auth.publisher(req, res, async () => {
++ try {
++ const row = req.publisher && await service.getByPublicId(req.params.id);
++ if (!row) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
++ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');
++ req.agent = row;
++ next();
++ } catch (err) {
++ console.error('[AgentStream] desk auth failed:', err.message);
++ fail(res, 500, 'auth_failed', 'The request could not be authorised');
++ }
++ });
++ } catch (err) {
++ console.error('[AgentStream] desk ticket failed:', err.message);
++ return fail(res, 500, 'auth_failed', 'The request could not be authorised');
++ }
++}
++
++// ── Create ─────────────────────────────────────────────────────
++
++router.post('/', auth.publisher, async (req, res) => {
++ try {
++ const body = req.body || {};
++ const title = stringOf(body.title, 200);
++ const task = cleanTask(body.task);
++ const visibility = service.VISIBILITIES.includes(body.visibility) ? body.visibility : null;
++ const allowSites = cleanSites(body.allow_sites);
++ if (!title) return fail(res, 400, 'invalid_title', 'A title of 1–200 characters is required');
++ if (!task) return fail(res, 400, 'invalid_task', 'task needs source (codes, actor, desk, node), ref and title');
++ if (!visibility) return fail(res, 400, 'invalid_visibility', 'visibility is private, unlisted or public');
++ if (allowSites === null) return fail(res, 400, 'invalid_allow_sites', 'allow_sites is a list of at most 50 http(s) addresses');
++
++ const row = await service.create({ user: req.publisher.user, title, task, visibility, allowSites });
++ const publish = await tickets.issuePublish(row.managed_stream_id);
++ const stream = service.publicView(row);
++ if (row.unlisted_secret) stream.page_url = `${stream.page_url}?k=${encodeURIComponent(row.unlisted_secret)}`;
++ res.status(201).json({ stream, publish: { whip_url: service.whipUrl(row.public_id), ...publish } });
++ } catch (err) {
++ if (err.status) return res.status(err.status).json({ error: err.message, code: err.code });
++ console.error('[AgentStream] create failed:', err.message);
++ fail(res, 500, 'create_failed', 'The agent stream could not be created');
++ }
++});
++
++// ── Read ───────────────────────────────────────────────────────
++
++router.get('/:id', auth.viewer, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ const isOwner = !!(req.viewer && Number(req.viewer.id) === Number(row.user_id));
++ const secret = typeof req.query.k === 'string' ? req.query.k : null;
++ const presented = typeof req.query.vt === 'string' ? await tickets.findViewById(req.query.vt, row.managed_stream_id) : null;
++ const allowed = isOwner || presented || await service.mayView(row, { viewer: req.viewer, secret });
++ if (!allowed) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
++
++ const stream = service.publicView(row);
++ if (isOwner && row.visibility === 'unlisted' && row.unlisted_secret) stream.page_url = `${stream.page_url}?k=${encodeURIComponent(row.unlisted_secret)}`;
++ const view = (isOwner || row.visibility !== 'public')
++ ? await tickets.issueView(row.managed_stream_id, {
++ subject: req.viewer ? await auth.viewerSubject(req.viewer) : null,
++ via: isOwner ? 'owner' : (row.visibility === 'unlisted' ? 'link' : 'share'),
++ })
++ : null;
++ res.json({
++ stream,
++ live_stream_id: row.live_session_id ? Number(row.live_session_id) : null,
++ can_manage: isOwner,
++ // The curtain's reason is the owner's alone; the resource itself never carries it.
++ ...(isOwner && Number(row.curtain_up) && row.curtain_reason ? { curtain_reason: row.curtain_reason } : {}),
++ ...(view ? { view_ticket: view.ticket, view_ticket_expires_at: view.expires_at } : {}),
++ });
++ } catch (err) {
++ console.error('[AgentStream] read failed:', err.message);
++ fail(res, 500, 'read_failed', 'The agent stream could not be read');
++ }
++});
++
++// ── Visibility and the unlisted link ───────────────────────────
++
++router.put('/:id/visibility', auth.publisher, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');
++ const visibility = req.body && req.body.visibility;
++ if (!service.VISIBILITIES.includes(visibility)) return fail(res, 400, 'invalid_visibility', 'visibility is private, unlisted or public');
++ const next = await service.setVisibility(row, visibility);
++ const stream = service.publicView(next);
++ if (next.unlisted_secret) stream.page_url = `${stream.page_url}?k=${encodeURIComponent(next.unlisted_secret)}`;
++ res.json({ stream });
++ } catch (err) {
++ console.error('[AgentStream] visibility failed:', err.message);
++ fail(res, 500, 'visibility_failed', 'The visibility could not be changed');
++ }
++});
++
++router.post('/:id/rotate-link', auth.publisher, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');
++ if (row.visibility !== 'unlisted') return fail(res, 409, 'not_unlisted', 'Only an unlisted stream has a link secret');
++ const secret = await service.rotateLink(row);
++ const stream = service.publicView(row);
++ stream.page_url = `${stream.page_url}?k=${encodeURIComponent(secret)}`;
++ res.json({ stream });
++ } catch (err) {
++ console.error('[AgentStream] rotate link failed:', err.message);
++ fail(res, 500, 'rotate_failed', 'The link could not be rotated');
++ }
++});
++
++// ── The publish ticket ─────────────────────────────────────────
++
++router.post('/:id/ticket', auth.publisher, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');
++ if (row.state === 'ended') return fail(res, 409, 'agent_stream_ended', 'This agent stream has ended');
++ const publish = await tickets.issuePublish(row.managed_stream_id);
++ res.json({ publish: { whip_url: service.whipUrl(row.public_id), ...publish } });
++ } catch (err) {
++ console.error('[AgentStream] ticket renew failed:', err.message);
++ fail(res, 500, 'ticket_failed', 'The publish ticket could not be renewed');
++ }
++});
++
++router.delete('/:id/ticket', auth.publisher, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');
++ await tickets.revokeAll(row.managed_stream_id);
++ // The kill switch: the desk loses its session now, never at its next renewal.
++ const current = await service.getByManagedStreamId(row.managed_stream_id);
++ await service.end(current, { reason: 'ticket_revoked' });
++ res.json({ ok: true, stream: service.publicView(await service.getByManagedStreamId(row.managed_stream_id)) });
++ } catch (err) {
++ console.error('[AgentStream] ticket revoke failed:', err.message);
++ fail(res, 500, 'revoke_failed', 'The publish ticket could not be revoked');
++ }
++});
++
++// ── What the desk reads and sends ──────────────────────────────
++
++router.get('/:id/state', deskOrOwner, async (req, res) => {
++ try {
++ const row = req.agent;
++ const encode = row.visibility === 'public' || service.viewersNow(row.live_session_id) > 0;
++ res.set('Cache-Control', 'no-store');
++ res.json({
++ viewers: service.viewersNow(row.live_session_id),
++ encode,
++ curtain: {
++ up: !!Number(row.curtain_up),
++ since: Number(row.curtain_up) ? service.iso(row.curtain_since) : null,
++ ...(Number(row.curtain_up) && row.curtain_reason ? { reason: row.curtain_reason } : {}),
++ },
++ ends_at: service.iso(await tickets.currentExpiry(row.managed_stream_id)),
++ });
++ } catch (err) {
++ console.error('[AgentStream] state failed:', err.message);
++ fail(res, 500, 'state_failed', 'The state could not be read');
++ }
++});
++
++router.put('/:id/frame', deskOrOwner, express.raw({ type: ['image/jpeg', 'image/webp'], limit: service.MAX_FRAME_BYTES }), async (req, res) => {
++ try {
++ if (!Buffer.isBuffer(req.body) || req.body.length === 0) return fail(res, 415, 'frame_not_an_image', 'Send the frame as image/jpeg or image/webp');
++ if (req.body.length > service.MAX_FRAME_BYTES) return fail(res, 413, 'frame_too_large', 'A frame is at most 200 KB');
++ if (!await service.setFrame(req.agent, req.body)) return fail(res, 415, 'frame_not_an_image', 'Send the frame as JPEG or WebP');
++ res.status(204).end();
++ } catch (err) {
++ console.error('[AgentStream] frame failed:', err.message);
++ fail(res, 500, 'frame_failed', 'The frame could not be stored');
++ }
++});
++
++router.get('/:id/frame', auth.viewer, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ const isOwner = !!(req.viewer && Number(req.viewer.id) === Number(row.user_id));
++ const ticket = typeof req.query.vt === 'string' ? await tickets.findViewById(req.query.vt, row.managed_stream_id) : null;
++ const allowed = isOwner || ticket || await service.mayView(row, { viewer: req.viewer, secret: typeof req.query.k === 'string' ? req.query.k : null });
++ if (!allowed) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
++ const picture = row.last_frame_path ? await frames.read(row.last_frame_path) : null;
++ if (!picture) return fail(res, 404, 'no_frame', 'The desk has not sent a frame yet');
++ res.set('Content-Type', picture.type);
++ res.set('Cache-Control', 'no-store');
++ res.set('ETag', `"${service.sha256(picture.bytes).slice(0, 32)}"`);
++ res.send(picture.bytes);
++ } catch (err) {
++ console.error('[AgentStream] frame read failed:', err.message);
++ fail(res, 500, 'frame_read_failed', 'The frame could not be read');
++ }
++});
++
++router.post('/:id/curtain', deskOrOwner, async (req, res) => {
++ try {
++ const body = req.body || {};
++ if (typeof body.up !== 'boolean') return fail(res, 400, 'invalid_curtain', 'up is true or false');
++ const reason = service.CURTAIN_REASONS.includes(body.reason) ? body.reason : 'detector';
++ const row = await service.setCurtain(req.agent, body.up, reason);
++ // The consumers are paused/resumed by the SFU, and every player learns the screen is private (never why).
++ try { require('../streaming/curtains').apply(row.live_session_id); } catch (err) { console.warn('[AgentStream] curtain apply:', err.message); }
++ res.json({ curtain: { up: !!Number(row.curtain_up), since: service.iso(row.curtain_since) } });
++ } catch (err) {
++ console.error('[AgentStream] curtain failed:', err.message);
++ fail(res, 500, 'curtain_failed', 'The curtain could not be set');
++ }
++});
++
++router.post('/:id/end', deskOrOwner, async (req, res) => {
++ try {
++ const row = await service.end(req.agent, { reason: req.ticket ? 'desk_ended' : 'owner_ended' });
++ res.json({ stream: service.publicView(row) });
++ } catch (err) {
++ console.error('[AgentStream] end failed:', err.message);
++ fail(res, 500, 'end_failed', 'The stream could not be ended');
++ }
++});
++
++// ── View tickets ───────────────────────────────────────────────
++
++router.post('/:id/view-ticket', auth.viewer, async (req, res) => {
++ try {
++ const row = await load(req, res);
++ if (!row) return;
++ const secret = (req.body && typeof req.body.k === 'string') ? req.body.k : (typeof req.query.k === 'string' ? req.query.k : null);
++ const isOwner = !!(req.viewer && Number(req.viewer.id) === Number(row.user_id));
++ // A page renews with the ticket it holds, before it expires, while the renewal chain lasts (four hours).
++ const renewal = (req.body && typeof req.body.renew === 'string') ? req.body.renew : (typeof req.query.renew === 'string' ? req.query.renew : null);
++ const previous = renewal ? await tickets.findViewById(renewal, row.managed_stream_id) : null;
++ if (!isOwner && !previous) {
++ const allowed = await service.mayView(row, { viewer: req.viewer, secret });
++ if (!allowed) return fail(res, 403, 'view_not_allowed', 'This stream is not shared with you');
++ }
++ const view = await tickets.issueView(row.managed_stream_id, {
++ subject: req.viewer ? await auth.viewerSubject(req.viewer) : null,
++ via: isOwner ? 'owner' : (row.visibility === 'unlisted' ? 'link' : 'share'),
++ renewsUntil: previous ? previous.renews_until : null,
++ });
++ res.json(view);
++ } catch (err) {
++ console.error('[AgentStream] view ticket failed:', err.message);
++ fail(res, 500, 'view_ticket_failed', 'A view ticket could not be minted');
++ }
++});
++
++module.exports = router;
ddiff --git a/server/agent-streams/service.js b/server/agent-streams/service.js
nnew file mode 100644
iindex 00000000..7f3d8f89
---- /dev/null
++++ b/server/agent-streams/service.js
@@@ -0,0 +1,355 @@
++'use strict';
++/**
++ * Agent streams (the Agent Streams plan S1, design 2026-11-06).
++ *
++ * An agent stream is one `managed_streams` row of kind 'agent' (public id ags_<ULID>) plus one `agent_streams` row for
++ * the agent-only facts, and an ordinary `streams` row while it is live — the same one the WHIP ingest and the player
++ * use. Everything here is about that pair and its two tickets:
++ *
++ * - a publish ticket (`wpt_`, four hours) lets a desk publish by WHIP. Only its sha256 is stored. Renewing replaces
++ * it; revoking it ends the WHIP session, which is the kill switch.
++ * - a view ticket (`wvt_`, ten minutes, renewed while the page is open) lets a player join a private or unlisted
++ * stream. It is minted for the owner, for somebody the sharing system allows (service live, kind agent-stream), or
++ * for the holder of the unlisted link's secret.
++ *
++ * Visibility is enforced at the routes (this file's `mayView`) and again at the watch socket
++ * (server/streaming/broadcast-server.js), so a page that forgets its ticket still shows nothing.
++ */
++const crypto = require('crypto');
++const config = require('../config');
++const db = require('../db/database');
++const contracts = require('openvibe-contracts');
++const identity = require('../auth/identity-sync');
++const sharing = require('./sharing');
++
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const AGENT_ID_RE = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
++const VISIBILITIES = ['private', 'unlisted', 'public'];
++const MODES = ['watch', 'narrated', 'copilot', 'chatplays'];
++const TASK_SOURCES = ['codes', 'actor', 'desk', 'node'];
++const CURTAIN_REASONS = ['password_field', 'secret_file', 'env_print', 'manual', 'site', 'detector'];
++const PUBLISH_TICKET_TTL_MS = 4 * 60 * 60 * 1000;
++const VIEW_TICKET_TTL_MS = 10 * 60 * 1000;
++const MAX_FRAME_BYTES = 200 * 1024;
++const SHARE_KIND = 'agent-stream';
++
++const sha256 = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
++const token = (prefix, bytes = 32) => prefix + crypto.randomBytes(bytes).toString('base64url');
++const agentId = () => 'ags_' + contracts.ids.ulid();
++const linkSecret = () => crypto.randomBytes(16).toString('base64url').slice(0, 22);
++
++/** A timestamp for the contract: Date, ISO text or the database's text form → ISO, or null. */
++function iso(value) {
++ if (value == null || value === '') return null;
++ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
++ const d = new Date(String(value).includes('T') ? value : `${String(value).replace(' ', 'T')}Z`);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++}
++
++/** The public page address, with the unlisted link's secret for its owner. */
++function pageUrl(stream) {
++ const base = String(config.baseUrl || 'https://openvibe.live').replace(/\/+$/, '');
++ const user = encodeURIComponent(stream.username || '');
++ return `${base}/@${user}/agents/${stream.public_id}`;
++}
++
++/** The WHIP ingest address the desk publishes to. */
++function whipUrl(publicId) {
++ const base = String(config.whip?.publicUrl || 'https://whip.openvibe.live').replace(/\/+$/, '');
++ return `${base}/whip/agent/${publicId}`;
++}
++
++function frameUrl(publicId) {
++ const base = String(config.baseUrl || 'https://openvibe.live').replace(/\/+$/, '');
++ return `${base}/api/streams/agent/${publicId}/frame`;
++}
++
++/** The share ref the sharing system knows this stream by (openvibe-sdk/share, service live). */
++function shareRef(publicId) { return { service: 'live', kind: SHARE_KIND, id: publicId }; }
++
++// ── Reading one stream ─────────────────────────────────────────
++
++const SELECT = `
++ SELECT ms.id AS managed_stream_id, ms.public_id, ms.user_id, ms.title, ms.channel_id, ms.kind,
++ ms.created_at AS slot_created_at,
++ a.task, a.visibility, a.mode, a.state, a.curtain_up, a.curtain_since, a.curtain_reason,
++ a.started_at, a.ended_at, a.worked_seconds, a.last_frame_path, a.last_frame_at, a.vod_id, a.allow_sites,
++ a.unlisted_secret_hash,
++ (SELECT s.id FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS live_session_id,
++ (SELECT la.subject_id FROM linked_accounts la WHERE la.service = 'network' AND la.user_id = ms.user_id) AS owner_subject,
++ u.username, u.display_name, u.avatar_url, u.profile_color, u.is_banned, u.is_owner
++ FROM managed_streams ms
++ JOIN agent_streams a ON a.managed_stream_id = ms.id
++ JOIN users u ON u.id = ms.user_id`;
++
++async function getByPublicId(publicId) {
++ if (!AGENT_ID_RE.test(String(publicId || ''))) return null;
++ return await db.get(`${SELECT} WHERE ms.public_id = ?`, [publicId]) || null;
++}
++
++async function getByManagedStreamId(managedStreamId) {
++ return await db.get(`${SELECT} WHERE ms.id = ?`, [managedStreamId]) || null;
++}
++
++/** The live `streams` row of this agent stream, if one is up. */
++async function liveSession(managedStreamId) {
++ return await db.get('SELECT id, started_at FROM streams WHERE managed_stream_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [managedStreamId]) || null;
++}
++
++async function listByUser(userId) {
++ return await db.all(`${SELECT} WHERE ms.user_id = ? ORDER BY a.created_at DESC`, [userId]);
++}
++
++/** Live agent streams of an account (the slot rule counts these, not the normal ones). */
++async function countLiveByUser(userId) {
++ return Number((await db.get("SELECT COUNT(*) AS n FROM agent_streams a JOIN managed_streams ms ON ms.id = a.managed_stream_id WHERE ms.user_id = ? AND a.state = 'live'", [userId]))?.n || 0);
++}
++
++/** How many agent streams a person may have live at once: one, or five for Live's owner. */
++function liveLimit(user) { return user && user.is_owner ? 5 : 1; }
++
++/** The owner's Network subject, or null for an account Network has never named. */
++async function ownerSubject(userId) { return await identity.subjectOf(userId); }
++
++/** Viewers watching now: the sockets attached to the stream's live session, plus any WHEP player. */
++function viewersNow(sessionId) {
++ if (!sessionId) return 0;
++ let sockets = 0;
++ try {
++ const room = require('../streaming/broadcast-server').rooms.get(Number(sessionId));
++ sockets = room ? room.viewers.size : 0;
++ } catch { /* no socket server in a test */ }
++ let whep = 0;
++ try { whep = require('../streaming/whep-handler').countForStream(sessionId); } catch { /* WHEP optional */ }
++ return sockets + whep;
++}
++
++/** How long the agent has worked: up to now while live, the stored total once ended. */
++function workedSeconds(row) {
++ if (row.state === 'live' && row.started_at) {
++ const started = Date.parse(iso(row.started_at));
++ if (Number.isFinite(started)) return Math.max(0, Math.round((Date.now() - started) / 1000));
++ }
++ return Math.max(0, Number(row.worked_seconds) || 0);
++}
++
++/**
++ * The resource (live.agent-stream@1) as its owner or a viewer may see it. A viewer never learns the curtain's reason
++ * or the unlisted link's secret.
++ */
++function publicView(row) {
++ return {
++ id: row.public_id,
++ owner: row.owner_subject || null,
++ channel: row.username,
++ title: row.title,
++ task: typeof row.task === 'string' ? JSON.parse(row.task) : (row.task || {}),
++ visibility: row.visibility,
++ mode: row.mode,
++ state: row.state,
++ curtain: {
++ up: !!Number(row.curtain_up),
++ ...(Number(row.curtain_up) && row.curtain_since ? { since: iso(row.curtain_since) } : {}),
++ },
++ viewers: viewersNow(row.live_session_id),
++ started_at: iso(row.started_at),
++ ended_at: iso(row.ended_at),
++ worked_seconds: workedSeconds(row),
++ last_frame_url: row.last_frame_path ? frameUrl(row.public_id) : null,
++ vod_id: row.vod_id || null,
++ page_url: pageUrl(row),
++ label: 'AI agent',
++ };
++}
++
++// ── Creating ───────────────────────────────────────────────────
++
++/**
++ * Create the slot and its agent row. The caller has already validated the body and checked the live limit and the
++ * owner's subject. Returns the joined row.
++ *
++ * `visibility` 'unlisted' gets a link secret here; only its sha256 is stored, and the raw secret is returned once.
++ */
++async function create({ user, title, task, visibility = 'private', allowSites = [], mode = 'watch' }) {
++ const subject = await ownerSubject(user.id);
++ if (!SUBJECT_RE.test(String(subject || ''))) {
++ const err = new Error('this account has no OpenVibe subject yet; sign in again');
++ err.status = 409; err.code = 'identity.no_subject';
++ throw err;
++ }
++ const channel = await db.ensureChannel(user.id);
++ const publicId = agentId();
++ const secret = visibility === 'unlisted' ? linkSecret() : null;
++ const labels = JSON.stringify({ labels: [], suggested: [] });
++ const result = await db.getDb().tx(async () => {
++ const slot = await db.run(
++ `INSERT INTO managed_streams (user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw, content_labels, kind, public_id)
++ VALUES (?, ?, NULL, ?, '', NULL, 'webrtc', 'whip', NULL, 0, ?, 'agent', ?) RETURNING id`,
++ [user.id, channel.id, String(title).slice(0, 200), labels, publicId]
++ );
++ await db.run(
++ `INSERT INTO agent_streams (managed_stream_id, task, visibility, mode, state, allow_sites, unlisted_secret_hash)
++ VALUES (?, ?::jsonb, ?, ?, 'idle', ?::jsonb, ?)`,
++ [slot.lastInsertRowid, JSON.stringify(task), visibility, mode, JSON.stringify(allowSites || []), secret ? sha256(secret) : null]
++ );
++ return slot.lastInsertRowid;
++ });
++ const row = await getByManagedStreamId(result);
++ if (secret) row.unlisted_secret = secret; // the one time the raw link secret exists outside the page
++ await sharing.register(row);
++ return row;
++}
++
++// ── State changes ──────────────────────────────────────────────
++
++/**
++ * One change to an agent stream, and its event. Runs in one transaction so the outbox row exists if and only if the
++ * change committed. `field` is the `agent_streams` column list to write.
++ */
++async function update(row, { set = {}, event = null }) {
++ const names = Object.keys(set);
++ if (!names.length) return;
++ const env = event ? {
++ event_type: 'live.agent_stream.changed',
++ actor: row.owner_subject ? { type: 'user', id: row.owner_subject } : { type: 'service', id: 'live' },
++ subject: { type: 'agent_stream', id: row.public_id },
++ visibility: row.visibility === 'public' ? 'public' : 'internal',
++ priority: event.kind === 'started' || event.kind === 'ended' ? 'important' : 'normal',
++ payload: { stream_id: row.public_id, kind: event.kind, at: new Date().toISOString(), ...(event.extra || {}) },
++ } : null;
++ let outbox;
++ await db.getDb().tx(async () => {
++ await db.run(
++ `UPDATE agent_streams SET ${names.map((n) => `${n} = ?`).join(', ')}, updated_at = now() WHERE managed_stream_id = ?`,
++ [...names.map((n) => set[n]), row.managed_stream_id]
++ );
++ if (env) outbox = await require('../events/stream-events').enqueue(env);
++ });
++ if (outbox) require('../events/stream-events').kick();
++ return await getByManagedStreamId(row.managed_stream_id);
++}
++
++/** The publish session attached (the WHIP POST asked for a stream): state live, start the work clock. */
++async function markPublishing(row) {
++ if (row.state === 'live') return row;
++ return await update(row, {
++ set: { state: 'live', started_at: row.started_at || new Date(), ended_at: null },
++ event: { kind: 'started' },
++ });
++}
++
++/** The stream ended. Worked seconds are frozen, the session (if any) and every publish ticket go. */
++async function end(row, { reason = 'ended' } = {}) {
++ if (row.state === 'ended') return row;
++ const session = await liveSession(row.managed_stream_id);
++ const now = new Date();
++ const started = iso(row.started_at);
++ const worked = started ? Math.max(0, Math.round((now.getTime() - Date.parse(started)) / 1000)) : workedSeconds(row);
++ const current = await getByManagedStreamId(row.managed_stream_id);
++ const next = await update(current, {
++ set: { state: 'ended', ended_at: now, worked_seconds: worked, curtain_up: 0, curtain_since: null, curtain_reason: null },
++ event: { kind: 'ended', extra: { reason } },
++ });
++ await require('./publish-tickets').revokeAll(row.managed_stream_id);
++ if (session) {
++ try {
++ const whip = require('../streaming/whip-handler');
++ await whip.endSessionsForStream(session.id, 'agent_ended');
++ await whip.endActiveWhipStream(session.id, reason);
++ } catch (err) { console.warn(`[AgentStream] ending session ${session.id} failed: ${err.message}`); }
++ }
++ try { require('../streaming/curtains').lower(session ? session.id : null, { notify: false }); } catch { /* */ }
++ return next;
++}
++
++async function setVisibility(row, visibility) {
++ if (!VISIBILITIES.includes(visibility)) return null;
++ const secret = visibility === 'unlisted' && !row.unlisted_secret_hash ? linkSecret() : null;
++ const next = await update(row, {
++ set: { visibility, ...(secret ? { unlisted_secret_hash: sha256(secret) } : {}) },
++ event: { kind: 'visibility', extra: { visibility } },
++ });
++ await sharing.setAudience(row, visibility);
++ if (secret) next.unlisted_secret = secret;
++ return next;
++}
++
++/** A fresh unlisted link secret: the old links stop working at once. */
++async function rotateLink(row) {
++ const secret = linkSecret();
++ await update(row, { set: { unlisted_secret_hash: sha256(secret) } });
++ return secret;
++}
++
++/** The desk's curtain. Returns the updated row; `event` is the public change (the reason stays here). */
++async function setCurtain(row, up, reason) {
++ if (up) {
++ if (Number(row.curtain_up)) return await getByManagedStreamId(row.managed_stream_id);
++ return await update(row, {
++ set: { curtain_up: 1, curtain_since: new Date(), curtain_reason: CURTAIN_REASONS.includes(reason) ? reason : 'detector' },
++ event: { kind: 'curtain', extra: { curtain: { up: true } } },
++ });
++ }
++ if (!Number(row.curtain_up)) return await getByManagedStreamId(row.managed_stream_id);
++ return await update(row, {
++ set: { curtain_up: 0, curtain_since: null, curtain_reason: null },
++ event: { kind: 'curtain', extra: { curtain: { up: false } } },
++ });
++}
++
++/**
++ * Store the desk's last frame (JPEG or WebP, at most 200 KB). The file is replaced atomically, so a reader never sees a
++ * half-written picture. Returns false for a body that is neither.
++ */
++async function setFrame(row, bytes) {
++ if (!Buffer.isBuffer(bytes) || bytes.length === 0 || bytes.length > MAX_FRAME_BYTES) return false;
++ const jpeg = bytes.length > 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
++ const webp = bytes.length > 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
++ if (!jpeg && !webp) return false;
++ const frames = require('./frames');
++ const name = `${row.public_id}.${webp ? 'webp' : 'jpg'}`;
++ await frames.write(name, bytes);
++ await db.run('UPDATE agent_streams SET last_frame_path = ?, last_frame_at = now(), updated_at = now() WHERE managed_stream_id = ?', [name, row.managed_stream_id]);
++ return true;
++}
++
++async function setVod(row, vodId) {
++ if (!vodId || row.vod_id === String(vodId)) return;
++ await db.run('UPDATE agent_streams SET vod_id = ?, updated_at = now() WHERE managed_stream_id = ?', [String(vodId), row.managed_stream_id]);
++}
++
++// ── Who may watch ──────────────────────────────────────────────
++
++/**
++ * Whether this viewer may watch this stream. `secret` is the page's ?k= value (unlisted links). The owner always may;
++ * a public stream is open; an unlisted one needs the link's secret; a private one the sharing system's answer.
++ */
++async function mayView(row, { viewer = null, secret = null } = {}) {
++ if (viewer && Number(viewer.id) === Number(row.user_id)) return true;
++ if (row.visibility === 'public') return true;
++ if (row.visibility === 'unlisted') {
++ if (!secret || !row.unlisted_secret_hash) return false;
++ return secureEqual(sha256(secret), row.unlisted_secret_hash);
++ }
++ if (!viewer) return false;
++ const subject = await ownerSubject(viewer.id);
++ if (!SUBJECT_RE.test(String(subject || ''))) return false;
++ return await sharing.canView(shareRef(row.public_id), { subject: { type: 'user', id: subject } });
++}
++
++/** The unlisted link's secret matches (constant time). */
++function secureEqual(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
++module.exports = {
++ AGENT_ID_RE, VISIBILITIES, MODES, TASK_SOURCES, CURTAIN_REASONS, SHARE_KIND,
++ PUBLISH_TICKET_TTL_MS, VIEW_TICKET_TTL_MS, MAX_FRAME_BYTES,
++ sha256, token, agentId, iso, pageUrl, whipUrl, shareRef,
++ getByPublicId, getByManagedStreamId, liveSession, listByUser, countLiveByUser, liveLimit, ownerSubject,
++ viewersNow, workedSeconds, publicView,
++ create, update, markPublishing, end, setVisibility, rotateLink, setCurtain, setFrame, setVod,
++ mayView,
++};
ddiff --git a/server/agent-streams/sharing.js b/server/agent-streams/sharing.js
nnew file mode 100644
iindex 00000000..dc90b2ad
---- /dev/null
++++ b/server/agent-streams/sharing.js
@@@ -0,0 +1,147 @@
++'use strict';
++/**
++ * The sharing system, for agent streams (openvibe-sdk/share, service `live`, kind `agent-stream`).
++ *
++ * The rules of who may open a private agent stream are not Live's own: they are the sharing system's, the same ones
++ * the Share sheet offers everywhere on the network (Everyone / Anyone with the link / Specific people / Only me). A
++ * stream registers itself when it is created, follows its visibility switch, and asks `canView` on every view.
++ *
++ * The SDK module is optional at runtime: Live must boot and serve its other pages on a release whose pinned
++ * openvibe-sdk predates `share`. Without it, only the owner can open a private stream — fail closed — and the Share
++ * sheet's access part is not offered. Nothing else changes.
++ *
++ * The tables (share_*) are created by Live's migration 0027 from the SDK's own expand-only schema; this module never
++ * creates one at runtime. Network is asked only to turn user names into subjects (the sheet's lookup) and to resolve
++ * people for display.
++ */
++const db = require('../db/database');
++
++const SERVICE = 'live';
++const KIND = 'agent-stream';
++const AUDIENCE = { private: 'private', unlisted: 'link', public: 'public' };
++
++let store;
++let loaded = false;
++let networkOverride = null;
++
++/** Tests and a restore drill: answer Network's name lookups locally. */
++function _setNetwork(client) {
++ networkOverride = client || null;
++ store = undefined;
++ loaded = false;
++}
++
++/** Network's internal JSON client, with Live's service token for each call. */
++function networkClient() {
++ if (networkOverride) return networkOverride;
++ const principal = require('../net/network-principal');
++ const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ return {
++ async json({ path, method = 'GET', query, json }) {
++ const headers = await principal.headersFor(path);
++ const q = query ? `?${new URLSearchParams(query)}` : '';
++ const res = await fetch(`${base}${path}${q}`, {
++ method,
++ headers: { 'Content-Type': 'application/json', ...headers },
++ ...(json === undefined ? {} : { body: JSON.stringify(json) }),
++ signal: AbortSignal.timeout(15_000),
++ });
++ if (!res.ok) {
++ const err = new Error(`Network ${path} answered ${res.status}`);
++ err.status = res.status;
++ throw err;
++ }
++ return await res.json();
++ },
++ };
++}
++
++/** Events go through Live's own outbox, inside the sharing transaction (the SDK outbox refuses otherwise). */
++const events = {
++ async publish(envelope) {
++ const outbox = require('../events/stream-events');
++ outbox.init();
++ if (await outbox.enqueue(envelope)) outbox.kick();
++ },
++};
++
++function sdk() {
++ if (loaded) return store;
++ loaded = true;
++ let mod;
++ try { mod = require('openvibe-sdk/share'); } catch { mod = null; }
++ if (!mod || typeof mod.createShares !== 'function') return (store = null);
++ try {
++ store = mod.createShares({ db: db.getDb(), network: networkClient(), events, notify: async () => {} });
++ } catch (err) {
++ console.warn(`[AgentStream] sharing unavailable: ${err.message}`);
++ store = null;
++ }
++ return store;
++}
++
++/** Whether the sharing system is installed in this release. */
++function available() {
++ try { return !!sdk(); } catch { return false; }
++}
++
++/** The store, or null when this release has no sharing. */
++function store_() {
++ try { return sdk(); } catch { return null; }
++}
++
++function audienceFor(visibility) { return AUDIENCE[visibility] || 'private'; }
++
++/** Register a new stream. Its visibility is the initial audience; the owner is the row's subject. */
++async function register(row) {
++ const shares = store_();
++ if (!shares || !row.owner_subject) return;
++ try {
++ await shares.register({ service: SERVICE, kind: KIND, id: row.public_id }, {
++ owner_subject: { type: 'user', id: row.owner_subject },
++ audience: audienceFor(row.visibility),
++ });
++ } catch (err) {
++ // The visibility column is still the rule the routes read; a failed registration only means no guests yet.
++ console.warn(`[AgentStream] share registration for ${row.public_id} failed: ${err.message}`);
++ }
++}
++
++/** Follow the visibility switch, as the owner (the store's audience rule is its own). */
++async function setAudience(row, visibility) {
++ const shares = store_();
++ if (!shares || !row.owner_subject) return;
++ try {
++ await shares.setAudience({ subject: { type: 'user', id: row.owner_subject }, staff: false },
++ { service: SERVICE, kind: KIND, id: row.public_id }, audienceFor(visibility));
++ } catch (err) {
++ console.warn(`[AgentStream] share audience for ${row.public_id} not saved: ${err.message}`);
++ }
++}
++
++/** Whether the sharing system lets this viewer open the stream. `viewer` is { subject, staff? }. */
++async function canView(ref, viewer) {
++ const shares = store_();
++ if (!shares || !viewer || !viewer.subject) return false;
++ try {
++ return await shares.canView(viewer, ref);
++ } catch {
++ return false;
++ }
++}
++
++/** The share router, mounted at /api/v1 (the Share sheet's access part). Null when the SDK has no sharing. */
++function router(options) {
++ const shares = store_();
++ if (!shares) return null;
++ return shares.router({ service: SERVICE, ...options });
++}
++
++/** Send the sharing system's queued deliveries (events and notices). Called by the housekeeping loop, never inline. */
++async function flushDeliveries() {
++ const shares = store_();
++ if (!shares) return 0;
++ return await shares.flush();
++}
++
++module.exports = { SERVICE, KIND, available, register, setAudience, canView, router, flushDeliveries, audienceFor, _setNetwork, _reset() { store = undefined; loaded = false; networkOverride = null; } };
ddiff --git a/server/agent-streams/sweep.js b/server/agent-streams/sweep.js
nnew file mode 100644
iindex 00000000..65c2d3d3
---- /dev/null
++++ b/server/agent-streams/sweep.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * The agent-stream housekeeping loop, started at boot beside Live's other jobs (never under LIVE_DRILL).
++ *
++ * - A live agent stream whose publish ticket expired with no renewal ends: the desk lost the right to publish, so the
++ * session and the card end rather than lingering as a stream nobody can stop. (A WHIP session that drops ends the
++ * stream at once through the WHIP cleanup, not here; the two-minute rule is the outer bound for a desk that went
++ * silent without closing its transport.)
++ * - Expired tickets are pruned after a day.
++ * - The sharing system's queued deliveries (events to Network) are flushed.
++ *
++ * One minute is enough: the desk renews at three hours, and a dead stream staying up for at most a minute costs nothing.
++ */
++const db = require('../db/database');
++
++const EVERY_MS = 60 * 1000;
++let timer = null;
++
++async function sweep() {
++ const service = require('./service');
++ const tickets = require('./tickets');
++
++ const rows = await db.all(`SELECT ms.id AS managed_stream_id, ms.public_id
++ FROM agent_streams a JOIN managed_streams ms ON ms.id = a.managed_stream_id
++ WHERE a.state = 'live' LIMIT 200`);
++ for (const row of rows) {
++ try {
++ const expiry = await tickets.currentExpiry(row.managed_stream_id);
++ const session = await service.liveSession(row.managed_stream_id);
++ if (expiry) continue;
++ const full = await service.getByManagedStreamId(row.managed_stream_id);
++ if (full) await service.end(full, { reason: session ? 'ticket_expired' : 'session_ended' });
++ } catch (err) {
++ console.warn(`[AgentStream] sweep of ${row.public_id} failed: ${err.message}`);
++ }
++ }
++
++ try { await tickets.prune(); } catch (err) { console.warn('[AgentStream] ticket prune failed:', err.message); }
++ try {
++ const sharing = require('./sharing');
++ if (sharing.available()) await sharing.flushDeliveries();
++ } catch (err) { console.warn('[AgentStream] sharing deliveries:', err.message); }
++}
++
++function start() {
++ if (timer) return timer;
++ timer = setInterval(() => { sweep().catch((err) => console.warn('[AgentStream] sweep failed:', err.message)); }, EVERY_MS);
++ if (timer.unref) timer.unref();
++ return timer;
++}
++
++function stop() {
++ if (timer) clearInterval(timer);
++ timer = null;
++}
++
++module.exports = { sweep, start, stop, EVERY_MS };
ddiff --git a/server/agent-streams/tickets.js b/server/agent-streams/tickets.js
nnew file mode 100644
iindex 00000000..ff52da11
---- /dev/null
++++ b/server/agent-streams/tickets.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * Agent-stream tickets.
++ *
++ * A publish ticket (`wpt_`, 32 random bytes, four hours) is what a desk publishes with; Live stores only its sha256
++ * and never answers it again. One ticket is current per stream: renewing replaces it (the desk renews at three hours),
++ * and revoking it is the kill switch — the route ends the WHIP session at once.
++ *
++ * A view ticket (`wvt_`, ten minutes) is what a player joins a private or unlisted stream with. It names the stream,
++ * the person it was minted for (owner, a guest the sharing system allowed, or the holder of the unlisted link) and
++ * when it dies. The page renews it while it is open; the embed's ticket is minted server-side for a caller with no
++ * cookies.
++ */
++const db = require('../db/database');
++const { PUBLISH_TICKET_TTL_MS, VIEW_TICKET_TTL_MS, sha256, token } = require('./service');
++
++/** How long a chain of view-ticket renewals may last (the design's "at most 4 h"). */
++const VIEW_RENEW_WINDOW_MS = 4 * 60 * 60 * 1000;
++
++function expiry(ms) { return new Date(Date.now() + ms); }
++
++// ── Publish tickets ────────────────────────────────────────────
++
++/** A fresh publish ticket for this stream. Any earlier one stops working at once (one desk, one ticket). */
++async function issuePublish(managedStreamId) {
++ const raw = token('wpt_');
++ const expires = expiry(PUBLISH_TICKET_TTL_MS);
++ await db.getDb().tx(async () => {
++ await db.run('UPDATE agent_publish_tickets SET revoked_at = now() WHERE managed_stream_id = ? AND revoked_at IS NULL', [managedStreamId]);
++ await db.run('INSERT INTO agent_publish_tickets (managed_stream_id, token_hash, expires_at) VALUES (?, ?, ?)',
++ [managedStreamId, sha256(raw), expires]);
++ });
++ return { ticket: raw, expires_at: expires.toISOString() };
++}
++
++/** The live ticket this raw value names, or null. Expired and revoked tickets are not tickets. */
++async function findPublish(raw) {
++ if (typeof raw !== 'string' || !/^wpt_[A-Za-z0-9_-]{43}$/.test(raw)) return null;
++ const row = await db.get(`SELECT * FROM agent_publish_tickets
++ WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > now()`, [sha256(raw)]);
++ return row || null;
++}
++
++/** The session a ticket is bound to (the first WHIP POST that accepted it), if any. */
++async function bindSession(ticketId, sessionId) {
++ await db.run('UPDATE agent_publish_tickets SET session_id = COALESCE(session_id, ?) WHERE id = ?', [sessionId, ticketId]);
++}
++
++/** Every ticket of this stream stops working (revoked, expired or replaced). */
++async function revokeAll(managedStreamId) {
++ return await db.run('UPDATE agent_publish_tickets SET revoked_at = now() WHERE managed_stream_id = ? AND revoked_at IS NULL', [managedStreamId]);
++}
++
++/** The stream's current ticket expiry, for the desk's `ends_at`. */
++async function currentExpiry(managedStreamId) {
++ const row = await db.get(`SELECT expires_at FROM agent_publish_tickets
++ WHERE managed_stream_id = ? AND revoked_at IS NULL AND expires_at > now() ORDER BY expires_at DESC LIMIT 1`, [managedStreamId]);
++ return row ? row.expires_at : null;
++}
++
++// ── View tickets ───────────────────────────────────────────────
++
++/**
++ * Mint a view ticket for this stream. `via` is who let the caller in. A chain of renewals may last four hours from its
++ * first mint: `renewsUntil` carries that ceiling forward, a new ticket starts it.
++ */
++async function issueView(managedStreamId, { subject = null, via = 'owner', renewsUntil = null } = {}) {
++ const raw = token('wvt_');
++ const now = Date.now();
++ const ceiling = renewsUntil ? new Date(renewsUntil) : new Date(now + VIEW_RENEW_WINDOW_MS);
++ const expires = new Date(Math.min(now + VIEW_TICKET_TTL_MS, ceiling.getTime()));
++ await db.run('INSERT INTO agent_view_tickets (managed_stream_id, token_hash, subject, via, expires_at, renews_until) VALUES (?, ?, ?, ?, ?, ?)',
++ [managedStreamId, sha256(raw), subject, via, expires, ceiling]);
++ return { ticket: raw, expires_at: expires.toISOString() };
++}
++
++/** The live view ticket this raw value names, for this stream, or null. */
++async function findViewById(raw, managedStreamId) {
++ if (typeof raw !== 'string' || !/^wvt_[A-Za-z0-9_-]{43}$/.test(raw)) return null;
++ const row = await db.get(`SELECT * FROM agent_view_tickets
++ WHERE token_hash = ? AND managed_stream_id = ? AND expires_at > now()`, [sha256(raw), managedStreamId]);
++ return row || null;
++}
++
++/** Housekeeping: tickets that expired are useless; they go so the table stays small. */
++async function prune() {
++ await db.run('DELETE FROM agent_publish_tickets WHERE expires_at < now() - interval \'1 day\'');
++ await db.run('DELETE FROM agent_view_tickets WHERE expires_at < now() - interval \'1 day\'');
++}
++
++module.exports = {
++ PUBLISH_TICKET_TTL_MS, VIEW_TICKET_TTL_MS,
++ issuePublish, findPublish, bindSession, revokeAll, currentExpiry,
++ issueView, findViewById, prune,
++};
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 702fa360..846fd14f 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -140,6 +140,31 @@ async function exportPart(d, userId) {
// ── Deletion ───────────────────────────────────────────────────
/** Erase Live's rows of these Live users in one transaction → { erased, retained }. */
++/**
++ * The person's rows in the sharing system (openvibe-sdk/share; Live owns the share_* tables). Those rows name people by
++ * canonical subject in jsonb, which the user_id scan above cannot see, so they are erased here by subject: their
++ * membership, access requests and offers, and the shares they own (with those shares' members, requests and offers).
++ * share_log stays: it is an append-only audit trail of who changed what, like the moderation records Live keeps.
++ */
++async function eraseSharing(d, subjects) {
++ if (!(await d.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'shares'").get())) return 0;
++ let n = 0;
++ for (const subject of subjects) {
++ if (!SUBJECT_RE.test(String(subject || ''))) continue;
++ n += (await d.prepare("DELETE FROM share_members WHERE subject->>'id' = ?").run(subject)).changes;
++ n += (await d.prepare("DELETE FROM share_access_requests WHERE subject->>'id' = ?").run(subject)).changes;
++ n += (await d.prepare("DELETE FROM share_transfers WHERE from_subject->>'id' = ? OR to_subject->>'id' = ?").run(subject, subject)).changes;
++ const owned = await d.prepare("SELECT service, kind, resource_id FROM shares WHERE owner_subject->>'id' = ?").all(subject);
++ for (const ref of owned) {
++ for (const table of ['share_members', 'share_access_requests', 'share_transfers']) {
++ await d.prepare(`DELETE FROM ${table} WHERE service = ? AND kind = ? AND resource_id = ?`).run(ref.service, ref.kind, ref.resource_id);
++ }
++ n += (await d.prepare('DELETE FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').run(ref.service, ref.kind, ref.resource_id)).changes;
++ }
++ }
++ return n;
++}
++
async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
const erased = {};
const retained = {};
@@@ -220,6 +245,8 @@ async function apply(ev, { send = networkCall } = {}) {
const ids = [...new Set((await Promise.all(subjects.map(async (s) => await liveUserOf(d, s)))).filter(Boolean))];
for (const id of [...ids]) for (const m of (await hasColumn(d, 'users', 'merged_into') ? await d.prepare('SELECT id FROM users WHERE merged_into = ?').all(id) : [])) if (!ids.includes(m.id)) ids.push(m.id);
const counts = await eraseUsers(d, ids);
++ const sharing = await eraseSharing(d, subjects);
++ if (sharing) counts.erased.shares = (counts.erased.shares || 0) + sharing;
if (await d.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'subject_projection'").get()) {
await d.prepare(`DELETE FROM subject_projection WHERE subject_id IN (${subjects.map(() => '?').join(',')})`).run(...subjects);
}
@@@ -241,4 +268,4 @@ async function apply(ev, { send = networkCall } = {}) {
async function hasColumn(d, table, col) { return (await columnNames(d, table)).includes(col); }
--module.exports = { apply, exportPart, eraseUsers, personColumns, FROZEN, RETAIN };
++module.exports = { apply, exportPart, eraseUsers, eraseSharing, personColumns, FROZEN, RETAIN };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 990a28cf..b20747e5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1082,7 +1082,7 @@ async function getManagedStreamsByUserId(userId) {
(SELECT s.is_live FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS is_currently_live,
(SELECT s.id FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS live_session_id
FROM managed_streams ms
-- WHERE ms.user_id = ?
++ WHERE ms.user_id = ? AND ms.kind = 'normal'
ORDER BY ms.sort_order ASC, ms.created_at ASC
`, [userId]);
}
@@@ -1317,7 +1317,8 @@ async function updateManagedStreamBroadcastSettings(managedStreamId, userId, set
}
async function countManagedStreamsByUser(userId) {
-- return (await get('SELECT COUNT(*) AS count FROM managed_streams WHERE user_id = ?', [userId]))?.count || 0;
++ // Agent streams (kind 'agent') are cards of their own and never take one of the person's normal slots.
++ return (await get("SELECT COUNT(*) AS count FROM managed_streams WHERE user_id = ? AND kind = 'normal'", [userId]))?.count || 0;
}
async function getManagedStreamLimit(user) {
ddiff --git a/server/index.js b/server/index.js
iindex a0a1c3fb..d9d01977 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -443,6 +443,10 @@ app.use(require('./web/whip-host').createWhipHostRedirect({ whipUrl: () => (conf
// that is what lets a static, backend-less site publish from the browser (docs/whip.md).
// It must therefore bypass the credentialed allowlist below, which would 403 the preflight.
app.use('/whip', whipHandler.whipCors);
++// A WHEP player is usually on someone else's page (Node's latency probe, a third-party player), so /whep takes a view
++// ticket, never a cookie, and is open to every origin for the same reason /whip is.
++const whepHandler = require('./streaming/whep-handler');
++app.use('/whep', whepHandler.whepCors);
// Views counted from openvibe.video's watch pages (a beacon from the viewer's browser): its own narrow
// origin rule and no cookie, so it sits before the credentialed allowlist too.
app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ rateLimit }));
@@@ -470,6 +474,7 @@ const allowlistedCors = cors({
});
app.use((req, res, next) => {
if (req.path === '/whip' || req.path.startsWith('/whip/')) return next();
++ if (req.path === '/whep' || req.path.startsWith('/whep/')) return next();
return allowlistedCors(req, res, next);
});
app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
@@@ -805,7 +810,42 @@ app.use('/api/auth', authRoutes);
app.use('/api/bench-stream', require('./bench-stream/routes'));
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
app.use('/api/streams', require('./bot/routes'));
++// Agent streams (Agent Streams S1): /api/streams/agent/* — the resource, its tickets, the curtain, the frames. Mounted
++// before the slot/stream router so its paths are never read as a stream id.
++app.use('/api/streams/agent', require('./agent-streams/routes'));
app.use('/api/streams', streamRoutes);
++// The sharing system's HTTP surface (the Share sheet's access part) for agent streams, at /api/v1/<kind>/<id>/share.
++// Built on the first request, not at load (the database opens at boot), and absent when the pinned openvibe-sdk
++// predates `share`; then only the owner can open a private stream, which fails closed.
++let _shareRouter;
++function shareRouterFor(req, res, next) {
++ if (!req.path.startsWith('/api/v1/')) return next();
++ if (_shareRouter === undefined) {
++ const sharing = require('./agent-streams/sharing');
++ _shareRouter = sharing.router({
++ authenticate: async (request) => {
++ await require('./auth/auth').optionalAuth(request, null, () => {});
++ if (!request.user) return null;
++ const subject = await require('./agent-streams/auth').viewerSubject(request.user);
++ if (!subject) return null;
++ return { subject: { type: 'user', id: subject }, staff: ['global_mod', 'admin'].includes(request.user.role) };
++ },
++ describe: async (ref) => {
++ const stream = await require('./agent-streams/service').getByPublicId(ref.id);
++ return stream ? {
++ title: stream.title,
++ link: `https://openvibe.live/@${encodeURIComponent(stream.username)}/agents/${stream.public_id}`,
++ roles: ['viewer'],
++ audiences: ['private', 'link', 'people'],
++ transferable: false,
++ } : {};
++ },
++ }) || null;
++ }
++ if (!_shareRouter) return next();
++ return _shareRouter(req, res, next);
++}
++app.use(shareRouterFor);
// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
const liveEvents = require('./streaming/live-events');
app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
@@@ -826,6 +866,8 @@ app.use('/api/today', require('./today/routes').createTodayRoutes()); // a cha
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
++// An agent stream's embed for the task page and the Dock (Agent Streams S1): standalone, first-party frames only.
++app.get('/embed/agent/:id', require('./agent-streams/pages').embed);
app.use('/api/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
@@@ -1114,6 +1156,19 @@ app.get('/privacy', (req, res) => {
// ── WHIP Endpoint (WebRTC-HTTP Ingestion Protocol) ───────────
// OBS and other WHIP-compatible encoders send WebRTC media via HTTP POST.
// Body is raw SDP (application/sdp), auth via Bearer token.
++// WHEP for agent streams: the viewer's offer in, an SDP answer out; a view ticket, not a cookie, authorises it.
++app.options('/whep/agent/:id', whepHandler.handleWhepOptions);
++app.options('/whep/agent/:id/:resourceId', whepHandler.handleWhepOptions);
++app.post('/whep/agent/:id', express.text({ type: 'application/sdp', limit: '64kb' }), whepHandler.handleWhepPost);
++app.patch('/whep/agent/:id/:resourceId', express.text({ type: 'application/trickle-ice-sdpfrag', limit: '16kb' }), whepHandler.handleWhepPatch);
++app.delete('/whep/agent/:id/:resourceId', whepHandler.handleWhepDelete);
++app.options('/whip/agent/:id', whipHandler.handleWhipOptions);
++app.options('/whip/agent/:id/:resourceId', whipHandler.handleWhipOptions);
++// An agent stream publishes with a wpt_ publish ticket, not a stream key; these paths are matched before the
++// stream-key forms so `agent` is never read as a stream.
++app.post('/whip/agent/:id', express.text({ type: 'application/sdp', limit: '64kb' }), whipHandler.handleAgentWhipPost);
++app.patch('/whip/agent/:id/:resourceId', express.text({ type: 'application/trickle-ice-sdpfrag', limit: '16kb' }), whipHandler.handleAgentWhipPatch);
++app.delete('/whip/agent/:id/:resourceId', whipHandler.handleAgentWhipDelete);
app.options('/whip/:streamId', whipHandler.handleWhipOptions);
app.options('/whip/:streamId/:resourceId', whipHandler.handleWhipOptions);
app.post('/whip/:streamId', express.text({ type: 'application/sdp', limit: '64kb' }), whipHandler.handleWhipPost);
@@@ -1515,6 +1570,9 @@ async function start() {
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
++ // An agent stream whose publish ticket expired with no renewal ends; expired tickets and sharing deliveries
++ // are pruned on the same minute (server/agent-streams/sweep.js).
++ try { require('./agent-streams/sweep').start(); } catch (e) { console.warn('[AgentStream] sweep not started:', e.message); }
try { require('./recap/recap').start(); } catch (e) { console.warn('[Recap] job not started:', e.message); }
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
ddiff --git a/server/media-proxy/outcomes.js b/server/media-proxy/outcomes.js
iindex 4d17f952..ede64f64 100644
---- a/server/media-proxy/outcomes.js
++++ b/server/media-proxy/outcomes.js
@@@ -102,7 +102,16 @@ async function apply(event, data) {
// was already built while the stream was running — no second transcription.
try {
const sid = data.stream_id || data.streamId;
-- if (sid) await db.linkTimelineToVod(sid, vodId);
++ if (sid) {
++ await db.linkTimelineToVod(sid, vodId);
++ // An agent stream's card names its VOD once Media has it (the design's vod_id).
++ const slot = await db.get('SELECT managed_stream_id FROM streams WHERE id = ?', [sid]);
++ if (slot && slot.managed_stream_id) {
++ const service = require('../agent-streams/service');
++ const agent = await service.getByManagedStreamId(slot.managed_stream_id);
++ if (agent) await service.setVod(agent, vodId);
++ }
++ }
} catch { /* */ }
return {
outcome: 'vod_ready',
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex 541ea942..89f3b34f 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -36,6 +36,15 @@ const MAX_SEND_BACKPRESSURE = 512 * 1024;
// consumer every 2 s (the viewer's own recovery ladder asks at that pace too).
const VIEWER_KEYFRAME_ASK_MS = 2000;
++/**
++ * The agent stream a live session belongs to, or null. A normal slot has no agent row, so the join just finds nothing.
++ * Used for the two things an agent stream adds to viewing: its view rule and its curtain.
++ */
++async function agentRowForStream(stream) {
++ if (!stream || !stream.managed_stream_id) return null;
++ try { return await require('../agent-streams/service').getByManagedStreamId(stream.managed_stream_id); } catch { return null; }
++}
++
class BroadcastServer extends EventEmitter {
constructor() {
super();
@@@ -263,6 +272,7 @@ class BroadcastServer extends EventEmitter {
// ask the SFU for a router and a WebRTC transport — about 100 anonymous sockets exhausted the
// RTC port range and locked out real viewers and ingest.
const ip = String(req.headers['cf-connecting-ip'] || (req.socket && req.socket.remoteAddress) || '');
++ let agentStream = null;
if (role === 'viewer') {
let stream = null;
try { stream = await db.getStreamById(streamId); } catch { stream = null; }
@@@ -270,6 +280,26 @@ class BroadcastServer extends EventEmitter {
ws.close(4004, 'Stream not live');
return;
}
++ // An agent stream is watched only by those the view rule allows: the owner, a guest the sharing system let
++ // in, or the holder of an unlisted link's secret (all of whom the page exchanged for a view ticket). The
++ // route already refuses to hand out the live session id to anybody else; this is the second check, on the
++ // socket itself, so a page that forgets its ticket still shows nothing.
++ agentStream = await agentRowForStream(stream);
++ if (agentStream && agentStream.visibility !== 'public') {
++ const isOwner = !!(user && Number(user.id) === Number(agentStream.user_id));
++ let ticketed = false;
++ if (!isOwner) {
++ const vt = url.searchParams.get('vt');
++ if (vt) {
++ try { ticketed = !!await require('../agent-streams/tickets').findViewById(vt, agentStream.managed_stream_id); } catch { ticketed = false; }
++ }
++ }
++ if (!isOwner && !ticketed) {
++ console.warn(`[Broadcast] Viewer refused for agent stream ${agentStream.public_id}: no view ticket`);
++ ws.close(4003, 'View ticket required');
++ return;
++ }
++ }
let fromIp = 0;
for (const info of this.clients.values()) if (info.role === 'viewer' && info.ip === ip) fromIp++;
if (ip && fromIp >= MAX_VIEWER_SOCKETS_PER_IP) {
@@@ -327,6 +357,11 @@ class BroadcastServer extends EventEmitter {
room.viewers.set(peerId, ws);
console.log(`[Broadcast] Viewer connected: stream ${streamId} (${peerId})`);
++ // A viewer who joins while the desk has its curtain up sees nothing until it comes down.
++ if (agentStream && Number(agentStream.curtain_up)) {
++ try { require('./curtains').raise(streamId); } catch { /* the SFU may not have a room yet */ }
++ }
++
// If broadcaster is already connected, notify viewer
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
this.safeSend(ws, { type: 'broadcaster-ready', peerId });
@@@ -973,6 +1008,8 @@ class BroadcastServer extends EventEmitter {
);
if (!client._sfuViewerConsumerIds) client._sfuViewerConsumerIds = [];
client._sfuViewerConsumerIds.push(result.id);
++ // The curtain stays up for a viewer who arrives while it is up: their new consumer starts paused.
++ try { require('./curtains').applyToConsumer(client.streamId, result.id); } catch { /* not an agent stream */ }
this.safeSend(ws, {
type: 'sfu-viewer-consumed',
ddiff --git a/server/streaming/curtains.js b/server/streaming/curtains.js
nnew file mode 100644
iindex 00000000..3a77c25d
---- /dev/null
++++ b/server/streaming/curtains.js
@@@ -0,0 +1,117 @@
++'use strict';
++/**
++ * The curtain, at Live (Agent Streams S1, design 2026-11-06).
++ *
++ * The desk blanks its own picture before this runs; this is the second, independent enforcement. While the curtain is
++ * up, every viewer's consumer of that stream's SFU room is paused — a desk that failed to blank still shows nothing —
++ * and every player is told `{ type: 'curtain', up: true }` so it can show "Private screen". The reason never crosses
++ * this boundary; only the owner reads it from the stream resource.
++ *
++ * A viewer who joins while the curtain is up gets a consumer that is paused the moment it exists. Pausing is kept in
++ * memory per session (mediasoup consumers are in memory too); after a restart the row's curtain_up is the truth, and
++ * `isUp` reads it for a viewer that connects then.
++ */
++const db = require('../db/database');
++
++/** sessionId -> Set of consumer ids this module paused (only those are resumed; restream consumers never are). */
++const paused = new Map();
++
++function consumersOf(sessionId) {
++ if (!sessionId) return [];
++ try {
++ const room = require('./webrtc-sfu').rooms.get(`stream-${Number(sessionId)}`);
++ if (!room) return [];
++ const out = [];
++ for (const [id, entry] of room.consumers) {
++ if (entry && entry.peerId && String(entry.peerId).startsWith('__')) continue; // restream/plain transports
++ out.push({ id, consumer: entry.consumer });
++ }
++ return out;
++ } catch { return []; }
++}
++
++function tell(sessionId, up) {
++ try {
++ const room = require('./broadcast-server').rooms.get(Number(sessionId));
++ if (!room) return;
++ const server = require('./broadcast-server');
++ for (const [, ws] of room.viewers) server.safeSend(ws, { type: 'curtain', up: !!up });
++ } catch { /* a viewer socket going away is not an error */ }
++}
++
++/** Raise the curtain for a session: pause what is playing, and everything that starts playing. */
++function raise(sessionId) {
++ if (!sessionId) return 0;
++ const id = Number(sessionId);
++ const set = paused.get(id) || new Set();
++ let count = 0;
++ for (const { id: consumerId, consumer } of consumersOf(id)) {
++ try { consumer.pause(); set.add(consumerId); count++; } catch { /* already closed */ }
++ }
++ paused.set(id, set);
++ tell(id, true);
++ return count;
++}
++
++/** Lower it: resume exactly the consumers this module paused, and tell the players. */
++function lower(sessionId, { notify = true } = {}) {
++ if (!sessionId) return 0;
++ const id = Number(sessionId);
++ const set = paused.get(id);
++ if (!set) { if (notify) tell(id, false); return 0; }
++ let count = 0;
++ for (const consumerId of set) {
++ for (const entry of consumersOf(id)) {
++ if (entry.id !== consumerId) continue;
++ try { entry.consumer.resume(); count++; } catch { /* already closed */ }
++ }
++ }
++ paused.delete(id);
++ if (notify) tell(id, false);
++ return count;
++}
++
++/** Follow the row: up → raise, down → lower. Used by the curtain route and by a viewer that connects late. */
++function apply(sessionId, up) {
++ return up ? raise(sessionId) : lower(sessionId);
++}
++
++/** Pause one consumer that was just created, while the curtain is up. */
++function applyToConsumer(sessionId, consumerId) {
++ if (!sessionId || !consumerId) return false;
++ const id = Number(sessionId);
++ const set = paused.get(id);
++ if (!set) return false;
++ for (const entry of consumersOf(id)) {
++ if (entry.id !== consumerId) continue;
++ try { entry.consumer.pause(); set.add(consumerId); return true; } catch { return false; }
++ }
++ return false;
++}
++
++/**
++ * Whether this live session's agent stream has its curtain up. A non-agent session answers false without a shrug —
++ * the query matches the slot's kind.
++ */
++async function isUp(sessionId) {
++ if (!sessionId) return false;
++ try {
++ const row = await db.get(`SELECT a.curtain_up FROM streams s
++ JOIN managed_streams ms ON ms.id = s.managed_stream_id
++ JOIN agent_streams a ON a.managed_stream_id = ms.id
++ WHERE s.id = ? AND ms.kind = 'agent'`, [sessionId]);
++ return !!(row && Number(row.curtain_up));
++ } catch { return false; }
++}
++
++/** A consumer is gone (a WHEP viewer left, a transport closed): stop tracking it. */
++function forget(sessionId, consumerIds = []) {
++ const set = paused.get(Number(sessionId));
++ if (!set) return;
++ for (const id of consumerIds) set.delete(id);
++ if (!set.size) paused.delete(Number(sessionId));
++}
++
++function _reset() { paused.clear(); }
++
++module.exports = { raise, lower, apply, applyToConsumer, isUp, consumersOf, forget, _reset };
ddiff --git a/server/streaming/whep-handler.js b/server/streaming/whep-handler.js
nnew file mode 100644
iindex 00000000..c631d4d9
---- /dev/null
++++ b/server/streaming/whep-handler.js
@@@ -0,0 +1,438 @@
++'use strict';
++/**
++ * OpenVibe.Live — WHEP for agent streams (Agent Streams S1, the design's "Added 2026-11-06").
++ *
++ * POST /whep/agent/<ags_id> takes an SDP offer and answers an SDP answer for the stream's current live session, the
++ * way /whip takes an ingest offer. A viewer authenticates with its **view ticket** (`Authorization: Bearer wvt_…`);
++ * a public stream needs none. DELETE ends the session. CORS is the WHIP endpoint's (this serves third-party players
++ * and Node's latency probe, `scripts/stream-viewer`), while Live's own player keeps /ws/broadcast.
++ *
++ * The server is the offerer's peer in the SFU: it creates one recv transport, consumes the stream's audio and video
++ * producers with the codecs the client offered, and writes an answer whose m-sections are `sendonly` with the
++ * consumers' own RTP parameters. Consumers are ordinary room consumers, so the curtain pauses them like any other
++ * (server/streaming/curtains.js) — a WHEP player sees nothing while the desk's curtain is up.
++ */
++const crypto = require('crypto');
++const config = require('../config');
++const webrtcSFU = require('./webrtc-sfu');
++const whip = require('./whip-handler');
++
++let sdpTransform;
++try {
++ sdpTransform = require('sdp-transform');
++} catch {
++ console.warn('[WHEP] sdp-transform not installed — WHEP endpoint disabled');
++}
++
++/** resourceId → { streamId, agentId, managedStreamId, peerId, transportId, consumerIds, createdAt } */
++const sessions = new Map();
++
++const WHEP_CORS_HEADERS = Object.freeze({
++ 'Access-Control-Allow-Origin': '*',
++ 'Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS',
++ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',
++ 'Access-Control-Expose-Headers': 'Location, X-WHEP-ERROR',
++ 'Access-Control-Max-Age': '86400',
++ 'Cross-Origin-Resource-Policy': 'cross-origin',
++});
++
++function applyWhepCorsHeaders(res) {
++ for (const [name, value] of Object.entries(WHEP_CORS_HEADERS)) res.set(name, value);
++ return res;
++}
++
++/** Express middleware for /whep: open CORS (a WHEP player is usually on someone else's page) and preflights. */
++function whepCors(req, res, next) {
++ applyWhepCorsHeaders(res);
++ if (req.method === 'OPTIONS') return res.status(204).end();
++ next();
++}
++
++function sendWhepError(res, status, code, message) {
++ res.status(status)
++ .set('X-WHEP-ERROR', code)
++ .json({ error: message, error_code: code });
++}
++
++function resourceId() { return crypto.randomBytes(16).toString('hex'); }
++
++function locationFor(req, agentId, resource) {
++ const host = (typeof req?.get === 'function' && req.get('host')) ? `${req.protocol || 'https'}://${req.get('host')}` : null;
++ const base = host || config.whip?.publicUrl || config.webrtc?.publicUrl || config.baseUrl;
++ try { return new URL(`/whep/agent/${agentId}/${resource}`, base).toString(); } catch { return `/whep/agent/${agentId}/${resource}`; }
++}
++
++/** fmtp parameters, numbers where they are numbers (the H264 profile-level-id stays a string). */
++function parseFmtp(config) {
++ const out = {};
++ for (const part of String(config || '').split(';')) {
++ const trimmed = part.trim();
++ if (!trimmed) continue;
++ const at = trimmed.indexOf('=');
++ if (at < 1) { out[trimmed] = true; continue; }
++ const key = trimmed.slice(0, at).trim();
++ const value = trimmed.slice(at + 1).trim();
++ out[key] = /^\d+$/.test(value) ? parseInt(value, 10) : value;
++ }
++ return out;
++}
++
++/**
++ * The client's RTP capabilities, read from its offer: what it says it can receive. This is what mediasoup's
++ * `canConsume` and `consumer.rtpParameters` are negotiated against, so the answer carries codecs the client offered.
++ */
++function capabilitiesFromOffer(offerSdp) {
++ const codecs = [];
++ const headerExtensions = [];
++ for (const media of offerSdp.media || []) {
++ if (media.port === 0) continue;
++ const rtpByPt = new Map((media.rtp || []).map((r) => [r.payload, r]));
++ const fmtpByPt = new Map((media.fmtp || []).map((f) => [f.payload, f.config]));
++ const fbByPt = new Map();
++ for (const fb of media.rtcpFb || []) {
++ if (!fbByPt.has(fb.payload)) fbByPt.set(fb.payload, []);
++ fbByPt.get(fb.payload).push({ type: fb.type, parameter: fb.subtype || '' });
++ }
++ for (const pt of String(media.payloads || '').split(/\s+/).filter(Boolean).map(Number)) {
++ const r = rtpByPt.get(pt);
++ if (!r) continue;
++ const mimeType = `${media.type}/${r.codec}`;
++ if (mimeType.toLowerCase().endsWith('/rtx')) continue;
++ codecs.push({
++ mimeType,
++ clockRate: r.rate,
++ ...(r.encoding ? { channels: r.encoding } : {}),
++ parameters: parseFmtp(fmtpByPt.get(pt)),
++ rtcpFeedback: fbByPt.get(pt) || [],
++ });
++ }
++ for (const ext of media.ext || []) {
++ if (!ext.uri) continue;
++ headerExtensions.push({ kind: media.type, uri: ext.uri, preferredId: ext.value, preferredEncrypt: false, direction: 'sendrecv' });
++ }
++ }
++ return { codecs, headerExtensions };
++}
++
++/**
++ * The SDP answer for what the server will send: one `sendonly` m-section per consumed producer, its payload types,
++ * fmtp, header extensions and SSRC straight from the consumer's RTP parameters, BUNDLEd like the offer.
++ */
++function buildWhepAnswer(transport, offerSdp, consumersByIndex, { resourceId: resource } = {}) {
++ const { iceParameters, iceCandidates, dtlsParameters } = transport;
++ const fingerprint = whip._selectDtlsFingerprint(dtlsParameters.fingerprints);
++ const setup = whip._getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';
++ const serverAddress = iceCandidates?.[0]?.ip || config.mediasoup.announcedIp || '127.0.0.1';
++ const bundledMids = [];
++ const sdpObj = {
++ version: 0,
++ origin: { username: '-', sessionId: String(Date.now()), sessionVersion: 2, netType: 'IN', ipVer: 4, address: serverAddress },
++ name: 'OpenVibe.Live',
++ timing: { start: 0, stop: 0 },
++ icelite: 'ice-lite',
++ groups: [],
++ msidSemantic: { semantic: 'WMS', token: '*' },
++ media: [],
++ };
++
++ for (const [index, offerMedia] of (offerSdp.media || []).entries()) {
++ const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(index);
++ const consumer = consumersByIndex[index];
++ if (!consumer) {
++ sdpObj.media.push({
++ type: offerMedia.type,
++ port: 0,
++ protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',
++ payloads: String(offerMedia.payloads || '0'),
++ mid,
++ direction: 'inactive',
++ });
++ continue;
++ }
++ const params = consumer.rtpParameters;
++ bundledMids.push(mid);
++ const answerMedia = {
++ type: offerMedia.type,
++ port: 9,
++ protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',
++ payloads: '',
++ connection: { ip: '0.0.0.0', version: 4 },
++ mid,
++ iceUfrag: iceParameters.usernameFragment,
++ icePwd: iceParameters.password,
++ fingerprint: { type: fingerprint.algorithm, hash: fingerprint.value },
++ setup,
++ direction: 'sendonly',
++ rtcpMux: 'rtcp-mux',
++ rtp: [],
++ fmtp: [],
++ rtcpFb: [],
++ ext: [],
++ candidates: iceCandidates.map((c) => {
++ const cand = { foundation: c.foundation, component: 1, transport: c.protocol.toUpperCase(), priority: c.priority, ip: c.ip, port: c.port, type: c.type };
++ if (c.tcpType) cand.tcptype = c.tcpType;
++ return cand;
++ }),
++ };
++
++ const payloads = [];
++ for (const codec of params.codecs) {
++ payloads.push(codec.payloadType);
++ answerMedia.rtp.push({ payload: codec.payloadType, codec: codec.mimeType.split('/')[1], rate: codec.clockRate, encoding: codec.channels });
++ if (codec.parameters && Object.keys(codec.parameters).length) {
++ answerMedia.fmtp.push({ payload: codec.payloadType, config: Object.entries(codec.parameters).map(([k, v]) => `${k}=${v}`).join(';') });
++ }
++ for (const fb of codec.rtcpFeedback || []) {
++ answerMedia.rtcpFb.push({ payload: codec.payloadType, type: fb.type, subtype: fb.parameter || undefined });
++ }
++ }
++ answerMedia.payloads = payloads.join(' ');
++
++ for (const ext of params.headerExtensions || []) answerMedia.ext.push({ value: ext.id, uri: ext.uri });
++
++ const encoding = (params.encodings || [])[0] || {};
++ if (encoding.ssrc) {
++ const cname = `whep-${resource || 'viewer'}`;
++ answerMedia.ssrcs = [
++ { id: String(encoding.ssrc), attribute: 'cname', value: cname },
++ { id: String(encoding.ssrc), attribute: 'msid', value: `${cname} ${offerMedia.type}` },
++ ];
++ if (encoding.rtx && encoding.rtx.ssrc) answerMedia.ssrcs.push({ id: String(encoding.rtx.ssrc), attribute: 'cname', value: cname });
++ }
++
++ sdpObj.media.push(answerMedia);
++ }
++
++ if (bundledMids.length) sdpObj.groups.push({ type: 'BUNDLE', mids: bundledMids.join(' ') });
++ return sdpTransform.write(sdpObj);
++}
++
++/** The live session and agent stream a WHEP request names, or null. */
++async function targetFor(requestId) {
++ const service = require('../agent-streams/service');
++ const agent = await service.getByPublicId(requestId);
++ if (!agent) return null;
++ return { agent, live: await service.liveSession(agent.managed_stream_id) };
++}
++
++/**
++ * The curtain is a viewer's reality too: a WHEP consumer born while it is up starts paused.
++ *
++ * The row is the truth (a restart empties the in-memory set), so the curtain is raised from the row before the
++ * consumer is paused — raising it is what makes the pause stick for the consumers that follow, and it puts every
++ * other viewer of the stream in the same state.
++ */
++async function pauseUnderCurtain(sessionId, consumerId, curtainUp = null) {
++ try {
++ const curtains = require('./curtains');
++ const up = (curtainUp === null || curtainUp === undefined)
++ ? await curtains.isUp(sessionId)
++ : Number(curtainUp) === 1;
++ if (!up) return false;
++ curtains.raise(sessionId);
++ return curtains.applyToConsumer(sessionId, consumerId);
++ } catch (err) {
++ console.warn('[WHEP] curtain pause failed:', err.message);
++ return false;
++ }
++}
++
++/**
++ * POST /whep/agent/:id — the viewer's offer in, the server's sendonly answer out.
++ *
++ * A private or unlisted stream needs a view ticket, whatever the caller's cookies say (a WHEP client is often on
++ * another site); a public one needs none. A stream that is not publishing has nothing to send: 409 not_live.
++ */
++async function handleWhepPost(req, res) {
++ if (!sdpTransform) return sendWhepError(res, 503, 'whep_unavailable', 'WHEP not available (sdp-transform not installed)');
++ try {
++ const target = await targetFor(req.params.id);
++ if (!target) return sendWhepError(res, 404, 'agent_stream_not_found', 'No such agent stream');
++ const { agent, live } = target;
++
++ if (agent.visibility !== 'public') {
++ const header = String(req.headers.authorization || '');
++ const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
++ const ticket = bearer ? await require('../agent-streams/tickets').findViewById(bearer, agent.managed_stream_id) : null;
++ if (!ticket) return sendWhepError(res, 401, 'invalid_ticket', 'A view ticket is required to watch this stream');
++ }
++ if (agent.state !== 'live' || !live) return sendWhepError(res, 409, 'not_live', 'This agent stream is not publishing');
++ if (!webrtcSFU.ready) return sendWhepError(res, 503, 'sfu_unavailable', 'WebRTC SFU unavailable');
++
++ const offerSdpStr = req.body;
++ if (!offerSdpStr || typeof offerSdpStr !== 'string') return sendWhepError(res, 400, 'missing_sdp', 'Missing SDP offer in request body');
++ let offerSdp;
++ try { offerSdp = sdpTransform.parse(offerSdpStr); } catch { return sendWhepError(res, 400, 'invalid_sdp', 'Invalid SDP offer'); }
++
++ const roomId = `stream-${live.id}`;
++ const room = webrtcSFU.rooms?.get(roomId);
++ if (!room) return sendWhepError(res, 409, 'not_live', 'No media for this stream right now');
++
++ const capabilities = capabilitiesFromOffer(offerSdp);
++ const producers = webrtcSFU.getProducers(roomId) || [];
++ const chosen = ['video', 'audio'].map((kind) => {
++ const current = webrtcSFU.findProducerByKind(roomId, kind);
++ return producers.find((p) => p.id === current?.id) || producers.find((p) => p.kind === kind) || null;
++ }).filter(Boolean);
++ if (!chosen.length) return sendWhepError(res, 409, 'no_source', 'The stream has no audio or video yet');
++
++ const resource = resourceId();
++ const peerId = `whep-${resource}`;
++ let transportInfo;
++ try {
++ transportInfo = await webrtcSFU.createTransport(roomId, peerId, { iceConsentTimeout: 0 });
++ } catch (err) {
++ console.warn('[WHEP] transport create failed:', err.message);
++ return sendWhepError(res, 502, 'transport_creation_failed', 'WHEP transport creation failed');
++ }
++ const transport = webrtcSFU.rooms.get(roomId)?.transports.get(`${peerId}-${transportInfo.id}`);
++ if (!transport) return sendWhepError(res, 502, 'transport_creation_failed', 'WHEP transport creation failed');
++
++ const session = {
++ streamId: Number(live.id),
++ agentId: agent.public_id,
++ managedStreamId: agent.managed_stream_id,
++ peerId,
++ transportId: transportInfo.id,
++ consumerIds: [],
++ createdAt: Date.now(),
++ resourceId: resource,
++ };
++ sessions.set(resource, session);
++
++ // A viewer that goes away must not linger as a viewer: the transport closing ends its session.
++ transport.on('dtlsstatechange', (state) => {
++ if (state === 'closed' || state === 'failed') cleanupWhepSession(resource).catch(() => {});
++ });
++ transport.on('icestatechange', (state) => {
++ if (state === 'failed') cleanupWhepSession(resource).catch(() => {});
++ });
++
++ try {
++ await webrtcSFU.connectTransport(roomId, peerId, transportInfo.id, whip._extractDtlsParameters(offerSdp));
++ } catch (err) {
++ console.warn('[WHEP] DTLS negotiation failed:', err.message);
++ await cleanupWhepSession(resource);
++ return sendWhepError(res, 502, 'dtls_negotiation_failed', 'DTLS negotiation failed');
++ }
++
++ // One consumer per offered m-section that matches a producer kind; extra sections are rejected in the answer.
++ const consumersByIndex = [];
++ for (const [index, media] of (offerSdp.media || []).entries()) {
++ if (media.port === 0) continue;
++ const producer = chosen.find((p) => p.kind === media.type);
++ if (!producer) continue;
++ try {
++ const consumer = await webrtcSFU.consume(roomId, peerId, transportInfo.id, producer.id, capabilities);
++ session.consumerIds.push(consumer.id);
++ consumersByIndex[index] = consumer;
++ // The curtain is a viewer's reality too: a consumer born while it is up starts paused.
++ await pauseUnderCurtain(live.id, consumer.id, agent.curtain_up);
++ } catch (err) {
++ console.warn(`[WHEP] consume ${media.type} failed:`, err.message);
++ }
++ }
++ if (!session.consumerIds.length) {
++ await cleanupWhepSession(resource);
++ return sendWhepError(res, 406, 'no_compatible_codecs', 'No compatible codecs in the offer');
++ }
++
++ let answerSdp;
++ try {
++ answerSdp = buildWhepAnswer(transport, offerSdp, consumersByIndex, { resourceId: resource });
++ } catch (err) {
++ console.error('[WHEP] answer generation failed:', err.message);
++ await cleanupWhepSession(resource);
++ return sendWhepError(res, 500, 'answer_generation_failed', 'Failed to generate the SDP answer');
++ }
++
++ console.log(`[WHEP] Session ${resource} for ${agent.public_id} (stream ${live.id}, ${session.consumerIds.length} consumer(s))`);
++ res.status(201)
++ .set('Content-Type', 'application/sdp')
++ .set('Location', locationFor(req, agent.public_id, resource))
++ .set('Access-Control-Expose-Headers', WHEP_CORS_HEADERS['Access-Control-Expose-Headers'])
++ .send(answerSdp);
++ } catch (err) {
++ console.error('[WHEP] POST error:', err.message || err);
++ sendWhepError(res, 500, 'internal_server_error', 'WHEP negotiation failed');
++ }
++}
++
++/** PATCH /whep/agent/:id/:resourceId — trickle for a session this endpoint opened. */
++function handleWhepPatch(req, res) {
++ const session = sessions.get(req.params.resourceId);
++ if (!session || session.agentId !== req.params.id) return sendWhepError(res, 404, 'session_not_found', 'Session not found');
++ return res.status(204).end();
++}
++
++/** DELETE /whep/agent/:id/:resourceId — the viewer leaves; the stream keeps going. */
++async function handleWhepDelete(req, res) {
++ const session = sessions.get(req.params.resourceId);
++ if (!session || session.agentId !== req.params.id) return sendWhepError(res, 404, 'session_not_found', 'Session not found');
++ await cleanupWhepSession(req.params.resourceId);
++ return res.status(200).end();
++}
++
++function handleWhepOptions(req, res) {
++ applyWhepCorsHeaders(res.status(204)).end();
++}
++
++/** Close a WHEP session: its consumers, then its transport. Known to the curtain as ordinary consumers. */
++async function cleanupWhepSession(resource) {
++ const session = sessions.get(resource);
++ if (!session) return;
++ sessions.delete(resource);
++ try {
++ const room = webrtcSFU.rooms?.get(`stream-${session.streamId}`);
++ if (room) {
++ for (const consumerId of session.consumerIds) {
++ const entry = room.consumers.get(consumerId);
++ if (entry) {
++ try { entry.consumer.close(); } catch { /* already closed */ }
++ room.consumers.delete(consumerId);
++ }
++ }
++ const key = `${session.peerId}-${session.transportId}`;
++ const transport = room.transports.get(key);
++ if (transport) {
++ try { transport.close(); } catch { /* already closed */ }
++ room.transports.delete(key);
++ }
++ }
++ } catch (err) {
++ console.warn('[WHEP] cleanup error:', err.message);
++ }
++ try { require('./curtains').forget(session.streamId, session.consumerIds); } catch { /* */ }
++ console.log(`[WHEP] Session ${resource} cleaned up (stream ${session.streamId})`);
++}
++
++/** How many WHEP viewers a live session has (they count as viewers: the desk encodes only while someone watches). */
++function countForStream(streamId) {
++ let n = 0;
++ for (const session of sessions.values()) if (session.streamId === Number(streamId)) n++;
++ return n;
++}
++
++function closeAllForStream(streamId) {
++ for (const [resource, session] of sessions) {
++ if (session.streamId === Number(streamId)) cleanupWhepSession(resource).catch(() => {});
++ }
++}
++
++module.exports = {
++ handleWhepPost,
++ handleWhepPatch,
++ handleWhepDelete,
++ handleWhepOptions,
++ whepCors,
++ applyWhepCorsHeaders,
++ WHEP_CORS_HEADERS,
++ sessions,
++ cleanupWhepSession,
++ countForStream,
++ closeAllForStream,
++ available: !!sdpTransform,
++ _capabilitiesFromOffer: capabilitiesFromOffer,
++ _buildWhepAnswer: buildWhepAnswer,
++ _pauseUnderCurtain: pauseUnderCurtain,
++};
ddiff --git a/server/streaming/whip-handler.js b/server/streaming/whip-handler.js
iindex 1b4c4910..69ea8717 100644
---- a/server/streaming/whip-handler.js
++++ b/server/streaming/whip-handler.js
@@@ -55,10 +55,15 @@ function buildWhipResourceUrl(req, streamId, resourceId) {
: null;
const baseUrl = requestOrigin
|| normalizeOrigin(config.whip?.publicUrl || config.webrtc?.publicUrl || config.baseUrl);
++ return buildWhipResourceUrlOn(baseUrl, streamId, resourceId);
++}
++
++/** `/whip/<streamId>/<resourceId>`, or another path form (an agent stream's `/whip/agent/<id>/<resource>`) on `baseUrl`. */
++function buildWhipResourceUrlOn(baseUrl, streamId, resourceId, basePath = '/whip') {
try {
-- return new URL(`/whip/${streamId}/${resourceId}`, baseUrl).toString();
++ return new URL(`${basePath}/${streamId}/${resourceId}`, baseUrl).toString();
} catch {
-- return `/whip/${streamId}/${resourceId}`;
++ return `${basePath}/${streamId}/${resourceId}`;
}
}
@@@ -105,9 +110,13 @@ function whipCors(req, res, next) {
next();
}
--function buildWhipResponseHeaders(req, streamId, resourceId) {
++function buildWhipResponseHeaders(req, streamId, resourceId, basePath = '/whip') {
++ const origin = (typeof req?.get === 'function' && req.get('host'))
++ ? normalizeOrigin(`${req.protocol || 'https'}://${req.get('host')}`)
++ : null;
++ const baseUrl = origin || normalizeOrigin(config.whip?.publicUrl || config.webrtc?.publicUrl || config.baseUrl);
return {
-- Location: buildWhipResourceUrl(req, streamId, resourceId),
++ Location: buildWhipResourceUrlOn(baseUrl, streamId, resourceId, basePath),
'Access-Control-Expose-Headers': WHIP_CORS_HEADERS['Access-Control-Expose-Headers'],
};
}
@@@ -226,6 +235,7 @@ async function endActiveWhipStream(streamId, reason = 'whip_cleanup') {
try { if (chatRelayService) chatRelayService.stopForStream(streamId); } catch (err) { /* ignore */ }
try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch (err) { /* ignore */ }
try { require('./broadcast-server').endStream(streamId); } catch (err) { /* ignore */ }
++ try { require('./whep-handler').closeAllForStream(streamId); } catch (err) { /* ignore */ }
try { webrtcSFU.closeRoom(`stream-${streamId}`); } catch (err) { /* ignore */ }
try { await require('./calls-authority').removeStreamChannel(streamId); } catch (err) { /* ignore */ }
}
@@@ -635,6 +645,117 @@ async function autoCreateWhipSession(managedStream, user) {
}
}
++/**
++ * Open the live session for an agent stream. Unlike a slot's auto-create there is no chat relay, no AI chatbot and no
++ * restream destination in S1 (an agent stream has no chat), but the recorder is the usual one: the VOD is the card's
++ * recording of the work.
++ */
++async function createAgentSession(agent) {
++ try {
++ const user = await db.getUserById(agent.user_id);
++ if (!user) return null;
++ await db.ensureChannel(user.id);
++ const result = await db.createStream({
++ user_id: user.id,
++ channel_id: agent.channel_id || null,
++ managed_stream_id: agent.managed_stream_id,
++ title: agent.title || 'Agent stream',
++ description: '',
++ category: null,
++ protocol: 'webrtc',
++ is_nsfw: 0,
++ });
++ const streamId = result.lastInsertRowid;
++ await db.run('UPDATE streams SET last_heartbeat = ov_now() WHERE id = ?', [streamId]);
++ await db.run(`INSERT INTO cameras (stream_id, camera_index, label, protocol) VALUES (?, 0, 'Main', 'webrtc')`, [streamId]);
++ setTimeout(async () => {
++ try {
++ const stream = await db.getStreamById(streamId);
++ const mode = await db.resolveStreamRecordingMode(stream || {});
++ if (mode !== 'none') recorder.startRecording(streamId, 'webrtc', {}, { mode });
++ } catch (err) { console.warn(`[WHIP] agent stream ${streamId} recording not started: ${err.message}`); }
++ }, 2000);
++ console.log(`[WHIP] Agent stream ${agent.public_id} opened session ${streamId} (user: ${user.username})`);
++ return await db.getStreamById(streamId);
++ } catch (err) {
++ console.error('[WHIP] Agent session create failed:', err.message);
++ return null;
++ }
++}
++
++/** The publish ticket the request carries, and the agent stream it names (null for anything else). */
++async function agentForTicket(req) {
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer wpt_')) return null;
++ const tickets = require('../agent-streams/tickets');
++ const ticket = await tickets.findPublish(header.slice(7).trim());
++ if (!ticket) return null;
++ const agent = await require('../agent-streams/service').getByManagedStreamId(ticket.managed_stream_id);
++ if (!agent || agent.public_id !== req.params.id) return null;
++ return { ticket, agent };
++}
++
++/**
++ * POST /whip/agent/:id — an agent desk publishing its screen.
++ *
++ * The bearer is the publish ticket (`wpt_…`), never a stream key. A ticket that does not verify, has expired, is
++ * revoked or names another stream is `401 invalid_ticket`. A second session while one is up is `409 already_publishing`
++ * (the desk replaces a dead session by ending it first, or by its ticket expiring). The rule that a person has one live
++ * agent stream (five for the owner) is applied here, where going live actually happens.
++ */
++async function handleAgentWhipPost(req, res) {
++ if (!sdpTransform) return sendWhipError(res, 503, 'whip_unavailable', 'WHIP not available (sdp-transform not installed)');
++ try {
++ const found = await agentForTicket(req);
++ if (!found) {
++ logWhipStage('agent_auth_fail', req.params.id, { reason: 'invalid_ticket' });
++ return sendWhipError(res, 401, 'invalid_ticket', 'The publish ticket is not valid for this stream');
++ }
++ const { ticket, agent } = found;
++ if (agent.state === 'ended') return sendWhipError(res, 401, 'invalid_ticket', 'This agent stream has ended');
++
++ const live = await require('../agent-streams/service').liveSession(agent.managed_stream_id);
++ if (live && (hasActiveSessionsForStream(live.id) || findSessionsByStreamId(live.id).length)) {
++ return sendWhipError(res, 409, 'already_publishing', 'This agent stream already has a WHIP session');
++ }
++
++ const user = await db.getUserById(agent.user_id);
++ if (!user) return sendWhipError(res, 401, 'user_not_found', 'Stream owner not found');
++ if (user.is_banned) return sendWhipError(res, 403, 'user_banned', 'Account is banned');
++
++ const service = require('../agent-streams/service');
++ const others = (await service.countLiveByUser(user.id)) - (agent.state === 'live' ? 1 : 0);
++ if (others >= service.liveLimit(user)) {
++ logWhipStage('agent_auth_fail', agent.public_id, { reason: 'live.agent_stream_limit', live: others });
++ return sendWhipError(res, 409, 'live.agent_stream_limit', `This account already has ${others} agent stream(s) live; the limit is ${service.liveLimit(user)}`);
++ }
++
++ let stream = live;
++ if (!stream) {
++ stream = await createAgentSession(agent);
++ if (!stream) return sendWhipError(res, 500, 'session_create_failed', 'Failed to create the stream session');
++ }
++ return await runWhipOffer(req, res, { stream, userId: user.id, agent, ticket });
++ } catch (err) {
++ console.error('[WHIP] agent POST error:', err.message || err);
++ sendWhipError(res, 500, 'internal_server_error', 'WHIP negotiation failed');
++ }
++}
++
++/** PATCH /whip/agent/:id/:resourceId — trickle, only for the session that stream owns. */
++async function handleAgentWhipPatch(req, res) {
++ const session = sessions.get(req.params.resourceId);
++ if (!session || session.agentStreamId !== req.params.id) return sendWhipError(res, 404, 'session_not_found', 'Session not found');
++ return await handleWhipPatch(req, res);
++}
++
++/** DELETE /whip/agent/:id/:resourceId — the desk leaving; the stream ends if this was its last session. */
++async function handleAgentWhipDelete(req, res) {
++ const session = sessions.get(req.params.resourceId);
++ if (!session || session.agentStreamId !== req.params.id) return sendWhipError(res, 404, 'session_not_found', 'Session not found');
++ return await handleWhipDelete(req, res);
++}
++
/**
* POST /whip/:streamId — WHIP offer → answer
*
@@@ -674,6 +795,11 @@ async function handleWhipPost(req, res) {
if (isStreamKey) {
const managedStream = await db.getManagedStreamByStreamKey(pathParam);
++ if (managedStream && managedStream.kind === 'agent') {
++ // An agent stream has no stream key; it publishes at /whip/agent/<ags> with a ticket.
++ logWhipStage('auth_key_fail', pathParam, { reason: 'agent_slot' });
++ return sendWhipError(res, 401, 'invalid_stream_key', 'Stream key not recognized');
++ }
if (!managedStream) {
logWhipStage('auth_key_fail', pathParam, { reason: 'stream_key_not_found' });
return sendWhipError(res, 401, 'invalid_stream_key', 'Stream key not recognized');
@@@ -734,6 +860,9 @@ async function handleWhipPost(req, res) {
// First try: slot ID + key
const managedStream = await db.getManagedStreamById(slotId);
++ if (managedStream && managedStream.kind === 'agent') {
++ return sendWhipError(res, 401, 'invalid_key', 'An agent stream publishes with its ticket at /whip/agent/<id>');
++ }
if (!managedStream) {
logWhipStage('auth_slot_fail', pathParam, { reason: 'slot_not_found' });
return sendWhipError(res, 404, 'slot_not_found', 'Stream slot not found');
@@@ -845,6 +974,20 @@ async function handleWhipPost(req, res) {
logWhipStage('auth_jwt', pathParam, { user_id: userId });
}
++ return await runWhipOffer(req, res, { stream, userId });
++ } catch (err) {
++ console.error('[WHIP] POST error:', err.message || err);
++ sendWhipError(res, 500, 'internal_server_error', 'WHIP negotiation failed');
++ }
++}
++
++/**
++ * The offer → answer path once the caller is known: SDP, transport, producers, the answer. Shared by the stream-key
++ * forms and the agent-stream ticket form, which differs only in how the caller proved themselves and in the resource
++ * address it answers with.
++ */
++async function runWhipOffer(req, res, { stream, userId, agent = null, ticket = null }) {
++ try {
const streamId = stream.id;
if (!webrtcSFU.ready) return sendWhipError(res, 503, 'sfu_unavailable', 'WebRTC SFU unavailable');
logWhipStage('stream_validation', streamId, { protocol: stream.protocol, live: stream.is_live });
@@@ -893,6 +1036,11 @@ async function handleWhipPost(req, res) {
resourceId,
iceReady: false,
heartbeatTimer: null,
++ // An agent stream owns this session: the bearer was its publish ticket, and the resource lives under
++ // /whip/agent/<ags>/… so the desk's PATCH and DELETE address the same kind.
++ agentStreamId: agent ? agent.public_id : null,
++ agentManagedStreamId: agent ? agent.managed_stream_id : null,
++ ticketId: ticket ? ticket.id : null,
};
sessions.set(resourceId, session);
await touchWhipHeartbeat(streamId, 'whip_post');
@@@ -1085,9 +1233,18 @@ async function handleWhipPost(req, res) {
console.log(`[WHIP] Session ${resourceId} created for stream ${streamId} (${session.producerIds.length} producer(s))`);
logWhipStage('response_send', streamId, { resourceId, status: 201 });
++ // An agent stream is publishing now: the state goes live and the ticket is bound to this session, so a second
++ // desk with the same ticket is a second session (and is refused by the check above, never replacing this one).
++ if (agent) {
++ try {
++ await require('../agent-streams/tickets').bindSession(ticket.id, resourceId);
++ await require('../agent-streams/service').markPublishing(agent);
++ } catch (err) { console.warn(`[WHIP] agent stream ${agent.public_id} state not marked: ${err.message}`); }
++ }
++
res.status(201)
.set('Content-Type', 'application/sdp')
-- .set(buildWhipResponseHeaders(req, streamId, resourceId))
++ .set(buildWhipResponseHeaders(req, streamId, resourceId, agent ? '/whip/agent' : '/whip'))
.send(answerSdp);
} catch (err) {
@@@ -1183,6 +1340,15 @@ async function cleanupSession(resourceId, { endStreamIfNoActiveSessions = true,
if (endStreamIfNoActiveSessions && !hasActiveSessionsForStream(session.streamId)) {
await endActiveWhipStream(session.streamId, reason || 'session_cleanup');
++ // An agent stream lives exactly as long as its one WHIP session: when the desk leaves, the card stays but the
++ // state ends (and the worked time and the last frame are what remain).
++ if (session.agentManagedStreamId) {
++ try {
++ const service = require('../agent-streams/service');
++ const row = await service.getByManagedStreamId(session.agentManagedStreamId);
++ if (row && row.state !== 'ended') await service.end(row, { reason: 'session_ended' });
++ } catch (err) { console.warn(`[WHIP] agent stream ${session.agentStreamId} not ended: ${err.message}`); }
++ }
}
}
@@@ -1204,20 +1370,27 @@ module.exports = {
handleWhipPost,
handleWhipPatch,
handleWhipDelete,
++ handleAgentWhipPost,
++ handleAgentWhipPatch,
++ handleAgentWhipDelete,
handleWhipOptions,
whipCors,
applyWhipCorsHeaders,
WHIP_CORS_HEADERS,
buildWhipResourceUrl,
++ buildWhipResourceUrlOn,
buildWhipResponseHeaders,
hasSfuProducers,
hasActiveSessionsForStream,
sessions,
cleanupSession,
endSessionsForStream,
++ endActiveWhipStream,
available: !!sdpTransform,
_extractRtpParameters: extractRtpParameters,
_extractDtlsParameters: extractDtlsParameters,
_buildSdpAnswer: buildSdpAnswer,
_selectDtlsFingerprint: selectDtlsFingerprint,
++ _getDtlsSetupAttribute: getDtlsSetupAttribute,
++ _sendWhipError: sendWhipError,
};
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex fb1c8ddd..e4010444 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -143,6 +143,16 @@ async function channelStatus(segment) {
} catch { return OK; }
}
++/** /@user/agents/<ags_…>: an agent stream that exists, under its owner's channel. */
++async function agentStatus(segment, id) {
++ const m = CHANNEL_RE.exec(segment);
++ if (!m || !/^ags_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return NOT_FOUND;
++ try {
++ const row = await require('../agent-streams/service').getByPublicId(id);
++ return row && String(row.username || '').toLowerCase() === m[1].toLowerCase() ? OK : NOT_FOUND;
++ } catch { return OK; }
++}
++
async function streamStatus(id) {
if (!ID_RE.test(id)) return NOT_FOUND;
try { return await db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }
@@@ -159,7 +169,10 @@ async function statusFor(req) {
const [first, second] = segs;
if (segs.length === 1 && EXACT.has(first)) return OK;
if (PREFIX.has(first)) return OK;
-- if (first.startsWith('@')) return segs.length <= 2 ? await channelStatus(first) : NOT_FOUND;
++ if (first.startsWith('@')) {
++ if (segs.length === 3 && segs[1] === 'agents') return await agentStatus(first, segs[2]);
++ return segs.length <= 2 ? await channelStatus(first) : NOT_FOUND;
++ }
if (segs.length !== 2) return NOT_FOUND;
switch (first) {
case 'vod': return await mediaItemStatus(req, 'vod', second);
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 60366261..ef74773c 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -71,6 +71,13 @@ const ulid = () => ids.ulid();
for (const t of chatTables) exempt[t] = 'Chat owns and erases it';
exempt.hidden_relay_users = 'a moderation record of a relay account hidden from a channel, kept for accountability';
exempt.config_snapshots = 'the configuration journal: created_by/activated_by are actor refs, not an account row';
++ // The sharing system's rows name people by canonical subject (jsonb), so the user_id scan cannot see them;
++ // server/auth/account-data.js erases them by subject in the same deletion (subject and owner records).
++ exempt.shares = 'sharing: the person\'s own shares, erased by subject (eraseSharing)';
++ exempt.share_members = 'sharing: membership by subject, erased by subject (eraseSharing)';
++ exempt.share_access_requests = 'sharing: access requests by subject, erased by subject (eraseSharing)';
++ exempt.share_transfers = 'sharing: offers by subject, erased by subject (eraseSharing)';
++ exempt.agent_view_tickets = 'agent streams: view tickets carry the viewer\'s subject; the rows go with the stream (managed_streams cascade)';
// Every other table with a person column is handled by the schema-driven export and erase below.
const handled = [...cols.keys()].filter((t) => !chatTables.includes(t));
const missing = await uncovered(d, { taskKeyed: handled, exempt });
ddiff --git a/test/agent-streams-whep.test.js b/test/agent-streams-whep.test.js
nnew file mode 100644
iindex 00000000..76450fe3
---- /dev/null
++++ b/test/agent-streams-whep.test.js
@@@ -0,0 +1,307 @@
++'use strict';
++/**
++ * Agent streams: WHEP (the design's "Added 2026-11-06").
++ *
++ * POST /whep/agent/<ags> takes a viewer's offer and answers an SDP answer the way /whip takes an ingest offer.
++ * A private or unlisted stream needs a view ticket; a public one needs none. A stream that is not publishing has
++ * nothing to send (409 not_live). The viewer's consumers are ordinary room consumers, so the curtain pauses them.
++ * DELETE ends the session, never the stream.
++ *
++ * The media path itself needs a running mediasoup, so a request that gets past authentication and finds a live session
++ * stops at `503 sfu_unavailable` here; the SDP answer and the capability mapping are tested directly.
++ */
++const assert = require('assert');
++const sdpTransform = require('sdp-transform');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++console.error = () => {};
++
++const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2' };
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++
++function fakeRes() {
++ return {
++ statusCode: 200,
++ headers: {},
++ body: null,
++ status(code) { this.statusCode = code; return this; },
++ set(key, value) { this.headers[key] = value; return this; },
++ json(body) { this.body = body; return this; },
++ send(body) { this.body = body; return this; },
++ end() { return this; },
++ };
++}
++
++const OFFER = sdpTransform.write({
++ version: 0,
++ origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },
++ name: '-',
++ timing: { start: 0, stop: 0 },
++ media: [{
++ type: 'video', port: 9, protocol: 'UDP/TLS/RTP/SAVPF', payloads: '96 97', mid: '0',
++ direction: 'recvonly', setup: 'actpass', rtcpMux: 'rtcp-mux',
++ fingerprint: { type: 'sha-256', hash: 'AA:BB:CC' },
++ rtp: [{ payload: 96, codec: 'H264', rate: 90000 }, { payload: 97, codec: 'rtx', rate: 90000 }],
++ fmtp: [{ payload: 96, config: 'profile-level-id=42e01f;packetization-mode=1' }, { payload: 97, config: 'apt=96' }],
++ rtcpFb: [{ payload: 96, type: 'nack' }, { payload: 96, type: 'nack', subtype: 'pli' }],
++ ext: [{ value: 1, uri: 'urn:ietf:params:rtp-hdrext:sdes:mid' }],
++ }, {
++ type: 'audio', port: 9, protocol: 'UDP/TLS/RTP/SAVPF', payloads: '111', mid: '1',
++ direction: 'recvonly', setup: 'actpass', rtcpMux: 'rtcp-mux',
++ fingerprint: { type: 'sha-256', hash: 'AA:BB:CC' },
++ rtp: [{ payload: 111, codec: 'opus', rate: 48000, encoding: 2 }],
++ fmtp: [{ payload: 111, config: 'minptime=10;useinbandfec=1' }],
++ ext: [{ value: 1, uri: 'urn:ietf:params:rtp-hdrext:sdes:mid' }],
++ }],
++});
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ const addUser = async (id, username, role) => await raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, 0)`).run(id, username, username, `${username}@x`, role);
++ await addUser(1, 'ann', 'streamer');
++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?)").run(SID.ann);
++
++ const service = require('../server/agent-streams/service');
++ const tickets = require('../server/agent-streams/tickets');
++ const curtains = require('../server/streaming/curtains');
++ const whep = require('../server/streaming/whep-handler');
++ const webrtcSFU = require('../server/streaming/webrtc-sfu');
++ const ann = await db.getUserById(1);
++
++ const make = async (visibility, title) => await service.create({
++ user: ann, title, task: { source: 'node', ref: `d-${title}`, title: 'Work' }, visibility,
++ });
++
++ // A public stream, and a private one that is publishing.
++ const pub = await make('public', 'Public');
++ const priv = await make('private', 'Private');
++ const liveStream = await db.createStream({ user_id: 1, managed_stream_id: priv.managed_stream_id, title: 'Private', protocol: 'webrtc' });
++ await service.markPublishing(await service.getByManagedStreamId(priv.managed_stream_id));
++
++ const post = async (agent, { ticket, body = OFFER } = {}) => {
++ const res = fakeRes();
++ await whep.handleWhepPost({
++ params: { id: agent.public_id },
++ headers: ticket ? { authorization: `Bearer ${ticket}` } : {},
++ body,
++ protocol: 'https',
++ query: {},
++ get: (n) => (n === 'host' ? 'live.test' : undefined),
++ }, res);
++ return res;
++ };
++
++ quiet('agent streams: WHEP authorisation');
++ await check('a private stream refuses WHEP without a view ticket', async () => {
++ const res = await post(priv);
++ assert.strictEqual(res.statusCode, 401);
++ assert.strictEqual(res.headers['X-WHEP-ERROR'], 'invalid_ticket');
++ });
++ await check('an expired, another stream\'s or a made-up ticket is refused', async () => {
++ const expired = 'wvt_' + require('crypto').randomBytes(32).toString('base64url');
++ await db.run("INSERT INTO agent_view_tickets (managed_stream_id, token_hash, subject, via, expires_at, renews_until) VALUES (?, ?, NULL, 'owner', now() - interval '1 minute', now())",
++ [priv.managed_stream_id, service.sha256(expired)]);
++ assert.strictEqual((await post(priv, { ticket: expired })).statusCode, 401);
++ const otherStreams = await tickets.issueView(pub.managed_stream_id, { via: 'owner' });
++ assert.strictEqual((await post(priv, { ticket: otherStreams.ticket })).statusCode, 401);
++ assert.strictEqual((await post(priv, { ticket: 'wvt_' + 'x'.repeat(43) })).statusCode, 401);
++ });
++ await check('a valid view ticket passes authorisation and reaches the media path (503 without an SFU here)', async () => {
++ const view = await tickets.issueView(priv.managed_stream_id, { via: 'owner' });
++ const res = await post(priv, { ticket: view.ticket });
++ assert.strictEqual(res.statusCode, 503);
++ assert.strictEqual(res.headers['X-WHEP-ERROR'], 'sfu_unavailable');
++ });
++ await check('a public stream needs no ticket', async () => {
++ const res = await post(pub);
++ // Not publishing: nothing to send. The point is that it was not refused for a missing ticket.
++ assert.strictEqual(res.statusCode, 409);
++ assert.strictEqual(res.headers['X-WHEP-ERROR'], 'not_live');
++ });
++ await check('a public stream that is publishing reaches the media path', async () => {
++ const liveRow = await db.createStream({ user_id: 1, managed_stream_id: pub.managed_stream_id, title: 'Public', protocol: 'webrtc' });
++ await service.markPublishing(await service.getByManagedStreamId(pub.managed_stream_id));
++ try {
++ const res = await post(pub);
++ assert.strictEqual(res.statusCode, 503);
++ assert.strictEqual(res.headers['X-WHEP-ERROR'], 'sfu_unavailable');
++ } finally { void liveRow; }
++ });
++ await check('an unknown stream is 404, and DELETE of a session nobody opened is 404', async () => {
++ const missing = fakeRes();
++ await whep.handleWhepPost({ params: { id: 'ags_00000000000000000000000000' }, headers: {}, body: OFFER, protocol: 'https', get: () => 'live.test' }, missing);
++ assert.strictEqual(missing.statusCode, 404);
++ const gone = fakeRes();
++ await whep.handleWhepDelete({ params: { id: priv.public_id, resourceId: 'nope' } }, gone);
++ assert.strictEqual(gone.statusCode, 404);
++ });
++
++ quiet('agent streams: WHEP and the curtain');
++ await check('a WHEP consumer is paused by the curtain like any other viewer', async () => {
++ const roomId = `stream-${liveStream.lastInsertRowid}`;
++ const made = [];
++ const room = { producers: new Map(), consumers: new Map(), transports: new Map(), router: null };
++ for (const [id, peerId] of [['w1', 'whep-abc'], ['w2', 'peer-1']]) {
++ const consumer = { paused: false, pause() { this.paused = true; }, resume() { this.paused = false; } };
++ room.consumers.set(id, { consumer, peerId });
++ made.push(consumer);
++ }
++ webrtcSFU.rooms.set(roomId, room);
++ try {
++ assert.strictEqual(curtains.raise(liveStream.lastInsertRowid), 2);
++ assert.deepStrictEqual(made.map((c) => c.paused), [true, true]);
++ assert.strictEqual(curtains.lower(liveStream.lastInsertRowid), 2);
++ assert.deepStrictEqual(made.map((c) => c.paused), [false, false]);
++ } finally {
++ webrtcSFU.rooms.delete(roomId);
++ curtains._reset();
++ }
++ });
++ await check('a curtain pauses the WHEP consumer, even when the in-memory set is empty', async () => {
++ const sessionId = liveStream.lastInsertRowid;
++ const roomId = `stream-${sessionId}`;
++ const room = { producers: new Map(), consumers: new Map(), transports: new Map(), router: null };
++ const consumer = { paused: false, pause() { this.paused = true; }, resume() { this.paused = false; } };
++ room.consumers.set('w1', { consumer, peerId: 'whep-abc' });
++ webrtcSFU.rooms.set(roomId, room);
++ try {
++ // The row is the truth after a restart: the in-memory set is empty but the curtain is still up.
++ await db.run('UPDATE agent_streams SET curtain_up = 1 WHERE managed_stream_id = ?', [priv.managed_stream_id]);
++ curtains._reset();
++ assert.strictEqual(consumer.paused, false, 'not paused before the consumer arrives');
++ assert.strictEqual(await whep._pauseUnderCurtain(sessionId, 'w1'), true);
++ assert.strictEqual(consumer.paused, true, 'the WHEP consumer is paused by the curtain');
++ // Lowering the curtain resumes exactly what the curtain paused.
++ assert.strictEqual(curtains.lower(sessionId), 1);
++ assert.strictEqual(consumer.paused, false);
++ } finally {
++ await db.run('UPDATE agent_streams SET curtain_up = 0 WHERE managed_stream_id = ?', [priv.managed_stream_id]);
++ webrtcSFU.rooms.delete(roomId);
++ curtains._reset();
++ }
++ });
++ await check('a WHEP consumer born under a raised curtain starts paused', async () => {
++ const sessionId = liveStream.lastInsertRowid;
++ const roomId = `stream-${sessionId}`;
++ const room = { producers: new Map(), consumers: new Map(), transports: new Map(), router: null };
++ const consumer = { paused: false, pause() { this.paused = true; }, resume() { this.paused = false; } };
++ room.consumers.set('w1', { consumer, peerId: 'whep-abc' });
++ webrtcSFU.rooms.set(roomId, room);
++ try {
++ // What the handler passes: the row's own flag, so no second query is needed.
++ await whep._pauseUnderCurtain(sessionId, 'w1', 1);
++ assert.strictEqual(consumer.paused, true, 'the consumer starts paused');
++ // A stream with no curtain is not paused.
++ const other = { paused: false, pause() { this.paused = true; }, resume() { this.paused = false; } };
++ room.consumers.set('w2', { consumer: other, peerId: 'whep-def' });
++ await whep._pauseUnderCurtain(sessionId, 'w2', 0);
++ assert.strictEqual(other.paused, false, 'no curtain, no pause');
++ } finally {
++ webrtcSFU.rooms.delete(roomId);
++ curtains._reset();
++ }
++ });
++ await check('DELETE ends the WHEP session: its consumers and transport go, the stream stays', async () => {
++ const sessionId = liveStream.lastInsertRowid;
++ const roomId = `stream-${sessionId}`;
++ const room = { producers: new Map(), consumers: new Map(), transports: new Map(), router: null };
++ let closed = false;
++ const consumer = { closed: false, close() { this.closed = true; } };
++ const transport = { closed: false, close() { this.closed = true; closed = true; } };
++ room.consumers.set('c1', { consumer, peerId: 'whep-xyz' });
++ room.transports.set('whep-xyz-t1', transport);
++ webrtcSFU.rooms.set(roomId, room);
++ whep.sessions.set('res1', { streamId: sessionId, agentId: priv.public_id, peerId: 'whep-xyz', transportId: 't1', consumerIds: ['c1'] });
++ try {
++ const res = fakeRes();
++ await whep.handleWhepDelete({ params: { id: priv.public_id, resourceId: 'res1' } }, res);
++ assert.strictEqual(res.statusCode, 200);
++ assert.strictEqual(consumer.closed, true, 'the consumer is closed');
++ assert.strictEqual(closed, true, 'the transport is closed');
++ assert.strictEqual(whep.sessions.has('res1'), false, 'the session is gone');
++ assert.strictEqual(whep.countForStream(sessionId), 0, 'the viewer is not counted any more');
++ } finally {
++ whep.sessions.delete('res1');
++ webrtcSFU.rooms.delete(roomId);
++ }
++ });
++
++ quiet('agent streams: the WHEP answer');
++ await check('the offer\'s codecs become the receive capabilities, with their parameters', async () => {
++ const caps = whep._capabilitiesFromOffer(sdpTransform.parse(OFFER));
++ const h264 = caps.codecs.find((c) => c.mimeType === 'video/H264');
++ assert.ok(h264, 'H264 offered');
++ assert.strictEqual(h264.clockRate, 90000);
++ assert.strictEqual(h264.parameters['profile-level-id'], '42e01f');
++ assert.ok(h264.rtcpFeedback.some((f) => f.type === 'nack' && f.parameter === 'pli'));
++ assert.ok(!caps.codecs.some((c) => c.mimeType.toLowerCase().endsWith('/rtx')), 'rtx is not a receive codec');
++ assert.ok(caps.codecs.some((c) => c.mimeType === 'audio/opus' && c.channels === 2));
++ assert.ok(caps.headerExtensions.some((e) => e.kind === 'video' && e.uri.endsWith('sdes:mid')));
++ });
++ await check('the answer is sendonly, with the consumer\'s payload types, SSRC and BUNDLE', async () => {
++ const transport = {
++ iceParameters: { usernameFragment: 'ufrag', password: 'pwd' },
++ iceCandidates: [{ foundation: '1', protocol: 'udp', priority: 100, ip: '127.0.0.1', port: 40000, type: 'host' }],
++ dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB:CC' }] },
++ };
++ const consumer = {
++ rtpParameters: {
++ mid: '0',
++ codecs: [{
++ mimeType: 'video/H264', payloadType: 102, clockRate: 90000,
++ parameters: { 'profile-level-id': '42e01f', 'packetization-mode': 1 },
++ rtcpFeedback: [{ type: 'nack', parameter: '' }, { type: 'nack', parameter: 'pli' }],
++ }],
++ headerExtensions: [{ id: 1, uri: 'urn:ietf:params:rtp-hdrext:sdes:mid' }],
++ encodings: [{ ssrc: 123456 }],
++ },
++ };
++ const answer = whep._buildWhepAnswer(transport, sdpTransform.parse(OFFER), [consumer], { resourceId: 'abc' });
++ assert.match(answer, /^m=video 9 /m);
++ assert.match(answer, /a=sendonly/);
++ assert.match(answer, /a=rtpmap:102 H264\/90000/);
++ assert.match(answer, /a=fmtp:102 profile-level-id=42e01f;packetization-mode=1/);
++ assert.match(answer, /a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid/);
++ assert.match(answer, /a=ssrc:123456 cname:whep-abc/);
++ assert.match(answer, /a=group:BUNDLE 0/);
++ assert.match(answer, /a=setup:passive/);
++ assert.match(answer, /a=mid:0/);
++ // The audio section had no consumer: it is rejected, not silently sent.
++ assert.match(answer, /m=audio 0 /);
++ assert.doesNotMatch(answer, /a=sendonly[\s\S]*m=audio 0 [\s\S]*a=sendonly/);
++ });
++ await check('WHEP sessions count as viewers and can be listed per stream', async () => {
++ whep.sessions.set('r1', { streamId: 4242, agentId: priv.public_id, consumerIds: [] });
++ whep.sessions.set('r2', { streamId: 4242, agentId: priv.public_id, consumerIds: [] });
++ try {
++ assert.strictEqual(whep.countForStream(4242), 2);
++ assert.ok(service.viewersNow(4242) >= 2, 'they keep the desk encoding');
++ } finally {
++ whep.sessions.delete('r1');
++ whep.sessions.delete('r2');
++ }
++ });
++ await check('CORS is the WHIP endpoint\'s: every origin, POST/PATCH/DELETE/OPTIONS', async () => {
++ const res = fakeRes();
++ whep.handleWhepOptions({}, res);
++ assert.strictEqual(res.statusCode, 204);
++ assert.strictEqual(res.headers['Access-Control-Allow-Origin'], '*');
++ assert.match(res.headers['Access-Control-Allow-Methods'], /POST/);
++ assert.match(res.headers['Access-Control-Allow-Methods'], /DELETE/);
++ assert.strictEqual(res.headers['Access-Control-Expose-Headers'], 'Location, X-WHEP-ERROR');
++ });
++
++ if (failures) { quiet(`${failures} agent stream WHEP check(s) failed`); process.exit(1); }
++ quiet('agent streams (whep): ok');
++})().catch((e) => { quiet(e && e.stack || String(e)); process.exit(1); });
ddiff --git a/test/agent-streams-whip.test.js b/test/agent-streams-whip.test.js
nnew file mode 100644
iindex 00000000..22609777
---- /dev/null
++++ b/test/agent-streams-whip.test.js
@@@ -0,0 +1,150 @@
++'use strict';
++/**
++ * Agent streams: the WHIP ticket (Agent Streams S1).
++ *
++ * A desk publishes at POST /whip/agent/<ags> with `Authorization: Bearer wpt_…`. A ticket that does not verify, has
++ * expired, was revoked, or names another stream is 401 invalid_ticket; a second session while one is up is 409
++ * already_publishing; a person with a live agent stream already (one, or five for the owner) is 409
++ * live.agent_stream_limit. A valid ticket reaches the media path, which this test stops at the SFU (503 in a test
++ * process), proving the ticket itself was accepted.
++ */
++const assert = require('assert');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++console.error = () => {};
++
++const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2' };
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++
++function fakeRes() {
++ return {
++ statusCode: 200,
++ headers: {},
++ body: null,
++ status(code) { this.statusCode = code; return this; },
++ set(key, value) { this.headers[key] = value; return this; },
++ json(body) { this.body = body; return this; },
++ send(body) { this.body = body; return this; },
++ end() { return this; },
++ };
++}
++
++function fakeReq({ id, ticket, body = 'v=0\r\n' }) {
++ return {
++ params: { id },
++ headers: ticket ? { authorization: `Bearer ${ticket}` } : {},
++ body,
++ protocol: 'https',
++ query: {},
++ get(name) { return name === 'host' ? 'live.test' : undefined; },
++ };
++}
++
++const token = (prefix) => prefix + require('crypto').randomBytes(32).toString('base64url');
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ const addUser = async (id, username, role, extra = {}) => await raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0);
++ await addUser(1, 'ann', 'streamer');
++ await addUser(2, 'bob', 'streamer');
++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?), (2, 'network', '102', ?)").run(SID.ann, SID.bob);
++
++ const service = require('../server/agent-streams/service');
++ const tickets = require('../server/agent-streams/tickets');
++ const whip = require('../server/streaming/whip-handler');
++ const ann = await db.getUserById(1);
++ const bob = await db.getUserById(2);
++
++ const make = async (user, title) => await service.create({ user, title, task: { source: 'node', ref: `d-${title}`, title: 'Work' }, visibility: 'public' });
++ const a = await make(ann, 'A');
++ const b = await make(ann, 'B');
++ const bobStream = await make(bob, 'Bob');
++
++ quiet('agent streams: the publish ticket');
++ await check('a ticket that does not verify is 401 invalid_ticket', async () => {
++ const res = fakeRes();
++ await whip.handleAgentWhipPost(fakeReq({ id: a.public_id, ticket: token('wpt_') }), res);
++ assert.strictEqual(res.statusCode, 401);
++ assert.strictEqual(res.headers['X-WHIP-ERROR'], 'invalid_ticket');
++ });
++ await check('an expired ticket is 401 invalid_ticket', async () => {
++ const rawToken = token('wpt_');
++ await db.run("INSERT INTO agent_publish_tickets (managed_stream_id, token_hash, expires_at) VALUES (?, ?, now() - interval '1 minute')",
++ [a.managed_stream_id, service.sha256(rawToken)]);
++ const res = fakeRes();
++ await whip.handleAgentWhipPost(fakeReq({ id: a.public_id, ticket: rawToken }), res);
++ assert.strictEqual(res.statusCode, 401);
++ assert.strictEqual(res.headers['X-WHIP-ERROR'], 'invalid_ticket');
++ });
++ await check('a revoked ticket is 401 invalid_ticket', async () => {
++ const rawToken = token('wpt_');
++ await db.run('INSERT INTO agent_publish_tickets (managed_stream_id, token_hash, expires_at) VALUES (?, ?, now() + interval \'1 hour\')',
++ [a.managed_stream_id, service.sha256(rawToken)]);
++ await db.run('UPDATE agent_publish_tickets SET revoked_at = now() WHERE token_hash = ?', [service.sha256(rawToken)]);
++ const res = fakeRes();
++ await whip.handleAgentWhipPost(fakeReq({ id: a.public_id, ticket: rawToken }), res);
++ assert.strictEqual(res.statusCode, 401);
++ assert.strictEqual(res.headers['X-WHIP-ERROR'], 'invalid_ticket');
++ });
++ await check('another stream\'s ticket is 401 invalid_ticket', async () => {
++ const mine = await tickets.issuePublish(b.managed_stream_id);
++ const res = fakeRes();
++ await whip.handleAgentWhipPost(fakeReq({ id: a.public_id, ticket: mine.ticket }), res);
++ assert.strictEqual(res.statusCode, 401);
++ assert.strictEqual(res.headers['X-WHIP-ERROR'], 'invalid_ticket');
++ });
++
++ quiet('agent streams: one session, one live stream');
++ await check('a second WHIP session while one is up is 409 already_publishing', async () => {
++ const session = await db.createStream({ user_id: 1, managed_stream_id: a.managed_stream_id, title: 'A', protocol: 'webrtc' });
++ await service.markPublishing(await service.getByManagedStreamId(a.managed_stream_id));
++ const resourceId = 'res-a-1';
++ whip.sessions.set(resourceId, { streamId: session.lastInsertRowid, agentStreamId: a.public_id, agentManagedStreamId: a.managed_stream_id, iceReady: false, producerIds: [] });
++ try {
++ const ticket = await tickets.issuePublish(a.managed_stream_id);
++ const res = fakeRes();
++ await whip.handleAgentWhipPost(fakeReq({ id: a.public_id, ticket: ticket.ticket }), res);
++ assert.strictEqual(res.statusCode, 409);
++ assert.strictEqual(res.headers['X-WHIP-ERROR'], 'already_publishing');
++ } finally {
++ whip.sessions.delete(resourceId);
++ }
++ });
++ await check('a second live agent stream on the account is 409 live.agent_stream_limit', async () => {
++ // a is live from the check above; b is idle and wants to publish.
++ const ticket = await tickets.issuePublish(b.managed_stream_id);
++ const res = fakeRes();
++ await whip.handleAgentWhipPost(fakeReq({ id: b.public_id, ticket: ticket.ticket }), res);
++ assert.strictEqual(res.statusCode, 409);
++ assert.strictEqual(res.headers['X-WHIP-ERROR'], 'live.agent_stream_limit');
++ });
++ await check('a valid ticket reaches the media path (503 in a test process, not an auth error)', async () => {
++ const ticket = await tickets.issuePublish(bobStream.managed_stream_id);
++ const res = fakeRes();
++ await whip.handleAgentWhipPost(fakeReq({ id: bobStream.public_id, ticket: ticket.ticket }), res);
++ assert.strictEqual(res.statusCode, 503);
++ assert.strictEqual(res.headers['X-WHIP-ERROR'], 'sfu_unavailable');
++ });
++ await check('the owner may have five live agent streams (the owner rank)', async () => {
++ assert.strictEqual(service.liveLimit({ is_owner: 1 }), 5);
++ assert.strictEqual(service.liveLimit({ is_owner: 0 }), 1);
++ });
++ await check('a resource address on the agent path is answered under /whip/agent', async () => {
++ const headers = whip.buildWhipResponseHeaders(fakeReq({ id: a.public_id }), a.public_id, 'res-1', '/whip/agent');
++ assert.strictEqual(headers.Location, 'https://live.test/whip/agent/' + a.public_id + '/res-1');
++ });
++
++ if (failures) { quiet(`${failures} agent stream WHIP check(s) failed`); process.exit(1); }
++ quiet('agent streams (whip): ok');
++})().catch((e) => { quiet(e && e.stack || String(e)); process.exit(1); });
ddiff --git a/test/agent-streams.test.js b/test/agent-streams.test.js
nnew file mode 100644
iindex 00000000..17b3b1e2
---- /dev/null
++++ b/test/agent-streams.test.js
@@@ -0,0 +1,358 @@
++'use strict';
++/**
++ * Agent streams, Live's part (Agent Streams S1, design 2026-11-06).
++ *
++ * - an agent stream does not take one of the person's normal slots;
++ * - no route ever answers a stream_key for one (it has none, and the serializer would strip it anyway);
++ * - a private stream's join needs a view ticket: the owner gets one, a stranger does not, a person it is shared with
++ * does (the sharing system's own rows, service live, kind agent-stream);
++ * - an unlisted link works until it is rotated;
++ * - the curtain pauses the SFU consumers and resuming un-pauses exactly those;
++ * - the embed's frame-ancestors is the first-party list, never *;
++ * - the state the desk polls says whether to encode.
++ *
++ * Sign-in is stubbed with an x-test-user header (as authorization.test.js does); everything after that is production.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++console.error = () => {};
++
++const SID = {
++ ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1',
++ bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2',
++ cat: 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3',
++};
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ const addUser = async (id, username, role, extra = {}) => await raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0);
++ await addUser(1, 'ann', 'streamer');
++ await addUser(2, 'bob', 'user');
++ await addUser(3, 'cat', 'user');
++ await addUser(4, 'slash', 'admin', { is_owner: 1 });
++ const link = async (uid, sid) => await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(uid, String(100 + uid), sid);
++ await link(1, SID.ann); await link(2, SID.bob); await link(3, SID.cat); await link(4, SID.ann.replace('A1', 'D4'));
++
++ const service = require('../server/agent-streams/service');
++ const tickets = require('../server/agent-streams/tickets');
++ const curtains = require('../server/streaming/curtains');
++ const serializers = require('../server/web/serializers');
++ const webrtcSFU = require('../server/streaming/webrtc-sfu');
++ const sharing = require('../server/agent-streams/sharing');
++ // The sharing system asks Network who a subject is; in this test Network is the four accounts above.
++ sharing._setNetwork({
++ json: async ({ path, query, json }) => {
++ if (path === '/internal/identity/resolve-batch') {
++ const results = {};
++ for (const id of (json && json.subject_ids) || []) {
++ const username = id === SID.bob ? 'bob' : id === SID.cat ? 'cat' : 'ann';
++ results[id] = { subject: { type: 'user', id }, username, display_name: username, avatar_url: null };
++ }
++ return { results };
++ }
++ if (path === '/internal/identity/resolve') {
++ const username = query && query.username;
++ const id = username === 'bob' ? SID.bob : username === 'cat' ? SID.cat : SID.ann;
++ return { subject: { type: 'user', id }, username, display_name: username, avatar_url: null };
++ }
++ return {};
++ },
++ });
++ // The addresses the contract names (production's), not the test's localhost defaults.
++ const config = require('../server/config');
++ config.baseUrl = 'https://openvibe.live';
++ config.whip = { ...(config.whip || {}), publicUrl: 'https://whip.openvibe.live' };
++ const ann = await db.getUserById(1);
++ const bob = await db.getUserById(2);
++ const cat = await db.getUserById(3);
++
++ const agent = await service.create({
++ user: ann,
++ title: 'Fixing the auth bug',
++ task: { source: 'codes', ref: 'tsk_1', title: 'Fix auth', url: 'https://openvibe.codes/tasks/1' },
++ visibility: 'private',
++ });
++
++ quiet('agent streams: the slot');
++ await check('an agent stream does not take a normal slot', async () => {
++ const normal = await db.createManagedStream({ user_id: 1, channel_id: null, slug: 'main', title: 'Main', protocol: 'webrtc', stream_key: 'a'.repeat(40) });
++ assert.ok(normal.lastInsertRowid);
++ assert.strictEqual(await db.countManagedStreamsByUser(1), 1, 'only the normal slot counts');
++ const listed = await db.getManagedStreamsByUserId(1);
++ assert.deepStrictEqual(listed.map((s) => s.kind), ['normal'], 'the dashboard lists normal slots only');
++ });
++ await check('an agent row has no stream key anywhere in the database', async () => {
++ const row = await db.get('SELECT stream_key, kind, public_id FROM managed_streams WHERE id = ?', [agent.managed_stream_id]);
++ assert.strictEqual(row.kind, 'agent');
++ assert.strictEqual(row.stream_key, null);
++ assert.match(row.public_id, /^ags_[0-9A-HJKMNP-TV-Z]{26}$/);
++ });
++
++ // ── The API ─────────────────────────────────────────────────
++ const app = express();
++ app.use(express.json());
++ app.use((req, res, next) => { req.user = { 1: ann, 2: bob, 3: cat }[req.get('x-test-user')] || null; next(); });
++ app.use('/api/streams/agent', require('../server/agent-streams/routes'));
++ // The sharing system's own router, as server/index.js mounts it (the Share sheet's access part).
++ const shareRouter = sharing.router({
++ authenticate: async (req) => {
++ const user = { 1: ann, 2: bob, 3: cat }[req.get('x-test-user')] || null;
++ if (!user) return null;
++ const subject = await require('../server/agent-streams/auth').viewerSubject(user);
++ return subject ? { subject: { type: 'user', id: subject }, staff: false } : null;
++ },
++ describe: async () => ({}),
++ });
++ if (shareRouter) app.use(shareRouter);
++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, path, { as = 1, body, headers = {} } = {}) => {
++ const res = await fetch(`${base}${path}`, {
++ method,
++ headers: { 'Content-Type': 'application/json', 'x-test-user': String(as), ...headers },
++ body: body === undefined ? undefined : JSON.stringify(body),
++ });
++ const text = await res.text();
++ let data; try { data = JSON.parse(text); } catch { data = text; }
++ return { status: res.status, data, headers: res.headers };
++ };
++
++ try {
++ quiet('agent streams: the resources');
++ await check('the owner reads the resource, with no stream key and nothing secret', async () => {
++ const r = await call('GET', `/api/streams/agent/${agent.public_id}`, { as: 1 });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(serializers.findSecrets(r.data), [], 'no secret leaf in the answer');
++ assert.strictEqual(r.data.can_manage, true);
++ assert.strictEqual(r.data.stream.id, agent.public_id);
++ assert.strictEqual(r.data.stream.owner, SID.ann);
++ assert.strictEqual(r.data.stream.channel, 'ann');
++ assert.strictEqual(r.data.stream.label, 'AI agent');
++ assert.strictEqual(r.data.stream.visibility, 'private');
++ assert.strictEqual(r.data.stream.state, 'idle');
++ assert.ok(r.data.view_ticket && /^wvt_/.test(r.data.view_ticket));
++ assert.match(r.data.stream.page_url, /^https:\/\/openvibe\.live\/@ann\/agents\/ags_/);
++ });
++ await check('nobody else can read a private stream, nor get a view ticket', async () => {
++ const read = await call('GET', `/api/streams/agent/${agent.public_id}`, { as: 2 });
++ assert.strictEqual(read.status, 404);
++ const mint = await call('POST', `/api/streams/agent/${agent.public_id}/view-ticket`, { as: 2, body: {} });
++ assert.strictEqual(mint.status, 403);
++ const anon = await call('GET', `/api/streams/agent/${agent.public_id}`, { as: 0 });
++ assert.strictEqual(anon.status, 404);
++ });
++ await check('a person the stream is shared with can read it and gets a view ticket', async () => {
++ assert.ok(sharing.available(), 'the pinned openvibe-sdk has sharing');
++ // Through the public surface the Share sheet uses: the owner makes it people-only and adds bob.
++ const put = await call('PUT', `/api/v1/agent-stream/${agent.public_id}/share`, {
++ as: 1,
++ body: { audience: 'people', members: [{ subject: { type: 'user', id: SID.bob }, role: 'viewer' }] },
++ headers: { 'sec-fetch-site': 'same-origin' },
++ });
++ assert.strictEqual(put.status, 200, `share PUT answered ${put.status} ${JSON.stringify(put.data)}`);
++ assert.strictEqual(put.data.audience, 'people');
++ const read = await call('GET', `/api/streams/agent/${agent.public_id}`, { as: 2 });
++ assert.strictEqual(read.status, 200, 'bob reads it');
++ assert.ok(read.data.view_ticket, 'and gets a view ticket');
++ const anon = await call('POST', `/api/streams/agent/${agent.public_id}/view-ticket`, { as: 3, body: {} });
++ assert.strictEqual(anon.status, 403, 'cat still cannot');
++ });
++ await check('an unlisted link works until it is rotated', async () => {
++ const put = await call('PUT', `/api/streams/agent/${agent.public_id}/visibility`, { as: 1, body: { visibility: 'unlisted' } });
++ assert.strictEqual(put.status, 200);
++ const link = put.data.stream.page_url;
++ assert.match(link, /\?k=[A-Za-z0-9_-]{22}$/);
++ const k = new URL(link).searchParams.get('k');
++ const withLink = await call('GET', `/api/streams/agent/${agent.public_id}?k=${encodeURIComponent(k)}`, { as: 3 });
++ assert.strictEqual(withLink.status, 200, 'anyone with the link reads it');
++ assert.strictEqual(withLink.data.can_manage, false);
++ const mint = await call('POST', `/api/streams/agent/${agent.public_id}/view-ticket`, { as: 3, body: { k } });
++ assert.strictEqual(mint.status, 200);
++ const rotated = await call('POST', `/api/streams/agent/${agent.public_id}/rotate-link`, { as: 1, body: {} });
++ assert.strictEqual(rotated.status, 200);
++ const old = await call('GET', `/api/streams/agent/${agent.public_id}?k=${encodeURIComponent(k)}`, { as: 3 });
++ assert.strictEqual(old.status, 404, 'the old link is closed');
++ const freshK = new URL(rotated.data.stream.page_url).searchParams.get('k');
++ assert.notStrictEqual(freshK, k, 'the new link is a new secret');
++ const fresh = await call('GET', `/api/streams/agent/${agent.public_id}?k=${encodeURIComponent(freshK)}`, { as: 3 });
++ assert.strictEqual(fresh.status, 200, 'the new link works');
++ });
++ await check('the resource never carries a stream key, on any answer', async () => {
++ for (const [method, path, opts] of [
++ ['GET', `/api/streams/agent/${agent.public_id}`, { as: 1 }],
++ ['POST', `/api/streams/agent/${agent.public_id}/ticket`, { as: 1, body: {} }],
++ ['GET', `/api/streams/agent/${agent.public_id}/state`, { as: 1 }],
++ ]) {
++ const r = await call(method, path, opts);
++ assert.deepStrictEqual(serializers.findSecrets(r.data), [], `${method} ${path}`);
++ assert.ok(!JSON.stringify(r.data).includes('stream_key'));
++ }
++ });
++ await check('the desk\'s state says whether to encode, and ends when the ticket does', async () => {
++ const created = await call('POST', '/api/streams/agent', {
++ as: 1,
++ body: { title: 'Second', task: { source: 'desk', ref: 'd1', title: 'Work' }, visibility: 'private' },
++ });
++ assert.strictEqual(created.status, 201);
++ const { stream, publish } = created.data;
++ assert.match(publish.ticket, /^wpt_[A-Za-z0-9_-]{43}$/);
++ assert.match(publish.whip_url, /^https:\/\/whip\.openvibe\.live\/whip\/agent\/ags_/);
++ const state = await call('GET', `/api/streams/agent/${stream.id}/state`, { as: 1, headers: { Authorization: `Bearer ${publish.ticket}` } });
++ assert.strictEqual(state.status, 200);
++ assert.strictEqual(state.data.encode, false, 'nobody watches and it is private');
++ assert.ok(state.data.ends_at, 'the ticket expiry bounds the stream');
++ const bad = await call('GET', `/api/streams/agent/${stream.id}/state`, { as: 1, headers: { Authorization: `Bearer wpt_${'x'.repeat(43)}` } });
++ assert.strictEqual(bad.status, 401, 'a ticket that does not verify');
++ // A public stream is always encoded, watched or not.
++ await call('PUT', `/api/streams/agent/${stream.id}/visibility`, { as: 1, body: { visibility: 'public' } });
++ const pub = await call('GET', `/api/streams/agent/${stream.id}/state`, { as: 1, headers: { Authorization: `Bearer ${publish.ticket}` } });
++ assert.strictEqual(pub.data.encode, true);
++ });
++
++ quiet('agent streams: the curtain');
++ await check('up pauses every viewer consumer, down resumes exactly those', async () => {
++ const roomId = 'stream-999';
++ const made = [];
++ const room = { producers: new Map(), consumers: new Map(), transports: new Map(), router: null };
++ for (const id of ['c1', 'c2', 'restream']) {
++ const consumer = { paused: false, pause() { this.paused = true; }, resume() { this.paused = false; } };
++ room.consumers.set(id, { consumer, peerId: id === 'restream' ? '__restream__' : `peer-${id}` });
++ made.push({ id, consumer });
++ }
++ webrtcSFU.rooms.set(roomId, room);
++ try {
++ assert.strictEqual(curtains.raise(999), 2, 'the two viewers pause, the restream passthrough does not');
++ assert.deepStrictEqual(made.map((m) => m.consumer.paused), [true, true, false]);
++ assert.strictEqual(curtains.lower(999), 2);
++ assert.deepStrictEqual(made.map((m) => m.consumer.paused), [false, false, false]);
++ // A consumer that appears while the curtain is up starts paused, and nothing else does.
++ curtains.raise(999);
++ const late = { paused: false, pause() { this.paused = true; }, resume() { this.paused = false; } };
++ room.consumers.set('c3', { consumer: late, peerId: 'peer-c3' });
++ assert.strictEqual(curtains.applyToConsumer(999, 'c3'), true);
++ assert.strictEqual(late.paused, true);
++ curtains.lower(999);
++ } finally {
++ webrtcSFU.rooms.delete(roomId);
++ curtains._reset();
++ }
++ });
++
++ quiet('agent streams: the page and the embed code');
++ await check('the owner page and the embed join with a view ticket and never a stream key', async () => {
++ const fs = require('fs');
++ const path = require('path');
++ const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++ const player = read('public/js/agent-player.js');
++ assert.match(player, /role=viewer/, 'the viewer socket');
++ assert.match(player, /vt=\$\{encodeURIComponent\(viewTicket\)\}/, 'the view ticket on the join');
++ assert.ok(!/stream_key/.test(player));
++ const page = read('public/js/agent-stream.js');
++ assert.match(page, /view-ticket/);
++ assert.match(page, /loadAgentStreamPage/);
++ assert.ok(!/stream_key/.test(page), 'the page asks for no key');
++ const embed = read('public/js/agent-embed.js');
++ assert.match(embed, /AgentPlayer\.create/);
++ assert.match(embed, /renew/);
++ // The housekeeping loop starts no timer when it is loaded (a drill never starts one).
++ const before = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length;
++ const sweep = require('../server/agent-streams/sweep');
++ assert.strictEqual(process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length, before, 'no timer at load');
++ assert.strictEqual(sweep.EVERY_MS, 60000);
++ const t = sweep.start();
++ assert.ok(t && typeof t.unref === 'function');
++ sweep.stop();
++ });
++
++ await check('the owner page: one status line, the label on the player, an in-page End, no confirm()', async () => {
++ const fs = require('fs');
++ const path = require('path');
++ const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++ const page = read('public/js/agent-stream.js');
++ const fragment = read('public/fragments/agent-stream.html');
++ // One status line: Live, N watching, worked X min — or Waiting for the desk, or Ended.
++ assert.strictEqual((fragment.match(/id="agent-status-line"/g) || []).length, 1, 'one status line');
++ assert.match(page, /Live · \$\{count\} watching · worked/);
++ assert.match(page, /Waiting for the desk/);
++ assert.match(page, /Ended · worked/);
++ // The AI agent label rides on the player; the curtain says Private screen, the button offers to lift it.
++ assert.match(fragment, /class="agent-label agent-player-label">AI agent/);
++ assert.match(fragment, /id="agent-curtain"[^>]*>Private screen/);
++ assert.match(page, /Lift the curtain/);
++ // End asks in the page; a browser dialog is never used.
++ assert.match(fragment, /id="agent-end-confirm"/);
++ assert.ok(!/\bconfirm\(/.test(page), 'no confirm()');
++ // Public is disabled, with the note that says where public agent streams come from.
++ assert.match(fragment, /<option value="public" disabled/);
++ assert.match(fragment, /Public agent streams come with the Agents tab\./);
++ // The Reset link hint says the old links stop working.
++ assert.match(page, /old links stop working/);
++ });
++
++ await check('the embed: the picture fills the frame, with the label, the curtain and a small count on it', async () => {
++ const fs = require('fs');
++ const path = require('path');
++ const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++ const css = read('public/css/features/agent-stream.css');
++ const pages = read('server/agent-streams/pages.js');
++ assert.match(css, /object-fit: contain/, 'the picture is contained');
++ assert.match(css, /\.agent-embed-stage[^}]*align-items: center; justify-content: center/, 'centred in the frame');
++ assert.match(css, /\.agent-embed-viewers[^}]*font-size: 0\.6\drem/, 'the viewer count is small');
++ // The label, the curtain and the count ride on the picture, not in a bar above it.
++ const start = pages.indexOf('<div class="agent-embed-media"');
++ assert.ok(start > 0, 'the embed has a picture');
++ let depth = 0;
++ let media = '';
++ for (let i = start; i < pages.length; i++) {
++ if (pages.startsWith('<div', i)) depth++;
++ else if (pages.startsWith('</div>', i)) { depth--; if (depth === 0) { media = pages.slice(start, i + 6); break; } }
++ }
++ assert.ok(media, 'the picture is closed');
++ for (const part of ['agent-embed-label', 'agent-embed-curtain', 'agent-embed-viewers']) {
++ assert.ok(media.includes(part), `${part} is on the picture`);
++ }
++ assert.match(media, /Private screen/, 'the curtain says Private screen');
++ });
++ quiet('agent streams: the embed');
++ await check('frame-ancestors lists the first-party origins and never *', async () => {
++ const pages = require('../server/agent-streams/pages');
++ const embedApp = express();
++ embedApp.get('/embed/agent/:id', pages.embed);
++ const s = await new Promise((ok) => { const srv = http.createServer(embedApp).listen(0, '127.0.0.1', () => ok(srv)); });
++ try {
++ const res = await fetch(`http://127.0.0.1:${s.address().port}/embed/agent/${agent.public_id}`);
++ assert.strictEqual(res.status, 200);
++ const csp = res.headers.get('content-security-policy');
++ assert.match(csp, /frame-ancestors 'self' /);
++ for (const origin of ['https://openvibe.live', 'https://openvibe.codes', 'https://openvibe.actor']) {
++ assert.ok(csp.includes(origin), `${origin} may frame it`);
++ }
++ assert.ok(!/frame-ancestors[^;]*\*/.test(csp), 'never *');
++ assert.match(await res.text(), /id="agent-embed-video"/);
++ const bad = await fetch(`http://127.0.0.1:${s.address().port}/embed/agent/ags_00000000000000000000000000`);
++ assert.strictEqual(bad.status, 404);
++ } finally { s.close(); }
++ });
++ } finally {
++ server.close();
++ }
++
++ if (failures) { quiet(`${failures} agent stream check(s) failed`); process.exit(1); }
++ quiet('agent streams: ok');
++})().catch((e) => { quiet(e && e.stack || String(e)); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 50fc6b07..946d323e 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -357,6 +357,7 @@ async function check(name, fn) {
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.js': 'OpenVibe.Network (configured)',
'server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',
++ 'server/agent-streams/sharing.js': 'OpenVibe.Network (configured): the sharing system\'s name lookup and person resolution',
'server/ai/viewer-quota.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s daily AI-viewer cap',
'server/ai/byo-credentials.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s own key stored there',
'server/monetization/billing-client.js': 'OpenVibe.Billing (configured)',