openvibers/openvibe.live Commits
Agent streams S1: the desk stream, its tickets, the curtain and the two pages (#622)
Changes
@@@ -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 moderationnnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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;@@@ -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",@@@ -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",nnew file mode 100644@@@ -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; }@@@ -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": [nnew file mode 100644@@@ -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>@@@ -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 ───────────────────────────────────── -->nnew file mode 100644@@@ -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.';++ }++ });++})();nnew file mode 100644@@@ -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 };++})();nnew file mode 100644@@@ -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;++})();@@@ -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@@@ -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',};@@@ -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 beforehtmlBrotliKB: 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)};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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; } };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};@@@ -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 };@@@ -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_idFROM 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) {@@@ -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 chaapp.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 streamapp.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.@@@ -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',@@@ -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 viewerif (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',nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};@@@ -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 + keyconst 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,};@@@ -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);@@@ -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 });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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)',
@@ -81,6 +81,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
@@ -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/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/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/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/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/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
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -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;
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -2840,9 +2840,9 @@
@@ -2840,9 +2840,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.1",
"pg": "^8.23.1",
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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; }
@@ -309,6 +309,18 @@
@@ -309,6 +309,18 @@
"js": [
"js": [
"/js/app-search.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": [
"routes": [
@@ -380,6 +392,12 @@
@@ -380,6 +392,12 @@
"channel"
"channel"
]
]
},
},
+ {
+ "path": "^/@[A-Za-z0-9_]+/agents/",
+ "features": [
+ "agent-stream"
+ ]
+ },
{
{
"path": "^/@",
"path": "^/@",
"features": [
"features": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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>
@@ -184,6 +184,9 @@
@@ -184,6 +184,9 @@
<!-- ── CHANNEL PAGE (/:username) ─────────────────────── -->
<!-- ── 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>
<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 ────────────────────────────────────── -->
<!-- ── VOD PLAYER ────────────────────────────────────── -->
<!-- ── CLIP PLAYER ───────────────────────────────────── -->
<!-- ── CLIP PLAYER ───────────────────────────────────── -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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.';
+ }
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -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 };
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,333 @@
@@ -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;
+})();
@@ -936,6 +936,7 @@ function teardownRoute(nextPath) {
@@ -936,6 +936,7 @@ function teardownRoute(nextPath) {
if (typeof _goalCycleTimer !== 'undefined' && _goalCycleTimer) { clearInterval(_goalCycleTimer); _goalCycleTimer = null; }
if (typeof _goalCycleTimer !== 'undefined' && _goalCycleTimer) { clearInterval(_goalCycleTimer); _goalCycleTimer = null; }
stop('stopAiViewerActivity');
stop('stopAiViewerActivity');
stop('stopPlayerStatsPoll');
stop('stopPlayerStatsPoll');
+ stop('stopAgentStreamPage');
// The broadcast desk's polls only matter while you are on it. Leaving them running elsewhere
// 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.
// costs roughly a request a second for the rest of the session.
const goingToBroadcast = typeof nextPath === 'string' && nextPath.startsWith('/broadcast');
const goingToBroadcast = typeof nextPath === 'string' && nextPath.startsWith('/broadcast');
@@ -1269,6 +1270,15 @@ function routeFromURL() {
@@ -1269,6 +1270,15 @@ function routeFromURL() {
const mine = !!(currentUser && String(currentUser.username || '').toLowerCase() === username.toLowerCase());
const mine = !!(currentUser && String(currentUser.username || '').toLowerCase() === username.toLowerCase());
navigate(mine ? `/broadcast${location.search}` : `/@${username}`, true);
navigate(mine ? `/broadcast${location.search}` : `/@${username}`, true);
return;
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('@')) {
} else if (segments.length >= 1 && segments[0].startsWith('@')) {
// Channel page: /@username or /@username/:managedStreamIdOrSlug
// Channel page: /@username or /@username/:managedStreamIdOrSlug
// Backward compat: /@username?stream=sessionId
// Backward compat: /@username?stream=sessionId
@@ -116,7 +116,7 @@
@@ -116,7 +116,7 @@
// the wait is long enough to see (120 ms): a route already loaded never flashes one.
// the wait is long enough to see (120 ms): a route already loaded never flashes one.
const ROUTE_SHAPE = {
const ROUTE_SHAPE = {
'page-home': 'cards', 'page-content': 'cards', 'page-moments': 'cards', 'page-search': 'rows',
'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-chat': 'chat', 'page-updates': 'rows', 'page-recap': 'lines', 'page-rundown': 'rows',
'page-documentation': 'lines', 'page-paste-viewer': 'lines', 'page-profile': 'rows',
'page-documentation': 'lines', 'page-paste-viewer': 'lines', 'page-profile': 'rows',
};
};
@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
// "css" the render-blocking stylesheets.
const BUDGETS = {
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,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
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)
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)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,336 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,355 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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,
+};
@@ -140,6 +140,31 @@ async function exportPart(d, userId) {
@@ -140,6 +140,31 @@ async function exportPart(d, userId) {
// ── Deletion ───────────────────────────────────────────────────
// ── Deletion ───────────────────────────────────────────────────
/** Erase Live's rows of these Live users in one transaction → { erased, retained }. */
/** 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() } = {}) {
async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
const erased = {};
const erased = {};
const retained = {};
const retained = {};
@@ -220,6 +245,8 @@ async function apply(ev, { send = networkCall } = {}) {
@@ -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))];
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);
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 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()) {
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);
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 } = {}) {
@@ -241,4 +268,4 @@ async function apply(ev, { send = networkCall } = {}) {
async function hasColumn(d, table, col) { return (await columnNames(d, table)).includes(col); }
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 };
@@ -1082,7 +1082,7 @@ async function getManagedStreamsByUserId(userId) {
@@ -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.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
(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
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
ORDER BY ms.sort_order ASC, ms.created_at ASC
`, [userId]);
`, [userId]);
}
}
@@ -1317,7 +1317,8 @@ async function updateManagedStreamBroadcastSettings(managedStreamId, userId, set
@@ -1317,7 +1317,8 @@ async function updateManagedStreamBroadcastSettings(managedStreamId, userId, set
}
}
async function countManagedStreamsByUser(userId) {
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) {
async function getManagedStreamLimit(user) {
@@ -443,6 +443,10 @@ app.use(require('./web/whip-host').createWhipHostRedirect({ whipUrl: () => (conf
@@ -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).
// 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.
// It must therefore bypass the credentialed allowlist below, which would 403 the preflight.
app.use('/whip', whipHandler.whipCors);
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
// 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.
// origin rule and no cookie, so it sits before the credentialed allowlist too.
app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ rateLimit }));
app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ rateLimit }));
@@ -470,6 +474,7 @@ const allowlistedCors = cors({
@@ -470,6 +474,7 @@ const allowlistedCors = cors({
});
});
app.use((req, res, next) => {
app.use((req, res, next) => {
if (req.path === '/whip' || req.path.startsWith('/whip/')) return 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);
return allowlistedCors(req, res, next);
});
});
app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
@@ -805,7 +810,42 @@ app.use('/api/auth', authRoutes);
@@ -805,7 +810,42 @@ app.use('/api/auth', authRoutes);
app.use('/api/bench-stream', require('./bench-stream/routes'));
app.use('/api/bench-stream', require('./bench-stream/routes'));
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
app.use('/api/streams', require('./bot/routes'));
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);
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).
// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
const liveEvents = require('./streaming/live-events');
const liveEvents = require('./streaming/live-events');
app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
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
@@ -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/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.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
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/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
app.use('/api/admin/openre', require('./openre/routes'));
@@ -1114,6 +1156,19 @@ app.get('/privacy', (req, res) => {
@@ -1114,6 +1156,19 @@ app.get('/privacy', (req, res) => {
// ── WHIP Endpoint (WebRTC-HTTP Ingestion Protocol) ───────────
// ── WHIP Endpoint (WebRTC-HTTP Ingestion Protocol) ───────────
// OBS and other WHIP-compatible encoders send WebRTC media via HTTP POST.
// OBS and other WHIP-compatible encoders send WebRTC media via HTTP POST.
// Body is raw SDP (application/sdp), auth via Bearer token.
// 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', whipHandler.handleWhipOptions);
app.options('/whip/:streamId/:resourceId', whipHandler.handleWhipOptions);
app.options('/whip/:streamId/:resourceId', whipHandler.handleWhipOptions);
app.post('/whip/:streamId', express.text({ type: 'application/sdp', limit: '64kb' }), whipHandler.handleWhipPost);
app.post('/whip/:streamId', express.text({ type: 'application/sdp', limit: '64kb' }), whipHandler.handleWhipPost);
@@ -1515,6 +1570,9 @@ async function start() {
@@ -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).
// 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('./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); }
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); }
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
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
// (default off), so starting it is a no-op until switched on in admin.
@@ -102,7 +102,16 @@ async function apply(event, data) {
@@ -102,7 +102,16 @@ async function apply(event, data) {
// was already built while the stream was running — no second transcription.
// was already built while the stream was running — no second transcription.
try {
try {
const sid = data.stream_id || data.streamId;
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 { /* */ }
} catch { /* */ }
return {
return {
outcome: 'vod_ready',
outcome: 'vod_ready',
@@ -36,6 +36,15 @@ const MAX_SEND_BACKPRESSURE = 512 * 1024;
@@ -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).
// consumer every 2 s (the viewer's own recovery ladder asks at that pace too).
const VIEWER_KEYFRAME_ASK_MS = 2000;
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 {
class BroadcastServer extends EventEmitter {
constructor() {
constructor() {
super();
super();
@@ -263,6 +272,7 @@ class BroadcastServer extends EventEmitter {
@@ -263,6 +272,7 @@ class BroadcastServer extends EventEmitter {
// ask the SFU for a router and a WebRTC transport — about 100 anonymous sockets exhausted the
// 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.
// RTC port range and locked out real viewers and ingest.
const ip = String(req.headers['cf-connecting-ip'] || (req.socket && req.socket.remoteAddress) || '');
const ip = String(req.headers['cf-connecting-ip'] || (req.socket && req.socket.remoteAddress) || '');
+ let agentStream = null;
if (role === 'viewer') {
if (role === 'viewer') {
let stream = null;
let stream = null;
try { stream = await db.getStreamById(streamId); } catch { stream = null; }
try { stream = await db.getStreamById(streamId); } catch { stream = null; }
@@ -270,6 +280,26 @@ class BroadcastServer extends EventEmitter {
@@ -270,6 +280,26 @@ class BroadcastServer extends EventEmitter {
ws.close(4004, 'Stream not live');
ws.close(4004, 'Stream not live');
return;
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;
let fromIp = 0;
for (const info of this.clients.values()) if (info.role === 'viewer' && info.ip === ip) fromIp++;
for (const info of this.clients.values()) if (info.role === 'viewer' && info.ip === ip) fromIp++;
if (ip && fromIp >= MAX_VIEWER_SOCKETS_PER_IP) {
if (ip && fromIp >= MAX_VIEWER_SOCKETS_PER_IP) {
@@ -327,6 +357,11 @@ class BroadcastServer extends EventEmitter {
@@ -327,6 +357,11 @@ class BroadcastServer extends EventEmitter {
room.viewers.set(peerId, ws);
room.viewers.set(peerId, ws);
console.log(`[Broadcast] Viewer connected: stream ${streamId} (${peerId})`);
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 broadcaster is already connected, notify viewer
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
this.safeSend(ws, { type: 'broadcaster-ready', peerId });
this.safeSend(ws, { type: 'broadcaster-ready', peerId });
@@ -973,6 +1008,8 @@ class BroadcastServer extends EventEmitter {
@@ -973,6 +1008,8 @@ class BroadcastServer extends EventEmitter {
);
);
if (!client._sfuViewerConsumerIds) client._sfuViewerConsumerIds = [];
if (!client._sfuViewerConsumerIds) client._sfuViewerConsumerIds = [];
client._sfuViewerConsumerIds.push(result.id);
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, {
this.safeSend(ws, {
type: 'sfu-viewer-consumed',
type: 'sfu-viewer-consumed',
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,438 @@
@@ -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,
+};
@@ -55,10 +55,15 @@ function buildWhipResourceUrl(req, streamId, resourceId) {
@@ -55,10 +55,15 @@ function buildWhipResourceUrl(req, streamId, resourceId) {
: null;
: null;
const baseUrl = requestOrigin
const baseUrl = requestOrigin
|| normalizeOrigin(config.whip?.publicUrl || config.webrtc?.publicUrl || config.baseUrl);
|| 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 {
try {
- return new URL(`/whip/${streamId}/${resourceId}`, baseUrl).toString();
+ return new URL(`${basePath}/${streamId}/${resourceId}`, baseUrl).toString();
} catch {
} catch {
- return `/whip/${streamId}/${resourceId}`;
+ return `${basePath}/${streamId}/${resourceId}`;
}
}
}
}
@@ -105,9 +110,13 @@ function whipCors(req, res, next) {
@@ -105,9 +110,13 @@ function whipCors(req, res, next) {
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 {
return {
- Location: buildWhipResourceUrl(req, streamId, resourceId),
+ Location: buildWhipResourceUrlOn(baseUrl, streamId, resourceId, basePath),
'Access-Control-Expose-Headers': WHIP_CORS_HEADERS['Access-Control-Expose-Headers'],
'Access-Control-Expose-Headers': WHIP_CORS_HEADERS['Access-Control-Expose-Headers'],
};
};
}
}
@@ -226,6 +235,7 @@ async function endActiveWhipStream(streamId, reason = 'whip_cleanup') {
@@ -226,6 +235,7 @@ async function endActiveWhipStream(streamId, reason = 'whip_cleanup') {
try { if (chatRelayService) chatRelayService.stopForStream(streamId); } catch (err) { /* ignore */ }
try { if (chatRelayService) chatRelayService.stopForStream(streamId); } catch (err) { /* ignore */ }
try { require('../integrations/ai-chatbot-service').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('./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 { webrtcSFU.closeRoom(`stream-${streamId}`); } catch (err) { /* ignore */ }
try { await require('./calls-authority').removeStreamChannel(streamId); } catch (err) { /* ignore */ }
try { await require('./calls-authority').removeStreamChannel(streamId); } catch (err) { /* ignore */ }
}
}
@@ -635,6 +645,117 @@ async function autoCreateWhipSession(managedStream, user) {
@@ -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
* POST /whip/:streamId — WHIP offer → answer
*
*
@@ -674,6 +795,11 @@ async function handleWhipPost(req, res) {
@@ -674,6 +795,11 @@ async function handleWhipPost(req, res) {
if (isStreamKey) {
if (isStreamKey) {
const managedStream = await db.getManagedStreamByStreamKey(pathParam);
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) {
if (!managedStream) {
logWhipStage('auth_key_fail', pathParam, { reason: 'stream_key_not_found' });
logWhipStage('auth_key_fail', pathParam, { reason: 'stream_key_not_found' });
return sendWhipError(res, 401, 'invalid_stream_key', 'Stream key not recognized');
return sendWhipError(res, 401, 'invalid_stream_key', 'Stream key not recognized');
@@ -734,6 +860,9 @@ async function handleWhipPost(req, res) {
@@ -734,6 +860,9 @@ async function handleWhipPost(req, res) {
// First try: slot ID + key
// First try: slot ID + key
const managedStream = await db.getManagedStreamById(slotId);
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) {
if (!managedStream) {
logWhipStage('auth_slot_fail', pathParam, { reason: 'slot_not_found' });
logWhipStage('auth_slot_fail', pathParam, { reason: 'slot_not_found' });
return sendWhipError(res, 404, 'slot_not_found', 'Stream slot not found');
return sendWhipError(res, 404, 'slot_not_found', 'Stream slot not found');
@@ -845,6 +974,20 @@ async function handleWhipPost(req, res) {
@@ -845,6 +974,20 @@ async function handleWhipPost(req, res) {
logWhipStage('auth_jwt', pathParam, { user_id: userId });
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;
const streamId = stream.id;
if (!webrtcSFU.ready) return sendWhipError(res, 503, 'sfu_unavailable', 'WebRTC SFU unavailable');
if (!webrtcSFU.ready) return sendWhipError(res, 503, 'sfu_unavailable', 'WebRTC SFU unavailable');
logWhipStage('stream_validation', streamId, { protocol: stream.protocol, live: stream.is_live });
logWhipStage('stream_validation', streamId, { protocol: stream.protocol, live: stream.is_live });
@@ -893,6 +1036,11 @@ async function handleWhipPost(req, res) {
@@ -893,6 +1036,11 @@ async function handleWhipPost(req, res) {
resourceId,
resourceId,
iceReady: false,
iceReady: false,
heartbeatTimer: null,
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);
sessions.set(resourceId, session);
await touchWhipHeartbeat(streamId, 'whip_post');
await touchWhipHeartbeat(streamId, 'whip_post');
@@ -1085,9 +1233,18 @@ async function handleWhipPost(req, res) {
@@ -1085,9 +1233,18 @@ async function handleWhipPost(req, res) {
console.log(`[WHIP] Session ${resourceId} created for stream ${streamId} (${session.producerIds.length} producer(s))`);
console.log(`[WHIP] Session ${resourceId} created for stream ${streamId} (${session.producerIds.length} producer(s))`);
logWhipStage('response_send', streamId, { resourceId, status: 201 });
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)
res.status(201)
.set('Content-Type', 'application/sdp')
.set('Content-Type', 'application/sdp')
- .set(buildWhipResponseHeaders(req, streamId, resourceId))
+ .set(buildWhipResponseHeaders(req, streamId, resourceId, agent ? '/whip/agent' : '/whip'))
.send(answerSdp);
.send(answerSdp);
} catch (err) {
} catch (err) {
@@ -1183,6 +1340,15 @@ async function cleanupSession(resourceId, { endStreamIfNoActiveSessions = true,
@@ -1183,6 +1340,15 @@ async function cleanupSession(resourceId, { endStreamIfNoActiveSessions = true,
if (endStreamIfNoActiveSessions && !hasActiveSessionsForStream(session.streamId)) {
if (endStreamIfNoActiveSessions && !hasActiveSessionsForStream(session.streamId)) {
await endActiveWhipStream(session.streamId, reason || 'session_cleanup');
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 = {
@@ -1204,20 +1370,27 @@ module.exports = {
handleWhipPost,
handleWhipPost,
handleWhipPatch,
handleWhipPatch,
handleWhipDelete,
handleWhipDelete,
+ handleAgentWhipPost,
+ handleAgentWhipPatch,
+ handleAgentWhipDelete,
handleWhipOptions,
handleWhipOptions,
whipCors,
whipCors,
applyWhipCorsHeaders,
applyWhipCorsHeaders,
WHIP_CORS_HEADERS,
WHIP_CORS_HEADERS,
buildWhipResourceUrl,
buildWhipResourceUrl,
+ buildWhipResourceUrlOn,
buildWhipResponseHeaders,
buildWhipResponseHeaders,
hasSfuProducers,
hasSfuProducers,
hasActiveSessionsForStream,
hasActiveSessionsForStream,
sessions,
sessions,
cleanupSession,
cleanupSession,
endSessionsForStream,
endSessionsForStream,
+ endActiveWhipStream,
available: !!sdpTransform,
available: !!sdpTransform,
_extractRtpParameters: extractRtpParameters,
_extractRtpParameters: extractRtpParameters,
_extractDtlsParameters: extractDtlsParameters,
_extractDtlsParameters: extractDtlsParameters,
_buildSdpAnswer: buildSdpAnswer,
_buildSdpAnswer: buildSdpAnswer,
_selectDtlsFingerprint: selectDtlsFingerprint,
_selectDtlsFingerprint: selectDtlsFingerprint,
+ _getDtlsSetupAttribute: getDtlsSetupAttribute,
+ _sendWhipError: sendWhipError,
};
};
@@ -143,6 +143,16 @@ async function channelStatus(segment) {
@@ -143,6 +143,16 @@ async function channelStatus(segment) {
} catch { return OK; }
} 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) {
async function streamStatus(id) {
if (!ID_RE.test(id)) return NOT_FOUND;
if (!ID_RE.test(id)) return NOT_FOUND;
try { return await db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }
try { return await db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }
@@ -159,7 +169,10 @@ async function statusFor(req) {
@@ -159,7 +169,10 @@ async function statusFor(req) {
const [first, second] = segs;
const [first, second] = segs;
if (segs.length === 1 && EXACT.has(first)) return OK;
if (segs.length === 1 && EXACT.has(first)) return OK;
if (PREFIX.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;
if (segs.length !== 2) return NOT_FOUND;
switch (first) {
switch (first) {
case 'vod': return await mediaItemStatus(req, 'vod', second);
case 'vod': return await mediaItemStatus(req, 'vod', second);
@@ -71,6 +71,13 @@ const ulid = () => ids.ulid();
@@ -71,6 +71,13 @@ const ulid = () => ids.ulid();
for (const t of chatTables) exempt[t] = 'Chat owns and erases it';
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.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';
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.
// 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 handled = [...cols.keys()].filter((t) => !chatTables.includes(t));
const missing = await uncovered(d, { taskKeyed: handled, exempt });
const missing = await uncovered(d, { taskKeyed: handled, exempt });
new file mode 100644
new file mode 100644
@@ -0,0 +1,307 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,358 @@
@@ -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); });
@@ -357,6 +357,7 @@ async function check(name, fn) {
@@ -357,6 +357,7 @@ async function check(name, fn) {
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.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/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/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/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)',
'server/monetization/billing-client.js': 'OpenVibe.Billing (configured)',