openvibers/openrestream Commits
OpenRestream: the product at openre.stream is OpenRestream (owner, 2026-10-09) (#26)
Changes
@@@ -1,5 +1,5 @@# ═══════════════════════════════════════════════════════════════--# OpenRe.Stream — Environment Configuration++# OpenRestream — Environment Configuration# Copy to .env for development. In production this is /etc/openvibe/openre.env (0600), read by# every unit (openre-api, openre-session-coordinator, openre-rtmp-ingest@, openre-restream-worker@);# the units set NODE_ENV, PORT and OPENRE_RELEASE themselves.@@@ -22,16 +22,16 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000# Pin the Network public key instead of fetching /api/.well-known/jwks (PEM, \n escapes allowed).#OV_NETWORK_PUBLIC_KEY=# OAuth client `openre`: browser sign-in for the UI (redirect https://openre.stream/auth/callback)--# and OpenRe's own client-credentials tokens (Events, Media).++# and OpenRestream's own client-credentials tokens (Events, Media).OV_OAUTH_CLIENT_ID=openreOV_OAUTH_CLIENT_SECRET=#OV_OAUTH_REDIRECT_URI=https://openre.stream/auth/callback# A browser's Network JWT is accepted when its aud contains one of these.#OPENRE_USER_AUDIENCES=openvibe.openre,openvibe.network--# ── Store (PostgreSQL, shared by every OpenRe process) ────────++# ── Store (PostgreSQL, shared by every OpenRestream process) ────────# PostgreSQL (ADR-035): DATABASE_URL serves through PgBouncer, DATABASE_DIRECT_URL (the owner) runs migrations/ at boot;--# OpenVibe.Host `roles/data/add-service.sh openre` writes both. Every OpenRe process shares the database. Without++# OpenVibe.Host `roles/data/add-service.sh openre` writes both. Every OpenRestream process shares the database. Without# DATABASE_URL, development runs on an embedded PGlite database in data/pglite (OPENRE_PGLITE_DIR): one process only.DATABASE_URL=DATABASE_DIRECT_URL=@@@ -6,7 +6,7 @@ on:jobs:# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q). ADR-035: PostgreSQL# 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk scripts/test-services.sh); npm test runs every-- # file on PGlite, test:pg runs them all through PgBouncer, with the tests that start several OpenRe processes.++ # file on PGlite, test:pg runs them all through PgBouncer, with the tests that start several OpenRestream processes.test:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:@@@ -1,4 +1,4 @@--# OpenRe.Stream++# OpenRestream> Ingest and restream: stream definitions, keys, sessions, transport workers, outputs and output health.@@@ -9,7 +9,7 @@## Purpose--The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live keeps the creator/channel product and observes sessions through OpenRe APIs and events; transport workers survive Live deploys **and OpenRe API deploys**. This is the permanent fix for "restarting Live drops live RTMP streams" (hazard H1).++The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live keeps the creator/channel product and observes sessions through OpenRestream APIs and events; transport workers survive Live deploys **and OpenRestream API deploys**. This is the permanent fix for "restarting Live drops live RTMP streams" (hazard H1).## Owns@@@ -33,15 +33,15 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live- OpenVibe.Events (event relay; optional: rows wait in the outbox)- OpenVibe.Media (recording requests; optional: sessions work without it)- OpenVibe.Contracts v0.76.0 (ids, problem+json, capability checks), OpenVibe.SDK v0.26.0 (`db`, PostgreSQL outbox, token client, per-actor limits, the `service` kit), OpenVibe.Shared v2.5.0 (Frame, readiness), pinned by release tarball--- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): the store every OpenRe process shares, and the API's per-actor limit counters (optional)++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): the store every OpenRestream process shares, and the API's per-actor limit counters (optional)- OpenVibe.Live (`live.lineage.resolve`: which channel a Live-linked stream belongs to)- node-media-server 2.7.4 (the RTMP session code Live runs) and the system ffmpeg## What is ported, per protocol--| Transport | Live source | In OpenRe | State |++| Transport | Live source | In OpenRestream | State ||---|---|---|---|--| RTMP ingest | `server/streaming/rtmp-server.js` (node-media-server) | `workers/rtmp-ingest.js` — same NMS session code; OpenRe's own keys; publish renamed to `/live/<session id>` so no key reaches a play URL; loopback RTMP play + HTTP-FLV per generation; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |++| RTMP ingest | `server/streaming/rtmp-server.js` (node-media-server) | `workers/rtmp-ingest.js` — same NMS session code; OpenRestream's own keys; publish renamed to `/live/<session id>` so no key reaches a play URL; loopback RTMP play + HTTP-FLV per generation; SO_REUSEPORT drain | **ported, tested with real ffmpeg** || Restream (RTMP source) | `server/streaming/restream-manager.js` | `workers/restream/*` — same ffmpeg arguments (codec copy from HTTP-FLV), Twitch→RTMPS, Kick `/app`, SRT options, live ACK by `-progress`, backoff, rapid-crash circuit breaker, destination cooldown | **ported, tested with real ffmpeg** || Restream from a JSMPEG source | same | `jsmpegArgs()` — re-encodes the worker's MPEG-TS data tap (`http://127.0.0.1:<tapPort>/tap/<session>.ts`) | **ported, tested with real ffmpeg** || Restream from a WebRTC source | same | `webrtcArgs()`/`rtpInputArgs()` — re-encodes a mediasoup PlainRTP consumer the owning worker sends to ffmpeg's ports (SDP written by the worker's egress API) | **ported, tested with real ffmpeg + mediasoup** |@@@ -49,7 +49,7 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live| WebRTC (WHIP ingest + SFU + viewer signaling) | `whip-handler.js` + `webrtc-sfu.js` + `broadcast-server.js` (mediasoup; one worker owns all of it) | `workers/webrtc.js` + `workers/webrtc/{sfu,sdp,signaling}.js` — WHIP (RFC 9725) admission by key, one mediasoup Router per session, `/b/<key>` broadcaster + `/w/<id>` viewer signaling, loopback RTP/SDP egress for restream/recording/thumbnails; SO_REUSEPORT drain | **ported, tested with real mediasoup** || Live thumbnails | `media-proxy/live-thumbs.js` | `workers/thumbnails.js` — the owning worker grabs a frame from its own source (FLV, MPEG-TS tap or a PlainRTP consumer) and uploads it to Media (object ns `live`); the session carries `thumbnail_url` | **ported, tested against a stub Media** || JSMPEG | `jsmpeg-relay.js` | `workers/jsmpeg.js` — MPEG-TS POST admission by key in path, WebSocket viewers by playback id, loopback MPEG-TS data tap for restream; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |--| OAuth-linked destinations (per go-live Twitch/YouTube key refresh, YouTube broadcast creation) | `restream-manager._refreshDestFromConnection` | no (Live owns platform OAuth); OpenRe pushes to the stored key | **not ported** |++| OAuth-linked destinations (per go-live Twitch/YouTube key refresh, YouTube broadcast creation) | `restream-manager._refreshDestFromConnection` | no (Live owns platform OAuth); OpenRestream pushes to the stored key | **not ported** || Viewer counts from platforms, chat relay, PowerChat | restream-manager / integrations | no (product features, stay in Live) | not in scope |## Processes@@@ -66,7 +66,7 @@ openre-api (4500) openre-session-coordinator openre-rtmp-inges- **Generations.** Each worker process registers as generation N+1 of its kind. When a newer generation is `ready`, the coordinator marks older ones `draining` (deadline `OPENRE_DRAIN_MAX_MS`, default 24 h). A draining RTMP worker closes its public listener (the kernel sends new encoder connections to the new generation through the shared port) and keeps its publishers; a draining restream worker keeps its outputs. Idle → the process exits 0 by itself. At the deadline, remaining sessions are ended (encoders reconnect to the newest generation) and remaining outputs are handed over.- **Leases.** Every heartbeat (2 s) renews the worker and the lease of every session it owns in one transaction. A worker silent for `OPENRE_WORKER_LEASE_MS` (15 s) is `lost`: its sessions fail (`openre.session.failed`), its outputs go back to `pending` for another worker. A lost worker that wakes up drops its transports and exits.- **Isolation.** An output only ever changes its own row. A dead destination fails (`openre.output.failed`, destination cooldown 15 min → 24 h) and the session stays live (`test/rtmp-e2e.test.js`, `test/sessions.test.js`, `test/coordinator.test.js`).--- **Store.** PostgreSQL 18 through PgBouncer (`ov_openre` on the host's data role, ADR-035; schema in [migrations/](migrations/)), shared by every OpenRe process. Every transaction is SERIALIZABLE (`server/store/index.js`): an ingest worker admitting a publish and the coordinator failing a lost worker's session conflict, and PostgreSQL makes one of them retry. Admission is async, so the RTMP worker admits a publish before node-media-server's own publish handling runs. Valkey holds the API's per-actor limit counters.++- **Store.** PostgreSQL 18 through PgBouncer (`ov_openre` on the host's data role, ADR-035; schema in [migrations/](migrations/)), shared by every OpenRestream process. Every transaction is SERIALIZABLE (`server/store/index.js`): an ingest worker admitting a publish and the coordinator failing a lost worker's session conflict, and PostgreSQL makes one of them retry. Admission is async, so the RTMP worker admits a publish before node-media-server's own publish handling runs. Valkey holds the API's per-actor limit counters.## Running it@@@ -96,7 +96,7 @@ Called elsewhere, as the service principal `openre` (client credentials from Net| OpenVibe.Live | `live.lineage.resolve` | the channel a Live-linked stream definition belongs to || OpenVibe.Media | the `live` tenant key today (`MEDIA_API_KEY`); `media.object.upload` once `OPENRE_MEDIA_AUTH=service` | recording requests |--The full grant list, including what Live needs to call OpenRe, is under "Grants the lead adds in++The full grant list, including what Live needs to call OpenRestream, is under "Grants the lead adds inNetwork" below.## Auth@@@ -126,7 +126,7 @@ The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposa## Events--Written to `event_outbox` in the same transaction as the change (openvibe-sdk `createPgOutbox`); the coordinator's relay publishes them with OpenRe's service token. Envelopes validate against `events.event-envelope@1`; `visibility: internal`; no payload ever carries an ingest key or a destination key.++Written to `event_outbox` in the same transaction as the change (openvibe-sdk `createPgOutbox`); the coordinator's relay publishes them with OpenRestream's service token. Envelopes validate against `events.event-envelope@1`; `visibility: internal`; no payload ever carries an ingest key or a destination key.| Type | Subject | When ||---|---|---|@@@ -140,7 +140,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c## Playback--`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. A **webrtc** session's descriptor is `webrtc: { signaling_url, announced_ip }` (`signaling_url` is `ws(s)://<host>:<port>/w/<session id>`, the viewer signaling endpoint keyed by playback id, never the key; Live's watch page runs the mediasoup-client `sfu-viewer-*` flow against it). Every descriptor also carries `thumbnail_url` (the Media object the worker's latest grabbed frame was uploaded to, or `null`) and the session API reports `viewers` (connected consumers / relay sockets, from the worker heartbeat); `hls` is `null` (not produced). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRe session proxies `flv.internal_url` (see the Live patch). **AI taps** (decision 6: they stay in Live and read OpenRe's sources, never a Live-local ingest): an **rtmp** session's `flv.internal_url` (loopback HTTP-FLV) and a **webrtc** session's RTP descriptor on the owning worker's loopback egress API — `GET /rtp/<session id>/describe` (the codec facts, e.g. for Media's RTP ingest) and `GET /rtp/<session id>?vport=<p>&aport=<p>` (the SDP a pulling ffmpeg reads, a PlainRTP consumer per producer). Both are loopback-only, reachable by services on the worker's host.++`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. A **webrtc** session's descriptor is `webrtc: { signaling_url, announced_ip }` (`signaling_url` is `ws(s)://<host>:<port>/w/<session id>`, the viewer signaling endpoint keyed by playback id, never the key; Live's watch page runs the mediasoup-client `sfu-viewer-*` flow against it). Every descriptor also carries `thumbnail_url` (the Media object the worker's latest grabbed frame was uploaded to, or `null`) and the session API reports `viewers` (connected consumers / relay sockets, from the worker heartbeat); `hls` is `null` (not produced). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRestream session proxies `flv.internal_url` (see the Live patch). **AI taps** (decision 6: they stay in Live and read OpenRestream's sources, never a Live-local ingest): an **rtmp** session's `flv.internal_url` (loopback HTTP-FLV) and a **webrtc** session's RTP descriptor on the owning worker's loopback egress API — `GET /rtp/<session id>/describe` (the codec facts, e.g. for Media's RTP ingest) and `GET /rtp/<session id>?vport=<p>&aport=<p>` (the SDP a pulling ffmpeg reads, a PlainRTP consumer per producer). Both are loopback-only, reachable by services on the worker's host.## Recording@@@ -160,15 +160,15 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).| Port | Owner | Until ||---|---|---|--| 1935/tcp | Live's in-process RTMP (node-media-server) | Live's RTMP ingest is retired (after the last slot has run on OpenRe for two weeks, ADR-009) |++| 1935/tcp | Live's in-process RTMP (node-media-server) | Live's RTMP ingest is retired (after the last slot has run on OpenRestream for two weeks, ADR-009) || 9935/tcp (loopback) | Live's HTTP-FLV | same |--| **1936/tcp** | OpenRe `openre-rtmp-ingest` (all generations, SO_REUSEPORT) | permanent: URLs handed out as `rtmp://ingest.openre.stream:1936/live` keep working |--| 19360–19399/tcp (loopback) | OpenRe per-generation RTMP play + HTTP-FLV | permanent |--| **9736/tcp** | OpenRe `openre-jsmpeg` (all generations, SO_REUSEPORT): MPEG-TS POST + WS viewers | permanent: `http://ingest.openre.stream:9736/<key>/<w>/<h>/` and `ws://…/<session>` |--| 19710–19749/tcp (loopback) | OpenRe per-generation JSMPEG MPEG-TS data tap | permanent |--| **9936/tcp** | OpenRe `openre-webrtc` (all generations, SO_REUSEPORT): WHIP + `/b/<key>` and `/w/<id>` signaling (Live's WHIP is on its app port 3000) | permanent: `http(s)://ingest.openre.stream/whip/<key>` and `ws(s)://ingest.openre.stream/<b\|w>/…` |--| **10200–10300/udp** | OpenRe `openre-webrtc` mediasoup RTC (never Live's 10000–10100) | permanent: open at the provider edge, `MEDIASOUP_ANNOUNCED_IP` must be reachable |--| 19810–19849/tcp (loopback) | OpenRe per-generation WebRTC RTP/SDP egress API (restream, Media's RTP recorder, thumbnails) | permanent |++| **1936/tcp** | OpenRestream `openre-rtmp-ingest` (all generations, SO_REUSEPORT) | permanent: URLs handed out as `rtmp://ingest.openre.stream:1936/live` keep working |++| 19360–19399/tcp (loopback) | OpenRestream per-generation RTMP play + HTTP-FLV | permanent |++| **9736/tcp** | OpenRestream `openre-jsmpeg` (all generations, SO_REUSEPORT): MPEG-TS POST + WS viewers | permanent: `http://ingest.openre.stream:9736/<key>/<w>/<h>/` and `ws://…/<session>` |++| 19710–19749/tcp (loopback) | OpenRestream per-generation JSMPEG MPEG-TS data tap | permanent |++| **9936/tcp** | OpenRestream `openre-webrtc` (all generations, SO_REUSEPORT): WHIP + `/b/<key>` and `/w/<id>` signaling (Live's WHIP is on its app port 3000) | permanent: `http(s)://ingest.openre.stream/whip/<key>` and `ws(s)://ingest.openre.stream/<b\|w>/…` |++| **10200–10300/udp** | OpenRestream `openre-webrtc` mediasoup RTC (never Live's 10000–10100) | permanent: open at the provider edge, `MEDIASOUP_ANNOUNCED_IP` must be reachable |++| 19810–19849/tcp (loopback) | OpenRestream per-generation WebRTC RTP/SDP egress API (restream, Media's RTP recorder, thumbnails) | permanent || 4500/tcp (loopback) | `openre-api` | permanent |The switch to 1935: once Live no longer listens on 1935, set `OPENRE_RTMP_EXTRA_PORTS=1935` and deploy a worker generation (`deploy.sh workers`); from then on `rtmp://ingest.openre.stream/live` works too, and 1936 keeps working. `ingest.openre.stream` is a DNS-only record (RTMP cannot go through Cloudflare's proxy); open 1936/tcp at the host firewall and the provider edge.@@@ -187,7 +187,7 @@ sudo deploy/scripts/deploy.sh workers # deploy-legacy.sh: starts asudo deploy/scripts/deploy.sh status # deploy-legacy.sh```--- ovhost refuses an API restart while an ingest session is open (`--wait-idle` holds it, `--force` goes ahead), records every attempt (`ovhost releases openre`), prunes releases beyond five but never one a worker generation runs from, and announces the release. When ovhost is missing, too old or does not deploy OpenRe with `release-layout`, the wrapper runs [deploy/scripts/deploy-legacy.sh](deploy/scripts/deploy-legacy.sh), the previous script, unchanged (`OVHOST_LEGACY=1` forces it). `workers`, `status` and `prune` always run it.++- ovhost refuses an API restart while an ingest session is open (`--wait-idle` holds it, `--force` goes ahead), records every attempt (`ovhost releases openre`), prunes releases beyond five but never one a worker generation runs from, and announces the release. When ovhost is missing, too old or does not deploy OpenRestream with `release-layout`, the wrapper runs [deploy/scripts/deploy-legacy.sh](deploy/scripts/deploy-legacy.sh), the previous script, unchanged (`OVHOST_LEGACY=1` forces it). `workers`, `status` and `prune` always run it.- An **API deploy** never restarts a worker unit (no unit depends on another). Viewers of `openre.stream/play/…` reconnect; encoders, restreams and recordings do not notice.- A **worker deploy** starts `openre-rtmp-ingest@<sha>`, `openre-restream-worker@<sha>` and `openre-jsmpeg@<sha>`; older instances are disabled (not stopped) and exit on their own when drained. Never `systemctl restart` a worker instance during a broadcast; `systemctl stop` starts a drain and waits up to 30 min (`TimeoutStopSec`), then kills.@@@ -203,17 +203,17 @@ sudo deploy/scripts/deploy.sh status # deploy-legacy.sh[docs/live-patch.diff](docs/live-patch.diff) is the Live side, made against OpenVibe.Live `f11f809` and verified there (applies with `git apply`; Live's `npm test` 63/63 on Node 22.22.1). It is applied in Live (`f0ca18b`) and deployed in production, switched off: `OPENRE_URL` is not set in Live's env. **Switch off (the default) changes nothing**: with `OPENRE_URL` unset, or a slot on `'live'`, every changed code path returns what it returned before (`test/openre-switch.test.js` in the patch). The only observable difference is additive: owner responses built from `managed_streams` rows carry the two new columns (`ingest_authority: 'live'`, `openre_stream_id: null`). It adds:--- `server/openre/openre-client.js` — service-token client for OpenRe (streams by `external_ref`, create, rotate, sessions, playback descriptors).++- `server/openre/openre-client.js` — service-token client for OpenRestream (streams by `external_ref`, create, rotate, sessions, playback descriptors).- `managed_streams.ingest_authority` (`'live'` default | `'openre'`) + `openre_stream_id`, and an `openre_sessions` projection table (additive, in `initDb`).--- Live's RTMP `prePublish` refuses a slot key when the slot is on `'openre'`, and a personal key (`users.stream_key`) when any of the user's slots is (a stream can never be ingested twice, and Live never pushes the same destinations as OpenRe). Only RTMP moves; WHIP/browser/JSMPEG for that slot stay on Live.--- Go Live / stream-key UI for `'openre'` slots: `GET /api/streams/managed` returns the slot without its Live key; `GET /api/streams/managed/:id/profile` and `GET /api/streams/:id/endpoint` show OpenRe's RTMP URL and a key hint (never a key); `POST /api/streams/managed/:id/regenerate-key` rotates on OpenRe and shows the new key once; `GET /api/streams/:id/rtmp-status` reads the mirror. Small fallbacks in `broadcast.js` / `broadcast-workspace.js` display the hint.--- `POST /internal/openre-events` — OpenVibe.Events delivery endpoint (SDK `parseDelivery` + `createInbox`, consumer `live-openre-mirror`): `openre.session.started` creates or attaches the slot's `streams` row (control config, go-live notifications, Live's own `live.stream.started`), `ended`/`failed` end it and stop the RobotStreamer bridge, chat relays and AI bots as Live's own unpublish does; revision-ordered, exactly once; only for switched slots whose definition has `mirror_to_live` (consent). A 30 s reconcile asks OpenRe about mirrored live sessions (heartbeat refresh; ends rows OpenRe ended). Live's stale-stream cleanup skips mirrored sessions OpenRe confirmed in the last 30 min, and Live's boot-time restream resume skips them.--- Playback: `GET /api/streams/rtmp-proxy/:id.flv` proxies the session's `flv.internal_url` from OpenRe's playback descriptor (validated as a loopback `…/live/ses_….flv` URL), so Live's player is unchanged.++- Live's RTMP `prePublish` refuses a slot key when the slot is on `'openre'`, and a personal key (`users.stream_key`) when any of the user's slots is (a stream can never be ingested twice, and Live never pushes the same destinations as OpenRestream). Only RTMP moves; WHIP/browser/JSMPEG for that slot stay on Live.++- Go Live / stream-key UI for `'openre'` slots: `GET /api/streams/managed` returns the slot without its Live key; `GET /api/streams/managed/:id/profile` and `GET /api/streams/:id/endpoint` show OpenRestream's RTMP URL and a key hint (never a key); `POST /api/streams/managed/:id/regenerate-key` rotates on OpenRestream and shows the new key once; `GET /api/streams/:id/rtmp-status` reads the mirror. Small fallbacks in `broadcast.js` / `broadcast-workspace.js` display the hint.++- `POST /internal/openre-events` — OpenVibe.Events delivery endpoint (SDK `parseDelivery` + `createInbox`, consumer `live-openre-mirror`): `openre.session.started` creates or attaches the slot's `streams` row (control config, go-live notifications, Live's own `live.stream.started`), `ended`/`failed` end it and stop the RobotStreamer bridge, chat relays and AI bots as Live's own unpublish does; revision-ordered, exactly once; only for switched slots whose definition has `mirror_to_live` (consent). A 30 s reconcile asks OpenRestream about mirrored live sessions (heartbeat refresh; ends rows OpenRestream ended). Live's stale-stream cleanup skips mirrored sessions OpenRestream confirmed in the last 30 min, and Live's boot-time restream resume skips them.++- Playback: `GET /api/streams/rtmp-proxy/:id.flv` proxies the session's `flv.internal_url` from OpenRestream's playback descriptor (validated as a loopback `…/live/ses_….flv` URL), so Live's player is unchanged.- Restream routes refuse (409 + `manage_url`) edits/start/stop for destinations of `'openre'` slots: those restreams run and are managed on openre.stream.--- Admin: `GET /api/admin/openre/status`, `PUT /api/admin/openre/managed/:id/ingest-authority {authority, force}` — `'openre'` needs the slot offline, an RTMP slot (or `force`), the owner's canonical subject; it finds or creates the OpenRe definition and, in one transaction, flips the slot and rotates Live's own key for it. `'live'` flips back.++- Admin: `GET /api/admin/openre/status`, `PUT /api/admin/openre/managed/:id/ingest-authority {authority, force}` — `'openre'` needs the slot offline, an RTMP slot (or `force`), the owner's canonical subject; it finds or creates the OpenRestream definition and, in one transaction, flips the slot and rotates Live's own key for it. `'live'` flips back.- Env: `OPENRE_URL`, `OPENRE_PUBLIC_URL`, `OPENRE_EVENTS_SECRET` (in Live's `.env.example`). Unsetting `OPENRE_URL` is the emergency rollback for every switched slot at once.--Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRe sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` for an rtmp session and the worker's loopback RTP descriptor — `GET /rtp/<session id>/describe` and `?vport=&aport=` — for a webrtc one); Stream Manager destination editing through OpenRe's API (today: a link to openre.stream); platform viewer counts for OpenRe outputs.++Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRestream sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` for an rtmp session and the worker's loopback RTP descriptor — `GET /rtp/<session id>/describe` and `?vport=&aport=` — for a webrtc one); Stream Manager destination editing through OpenRestream's API (today: a link to openre.stream); platform viewer counts for OpenRestream outputs.## Migration@@@ -221,7 +221,7 @@ Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the Rob## Cutover runbook--ADR-009: one protocol at a time (RTMP → WHIP → JSMPEG → SFU), per slot, behind the switch, in a maintenance window agreed with the broadcaster; every key that existed before is rotated at the RTMP cutover; Live's ingest code stays until the last protocol has run on OpenRe for two weeks.++ADR-009: one protocol at a time (RTMP → WHIP → JSMPEG → SFU), per slot, behind the switch, in a maintenance window agreed with the broadcaster; every key that existed before is rotated at the RTMP cutover; Live's ingest code stays until the last protocol has run on OpenRestream for two weeks.### RTMP (ready)@@@ -241,7 +241,7 @@ the checks and the rollback for every step. In short:`scripts/cutover-preflight.js` checks, read-only, what each step needs: the release, the service,the env, the bind, DNS, the port from outside, the database, Live's env, the Events subscription and--one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is done. The release with++one slot. Status on 2026-09-23: OpenRestream is deployed (`655b98a10aaa`) and DNS is done. The release with`6dc78a5`, the public bind, the provider port, Live's settings and the subscription are not.### JSMPEG (worker ready)@@@ -256,13 +256,13 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d| Criterion (ADR-009 / plan W7) | Evidence ||---|---|--| OpenRe ingests and restreams without visiting Live | `test/rtmp-e2e.test.js`: real ffmpeg publish → OpenRe worker → ffmpeg restream to an RTMP sink; no Live process involved |--| Deploying the OpenRe API does not end worker-owned transports | `test/api-restart.test.js` (API process SIGTERM + restart while a worker process holds a live session; lease keeps renewing) and `test/rtmp-e2e.test.js` (same with a real encoder, restream and FLV playback afterwards) |++| OpenRestream ingests and restreams without visiting Live | `test/rtmp-e2e.test.js`: real ffmpeg publish → OpenRestream worker → ffmpeg restream to an RTMP sink; no Live process involved |++| Deploying the OpenRestream API does not end worker-owned transports | `test/api-restart.test.js` (API process SIGTERM + restart while a worker process holds a live session; lease keeps renewing) and `test/rtmp-e2e.test.js` (same with a real encoder, restream and FLV playback afterwards) || New sessions route to the newest ready generation; old workers drain | `test/rtmp-e2e.test.js` (second RTMP worker process: new publish on generation 2, generation 1 keeps its session, closes its listener and exits 0 when it ends), `test/coordinator.test.js` || A destination failure never terminates the source session | `test/rtmp-e2e.test.js` (dead destination fails, session and encoder stay), `test/sessions.test.js`, `test/coordinator.test.js` (lost restream worker) || Recording finalisation stays in Media | `test/recording-events.test.js`, `test/rtmp-e2e.test.js` (Media is asked to create/ingest/finalise; Media's ffmpeg pulls the loopback URL) || Every stream key that existed before is rotated | `test/migration.test.js` (old keys never imported; new keys unseen), Live patch rotates Live's own slot key at the switch; checklist step for personal keys |--| Deploying Live during a broadcast does not interrupt transport or recording | by construction (no OpenRe process talks to Live); proven for real only once a slot runs on OpenRe — not claimed here (every Live restart still stops Live's own restreams) |++| Deploying Live during a broadcast does not interrupt transport or recording | by construction (no OpenRestream process talks to Live); proven for real only once a slot runs on OpenRestream — not claimed here (every Live restart still stops Live's own restreams) || Switch off = no Live behaviour change | Live's full `npm test` with the patch applied, plus `test/openre-switch.test.js` |## Launch rule@@@ -1,5 +1,5 @@{-- "repository": "OpenVibers/OpenRe.Stream",++ "repository": "OpenVibers/OpenRestream","stage": "alpha","code": true,"wave": "W7",@@@ -27,7 +27,7 @@"openre.output.write"],"capabilitiesRegistered": true,-- "livePatch": "applied in Live f0ca18b and deployed; Live wired on 2026-09-23 (OPENRE_URL and the openre.session.* subscription, docs/cutover.md phase A); every Live slot keeps ingest_authority=live until its per-slot cutover (phase B), so no broadcaster uses OpenRe yet",++ "livePatch": "applied in Live f0ca18b and deployed; Live wired on 2026-09-23 (OPENRE_URL and the openre.session.* subscription, docs/cutover.md phase A); every Live slot keeps ingest_authority=live until its per-slot cutover (phase B), so no broadcaster uses OpenRestream yet","deployed": true,"deployment": "Deployed with `sudo ovhost deploy openre` (OpenVibe.Host strategy release-layout since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper and keeps workers/status/prune): API + coordinator on 127.0.0.1:4500, RTMP ingest public on 0.0.0.0:1936 (ingest.openre.stream, DNS-only; reachable from outside since 2026-09-23), Live wired (OPENRE_URL, openre.session.* subscription), preflight all PASS, restore drill passed. No slot switched yet: every slot is still ingested by Live until the per-slot cutover (docs/cutover.md phase B).","features": [@@@ -1,6 +1,6 @@#!/usr/bin/env bash# ═══════════════════════════════════════════════════════════════--# OpenRe.Stream deploy (run on the host as root; the lead runs it, never CI).++# OpenRestream deploy (run on the host as root; the lead runs it, never CI).## deploy/scripts/deploy.sh release [<git-ref>] check out <ref> (default origin/main) into# /opt/openre.stream/releases/<sha>, npm ci@@@ -20,7 +20,7 @@set -euo pipefailROOT=/opt/openre.stream--REPO=https://github.com/OpenVibers/OpenRe.Stream.git++REPO=https://github.com/OpenVibers/OpenRestream.gitAPI=http://127.0.0.1:4500NODE_BIN=${NODE_BIN:-node}@@@ -1,6 +1,6 @@#!/usr/bin/env bash# ═══════════════════════════════════════════════════════════════--# OpenRe.Stream deploy: a thin wrapper around `ovhost deploy openre` (OpenVibe.Host, strategy++# OpenRestream deploy: a thin wrapper around `ovhost deploy openre` (OpenVibe.Host, strategy# release-layout; roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md). Run on the host as root.## deploy/scripts/deploy.sh [deploy [<ref>]] ovhost deploy openre [--to <ref>]: release + api in one@@@ -1,5 +1,5 @@[Unit]--Description=OpenRe.Stream API and UI (openre-api): definitions, keys, destinations, sessions, playback proxy++Description=OpenRestream API and UI (openre-api): definitions, keys, destinations, sessions, playback proxy# The API owns no transport. Restarting it (every API deploy) never touches the transport# workers: they are separate units with no dependency on this one, in either direction.After=network.target postgresql.service pgbouncer.service valkey-server.service@@@ -1,5 +1,5 @@[Unit]--Description=OpenRe.Stream JSMPEG ingest+relay worker, release %i++Description=OpenRestream JSMPEG ingest+relay worker, release %i# One instance per release (instance name = the release directory). A worker deploy STARTS a new# instance; the coordinator then drains the older generation, which closes its public listener,# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.@@@ -1,5 +1,5 @@[Unit]--Description=OpenRe.Stream restream worker (ffmpeg per destination), release %i++Description=OpenRestream restream worker (ffmpeg per destination), release %i# One instance per release (instance name = the release directory). A worker deploy STARTS a new# instance; the coordinator gives it every new output and drains the older generation, whose# outputs run until their sessions end (or OPENRE_DRAIN_MAX_MS: then they move over) before it exits 0.@@@ -1,5 +1,5 @@[Unit]--Description=OpenRe.Stream RTMP ingest worker, release %i++Description=OpenRestream RTMP ingest worker, release %i# One instance per release (instance name = the release directory). A worker deploy STARTS a new# instance; the coordinator then drains the older generation, which closes its public listener,# keeps its publishers until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.@@@ -1,5 +1,5 @@[Unit]--Description=OpenRe.Stream session coordinator: worker leases and generations, output assignment, recording requests, event relay++Description=OpenRestream session coordinator: worker leases and generations, output assignment, recording requests, event relay# Stateless between ticks (everything is in the database): restarting it never ends a session.After=network.target postgresql.service pgbouncer.service valkey-server.serviceWants=postgresql.service pgbouncer.service valkey-server.service@@@ -1,5 +1,5 @@[Unit]--Description=OpenRe.Stream WebRTC worker (WHIP ingest + mediasoup SFU + viewer signaling), release %i++Description=OpenRestream WebRTC worker (WHIP ingest + mediasoup SFU + viewer signaling), release %i# One instance per release (instance name = the release directory). A worker deploy STARTS a new# instance; the coordinator then drains the older generation, which closes its public listener,# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.@@@ -1,13 +1,13 @@--# RTMP cutover runbook (OpenRe.Stream)++# RTMP cutover runbook (OpenRestream)--The order of work that moves RTMP ingest for Live's stream slots from Live to OpenRe.Stream, one slot++The order of work that moves RTMP ingest for Live's stream slots from Live to OpenRestream, one slotat a time (ADR-009). Every step says who does it, how to check it, and how to undo it.- **OWNER** steps need the owner: the provider firewall, a Live admin session in the browser, thebroadcasters, and a real restream destination.- **AGENT** steps are commands on the host (`ssh openvibe-ovh`, then `sudo`).- Every command, path and env name below was checked against the code at the commit that added this-- file: OpenRe `deploy/scripts/deploy.sh`, `server/config.js`, `scripts/*`; Live `server/openre/*`,++ file: OpenRestream `deploy/scripts/deploy.sh`, `server/config.js`, `scripts/*`; Live `server/openre/*`,`deploy/scripts/deploy.sh`; Events `server/api/subscriptions.js`. The production facts (paths,grants, subscriptions, listeners) were read on the host on 2026-09-23, without changing anything.@@@ -47,7 +47,7 @@ Everything below runs on the host. The scripts ship with the release (`scripts/`| `cutover-preflight.js` | read-only checks: `release service env bind dns port db live-env events [slot]` (`--only`, `--skip`, `--slot <id>`, `--json`, `--strict`, `--expect-release <sha>`, `--probe-url`, `--host-ip`) | nothing || `subscribe-live-events.js` | Live's Events subscription `openre.session.*` → Live, **as Live** (reads `/etc/openvibe/live.env`); `--dry-run`, `--disable`, `--enable` | one subscription in Events || `migrate-from-live.js` | imports slots and destinations from a Live **snapshot** (dry run unless `--apply`) | the PostgreSQL store (`ov_openre`) |--| `set-definition-state.js` | disables or re-enables one slot's OpenRe definition, **as Live** (per-slot rollback) | the PostgreSQL store (`ov_openre`) via the API |++| `set-definition-state.js` | disables or re-enables one slot's OpenRestream definition, **as Live** (per-slot rollback) | the PostgreSQL store (`ov_openre`) via the API |Shell helpers for the session (paste once per SSH session):@@@ -55,7 +55,7 @@ Shell helpers for the session (paste once per SSH session):SHA=<the 12-character release id printed by step A2># read-only preflight from that releasepf() { sudo node /opt/openre.stream/releases/$SHA/scripts/cutover-preflight.js --expect-release "$SHA" "$@"; }--# run a command in the current release with OpenRe's production env, AS THE SERVICE USER (ubuntu).++# run a command in the current release with OpenRestream's production env, AS THE SERVICE USER (ubuntu).openre_as_service() { sudo bash -c "set -a; . /etc/openvibe/openre.env; set +a; export NODE_ENV=production OPENRE_ENV_FILE=/nonexistent; cd /opt/openre.stream/current && sudo -E -u ubuntu $1"; }DEPLOY=/opt/openre.stream/releases/$SHA/deploy/scripts/deploy.sh```@@@ -198,7 +198,7 @@ Then repeat the off-host port check from A4.### A7. Rehearsal with a test slot and a real restream destination (OWNER + AGENT)--Use a **dedicated test account**, not the owner's own. Once any of a user's slots is on OpenRe, Live++Use a **dedicated test account**, not the owner's own. Once any of a user's slots is on OpenRestream, Liverefuses that user's personal RTMP key (`refusesLiveIngest`).1. **OWNER:** sign in to Live once with the test account, so it gets a canonical subject. Create an@@@ -211,7 +211,7 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).agent the slot id.2. **AGENT:** snapshot, then migrate that slot (commands in B2), then `pf --only slot --slot <id>`.3. **OWNER:** switch it with `await api('/admin/openre/managed/<id>/ingest-authority', { method: 'PUT', body: { authority: 'openre' } })`.-- On the Go Live page, press **Regenerate stream key**. The OpenRe key is shown once. In OBS, set++ On the Go Live page, press **Regenerate stream key**. The OpenRestream key is shown once. In OBS, setthe server to `rtmp://ingest.openre.stream:1936/live` and paste the key. Start streaming.4. **Check (AGENT and OWNER):**- The channel page on openvibe.live shows the stream, and the player works (Live proxies@@@ -225,10 +225,10 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).`sudo -u ubuntu bash -c "set -a; . /etc/openvibe/events.env; set +a; psql \"\$DATABASE_URL\" -c \"SELECT d.status, count(*) FROM deliveries d JOIN subscriptions s ON s.id = d.subscription_id WHERE s.consumer = 'live' AND s.topic_pattern = 'openre.session.*' GROUP BY 1\""`should all be `delivered`.5. **During the broadcast (AGENT):**-- - `sudo $DEPLOY api $SHA`: the stream, the restream and the recording do not drop. OpenRe++ - `sudo $DEPLOY api $SHA`: the stream, the restream and the recording do not drop. OpenRestreamplayback viewers reconnect.- `sudo /opt/openvibe.live/deploy/scripts/deploy.sh --restart` (no `--wait-idle`: the rehearsal-- itself is live on Live). First check `/api/streams` for anyone else live. The OpenRe session,++ itself is live on Live). First check `/api/streams` for anyone else live. The OpenRestream session,the restream and the recording keep running. After the restart, Live's 30 s reconcile keepsthe channel row live.- `deploy.sh workers` exercises a generation rollover only with a **newer** release (a new@@@ -254,7 +254,7 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).the same change, before `nginx -t`.- (b) Tell broadcasters that destinations are frozen at the cutover until (a).- **Hazard H4, the leaked keys.** 97 of 97 Live stream keys are identical to the pre-leak backup.-- Each slot's cutover rotates that slot's Live key (at the switch) and issues a new OpenRe key that++ Each slot's cutover rotates that slot's Live key (at the switch) and issues a new OpenRestream key thatonly the broadcaster sees. It does not rotate the broadcaster's personal key (`users.stream_key`),so step B5 does that. For slots that will not be cut over within days, rotate on Live now: thebroadcaster presses Regenerate on the Go Live page for each slot, and Regenerate on the dashboard@@@ -278,7 +278,7 @@ sudo chown ubuntu:ubuntu "$SNAP" && sudo chmod 600 "$SNAP" # it holds every Livopenre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID" # dry runopenre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID --apply" # import + checklistsudo rm -f "$SNAP"--pf --only slot --slot $ID # OpenRe <id> (active, mirrored); N destination(s), held ones counted++pf --only slot --slot $ID # OpenRestream <id> (active, mirrored); N destination(s), held ones counted```- The migration imports the slot as a definition with a **new key that nobody has seen**. Old keys@@@ -287,7 +287,7 @@ pf --only slot --slot $ID # OpenRe <id> (active, mirrored); N destination((disabled, with the reason recorded in `migration_map`). It is idempotent.- The printed checklist (`--checklist <file>` writes it to a file) is the broadcaster-facing listfor B3 to B6.--- **Undo:** nothing to undo on Live. The OpenRe definition does nothing until the switch. To be++- **Undo:** nothing to undo on Live. The OpenRestream definition does nothing until the switch. To betidy, `sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot $ID --state disabled`.### B3. Switch (OWNER, admin session)@@@ -297,15 +297,15 @@ await api('/admin/openre/managed/<ID>/ingest-authority', { method: 'PUT', body:// → { ingest_authority: 'openre', openre_stream_id, rtmp_url: 'rtmp://ingest.openre.stream:1936/live', live_key_rotated: true }```--In one transaction, Live points the slot at the OpenRe definition that B2 created and rotates its++In one transaction, Live points the slot at the OpenRestream definition that B2 created and rotates itsown copy of the slot key. From this moment Live refuses the old key. It also refuses the owner's--personal key for RTMP, because the owner now has a slot on OpenRe. A 409 means one of: the slot is++personal key for RTMP, because the owner now has a slot on OpenRestream. A 409 means one of: the slot islive on Live, it is not an RTMP slot, or the owner has no subject. Fix the cause. Do not use `force`for a non-RTMP slot.### B4. New key (broadcaster)--On the Go Live page for that slot, press **Regenerate stream key**. OpenRe rotates the key and shows++On the Go Live page for that slot, press **Regenerate stream key**. OpenRestream rotates the key and showsit once. In OBS, set Server to `rtmp://ingest.openre.stream:1936/live` and Stream Key to that key.### B5. Personal key (broadcaster; H4)@@@ -316,7 +316,7 @@ On the dashboard, press Regenerate for the personal stream key (`POST /api/auth/### B6. Test broadcast (broadcaster, AGENT, OWNER)The broadcaster goes live. Check the same things as A7 step 4. `sudo $DEPLOY status` shows the--session on the newest generation, and `await api('/admin/openre/status')` lists it. In the OpenRe DB,++session on the newest generation, and `await api('/admin/openre/status')` lists it. In the OpenRestream DB,the outputs are `live` and any held destinations are not running.### B-rollback (per slot)@@@ -324,12 +324,12 @@ the outputs are `live` and any held destinations are not running.1. The broadcaster stops streaming. The slot must be offline.2. **OWNER:** `await api('/admin/openre/managed/<ID>/ingest-authority', { method: 'PUT', body: { authority: 'live' } })`.3. **AGENT:** `sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot <ID> --state disabled`.-- OpenRe then refuses the key the broadcaster got in B4, so the slot's destinations can never be++ OpenRestream then refuses the key the broadcaster got in B4, so the slot's destinations can never bepushed twice.4. **Broadcaster:** press Regenerate on the Go Live page. The Live key was rotated at the switch.Point OBS back at the Live RTMP URL shown there.--To switch the slot to OpenRe again later, run `set-definition-state.js --slot <ID> --state active`++To switch the slot to OpenRestream again later, run `set-definition-state.js --slot <ID> --state active`first, then B3 to B6.**All slots at once (emergency):** undo A5. Remove `OPENRE_URL`, or restore `live.env.pre-openre`,@@@ -339,7 +339,7 @@ switch, so each broadcaster regenerates on the Go Live page.## JSMPEG protocol cutover (after RTMP)JSMPEG reuses the RTMP runbook above, with one **protocol precondition** (brief §4.5): the slot's--`managed_streams.protocol` must be `jsmpeg` and its OpenRe definition carries the `jsmpeg` protocol.++`managed_streams.protocol` must be `jsmpeg` and its OpenRestream definition carries the `jsmpeg` protocol.Live keeps its own `jsmpeg-relay.js` (9710/9711) until the last JSMPEG slot is cut over.### J-A. Once, before the first JSMPEG slot (AGENT)@@@ -401,9 +401,9 @@ done **once** (the worker generation and its ports), then per slot like every prgenerations drain and exit by themselves.- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant`media.object.upload` ns `live`); RTP recording uses `ingestRtpStart`/`ingestRtpStop` (Media ports-- 12000–12199, already allocated by Media). Confirm both for OpenRe's token before switching a slot.++ 12000–12199, already allocated by Media). Confirm both for OpenRestream's token before switching a slot.- **Live:** add `webrtc` to `OPENRE_PROTOCOLS` in `server/openre/authority.js` **only when the-- `openre-webrtc` generation is ready**, and point the broadcast/watch pages at the OpenRe signaling++ `openre-webrtc` generation is ready**, and point the broadcast/watch pages at the OpenRestream signalingendpoints (`sfu-*` messages are unchanged; the page reads `playback.webrtc.signaling_url`).### W-B1–W-B6 per slot (OWNER + broadcaster + AGENT)@@@ -465,7 +465,7 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCEthemselves. Check `systemctl status 'openre-webrtc@*'` and`sudo journalctl -u 'openre-webrtc@<sha>.service' -n 50` (it logs the refusal if`MEDIASOUP_ANNOUNCED_IP` is missing).--6. **Smoke (AGENT, from off the host too).** A WHIP POST with a key OpenRe never issued must be++6. **Smoke (AGENT, from off the host too).** A WHIP POST with a key OpenRestream never issued must berefused by the worker, not by nginx:```curl -sS -o /dev/null -D - -X POST -H 'content-type: application/sdp' --data-binary $'v=0\r\n' \@@@ -474,9 +474,9 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCEPASS: TLS verifies, `409` with `x-whip-error: unknown_key`. A `502`/`504` means the worker is notlistening on 9936; `x-whip-error: generation not taking sessions` means it is not ready yet; a`404` means the vhost did not load.--7. **Prerequisite for robot video.** OpenVibe.Bot must publish with keys OpenRe issued++7. **Prerequisite for robot video.** OpenVibe.Bot must publish with keys OpenRestream issued(`POST /api/v1/streams`, rotated with `POST /api/v1/streams/:id/keys/rotate`): Bot mints its own-- keys today and OpenRe refuses them exactly like the smoke key. That is Bot job R5++ keys today and OpenRestream refuses them exactly like the smoke key. That is Bot job R5(`bot-t15-r5-openvibe-bot-a`); until it ships, steps 1–6 work and a robot's camera is refused.**Rollback.** Nothing here touches RTMP, JSMPEG or the API.@@@ -500,14 +500,14 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCEand Media calls are off. Writes, `/play/` and sign-in answer 503.- Update `STATUS.json` and README "Status" after each phase.- Each slot's Live RTMP key stays in Live's database until Live's RTMP ingest is retired. That-- happens after the last slot has run on OpenRe for two weeks (ADR-009, register C-40). Port 1935-- moves to OpenRe then (`OPENRE_RTMP_EXTRA_PORTS=1935`, `deploy.sh workers`).++ happens after the last slot has run on OpenRestream for two weeks (ADR-009, register C-40). Port 1935++ moves to OpenRestream then (`OPENRE_RTMP_EXTRA_PORTS=1935`, `deploy.sh workers`).## Order of actions| # | Who | Action | Check ||---|---|---|---|--| A1 | OWNER | push OpenRe `main` (and OpenVibe.Host) | `git log origin/main` |++| A1 | OWNER | push OpenRestream `main` (and OpenVibe.Host) | `git log origin/main` || A2 | AGENT | `deploy.sh release origin/main`, `deploy.sh api <sha>` | `pf --only release,env,db`, `/api/ready` || A3 | AGENT | `OPENRE_RTMP_BIND=0.0.0.0`, `deploy.sh workers <sha>` | `pf --only service,env,bind` || A4 | **OWNER** | open 1936/tcp at the provider edge | off-host `--only port` PASS |@@@ -7,7 +7,7 @@ index 5b3d8ac..b30f33b 100644# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.#EVENTS_URL=http://127.0.0.1:4300+--+# OpenRe.Stream (roadmap Wave 7): the ingest/restream authority. Unset = off, and every stream slot+++# OpenRestream (roadmap Wave 7): the ingest/restream authority. Unset = off, and every stream slot+# is ingested by Live exactly as before. When set, an admin can switch one slot at a time+# (PUT /api/admin/openre/managed/:id/ingest-authority); only RTMP moves. Live's service principal+# (the OAuth client above) needs openre.stream.read, openre.stream.write, openre.key.rotate and@@@ -28,7 +28,7 @@ index 5ecdf41..1ebe063 100644_wsState.selectedMs = { ..._wsState.selectedMs, ...data.managed_stream };}_wsState.streamKey = data.stream_key || null;--+ // OpenRe-ingested slot: no key in the profile, only a hint (Regenerate shows a new key once).+++ // OpenRestream-ingested slot: no key in the profile, only a hint (Regenerate shows a new key once).+ _wsState.streamKeyHint = data.stream_key_hint || null;_wsState.profile = data.broadcast_settings || {};_wsState.profile.vibeCoding = _wsNormalizeVibeSettings(_wsReadStoredVibeSettings(_wsState.profile));@@@ -59,7 +59,7 @@ index f0db572..4610588 100644const ep = data.endpoint || {};const rtmpUrl = ep.rtmpUrl || `rtmp://${location.hostname}:1935/live`;- const streamKey = ep.streamKey || data.stream_key || 'N/A';--+ // An OpenRe-ingested slot never returns its key here, only a hint (Regenerate shows a new key once).+++ // An OpenRestream-ingested slot never returns its key here, only a hint (Regenerate shows a new key once).+ const streamKey = ep.streamKey || data.stream_key || ep.streamKeyHint || 'N/A';document.getElementById('bc-rtmp-url').textContent = rtmpUrl;document.getElementById('bc-rtmp-key').textContent = streamKey;@@@ -72,7 +72,7 @@ index e8f5903..60c20bf 100644database.exec('CREATE INDEX IF NOT EXISTS idx_vibe_events_stream ON vibe_coding_events(stream_id)');} catch (e) { console.warn('[DB] vibe_coding migration:', e.message); }--+ // OpenRe ingest switch (roadmap Wave 7): managed_streams.ingest_authority ('live' by default)+++ // OpenRestream ingest switch (roadmap Wave 7): managed_streams.ingest_authority ('live' by default)+ // and the openre_sessions mirror. Additive only; see server/openre/schema.js.+ try { require('../openre/schema').ensure(database); } catch (e) { console.warn('[DB] openre schema:', e.message); }+@@@ -87,7 +87,7 @@ index 63c0969..c88df08 100644function hasActiveLiveFeed(stream) {if (!stream) return false;--+ // OpenRe.Stream holds the ingest (a mirrored session it confirmed recently): not stale.+++ // OpenRestream holds the ingest (a mirrored session it confirmed recently): not stale.+ if (require('./openre/mirror').hasLiveSession(stream.id)) return true;const streamKey = getStreamKey(stream);if (stream.protocol === 'rtmp') {@@@ -96,7 +96,7 @@ index 63c0969..c88df08 100644// OpenVibe.Media → Live webhook (vod.ready / clip.ready …). Mounted BEFORE the// /internal router because it authenticates with an HMAC signature, not X-Internal-Key.app.post('/internal/media-webhook', require('./media-proxy/webhook'));--+// OpenVibe.Events → Live: OpenRe session lifecycle mirrored into `streams` (signed delivery,+++// OpenVibe.Events → Live: OpenRestream session lifecycle mirrored into `streams` (signed delivery,+// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).+app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);@@@ -114,7 +114,7 @@ index 63c0969..c88df08 100644// 5b. Resume enabled restreams for streams that survived the restart.// WHIP/RTMP broadcasters have no browser session to re-start them manually.for (const stream of db.getLiveStreams()) {--+ // OpenRe restreams its own sessions; Live must not start a second push for them.+++ // OpenRestream restreams its own sessions; Live must not start a second push for them.+ if (require('./openre/mirror').ownsStream(stream.id)) continue;restreamManager.resumeForStream(stream.id, stream.user_id, {protocol: stream.protocol,@@@ -123,8 +123,8 @@ index 63c0969..c88df08 100644// 6e. Start periodic viewer count polling for restream destinationsrestreamManager.startViewerCountPolling();--+ // 6g. OpenRe mirror reconcile (only runs when OPENRE_URL is set; idle with nothing mirrored).--+ try { require('./openre/mirror').start(); } catch (e) { console.warn('[OpenRe] mirror reconcile not started:', e.message); }+++ // 6g. OpenRestream mirror reconcile (only runs when OPENRE_URL is set; idle with nothing mirrored).+++ try { require('./openre/mirror').start(); } catch (e) { console.warn('[OpenRestream] mirror reconcile not started:', e.message); }+// 6f. VOD storage/offload is owned by OpenVibe.Media now — nothing to start here.@@@ -139,14 +139,14 @@ index 0000000..fa4b4ab+/**+ * The per-slot ingest switch: managed_streams.ingest_authority = 'live' (default) | 'openre'.+ *--+ * 'openre' means OpenRe.Stream ingests this slot's RTMP: Live's RTMP server refuses the slot's--+ * key (so a stream can never be ingested twice), the Go Live UI shows OpenRe's ingest URL and--+ * rotates OpenRe's key, and the slot's sessions reach `streams` through the mirror (mirror.js).+++ * 'openre' means OpenRestream ingests this slot's RTMP: Live's RTMP server refuses the slot's+++ * key (so a stream can never be ingested twice), the Go Live UI shows OpenRestream's ingest URL and+++ * rotates OpenRestream's key, and the slot's sessions reach `streams` through the mirror (mirror.js).+ * Only RTMP moves (OPENRE_PROTOCOLS); WHIP, browser/WebRTC and JSMPEG stay on Live until--+ * OpenRe carries them.+++ * OpenRestream carries them.+ *+ * Switch off = zero behaviour change: every helper here returns what Live did before unless the--+ * slot says 'openre' AND OpenRe is configured (openre-client enabled()).+++ * slot says 'openre' AND OpenRestream is configured (openre-client enabled()).+ */+const crypto = require('crypto');+const db = require('../db/database');@@@ -163,7 +163,7 @@ index 0000000..fa4b4ab+ return id ? db.getManagedStreamById(id) : null;+}+--+/** authorityOf(slotById(id)) === 'openre', without reading the slot when OpenRe is not configured. */+++/** authorityOf(slotById(id)) === 'openre', without reading the slot when OpenRestream is not configured. */+function slotIsOpenre(id) {+ if (!id || !client.enabled()) return false;+ return authorityOf(slotById(id)) === 'openre';@@@ -171,7 +171,7 @@ index 0000000..fa4b4ab++/**+ * Live's RTMP publish handler asks this before accepting a key. A slot key is refused when its slot--+ * is on OpenRe; a personal key (users.stream_key, no slot) is refused when any of the user's slots is,+++ * is on OpenRestream; a personal key (users.stream_key, no slot) is refused when any of the user's slots is,+ * because Live would attach it to the user's live rows and push the user's destinations twice.+ */+function refusesLiveIngest({ managedStream, user = null, protocol = 'rtmp' }) {@@@ -197,7 +197,7 @@ index 0000000..fa4b4ab+}++/**--+ * What the Go Live UI shows for an OpenRe slot: OpenRe's RTMP URL and the hint of its current key+++ * What the Go Live UI shows for an OpenRestream slot: OpenRestream's RTMP URL and the hint of its current key+ * (the key itself is only ever shown once, by a rotation).+ */+async function ingestFor(slot, subject) {@@@ -210,15 +210,15 @@ index 0000000..fa4b4ab+ rtmp_url: rtmp ? rtmp.url : null,+ stream_key_hint: rtmp && rtmp.key_hint+ ? `Shown once: press Regenerate for a new key (current key ends in …${rtmp.key_hint})`--+ : 'Press Regenerate to get your OpenRe stream key',+++ : 'Press Regenerate to get your OpenRestream stream key',+ openre_manage_url: client.manageUrl(stream ? stream.id : slot.openre_stream_id),+ };+}+--+/** Regenerate on an OpenRe slot: OpenRe rotates, the new key is returned once. */+++/** Regenerate on an OpenRestream slot: OpenRestream rotates, the new key is returned once. */+async function rotateFor(slot, subject) {+ const streamId = slot.openre_stream_id || (await client.streamForSlot(slot.id) || {}).id;--+ if (!streamId) throw new client.OpenReError('this slot has no OpenRe stream yet', 409);+++ if (!streamId) throw new client.OpenReError('this slot has no OpenRestream stream yet', 409);+ const r = await client.rotateKey(streamId, { subject });+ return { stream_key: r.key.key, stream_key_managed_by: 'openre', rtmp_url: r.ingest && r.ingest.rtmp ? r.ingest.rtmp.url : null };+}@@@ -231,8 +231,8 @@ index 0000000..fa4b4ab++/**+ * The admin switch (PUT /api/admin/openre/managed/:id/ingest-authority).--+ * 'openre': needs OpenRe configured, the slot offline on Live, the owner's canonical subject;--+ * finds or creates the OpenRe definition, then (one transaction) flips the slot and+++ * 'openre': needs OpenRestream configured, the slot offline on Live, the owner's canonical subject;+++ * finds or creates the OpenRestream definition, then (one transaction) flips the slot and+ * rotates Live's own key for it, so no key that existed before works anywhere.+ * 'live': flips back (rollback). Live's key was rotated at the switch: the broadcaster+ * regenerates it on the Go Live page.@@@ -243,15 +243,15 @@ index 0000000..fa4b4ab+ if (!['live', 'openre'].includes(authority)) return { status: 400, error: "authority must be 'live' or 'openre'" };+ if (authority === 'live') {+ db.run("UPDATE managed_streams SET ingest_authority = 'live', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [slot.id]);--+ return { status: 200, body: { managed_stream_id: slot.id, ingest_authority: 'live', next: 'The broadcaster regenerates the stream key on the Go Live page (the Live key was rotated when the slot moved to OpenRe).' } };+++ return { status: 200, body: { managed_stream_id: slot.id, ingest_authority: 'live', next: 'The broadcaster regenerates the stream key on the Go Live page (the Live key was rotated when the slot moved to OpenRestream).' } };+ }--+ if (!client.enabled()) return { status: 409, error: 'OpenRe is not configured on this Live (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)' };+++ if (!client.enabled()) return { status: 409, error: 'OpenRestream is not configured on this Live (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)' };+ if (slot.ingest_authority === 'openre') return { status: 200, body: { managed_stream_id: slot.id, ingest_authority: 'openre', openre_stream_id: slot.openre_stream_id, unchanged: true } };+ const live = db.get('SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 LIMIT 1', [slot.id]);+ if (live) return { status: 409, error: `Slot ${slot.id} is live on Live (stream ${live.id}); switch it in a maintenance window, while it is offline` };+ const method = String(slot.streaming_method || '').toLowerCase();+ if (!force && slot.protocol !== 'rtmp' && !['rtmp', 'obs'].includes(method)) {--+ return { status: 409, error: `Slot ${slot.id} is a ${slot.protocol}${method ? `/${method}` : ''} slot; OpenRe only ingests RTMP today (pass force to switch anyway)` };+++ return { status: 409, error: `Slot ${slot.id} is a ${slot.protocol}${method ? `/${method}` : ''} slot; OpenRestream only ingests RTMP today (pass force to switch anyway)` };+ }+ const subject = require('../auth/identity-sync').subjectOf(slot.user_id);+ if (!subject) return { status: 409, error: 'The owner has no canonical subject yet (they need to sign in to Live once)' };@@@ -263,7 +263,7 @@ index 0000000..fa4b4ab+ }+ const newLiveKey = crypto.randomBytes(20).toString('hex');+ const flipped = db.getDb().transaction(() => {--+ // Checked again here: a Live publish may have started while OpenRe was being asked.+++ // Checked again here: a Live publish may have started while OpenRestream was being asked.+ if (db.get('SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 LIMIT 1', [slot.id])) return false;+ db.run("UPDATE managed_streams SET ingest_authority = 'openre', openre_stream_id = ?, stream_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [stream.id, newLiveKey, slot.id]);+ return true;@@@ -277,7 +277,7 @@ index 0000000..fa4b4ab+ openre_stream_id: stream.id,+ rtmp_url: stream.ingest && stream.ingest.rtmp ? stream.ingest.rtmp.url : null,+ live_key_rotated: true,--+ next: 'The broadcaster presses Regenerate on the Go Live page (OpenRe issues the key) and pastes the new server and key into OBS.',+++ next: 'The broadcaster presses Regenerate on the Go Live page (OpenRestream issues the key) and pastes the new server and key into OBS.',+ },+ };+}@@@ -291,19 +291,19 @@ index 0000000..5a32b1c@@ -0,0 +1,216 @@+'use strict';+/**--+ * OpenRe sessions → Live's `streams` table (roadmap Wave 7, ADR-009: Live observes sessions through--+ * OpenRe events and APIs). Watch pages, discovery, go-live notifications and analytics keep working--+ * because an OpenRe-ingested session becomes an ordinary live `streams` row.+++ * OpenRestream sessions → Live's `streams` table (roadmap Wave 7, ADR-009: Live observes sessions through+++ * OpenRestream events and APIs). Watch pages, discovery, go-live notifications and analytics keep working+++ * because an OpenRestream-ingested session becomes an ordinary live `streams` row.+ *+ * POST /internal/openre-events the endpoint of Live's OpenVibe.Events subscription (topic+ * openre.session.*), signed with OPENRE_EVENTS_SECRET. Each event+ * is applied exactly once (openvibe-sdk inbox, consumer+ * 'live-openre-mirror'), in revision order per session.--+ * reconcile (every 30 s) for mirrored live sessions only: asks OpenRe for the session,--+ * keeps the row's heartbeat fresh, ends it if OpenRe says it ended+++ * reconcile (every 30 s) for mirrored live sessions only: asks OpenRestream for the session,+++ * keeps the row's heartbeat fresh, ends it if OpenRestream says it ended+ * (covers a missed event). Does nothing when no row is mirrored.+ *--+ * Consent: a session is mirrored only when its slot is switched to OpenRe on Live AND the OpenRe+++ * Consent: a session is mirrored only when its slot is switched to OpenRestream on Live AND the OpenRestream+ * definition says mirror_to_live (plan §15.10 "explicit visibility and consent").+ */+const db = require('../db/database');@@@ -312,7 +312,7 @@ index 0000000..5a32b1c++const CONSUMER = 'live-openre-mirror';+const RECONCILE_MS = 30000;--+// A mirrored session OpenRe has not confirmed for this long is left to Live's stale cleanup.+++// A mirrored session OpenRestream has not confirmed for this long is left to Live's stale cleanup.+const CONFIRM_WINDOW_MIN = 30;+let inbox = null;+let timer = null;@@@ -391,11 +391,11 @@ index 0000000..5a32b1c+ const channel = db.getChannelByUserId(user.id);+ const configId = slot.control_config_id || (channel && channel.active_control_config_id);+ if (configId) db.applyConfigToStream(configId, streamId);--+ } catch (e) { console.warn('[OpenRe] control config for mirrored stream failed:', e.message); }+++ } catch (e) { console.warn('[OpenRestream] control config for mirrored stream failed:', e.message); }+ const stream = db.getStreamById(streamId) || { id: streamId };--+ try { require('../streaming/golive-notify').notifyFollowersGoLive(user, stream); } catch (e) { console.warn('[OpenRe] go-live notify failed:', e.message); }+++ try { require('../streaming/golive-notify').notifyFollowersGoLive(user, stream); } catch (e) { console.warn('[OpenRestream] go-live notify failed:', e.message); }+ try { require('../streaming/live-events').announceGoLive(stream, user); } catch { /* */ }--+ console.log(`[OpenRe] session ${sessionId} mirrored into stream ${streamId} (${user.username}, slot ${slot.id})`);+++ console.log(`[OpenRestream] session ${sessionId} mirrored into stream ${streamId} (${user.username}, slot ${slot.id})`);+ },+ };+ }@@@ -419,7 +419,7 @@ index 0000000..5a32b1c+ try { require('../integrations/robotstreamer-service').stopForStream(streamId); } catch { /* */ }+ try { require('../integrations/chat-relay-service').stopForStream(streamId); } catch { /* */ }+ try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch { /* */ }--+ console.log(`[OpenRe] session ${sessionId} ${state}: stream ${streamId} ended`);+++ console.log(`[OpenRestream] session ${sessionId} ${state}: stream ${streamId} ended`);+ },+ };+}@@@ -441,20 +441,20 @@ index 0000000..5a32b1c+ after = r.after || null;+ return r.outcome;+ });--+ if (!result.duplicate && after) { try { after(); } catch (e) { console.warn('[OpenRe] post-commit step failed:', e.message); } }+++ if (!result.duplicate && after) { try { after(); } catch (e) { console.warn('[OpenRestream] post-commit step failed:', e.message); } }+ } catch (err) {--+ console.error('[OpenRe] event apply failed:', err.message);+++ console.error('[OpenRestream] event apply failed:', err.message);+ return res.status(500).json({ error: 'apply failed' }); // Events retries+ }+ return res.status(204).end();+}+--+/** Stream id → the mirrored OpenRe session (or null). */+++/** Stream id → the mirrored OpenRestream session (or null). */+function sessionForStream(streamId) {+ try { return db.get('SELECT * FROM openre_sessions WHERE stream_id = ? ORDER BY updated_at DESC LIMIT 1', [streamId]) || null; } catch { return null; }+}+--+/** Does OpenRe hold a live ingest for this stream (confirmed recently)? Used by Live's stale cleanup. */+++/** Does OpenRestream hold a live ingest for this stream (confirmed recently)? Used by Live's stale cleanup. */+function hasLiveSession(streamId) {+ try {+ return Boolean(db.get(`SELECT 1 FROM openre_sessions WHERE stream_id = ? AND state = 'live'@@@ -462,7 +462,7 @@ index 0000000..5a32b1c+ } catch { return false; }+}+--+/** Does OpenRe own this stream at all (so Live must not start its own restream/recording for it)? */+++/** Does OpenRestream own this stream at all (so Live must not start its own restream/recording for it)? */+function ownsStream(streamId) {+ return Boolean(sessionForStream(streamId));+}@@@ -473,14 +473,14 @@ index 0000000..5a32b1c+ if (!rows.length || !client.enabled()) return 0;+ let n = 0;+ for (const r of rows) {--+ // Live ended the row itself (End Stream, or stale cleanup while OpenRe was unreachable):+++ // Live ended the row itself (End Stream, or stale cleanup while OpenRestream was unreachable):+ // stop tracking it rather than keeping a live mirror of an offline stream.+ const row = r.stream_id ? db.getStreamById(r.stream_id) : null;+ if (row && !row.is_live) { db.run("UPDATE openre_sessions SET state = 'detached', updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]); continue; }+ let s;+ try { s = await client.getSession(r.session_id); } catch (err) {+ if (err.status === 404) s = { state: 'failed', revision: r.revision + 1, ended_at: null };--+ else continue; // OpenRe unreachable: keep the last known state (bounded by CONFIRM_WINDOW_MIN)+++ else continue; // OpenRestream unreachable: keep the last known state (bounded by CONFIRM_WINDOW_MIN)+ }+ if (s.state === 'live' || s.state === 'starting') {+ db.run("UPDATE openre_sessions SET confirmed_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]);@@@ -497,7 +497,7 @@ index 0000000..5a32b1c++function start() {+ if (timer || !client.enabled()) return;--+ timer = setInterval(() => { reconcileOnce().catch(err => console.warn('[OpenRe] reconcile failed:', err.message)); }, RECONCILE_MS);+++ timer = setInterval(() => { reconcileOnce().catch(err => console.warn('[OpenRestream] reconcile failed:', err.message)); }, RECONCILE_MS);+ if (timer.unref) timer.unref();+}+@@@ -513,8 +513,8 @@ index 0000000..6862412@@ -0,0 +1,127 @@+'use strict';+/**--+ * OpenRe.Stream client (roadmap Wave 7). Live asks OpenRe for stream definitions, ingest URLs,--+ * key rotation, sessions and playback descriptors; it never touches an OpenRe worker.+++ * OpenRestream client (roadmap Wave 7). Live asks OpenRestream for stream definitions, ingest URLs,+++ * key rotation, sessions and playback descriptors; it never touches an OpenRestream worker.+ *+ * Auth: Live's Network service principal (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET),+ * client-credentials tokens for audience openvibe.openre. Grants Live needs on openre:@@@ -522,7 +522,7 @@ index 0000000..6862412+ * Calls made for a streamer carry X-OV-Subject: <their usr_ subject>.+ *+ * Env:--+ * OPENRE_URL OpenRe API, host-local (http://127.0.0.1:4500). Unset = integration off:+++ * OPENRE_URL OpenRestream API, host-local (http://127.0.0.1:4500). Unset = integration off:+ * every slot behaves exactly as before, whatever its ingest_authority.+ * OPENRE_PUBLIC_URL for links to the standalone UI (default https://openre.stream)+ * OPENRE_EVENTS_SECRET the signing secret of Live's OpenVibe.Events subscription to openre.*@@@ -543,7 +543,7 @@ index 0000000..6862412+ };+}+--+/** True when Live is configured to talk to OpenRe at all. */+++/** True when Live is configured to talk to OpenRestream at all. */+function enabled() {+ const s = settings();+ return Boolean(s.url && s.clientSecret);@@@ -563,7 +563,7 @@ index 0000000..6862412+}++async function request(method, path, { body, subject, timeoutMs = TIMEOUT_MS } = {}) {--+ if (!enabled()) throw new OpenReError('OpenRe integration is not configured (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)', 0);+++ if (!enabled()) throw new OpenReError('OpenRestream integration is not configured (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)', 0);+ const s = settings();+ const headers = { Accept: 'application/json', Authorization: `Bearer ${await tokenClient().getToken()}` };+ if (subject) headers['X-OV-Subject'] = subject;@@@ -574,15 +574,15 @@ index 0000000..6862412+ res = await fetch(`${s.url}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });+ text = await res.text().catch(() => '');+ } catch (err) {--+ throw new OpenReError(`OpenRe unreachable (${method} ${path}): ${err.message}`, 0);+++ throw new OpenReError(`OpenRestream unreachable (${method} ${path}): ${err.message}`, 0);+ }+ let json = null;+ if (text) { try { json = JSON.parse(text); } catch { json = null; } }--+ if (!res.ok) throw new OpenReError((json && (json.detail || json.error)) || `OpenRe ${res.status} on ${method} ${path}`, res.status, json);+++ if (!res.ok) throw new OpenReError((json && (json.detail || json.error)) || `OpenRestream ${res.status} on ${method} ${path}`, res.status, json);+ return json;+}+--+/** The OpenRe stream definition serving a Live slot, or null. */+++/** The OpenRestream stream definition serving a Live slot, or null. */+async function streamForSlot(managedStreamId) {+ const r = await request('GET', `/api/v1/streams?external_ref=${encodeURIComponent(`live:managed_stream:${managedStreamId}`)}`);+ return (r && r.streams && r.streams[0]) || null;@@@ -646,8 +646,8 @@ index 0000000..836fd8c@@ -0,0 +1,49 @@+'use strict';+/**--+ * Admin routes for the OpenRe ingest switch (mounted at /api/admin/openre, admin only). The lead--+ * flips a slot during a maintenance window agreed with the broadcaster (OpenRe.Stream README,+++ * Admin routes for the OpenRestream ingest switch (mounted at /api/admin/openre, admin only). The lead+++ * flips a slot during a maintenance window agreed with the broadcaster (OpenRestream README,+ * "RTMP cutover runbook").+ *+ * GET /api/admin/openre/status configuration + switched slots@@@ -684,11 +684,11 @@ index 0000000..836fd8c+ try {+ const r = await authority.setAuthority(id, String((req.body && req.body.authority) || ''), { force: Boolean(req.body && req.body.force) });+ if (r.error) return res.status(r.status).json({ error: r.error });--+ console.log(`[OpenRe] admin ${req.user.username} set slot ${id} ingest authority to ${r.body.ingest_authority}`);+++ console.log(`[OpenRestream] admin ${req.user.username} set slot ${id} ingest authority to ${r.body.ingest_authority}`);+ return res.status(r.status).json(r.body);+ } catch (err) {--+ console.error('[OpenRe] switch failed:', err.message);--+ return res.status(err.status && err.status < 500 ? 409 : 502).json({ error: `OpenRe: ${err.message}` });+++ console.error('[OpenRestream] switch failed:', err.message);+++ return res.status(err.status && err.status < 500 ? 409 : 502).json({ error: `OpenRestream: ${err.message}` });+ }+});+@@@ -701,11 +701,11 @@ index 0000000..c0c0d85@@ -0,0 +1,36 @@+'use strict';+/**--+ * OpenRe ingest switch — Live-side schema (roadmap Wave 7, ADR-009). Additive and idempotent:+++ * OpenRestream ingest switch — Live-side schema (roadmap Wave 7, ADR-009). Additive and idempotent:+ *+ * managed_streams.ingest_authority 'live' (default) | 'openre' who ingests RTMP for this slot--+ * managed_streams.openre_stream_id the OpenRe stream definition serving the slot (std_…)--+ * openre_sessions Live's projection of OpenRe ingest sessions it mirrors into+++ * managed_streams.openre_stream_id the OpenRestream stream definition serving the slot (std_…)+++ * openre_sessions Live's projection of OpenRestream ingest sessions it mirrors into+ * `streams` (rebuilt from openre.session.* events; the+ * revision guard makes out-of-order delivery harmless)+ *@@@ -744,7 +744,7 @@ index 4b5f8e4..4ac081b 100644const router = express.Router();+/**--+ * A slot ingested by OpenRe.Stream (ingest_authority = 'openre') restreams from OpenRe: its+++ * A slot ingested by OpenRestream (ingest_authority = 'openre') restreams from OpenRestream: its+ * destinations are managed there (openre.stream), so Live refuses to edit or run them here rather+ * than letting two systems push the same stream. Slots on Live's own ingest are unaffected.+ */@@@ -753,7 +753,7 @@ index 4b5f8e4..4ac081b 100644+ if (!openre.slotIsOpenre(managedStreamId)) return false;+ const slot = openre.slotById(managedStreamId);+ res.status(409).json({--+ error: 'Restreaming for this stream slot is managed on OpenRe.Stream',+++ error: 'Restreaming for this stream slot is managed on OpenRestream',+ managed_by: 'openre',+ manage_url: require('../openre/openre-client').manageUrl(slot && slot.openre_stream_id),+ });@@@ -849,12 +849,12 @@ index 70a0857..2c5d856 100644restream_destinations: restreamDestinations,- });+ };--+ // OpenRe ingests this slot: its RTMP server and key come from OpenRe (the key is only+++ // OpenRestream ingests this slot: its RTMP server and key come from OpenRestream (the key is only+ // ever shown by Regenerate). Slots on Live's own ingest get exactly the response above.+ if (openreAuthority.authorityOf(ms) === 'openre') {+ const subject = require('../auth/identity-sync').subjectOf(ms.user_id);+ Object.assign(body, { stream_key: null }, await openreAuthority.ingestFor(ms, subject).catch((err) => ({--+ ingest_authority: 'openre', stream_key_managed_by: 'openre', rtmp_url: null, stream_key_hint: `OpenRe is unreachable (${err.message})`,+++ ingest_authority: 'openre', stream_key_managed_by: 'openre', rtmp_url: null, stream_key_hint: `OpenRestream is unreachable (${err.message})`,+ })));+ }+ res.json(body);@@@ -883,14 +883,14 @@ index 70a0857..2c5d856 100644return res.status(403).json({ error: 'Not your managed stream' });}--+ // OpenRe ingests this slot: OpenRe rotates its key (the old one stops working at once) and+++ // OpenRestream ingests this slot: OpenRestream rotates its key (the old one stops working at once) and+ // the new one is shown here, once.+ if (openreAuthority.authorityOf(ms) === 'openre') {+ try {+ const subject = require('../auth/identity-sync').subjectOf(ms.user_id);+ return res.json(await openreAuthority.rotateFor(ms, subject));+ } catch (err) {--+ return res.status(502).json({ error: `Could not rotate the key on OpenRe: ${err.message}` });+++ return res.status(502).json({ error: `Could not rotate the key on OpenRestream: ${err.message}` });+ }+ }+@@@ -911,13 +911,13 @@ index 70a0857..2c5d856 100644...(whipUrlWarning ? { whipUrlWarning } : {}),};+ } else if (stream.protocol === 'rtmp' && openreAuthority.slotIsOpenre(stream.managed_stream_id)) {--+ // OpenRe ingests this slot: its server URL, the hint of its key (never the key).+++ // OpenRestream ingests this slot: its server URL, the hint of its key (never the key).+ const slot = openreAuthority.slotById(stream.managed_stream_id);+ const ingest = await openreAuthority.ingestFor(slot, require('../auth/identity-sync').subjectOf(slot.user_id)).catch(() => ({}));+ endpoint = {+ rtmpUrl: ingest.rtmp_url || null,+ streamKey: null,--+ streamKeyHint: ingest.stream_key_hint || 'Press Regenerate to get your OpenRe stream key',+++ streamKeyHint: ingest.stream_key_hint || 'Press Regenerate to get your OpenRestream stream key',+ keyManagedBy: 'openre',+ flvUrl: `/api/streams/rtmp-proxy/${stream.id}.flv`,+ };@@@ -955,8 +955,8 @@ index 70a0857..2c5d856 100644+ let url;+ const mirrored = openreMirror.sessionForStream(stream.id);+ if (mirrored) {--+ // An OpenRe session: pull HTTP-FLV from the OpenRe worker that holds it, as named by--+ // OpenRe's playback descriptor (a loopback URL on this host; no key in it).+++ // An OpenRestream session: pull HTTP-FLV from the OpenRestream worker that holds it, as named by+++ // OpenRestream's playback descriptor (a loopback URL on this host; no key in it).+ if (mirrored.state !== 'live') return res.status(404).end();+ const pb = await require('../openre/openre-client').playback(mirrored.session_id).catch(() => null);+ if (!pb || !pb.flv || !/^http:\/\/127\.0\.0\.1:\d+\/live\/ses_[0-9A-Z]+\.flv$/.test(pb.flv.internal_url)) return res.status(502).end();@@@ -968,7 +968,7 @@ index 70a0857..2c5d856 100644+ url = `http://127.0.0.1:${nmsPort}/live/${flvKey}.flv`;+ }--+ if (req.destroyed) return undefined; // the viewer left while OpenRe was asked+++ if (req.destroyed) return undefined; // the viewer left while OpenRestream was askedconst http = require('http');const upstream = http.get(url, (nmsRes) => {if (nmsRes.statusCode !== 200) {@@@ -980,11 +980,11 @@ index fb8ba5c..c857f5b 100644return;}--+ // OpenRe.Stream ingests this slot (managed_streams.ingest_authority = 'openre'): refuse+++ // OpenRestream ingests this slot (managed_streams.ingest_authority = 'openre'): refuse+ // here so one stream can never be ingested twice. Slots on 'live' (the default) are+ // untouched. See server/openre/authority.js.+ if (require('../openre/authority').refusesLiveIngest({ managedStream, user: managedStream ? null : resolvedUser, protocol: 'rtmp' })) {--+ console.log(`[RTMP] Rejected: ${managedStream ? `slot ${managedStream.id}` : `personal key of ${resolvedUser.username}`} is ingested by OpenRe`);+++ console.log(`[RTMP] Rejected: ${managedStream ? `slot ${managedStream.id}` : `personal key of ${resolvedUser.username}`} is ingested by OpenRestream`);+ const session = this.nms.getSession(id);+ if (session) session.reject();+ return;@@@ -1001,9 +1001,9 @@ index 0000000..6ffdc33@@ -0,0 +1,231 @@+'use strict';+--+// OpenRe ingest switch (roadmap Wave 7, ADR-009). With OPENRE_URL unset, or a slot on 'live' (the+++// OpenRestream ingest switch (roadmap Wave 7, ADR-009). With OPENRE_URL unset, or a slot on 'live' (the+// default), nothing changes. With a slot switched to 'openre': Live's RTMP refuses its key, the--+// Go Live helpers show OpenRe's URL and rotate OpenRe's key, and OpenRe session events (signed+++// Go Live helpers show OpenRestream's URL and rotate OpenRestream's key, and OpenRestream session events (signed+// Events deliveries, applied once, in revision order) become ordinary `streams` rows.++const assert = require('assert');@@@ -1100,7 +1100,7 @@ index 0000000..6ffdc33+ assert.strictEqual(authority.serializeSlot(slot701), slot701, 'the same object: responses unchanged');+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: slot701 }), false);+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 601 } }), false);--+ // Even a slot marked 'openre' behaves as before while OpenRe is not configured.+++ // Even a slot marked 'openre' behaves as before while OpenRestream is not configured.+ d.prepare("UPDATE managed_streams SET ingest_authority = 'openre' WHERE id = 701").run();+ const marked = db.getManagedStreamById(701);+ assert.strictEqual(authority.authorityOf(marked), 'live');@@@ -1150,7 +1150,7 @@ index 0000000..6ffdc33+ assert.strictEqual(db.getManagedStreamByStreamKey('livekey701aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), undefined, "Live's old key is rotated away");+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: switched }), true, "Live's RTMP refuses the slot");+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: switched, protocol: 'whip' }), false, 'only RTMP moved');--+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 602 } }), false, 'personal keys of users without an OpenRe slot stay on Live');+++ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 602 } }), false, 'personal keys of users without an OpenRestream slot stay on Live');+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 601 } }), true, 'a personal key cannot double-ingest a switched channel');+ const shown = authority.serializeSlot(switched);+ assert.strictEqual(shown.stream_key, null);@@@ -1180,7 +1180,7 @@ index 0000000..6ffdc33+ assert.strictEqual(mirror.hasLiveSession(streamId), true, 'stale cleanup leaves it alone');+ assert.strictEqual(mirror.ownsStream(streamId), true, 'Live does not resume its own restreams for it');+--+ // Reconcile: OpenRe says live → heartbeat refreshed; says ended → row ended.+++ // Reconcile: OpenRestream says live → heartbeat refreshed; says ended → row ended.+ openreSessions[SES(4)] = { id: SES(4), state: 'live', revision: 2 };+ d.prepare("UPDATE streams SET last_heartbeat = datetime('now', '-10 minutes') WHERE id = ?").run(streamId);+ await mirror.reconcileOnce();@@@ -4,9 +4,9 @@"openvibe-contracts": ">=0.13.0 <1.0.0"},"id": "openre",-- "name": "OpenRe.Stream",++ "name": "OpenRestream","status": "alpha",-- "repository": "OpenVibers/OpenRe.Stream",++ "repository": "OpenVibers/OpenRestream","domains": ["openre.stream","ingest.openre.stream"@@@ -2,7 +2,7 @@"name": "openre-stream","version": "0.1.0","private": true,-- "description": "OpenRe.Stream: ingest and restream authority for the OpenVibe network. Stream definitions, hashed ingest keys, ingest sessions, transport workers with generations and leases, restream outputs with health and logs, recording requests to OpenVibe.Media.",++ "description": "OpenRestream: ingest and restream authority for the OpenVibe network. Stream definitions, hashed ingest keys, ingest sessions, transport workers with generations and leases, restream outputs with health and logs, recording requests to OpenVibe.Media.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -23,7 +23,7 @@],"author": "OpenVibers","license": "AGPL-3.0-only",-- "repository": "github:OpenVibers/OpenRe.Stream",++ "repository": "github:OpenVibers/OpenRestream","dependencies": {"better-sqlite3": "^11.10.0","cookie-parser": "^1.4.7",@@@ -15,13 +15,13 @@* bind OPENRE_RTMP_BIND is public and the RTMP port really listens on a public address* dns the ingest host resolves (A/AAAA) only to this host's addresses* port the ingest port answers an RTMP handshake from outside (see "port" below)-- * db OpenRe's PostgreSQL database (DATABASE_URL of openre.env, one READ ONLY transaction): no data++ * db OpenRestream's PostgreSQL database (DATABASE_URL of openre.env, one READ ONLY transaction): no data* checksum failure, no foreign key left unvalidated, the main tables' counts* live-env /etc/openvibe/live.env has OPENRE_URL, OPENRE_EVENTS_SECRET (32+ chars) and* OV_OAUTH_CLIENT_SECRET, and the running Live process was started with them* events Events has Live's openre.session.* subscription to /internal/openre-events, enabled,* with the secret Live holds (compared by hash, never printed), and no dead deliveries-- * slot only with --slot <id>: the Live slot and its OpenRe definition, for the per-slot step;++ * slot only with --slot <id>: the Live slot and its OpenRestream definition, for the per-slot step;* --slot all (or --all-slots): every Live slot, one slot:<id> line each, same rules** "port": from the host itself a connection to its own public address never crosses the provider@@@ -29,7 +29,7 @@* that is not the host (a checkout with `npm ci` is enough), or pass --probe-url with a probe you* trust (GET, `{host}` and `{port}` replaced, JSON answer with a boolean `open` or `reachable`).*-- * OpenRe and Events serve from PostgreSQL (ADR-035): their databases are read through the DATABASE_URL of their env++ * OpenRestream and Events serve from PostgreSQL (ADR-035): their databases are read through the DATABASE_URL of their env* files (--openre-db / --events-db take env:<file>, a postgres:// URL or a SQLite file); Live's is still its SQLite file.** Nothing is written anywhere: files are opened read-only and PostgreSQL is read in READ ONLY transactions, no secret value is@@@ -379,7 +379,7 @@ async function checkLiveEnv(ctx) {else if (env.OPENRE_URL.replace(/\/+$/, '') !== opts.openreUrl) problems.push(`OPENRE_URL = ${env.OPENRE_URL}, expected ${opts.openreUrl}`);if (!env.OPENRE_EVENTS_SECRET) problems.push('OPENRE_EVENTS_SECRET not set');else if (env.OPENRE_EVENTS_SECRET.length < 32) problems.push('OPENRE_EVENTS_SECRET shorter than 32 characters (Events refuses it)');-- if (!env.OV_OAUTH_CLIENT_SECRET) problems.push('OV_OAUTH_CLIENT_SECRET not set (Live cannot get OpenRe tokens)');++ if (!env.OV_OAUTH_CLIENT_SECRET) problems.push('OV_OAUTH_CLIENT_SECRET not set (Live cannot get OpenRestream tokens)');const notes = [];if (!env.OPENRE_PUBLIC_URL) notes.push('OPENRE_PUBLIC_URL unset (default https://openre.stream)');// The running process: systemd loaded the env file when Live started. Names only.@@@ -447,15 +447,15 @@ async function checkSlot(ctx, id = Number(ctx.opts.slot)) {if (live.user && live.user.is_banned) problems.push('owner is banned');if (!live.subject) problems.push('owner has no canonical subject (they sign in to Live once)');if (live.liveNow) problems.push(`live on Live right now (stream ${live.liveNow.id}): wait for the window`);-- if (s.protocol !== 'rtmp' && !['rtmp', 'obs'].includes(String(s.streaming_method || '').toLowerCase())) problems.push('not an RTMP slot (OpenRe carries RTMP only)');-- if (openre.defs.length > 1) problems.push(`${openre.defs.length} OpenRe definitions reference it`);++ if (s.protocol !== 'rtmp' && !['rtmp', 'obs'].includes(String(s.streaming_method || '').toLowerCase())) problems.push('not an RTMP slot (OpenRestream carries RTMP only)');++ if (openre.defs.length > 1) problems.push(`${openre.defs.length} OpenRestream definitions reference it`);const def = openre.defs[0];if (def) {-- facts.push(`OpenRe ${def.id} (${def.state}${def.mirror_to_live ? ', mirrored' : ', NOT mirrored to Live'})`);++ facts.push(`OpenRestream ${def.id} (${def.state}${def.mirror_to_live ? ', mirrored' : ', NOT mirrored to Live'})`);const held = openre.dests.filter((d) => d.hold_reason).length;facts.push(`${openre.dests.length} destination(s)${held ? `, ${held} held` : ''}`);-- if (s.openre_stream_id && s.openre_stream_id !== def.id) problems.push(`Live points at ${s.openre_stream_id}, OpenRe has ${def.id}`);-- if (openre.openSessions) facts.push(`${openre.openSessions} open OpenRe session(s)`);++ if (s.openre_stream_id && s.openre_stream_id !== def.id) problems.push(`Live points at ${s.openre_stream_id}, OpenRestream has ${def.id}`);++ if (openre.openSessions) facts.push(`${openre.openSessions} open OpenRestream session(s)`);} else facts.push(openre.slotMap ? `migration_map: ${openre.slotMap.status}${openre.slotMap.reason ? ` (${openre.slotMap.reason})` : ''}` : 'not migrated yet');if (problems.length) return ['FAIL', `${facts.join('; ')}: ${problems.join('; ')}`];return ['PASS', facts.join('; ')];@@@ -1,7 +1,7 @@#!/usr/bin/env node'use strict';/**-- * Import Live's stream slots and restream destinations into OpenRe, from a read-only snapshot of++ * Import Live's stream slots and restream destinations into OpenRestream, from a read-only snapshot of* Live's database, and print the per-channel RTMP cutover checklist.** node scripts/migrate-from-live.js --live-db /path/to/live-snapshot.db # dry run@@@ -9,7 +9,7 @@* ... [--slots 12,31] [--checklist /path/to/checklist.md]** Take the snapshot with `sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-snapshot.db"`-- * (Live's DB_PATH in production; never point this at the live file). Uses the OpenRe environment++ * (Live's DB_PATH in production; never point this at the live file). Uses the OpenRestream environment* (.env or OPENRE_ENV_FILE) for its own database and OPENRE_SECRETS_KEY; in production set* OPENRE_ENV_FILE=/etc/openvibe/openre.env and NODE_ENV=production (a unit sets NODE_ENV itself)* — docs/cutover.md has the command. Prints no secret: old keys are not imported and the new@@@ -1,10 +1,10 @@#!/usr/bin/env node'use strict';/**-- * Disable (or re-enable) the OpenRe stream definition of one Live slot, AS LIVE (docs/cutover.md,++ * Disable (or re-enable) the OpenRestream stream definition of one Live slot, AS LIVE (docs/cutover.md,* per-slot rollback). A disabled definition's keys are refused at the RTMP handshake-- * (`stream_disabled`), so after a slot goes back to Live nobody can publish to OpenRe with the key-- * the broadcaster got at the cutover, and OpenRe never pushes that slot's destinations. Running++ * (`stream_disabled`), so after a slot goes back to Live nobody can publish to OpenRestream with the key++ * the broadcaster got at the cutover, and OpenRestream never pushes that slot's destinations. Running* sessions are not ended (Live holds no openre.session.end grant): do it with the slot offline.** sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot 12 --state disabled@@@ -38,9 +38,9 @@ async function run({ slot, state, env, fetchImpl = globalThis.fetch, log = consoconst ref = `live:managed_stream:${slot}`;const found = await fetchImpl(`${openre}/api/v1/streams?external_ref=${encodeURIComponent(ref)}`, { headers, signal: AbortSignal.timeout(15000) });const list = await readJson(found);-- if (!found.ok) throw new Error(`OpenRe answered ${found.status}: ${list.code || ''} ${list.detail || ''}`.trim());++ if (!found.ok) throw new Error(`OpenRestream answered ${found.status}: ${list.code || ''} ${list.detail || ''}`.trim());const d = (list.streams || [])[0];-- if (!d) throw new Error(`OpenRe has no stream definition for ${ref}`);++ if (!d) throw new Error(`OpenRestream has no stream definition for ${ref}`);if (d.state === state) {log(`${d.id} (${ref}) is already ${state}`);return { id: d.id, state, changed: false, open_session: d.session ? d.session.id : null };@@@ -52,7 +52,7 @@ async function run({ slot, state, env, fetchImpl = globalThis.fetch, log = consosignal: AbortSignal.timeout(15000),});const b = await readJson(r);-- if (!r.ok) throw new Error(`OpenRe answered ${r.status}: ${b.code || ''} ${b.detail || ''}`.trim());++ if (!r.ok) throw new Error(`OpenRestream answered ${r.status}: ${b.code || ''} ${b.detail || ''}`.trim());log(`${d.id} (${ref}): ${d.state} → ${b.stream ? b.stream.state : state}${d.session ? `; session ${d.session.id} is still ${d.session.state} (not ended by this)` : ''}`);return { id: d.id, state: b.stream ? b.stream.state : state, changed: true, open_session: d.session ? d.session.id : null };}@@@ -1,7 +1,7 @@#!/usr/bin/env node'use strict';/**-- * Live's OpenVibe.Events subscription to OpenRe's session events (docs/cutover.md step A5):++ * Live's OpenVibe.Events subscription to OpenRestream's session events (docs/cutover.md step A5):* topic `openre.session.*` → Live's `POST /internal/openre-events`, signed with the secret Live holds* as OPENRE_EVENTS_SECRET. Events names the consumer after the calling service, so this runs AS LIVE:* it reads Live's env file (Live's OAuth client, grant [live, events.subscription.manage,@@@ -1,6 +1,6 @@'use strict';/**-- * OpenRe API v1 (openre-api). Every route checks one capability for service callers; owners++ * OpenRestream API v1 (openre-api). Every route checks one capability for service callers; owners* (Network user JWT) act on their own streams. Errors are RFC 9457 problem+json.** GET /api/v1/streams openre.stream.read list (owner, ?external_ref=live:managed_stream:12)@@@ -1,6 +1,6 @@'use strict';/**-- * OpenRe.Stream configuration. Everything comes from the environment (.env in development,++ * OpenRestream configuration. Everything comes from the environment (.env in development,* /etc/openvibe/openre.env in production); .env.example documents every name.** load(env) is pure so tests (and every worker process) can build a config without touching@@@ -75,7 +75,7 @@ function load(env = process.env) {// ── Ingest (RTMP) ───────────────────────────────────────rtmp: {// Public ingest port. Live's in-process ingest owns 1935 until the RTMP cutover-- // (README "Port plan"); OpenRe listens on 1936 until then.++ // (README "Port plan"); OpenRestream listens on 1936 until then.port: rtmpPort,// Further public ports served by every worker, e.g. 1935 once Live's own RTMP ingest is// retired (README "Port plan"): URLs handed out with :1936 keep working forever.@@@ -97,7 +97,7 @@ function load(env = process.env) {// ── Ingest (JSMPEG) ─────────────────────────────────────jsmpeg: {// Public port for both the MPEG-TS HTTP POST from the broadcaster's ffmpeg and the-- // WebSocket viewers. Live's relay owns 9710/9711 until the JSMPEG cutover; OpenRe++ // WebSocket viewers. Live's relay owns 9710/9711 until the JSMPEG cutover; OpenRestream// listens on 9736 until then, SO_REUSEPORT so two generations can listen during a drain.port: jsmpegPort,bindHost: env.OPENRE_JSMPEG_BIND || '0.0.0.0',@@@ -113,11 +113,11 @@ function load(env = process.env) {// ── Ingest (WebRTC: WHIP + the mediasoup SFU + viewer signaling) ──// One worker kind 'webrtc' owns WHIP ingest, the mediasoup producers/consumers and viewer// signaling (T4 decision 1: mediasoup is single-process, so ingest and consumption cannot be-- // split). Live's own SFU keeps UDP 10000–10100 until the WebRTC cutover; OpenRe listens on++ // split). Live's own SFU keeps UDP 10000–10100 until the WebRTC cutover; OpenRestream listens on// 10200–10300 (decision 3) so both stacks run side by side.webrtc: {// Public HTTP port: WHIP (POST/PATCH/DELETE /whip/…) and the WS signaling endpoints-- // (broadcaster and viewer). Live's WHIP lives on its app port 3000; OpenRe brings its own.++ // (broadcaster and viewer). Live's WHIP lives on its app port 3000; OpenRestream brings its own.// SO_REUSEPORT so two generations can listen during a drain.port: webrtcPort,bindHost: env.OPENRE_WEBRTC_BIND || '0.0.0.0',@@@ -1,6 +1,6 @@'use strict';/**-- * OpenRe's durable store: one PostgreSQL database (ov_openre, ADR-035) shared by every OpenRe process.++ * OpenRestream's durable store: one PostgreSQL database (ov_openre, ADR-035) shared by every OpenRestream process.** openre-api definitions, keys, destinations (owner writes)* openre-session-coordinator worker liveness, drain, output assignment, recordings, event relay@@@ -21,7 +21,7 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');/*** The serving handle: DATABASE_URL through PgBouncer, after the migrations ran as the owner (DATABASE_DIRECT_URL).* Without DATABASE_URL (development, tests), an embedded PGlite database: in OPENRE_PGLITE_DIR or data/pglite, or in-- * memory with { memory: true }. PGlite is one process: run the other OpenRe processes against PostgreSQL.++ * memory with { memory: true }. PGlite is one process: run the other OpenRestream processes against PostgreSQL.*/async function openDb(config, { memory = false, log = console, registry } = {}) {const quiet = { ...log, info() {}, log() {} };@@@ -9,7 +9,7 @@* user:password@, no whitespace or control characters, at most 2048 characters* - the host may not be (or resolve to) loopback, private, link-local, CGNAT, multicast or* unspecified addresses, unless OPENRE_DEST_ALLOW_PRIVATE is on (tests, a restream box on the-- * operator's LAN). Live allowed private hosts; OpenRe does not by default, and the migration++ * operator's LAN). Live allowed private hosts; OpenRestream does not by default, and the migration* holds such destinations with a recorded reason instead of importing them enabled.*/const dns = require('dns');@@@ -6,7 +6,7 @@* GET /robots.txt welcomes search and AI crawlers, keeps the signed-in owner pages, sign-in,* the API and playback out of the index, and always names the sitemap* GET /sitemap.xml the public, server-rendered pages (server/ui/routes.js) only-- * GET /llms.txt what OpenRe.Stream does today, in plain language++ * GET /llms.txt what OpenRestream does today, in plain language** Read-only and viewer-independent: built from the config (the public base URL) and nothing else.*/@@@ -31,25 +31,25 @@ function createDiscoveryRoutes({ config }) {router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(seo.llmsTxt({-- name: 'OpenRe.Stream',-- summary: 'Go live from your browser with no OBS and no follower minimum: on the OpenVibe network you open openvibe.live, press Go Live and allow your camera. OpenRe.Stream is the network\'s ingest and restream service behind it: it takes an RTMP feed from an encoder such as OBS and restreams the session to RTMP and SRT destinations.',++ name: 'OpenRestream',++ summary: 'Go live from your browser with no OBS and no follower minimum: on the OpenVibe network you open openvibe.live, press Go Live and allow your camera. OpenRestream is the network\'s ingest and restream service behind it: it takes an RTMP feed from an encoder such as OBS and restreams the session to RTMP and SRT destinations.',details: [-- 'OpenRe.Stream is the ingest and restream authority behind OpenVibe.Live: channels, discovery and watch pages stay on openvibe.live. OpenRe itself serves a JSON/REST API and a small server-rendered owner UI at openre.stream. The service is alpha.',++ 'OpenRestream is the ingest and restream authority behind OpenVibe.Live: channels, discovery and watch pages stay on openvibe.live. OpenRestream itself serves a JSON/REST API and a small server-rendered owner UI at openre.stream. The service is alpha.','','Going live, as it runs today:','- From a browser, with nothing to install: on OpenVibe.Live (https://openvibe.live, the Go Live button; guide at https://openvibe.live/docs/go-live-in-your-browser). There is no follower, subscriber or eligibility threshold.',-- '- With an encoder such as OBS: RTMP to rtmp://ingest.openre.stream:1936/live with the stream key OpenRe shows once when the stream is created. Any signed-in OpenVibe account can create a stream.',-- '- Browser WebRTC (WHIP, RFC 9725) and JSMPEG ingest are ported to OpenRe and tested, but their workers do not run in production yet; until they do, OpenVibe.Live serves them.',++ '- With an encoder such as OBS: RTMP to rtmp://ingest.openre.stream:1936/live with the stream key OpenRestream shows once when the stream is created. Any signed-in OpenVibe account can create a stream.',++ '- Browser WebRTC (WHIP, RFC 9725) and JSMPEG ingest are ported to OpenRestream and tested, but their workers do not run in production yet; until they do, OpenVibe.Live serves them.','',-- 'Restream: a session can fan out to several RTMP and SRT destinations at once, with per-output health, logs, backoff and a rapid-crash circuit breaker. Recording: OpenRe asks OpenVibe.Media to record a VOD or clips of an RTMP session. Playback: a live, non-private session is served as HTTP-FLV at https://openre.stream/play/<session id>.flv.',++ 'Restream: a session can fan out to several RTMP and SRT destinations at once, with per-output health, logs, backoff and a rapid-crash circuit breaker. Recording: OpenRestream asks OpenVibe.Media to record a VOD or clips of an RTMP session. Playback: a live, non-private session is served as HTTP-FLV at https://openre.stream/play/<session id>.flv.','','Ingest keys are random, stored only as a SHA-256 hash and shown once at creation or rotation.',].join('\n'),sections: [{ title: 'Start here', links: [-- { title: 'OpenRe.Stream home', url: abs('/') },++ { title: 'OpenRestream home', url: abs('/') },{ title: 'What shipped', url: abs('/updates') },-- { title: 'Source code', url: 'https://github.com/OpenVibers/OpenRe.Stream' },++ { title: 'Source code', url: 'https://github.com/OpenVibers/OpenRestream' },] },{ title: 'Machine-readable', links: [{ title: 'Sitemap', url: abs('/sitemap.xml') },@@@ -1,9 +1,9 @@'use strict';/**-- * OpenRe → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ * OpenRestream → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).*-- * Every OpenRe process can enqueue (the row commits in the same transaction as the change-- * it describes); only the session coordinator runs the relay that publishes rows with OpenRe's++ * Every OpenRestream process can enqueue (the row commits in the same transaction as the change++ * it describes); only the session coordinator runs the relay that publishes rows with OpenRestream's* service token (audience openvibe.events, capability events.event.publish). Events down, Network* down or no credentials yet: rows wait and are retried; nothing in the transport path waits.*@@@ -1,7 +1,7 @@'use strict';/**-- * OpenRe identifiers: <prefix>_<ULID> (the ULID from openvibe-contracts ids, so they sort by-- * creation time like every other OpenVibe id). Prefixes are OpenRe's own entity types.++ * OpenRestream identifiers: <prefix>_<ULID> (the ULID from openvibe-contracts ids, so they sort by++ * creation time like every other OpenVibe id). Prefixes are OpenRestream's own entity types.*/const { ids } = require('openvibe-contracts');@@@ -20,7 +20,7 @@ const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new Regfunction newId(kind, now) {const p = PREFIX[kind];-- if (!p) throw new TypeError(`no OpenRe id prefix for "${kind}"`);++ if (!p) throw new TypeError(`no OpenRestream id prefix for "${kind}"`);return `${p}_${ids.ulid(now)}`;}@@@ -48,7 +48,7 @@ async function start({ config, clock, fetchImpl = globalThis.fetch, log = consolif (require.main === module) {require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });start().then((h) => {-- // SIGTERM/SIGINT (openvibe-sdk/service, docs/service.md's OpenRe.Stream entry): the keys poller stops, requests++ // SIGTERM/SIGINT (openvibe-sdk/service, docs/service.md's OpenRestream entry): the keys poller stops, requests// in flight get 8 s, then Valkey and the database close in that order. A database close failure exits 1;// past the 10 s deadline the process also exits 1. Workers are separate processes and keep running.gracefulStop({@@@ -1,8 +1,8 @@'use strict';/**-- * Live lineage for OpenRe definitions (roadmap D20): which OpenVibe.Live channel a stream belongs to,++ * Live lineage for OpenRestream definitions (roadmap D20): which OpenVibe.Live channel a stream belongs to,* answered by Live's canonical resolver (GET /internal/lineage/resolve, capability live.lineage.resolve,-- * OpenRe's Network service token for audience openvibe.live) instead of a mapping of OpenRe's own.++ * OpenRestream's Network service token for audience openvibe.live) instead of a mapping of OpenRestream's own.* A definition is asked about when it mirrors into Live or points at a Live slot (external ref* live:managed_stream), with its owner subject and that slot id; display names are never sent.*@@@ -10,7 +10,7 @@* DELETE /api/v1/:app/vods/:id → drop an empty shell / an ephemeral clips-only recording** The rtmp_url is the owning worker's loopback play URL (rtmp://127.0.0.1:<port>/live/<session id>):-- * no ingest key ever leaves OpenRe. Media records and finalises; OpenRe only asks.++ * no ingest key ever leaves OpenRestream. Media records and finalises; OpenRestream only asks.** Auth: config.media.auth === 'key' sends the tenant app key (MEDIA_API_KEY), which Media's VOD* routes accept today. 'service' sends a Network service token (audience openvibe.media); Media's@@@ -1,13 +1,13 @@'use strict';/*** Import OpenVibe.Live stream slots (managed_streams) and their restream destinations from a-- * read-only snapshot of Live's database into OpenRe (scripts/migrate-from-live.js is the CLI).++ * read-only snapshot of Live's database into OpenRestream (scripts/migrate-from-live.js is the CLI).** Rules (ADR-009, plan §8.1):* - Live's database is opened read-only; nothing is written to it.-- * - Stream keys are NEVER imported. Every imported slot gets a new OpenRe key that nobody has++ * - Stream keys are NEVER imported. Every imported slot gets a new OpenRestream key that nobody has* seen; the streamer gets a usable key by rotating (Live's "Regenerate" once the slot is-- * switched to OpenRe, or openre.stream). Every key that existed before the cutover is dead++ * switched to OpenRestream, or openre.stream). Every key that existed before the cutover is dead* at the cutover.* - Every source row is imported, held with a reason, or excluded with a reason, and recorded in* migration_map. Re-running is idempotent: imported rows are skipped, held rows are retried.@@@ -165,7 +165,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =continue;}const v = validateDestinationUrl(d.server_url, { allowPrivate: config.outputs.allowPrivateHosts });-- const hold = !v.ok ? `server URL refused by OpenRe's destination rules: ${v.error}` : null;++ const hold = !v.ok ? `server URL refused by OpenRestream's destination rules: ${v.error}` : null;const input = {platform: ['youtube', 'twitch', 'kick', 'custom'].includes(d.platform) ? d.platform : 'custom',name: d.name || null,@@@ -198,7 +198,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =count(hold ? 'held' : 'imported');}if (own.some(d => d.connection_id) || unbound.some(d => d.connection_id)) {-- ch.notes.push('OAuth-linked destination(s): Live refreshed their ingest key (and created the YouTube broadcast) on every go-live; OpenRe pushes to the key stored at import. Check the platform key is a persistent one, or re-enter it on openre.stream after the cutover.');++ ch.notes.push('OAuth-linked destination(s): Live refreshed their ingest key (and created the YouTube broadcast) on every go-live; OpenRestream pushes to the key stored at import. Check the platform key is a persistent one, or re-enter it on openre.stream after the cutover.');}}return report;@@@ -207,7 +207,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =/** Markdown checklist per channel, for the lead and the broadcaster. Contains no secret. */function checklist(report, { openreUrl = 'https://openre.stream', rtmpUrl } = {}) {const lines = [];-- lines.push(`# OpenRe RTMP cutover checklist${report.apply ? '' : ' (dry run: nothing was written)'}`);++ lines.push(`# OpenRestream RTMP cutover checklist${report.apply ? '' : ' (dry run: nothing was written)'}`);lines.push('');lines.push(`Imported ${report.counts.imported}, held ${report.counts.held}, excluded ${report.counts.excluded}, already done ${report.counts.skipped}.`);lines.push('');@@@ -2,7 +2,7 @@/*** Two kinds of secret, handled differently:*-- * Ingest keys (what a streamer pastes into OBS) are OpenRe's own credentials. Only their SHA-256++ * Ingest keys (what a streamer pastes into OBS) are OpenRestream's own credentials. Only their SHA-256* is stored; the plain key exists once, in the response that created or rotated it. 256 bits of* randomness make a plain digest safe (no dictionary to try), and lookup stays one indexed read,* which matters because the RTMP worker authenticates synchronously inside the publish handshake.@@@ -234,7 +234,7 @@ function createDefinitions({ db, config, events, clock }) {/*** The RTMP (or later WHIP) worker's check, synchronous by design: a publish handshake is-- * answered inside node-media-server's prePublish handler. Only OpenRe's own hashed keys count:++ * answered inside node-media-server's prePublish handler. Only OpenRestream's own hashed keys count:* this never looks at any other service's database.*/async function resolveIngestKey(plain, protocol) {@@@ -14,7 +14,7 @@ const { createOutputs } = require('./outputs');const { createRecordings } = require('./recordings');/**-- * The handle every store gets: its transactions are SERIALIZABLE. Several OpenRe processes change the same rows (a++ * The handle every store gets: its transactions are SERIALIZABLE. Several OpenRestream processes change the same rows (a* publish admitted by ingest while the coordinator fails a lost worker's session); PostgreSQL detects the conflict* and the SDK runs the loser again (40001 is retried).*/@@@ -191,7 +191,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {}/**-- * Where to watch a session. Internal URLs are loopback addresses on the OpenRe host (Live and++ * Where to watch a session. Internal URLs are loopback addresses on the OpenRestream host (Live and* Media run there too); public_url is served by openre-api's /play proxy.*/async function playback(session) {@@@ -6,7 +6,7 @@ const ovServe = require('openvibe-shared/serve');* pinned openvibe-shared release) the way OpenVibe.Community renders it.*/const NETWORK_URL = 'https://openvibe.network';--const SITE = 'OpenRe.Stream';++const SITE = 'OpenRestream';function esc(v) {return String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));@@@ -34,7 +34,7 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}`;--const DESCRIPTION = 'OpenRe.Stream: stream definitions, ingest keys, sessions and restream outputs for the OpenVibe network.';++const DESCRIPTION = 'OpenRestream: stream definitions, ingest keys, sessions and restream outputs for the OpenVibe network.';function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [] }) {const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — ingest and restream for OpenVibe`;@@@ -51,7 +51,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)};const foot = { service: 'openre', variant: 'compact', mount: '#ov-footer', brandName: SITE, legalBase: config.liveUrl, updates: '/updates',-- links: [{ heading: SITE, items: [{ name: 'Streams', url: '/streams' }, { name: 'Source code', url: 'https://github.com/OpenVibers/OpenRe.Stream' }] }] };++ links: [{ heading: SITE, items: [{ name: 'Streams', url: '/streams' }, { name: 'Source code', url: 'https://github.com/OpenVibers/OpenRestream' }] }] };let footerSsr = '';try { footerSsr = require('openvibe-shared/footer').ssr({ service: 'openre', variant: 'compact', updates: '/updates' }); } catch { footerSsr = '<footer id="ov-footer"></footer>'; }let icon = '';@@@ -74,7 +74,7 @@ ${styles.map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}"></head><body><div id="navbar-mount"></div>--<noscript><nav class="ore" style="padding:8px 16px"><a href="/">OpenRe.Stream</a> · <a href="/streams">Streams</a> · <a href="/sessions">Sessions</a> · ${user ? '<a href="/auth/logout">Sign out</a>' : '<a href="/auth/login">Sign in</a>'}</nav></noscript>++<noscript><nav class="ore" style="padding:8px 16px"><a href="/">OpenRestream</a> · <a href="/streams">Streams</a> · <a href="/sessions">Sessions</a> · ${user ? '<a href="/auth/logout">Sign out</a>' : '<a href="/auth/login">Sign in</a>'}</nav></noscript><main id="main" class="ore">${body}</main>@@@ -3,7 +3,7 @@* The standalone UI (openre.stream). Server-rendered, useful without JavaScript, forms post back* to the same process and go through the same store rules as the API.*-- * / what OpenRe is, and the signed-in owner's streams++ * / what OpenRestream is, and the signed-in owner's streams* /streams definitions + create* /streams/:id ingest URL, keys (rotate: the new key is shown once), settings,* destinations (add, edit, delete, test, start, stop), sessions@@@ -43,7 +43,7 @@ function createUiRouter({ rt, auth, limits = null }) {if (req.caller.kind === 'user' && req.caller.subject) return true;page(req, res, {title: 'Sign in', canonicalPath: req.path,-- body: `<h1>Sign in</h1><p>OpenRe.Stream uses your OpenVibe account.</p><p><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,++ body: `<h1>Sign in</h1><p>OpenRestream uses your OpenVibe account.</p><p><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,}, 401);return false;}@@@ -84,17 +84,17 @@ function createUiRouter({ rt, auth, limits = null }) {// ── Pages ─────────────────────────────────────────────────-- // What shipped on OpenRe.Stream: the shared update log every OpenVibe site has.-- router.get('/updates', (req, res) => page(req, res, { canonicalPath: '/updates', robots: 'index,follow', title: 'What shipped on OpenRe.Stream', body: frame.updatesBody({ service: 'openre', siteName: 'OpenRe.Stream' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));++ // What shipped on OpenRestream: the shared update log every OpenVibe site has.++ router.get('/updates', (req, res) => page(req, res, { canonicalPath: '/updates', robots: 'index,follow', title: 'What shipped on OpenRestream', body: frame.updatesBody({ service: 'openre', siteName: 'OpenRestream' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));router.get('/', async (req, res) => {const signedIn = req.caller.kind === 'user' && req.caller.subject;const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];page(req, res, {canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],-- description: 'Go live from your browser with no OBS and no follower minimum on OpenVibe.Live, or restream an OBS feed to your other channels with OpenRe.Stream.',++ description: 'Go live from your browser with no OBS and no follower minimum on OpenVibe.Live, or restream an OBS feed to your other channels with OpenRestream.',body: `${frontPage(config)}${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}--${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream' })}`,++${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,});});@@@ -106,9 +106,9 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'function frontPage(cfg) {const live = cfg.liveUrl;return showcase.hero({-- eyebrow: 'OpenRe.Stream · alpha',++ eyebrow: 'OpenRestream · alpha',title: 'Go live from your browser.', accent: 'No OBS, no follower minimum.',-- lede: 'Open OpenVibe.Live, press Go Live and allow your camera: nothing to install, and no follower or subscriber threshold. OpenRe.Stream is the ingest and restream service behind it. With an encoder such as OBS, it takes your RTMP feed and sends the session on to your other channels.',++ lede: 'Open OpenVibe.Live, press Go Live and allow your camera: nothing to install, and no follower or subscriber threshold. OpenRestream is the ingest and restream service behind it. With an encoder such as OBS, it takes your RTMP feed and sends the session on to your other channels.',actions: [{ label: 'Go live in your browser', href: live, primary: true },{ label: 'Restream with OBS', href: '/streams' },@@@ -116,7 +116,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'],note: 'Alpha. RTMP ingest and RTMP/SRT restreaming run here today; browser (WHIP) and JSMPEG ingest are still served by OpenVibe.Live. Channels, discovery and watch pages stay on openvibe.live.',}) + showcase.features({-- title: 'What OpenRe does',++ title: 'What OpenRestream does',items: [{ icon: 'ov:stream', title: 'Ingest keys you control', text: 'Create a stream and get its RTMP server and key. The key is shown once, stored only as a hash, and can be rotated any time.' },{ icon: 'ov:live', title: 'One stream, several channels', text: 'Send a session to several RTMP or SRT destinations at once, each with its own health, logs and retry with backoff.' },@@@ -162,7 +162,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'page(req, res, {title: heading, canonicalPath: `/streams/${definition.id}`,body: `<h1>${esc(heading)}</h1>--<div class="card"><p><strong>Copy the key now.</strong> OpenRe stores only a hash of it; this page is the only time it is shown.</p>++<div class="card"><p><strong>Copy the key now.</strong> OpenRestream stores only a hash of it; this page is the only time it is shown.</p><label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code><label>Stream key</label><code class="secret">${esc(key.key)}</code></div><p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,@@@ -217,7 +217,7 @@ ${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><<div><label>Recording visibility</label><select name="recording_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.recording_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div><div><label>Playback</label><select name="playback_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.playback_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div><div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>--<label><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Mirror sessions into my OpenVibe.Live channel (consent; Live also has to switch this slot to OpenRe)</label>++<label><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Mirror sessions into my OpenVibe.Live channel (consent; Live also has to switch this slot to OpenRestream)</label><button type="submit">Save</button>`)}</div><h2>Sessions</h2>${sessionTable(sessions)}`,});@@@ -1,5 +1,5 @@'use strict';--// ADR-009 acceptance: "Deploying the OpenRe API does not end worker-owned transports."++// ADR-009 acceptance: "Deploying the OpenRestream API does not end worker-owned transports."// The API runs as its own process; a fake worker process owns a live session. The API is stopped// (SIGTERM, as systemd does on a deploy), the session stays live and its lease keeps being renewed// by the worker; a new API process starts and serves the same live session.@@@ -10,7 +10,7 @@ const { load } = require('../server/config');const { openRuntime } = require('../server/store');if (!multiProcess()) {-- console.log('api-restart: skipped (OpenRe processes sharing one database need PostgreSQL: npm run test:pg)');++ console.log('api-restart: skipped (OpenRestream processes sharing one database need PostgreSQL: npm run test:pg)');process.exit(0);}const t = suite('api-restart');@@@ -12,7 +12,7 @@ const { localAddresses, rtmpProbe } = require('../scripts/cutover-preflight');const { tmpDir, testEnv, child, waitFor, freePort, suite, silent, multiProcess } = require('./helpers');if (!multiProcess()) {-- console.log('bind-switch: skipped (OpenRe processes sharing one database need PostgreSQL: npm run test:pg)');++ console.log('bind-switch: skipped (OpenRestream processes sharing one database need PostgreSQL: npm run test:pg)');process.exit(0);}const t = suite('bind-switch');@@@ -53,7 +53,7 @@ t('the UI works without JavaScript: landing, sign-in prompt, streams', async ()api = await bootApi();const home = await request(api.base, 'GET', '/');assert.strictEqual(home.status, 200);-- assert.match(home.text, /OpenRe\.Stream/);++ assert.match(home.text, /OpenRestream/);assert.match(home.text, /theme-loader\.js/);assert.match(home.text, /<noscript><nav/);const signin = await request(api.base, 'GET', '/streams');@@@ -1,7 +1,7 @@'use strict';// The discovery routes (server/discovery.js) answer with the right content types: robots.txt// names the sitemap, sitemap.xml lists the public pages including /, and llms.txt describes what--// OpenRe.Stream does today. All three come from openvibe-shared/seo, the shared toolkit.++// OpenRestream does today. All three come from openvibe-shared/seo, the shared toolkit.const assert = require('assert');const seo = require('openvibe-shared/seo');const { bootApi, request, suite } = require('./helpers');@@@ -41,10 +41,10 @@ t('llms.txt is plain text, leads with browser go-live and says what runs today',const r = await request(api.base, 'GET', '/llms.txt');assert.strictEqual(r.status, 200);assert.match(r.headers.get('content-type'), /^text\/plain/);-- assert.ok(r.text.startsWith('# OpenRe.Stream\n'), 'starts with the site heading');++ assert.ok(r.text.startsWith('# OpenRestream\n'), 'starts with the site heading');assert.match(r.text.split('\n').slice(0, 4).join(' '), /Go live from your browser with no OBS and no follower minimum/, 'browser go-live leads');// Only the RTMP ingest and restream workers run in production: WHIP and JSMPEG are ported, not served yet.-- assert.match(r.text, /WHIP, RFC 9725\) and JSMPEG ingest are ported to OpenRe and tested, but their workers do not run in production yet/);++ assert.match(r.text, /WHIP, RFC 9725\) and JSMPEG ingest are ported to OpenRestream and tested, but their workers do not run in production yet/);} finally {await api.close();}@@@ -4,7 +4,7 @@* manual clock, runtime/API boot helpers, child-process helpers and a tiny sequential runner.** Databases (ADR-035): one per test directory, migrated. npm test: PGlite in memory, so one process (a test that-- * spawns OpenRe processes sharing it says it is skipped); npm run test:pg (OPENRE_TEST_STORE=pg): a schema of its own++ * spawns OpenRestream processes sharing it says it is skipped); npm run test:pg (OPENRE_TEST_STORE=pg): a schema of its own* on the PostgreSQL + PgBouncer containers, whose URLs every spawned process of the directory gets.*/const crypto = require('crypto');@@@ -68,7 +68,7 @@ async function testDb(dir) {return await databases.get(dir);}--/** Several OpenRe processes on one database need PostgreSQL (PGlite is one process). */++/** Several OpenRestream processes on one database need PostgreSQL (PGlite is one process). */const multiProcess = () => STORE === 'pg';/** A handle the test keeps: a runtime's or an API's close() leaves it open for the next one on the directory. */@@@ -22,7 +22,7 @@ if (!hasFfmpeg()) {process.exit(0);}if (!multiProcess()) {-- console.log('jsmpeg-e2e: skipped (the OpenRe processes share one database: PostgreSQL only, npm run test:pg)');++ console.log('jsmpeg-e2e: skipped (the OpenRestream processes share one database: PostgreSQL only, npm run test:pg)');process.exit(0);}const t = suite('jsmpeg-e2e');@@@ -90,7 +90,7 @@ let pubA;let sink;let viewer;--t('setup: stubs and the four OpenRe processes', async () => {++t('setup: stubs and the four OpenRestream processes', async () => {const network = await stubServer((req, res, body) => {if (req.url === '/oauth/token') {const p = new URLSearchParams(body);@@@ -249,7 +249,7 @@ t('a killed worker fails its session after the lease expires', async () => {assert.match(s.failure_reason, /lease|lost|expired/i);});--t('no OpenRe process crashed along the way, and no key reached a log', () => {++t('no OpenRestream process crashed along the way, and no key reached a log', () => {assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);for (const p of procs) {@@@ -1,6 +1,6 @@'use strict';// Live lineage (D20): the coordinator asks OpenVibe.Live's canonical resolver which channel a Live-linked--// definition belongs to, with OpenRe's service token; session events carry the answer.++// definition belongs to, with OpenRestream's service token; session events carry the answer.const assert = require('assert');const { runtime, manualClock, suite, OWNER, outboxEnvelopes, silent } = require('./helpers');const { createCoordinator } = require('../server/coordinator');@@@ -31,7 +31,7 @@ const RESOLVED = { status: 'resolved', channel: { id: '17', slug: 'goosely', ownt('a Live-linked definition is resolved with its owner and slot; an unlinked one is never asked about', async () => {const { rt, lineage, asked } = await setup(RESOLVED);const linked = (await rt.store.definitions.create({ owner_subject: OWNER, mirror_to_live: true, external_refs: [{ service: 'live', type: 'managed_stream', id: '12' }] })).definition;-- await rt.store.definitions.create({ owner_subject: OWNER, title: 'OpenRe only' });++ await rt.store.definitions.create({ owner_subject: OWNER, title: 'OpenRestream only' });const out = await lineage.refresh();assert.deepStrictEqual(out, { checked: 1, resolved: 1 });assert.deepStrictEqual(asked, [{ owner_subject: OWNER, slot_id: '12' }], 'owner subject and slot id, never a display name');@@@ -148,7 +148,7 @@ t('the CLI runs a dry run against a snapshot and prints the checklist', async ()const out = execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'migrate-from-live.js'), '--live-db', file], {env: { ...process.env, ...env, OPENRE_ENV_FILE: '/nonexistent' }, cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],});-- assert.match(out, /OpenRe RTMP cutover checklist \(dry run/);++ assert.match(out, /OpenRestream RTMP cutover checklist \(dry run/);assert.match(out, /@twoslots/);});@@@ -1,6 +1,6 @@'use strict';// scripts/cutover-preflight.js against a mocked host: files, commands, DNS, interfaces, the port--// probe and /api/ready are fakes; OpenRe's database is a real PostgreSQL one (PGlite) read in READ ONLY transactions,++// probe and /api/ready are fakes; OpenRestream's database is a real PostgreSQL one (PGlite) read in READ ONLY transactions,// Events' and Live's are real SQLite files opened read-only (Events' production PostgreSQL has the same tables).// Covers the host as it is today (2026-09-23: everything the cutover still needs fails), a host// ready for the first slot, each failure on its own, the off-host port check, the probe URL, the@@@ -27,7 +27,7 @@ const LIVE_PID = 4242;async function makeDbs() {const dir = tmpDir();-- // OpenRe's database: PGlite in memory, reached as the target 'pglite:openre' (host() below hands it out).++ // OpenRestream's database: PGlite in memory, reached as the target 'pglite:openre' (host() below hands it out).const openrePath = 'pglite:openre';const o = await openDb(load({ NODE_ENV: 'test' }), { memory: true });const now = Date.now();@@@ -380,7 +380,7 @@ t('slot: offline RTMP slot with a subject and a migrated definition passes; liveconst dbs = await makeDbs();let r = byId(await preflight(opts(dbs, ['--only', 'slot', '--slot', '12']), host(dbs)));assert.strictEqual(r.slot.status, 'PASS', r.slot.detail);-- assert.match(r.slot.detail, /slot 12 \(@rehearsal, rtmp\/obs\) authority live; OpenRe str_1 \(active, mirrored\); 0 destination\(s\)/);++ assert.match(r.slot.detail, /slot 12 \(@rehearsal, rtmp\/obs\) authority live; OpenRestream str_1 \(active, mirrored\); 0 destination\(s\)/);const l = new Database(dbs.livePath);await l.prepare('INSERT INTO streams (managed_stream_id, is_live) VALUES (12, 1)').run();await l.prepare('DELETE FROM linked_accounts').run();@@@ -1,6 +1,6 @@'use strict';// Recording requests (Media contract as Live's recorder uses it) and the event relay (outbox →--// OpenVibe.Events with OpenRe's service token), with stub Media / Network / Events servers.++// OpenVibe.Events with OpenRestream's service token), with stub Media / Network / Events servers.const assert = require('assert');const http = require('http');const { runtime, manualClock, suite, OWNER, silent, outboxTypes } = require('./helpers');@@@ -3,7 +3,7 @@// openre-restream-worker and openre-session-coordinator as separate child processes, a stub// OpenVibe.Network (token endpoint), a stub OpenVibe.Events and a stub OpenVibe.Media.//--// Proves: ingest with OpenRe's own keys (wrong and duplicate publishers refused), restream to a++// Proves: ingest with OpenRestream's own keys (wrong and duplicate publishers refused), restream to a// working destination while a dead destination fails without touching the session, recording// requested from Media with a key-free loopback URL that Media can actually pull, an API restart// during the broadcast that ends nothing, HTTP-FLV playback through the new API, a worker@@@ -22,7 +22,7 @@ if (!hasFfmpeg()) {}if (!multiProcess()) {-- console.log('rtmp-e2e: skipped (the four OpenRe processes share one database: PostgreSQL only, npm run test:pg)');++ console.log('rtmp-e2e: skipped (the four OpenRestream processes share one database: PostgreSQL only, npm run test:pg)');process.exit(0);}const t = suite('rtmp-e2e');@@@ -74,7 +74,7 @@ let pubA;let sessionA;let sink;--t('setup: stubs and the four OpenRe processes', async () => {++t('setup: stubs and the four OpenRestream processes', async () => {const network = await stubServer((req, res, body) => {if (req.url === '/oauth/token') {const p = new URLSearchParams(body);@@@ -263,10 +263,10 @@ t('events reached OpenVibe.Events through the outbox relay, without keys', asyncassert.deepStrictEqual(started.payload.external_refs.map(r => `${r.service}:${r.type}:${r.id}`), ['live:managed_stream:5', 'live:user:42']);});--t('no OpenRe process crashed along the way', () => {++t('no OpenRestream process crashed along the way', () => {assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);-- assert.strictEqual(ingest2.exitCode, null, `generation 2 exited:\n${ingest2.output}`); // No ingest or destination key in any OpenRe process log.++ assert.strictEqual(ingest2.exitCode, null, `generation 2 exited:\n${ingest2.output}`); // No ingest or destination key in any OpenRestream process log.for (const p of procs) {if (!p.output) continue;assert.ok(!p.output.includes(keyA), 'no ingest key in logs');@@@ -1,10 +1,10 @@'use strict';/**-- * openvibe-sdk/service in OpenRe.Stream (plan T1, lane A): the API's SIGTERM/SIGINT shutdown is the kit's++ * openvibe-sdk/service in OpenRestream (plan T1, lane A): the API's SIGTERM/SIGINT shutdown is the kit's* gracefulStop, not a hand-written handler. The keys poller stops, requests in flight drain for 8 s, then the* store closes in today's order (Valkey, then the database); a database close failure exits 1,* a clean stop exits 0, and past the 10 s deadline the process exits 1. The static half reads server/index.js;-- * the behavioural half drives gracefulStop with the same options OpenRe passes, exits stubbed.++ * the behavioural half drives gracefulStop with the same options OpenRestream passes, exits stubbed.*/const assert = require('assert');const fs = require('fs');@@@ -28,7 +28,7 @@ function fixtureOffer() {].join('\r\n');}--t('config: the WebRTC ports never collide with Live and the RTC range is OpenRe\'s own', () => {++t('config: the WebRTC ports never collide with Live and the RTC range is OpenRestream\'s own', () => {const c = load({});assert.strictEqual(c.webrtc.port, 9936);assert.strictEqual(c.webrtc.media.minPort, 10200);@@@ -58,7 +58,7 @@ t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {assert.strictEqual(rtp.mid, '0');const transport = { iceParameters: { usernameFragment: 'uf', password: 'pw' }, iceCandidates: [{ foundation: '1', protocol: 'udp', priority: 1, ip: '203.0.113.7', port: 10200, type: 'host' }], dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB' }] } };-- const answer = sdp.buildSdpAnswer(transport, offer, { video: { rtpParameters: rtp } }, { serverName: 'OpenRe' });++ const answer = sdp.buildSdpAnswer(transport, offer, { video: { rtpParameters: rtp } }, { serverName: 'OpenRestream' });const parsedAnswer = sdp.parse(answer);assert.strictEqual(parsedAnswer.media[0].direction, 'recvonly');assert.strictEqual(parsedAnswer.media[0].setup, 'passive');@@@ -31,7 +31,7 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit},});-- /** The loopback source URL for a session's protocol, or null when OpenRe cannot restream it yet. */++ /** The loopback source URL for a session's protocol, or null when OpenRestream cannot restream it yet. */async function inputUrlFor(session) {if (session.protocol === 'rtmp') {const pb = await store.sessions.playback(session);@@@ -73,7 +73,7 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exitegressBase = `http://127.0.0.1:${ep.egressPort}`;} else {input = await inputUrlFor(session);-- if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }++ if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRestream yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }}// Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;@@@ -3,7 +3,7 @@* FFmpeg argument building for restream outputs, ported from OpenVibe.Live* server/streaming/restream-manager.js (quality presets, destination URL normalisation, SRT,* output flags, friendly errors). Kept byte-for-byte equivalent where it matters so a stream that-- * restreams correctly from Live restreams the same way from OpenRe.++ * restreams correctly from Live restreams the same way from OpenRestream.** RTMP → destination is a codec copy (zero CPU); a JSMPEG source (the worker's MPEG-TS data tap,* read over HTTP) is re-encoded with encodingArgs(). A WebRTC source is mediasoup PlainRTP read@@@ -13,7 +13,7 @@* published in its worker row, so consumers always reach the process that holds the* publisher.*-- * Authentication happens inside the publish handshake, synchronously, against OpenRe's hashed++ * Authentication happens inside the publish handshake, synchronously, against OpenRestream's hashed* keys only. An accepted publish is renamed from /live/<key> to /live/<session id> before* node-media-server registers it, so the key never appears in any play URL, log or descriptor.*@@@ -1,6 +1,6 @@'use strict';/**-- * The worker interface every OpenRe transport worker implements (RTMP ingest, restream, and the++ * The worker interface every OpenRestream transport worker implements (RTMP ingest, restream, and the* WHIP/WebRTC/SFU/JSMPEG kinds as they are ported):** const w = createWorkerRuntime({ rt, kind, log, hooks });@@@ -4,7 +4,7 @@* and grabs one frame from it every interval, then uploads it to Media as an object under the `live`* namespace (grant media.object.upload ns live); the object's public URL becomes the session's* thumbnail_url and travels on the session state and its openre.session.* events. Live can no longer-- * read a local FLV/SFU/relay for OpenRe-owned sessions, so the frame has to come from here.++ * read a local FLV/SFU/relay for OpenRestream-owned sessions, so the frame has to come from here.** The grabber is protocol-agnostic: the caller passes the ffmpeg *input* arguments for its source* (an RTMP/HTTP-FLV URL, the JSMPEG data tap, or an SDP file for a PlainRTP consumer) and this module@@@ -2,7 +2,7 @@/*** WHIP SDP <-> mediasoup bridging, ported from OpenVibe.Live server/streaming/whip-handler.js* (offer validation, DTLS parameter extraction, RTP parameter extraction against the router's-- * capabilities, and the answer builder). The Live-specific stream-row code is not ported: OpenRe++ * capabilities, and the answer builder). The Live-specific stream-row code is not ported: OpenRestream* validates the offer, negotiates codecs and builds the answer, and nothing here touches a database.** `sdp-transform` is required lazily so the module (and the whole worker) can be loaded — and its@@@ -187,7 +187,7 @@ function extractRtpParameters(media, routerCapabilities, mediaIndex = 0) {* BUNDLE group (RFC 8843 §7.3.3), so a browser publisher whose extra section (a data channel, a* codec the router lacks) is rejected is not refused by its own SDP parser.*/--function buildSdpAnswer(transport, offerSdp, producersByKind, { serverName = 'OpenRe.Stream', fallbackAddress = '127.0.0.1' } = {}) {++function buildSdpAnswer(transport, offerSdp, producersByKind, { serverName = 'OpenRestream', fallbackAddress = '127.0.0.1' } = {}) {const { iceParameters, iceCandidates, dtlsParameters } = transport;const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';@@@ -316,7 +316,7 @@ function buildIceFragment(iceParameters, mirror = {}) {* listens on; the worker's PlainRTP consumer is already sending to it.*/function buildEgressSdp({ video, audio } = {}) {-- const lines = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenRe.Stream WebRTC egress', 'c=IN IP4 127.0.0.1', 't=0 0'];++ const lines = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenRestream WebRTC egress', 'c=IN IP4 127.0.0.1', 't=0 0'];const section = (type, c, port) => {const pt = c.payloadType;const codecName = (c.mimeType || `${type}/VP8`).split('/')[1];@@@ -1,14 +1,14 @@'use strict';/**-- * The mediasoup SFU, one Router per OpenRe session, ported from OpenVibe.Live++ * The mediasoup SFU, one Router per OpenRestream session, ported from OpenVibe.Live* server/streaming/webrtc-sfu.js. The differences that matter:*-- * - a "room" is an OpenRe ingest session id (not Live's `stream-<id>`), so a router's lifetime++ * - a "room" is an OpenRestream ingest session id (not Live's `stream-<id>`), so a router's lifetime* is exactly the session's;* - consumer/producer/transport bookkeeping is unchanged, so the viewer signaling and the* PlainRTP egress paths behave like Live's;* - PlainRTP consumers (restream, Media's RTP recording, thumbnails) send to an explicit remote-- * ip:port the caller chose, because on OpenRe the consumer side (ffmpeg) runs in another process.++ * ip:port the caller chose, because on OpenRestream the consumer side (ffmpeg) runs in another process.** `mediasoup` is required lazily: the module loads (and its turn-free helpers run) on a host where* the native dependency is not installed, and `createSfu().available` is false there. The worker@@@ -2,7 +2,7 @@/*** WS signaling for WebRTC, modelled on the producer and viewer halves of OpenVibe.Live's* server/streaming/broadcast-server.js so Live's broadcast and watch pages can point their-- * mediasoup-client at OpenRe:++ * mediasoup-client at OpenRestream:** ws://<host>:<port>/b/<ingest key> the broadcaster (Live's role: produce into the SFU)* ws://<host>:<port>/w/<session playback id> a viewer (consume; never carries the key)@@@ -129,7 +129,7 @@ function createSignaling({ sfu, iceServers, admit, endSession, log = console })try { adm = await admit(decodeURIComponent(bMatch[1])); } catch (err) { adm = { error: err.message }; }if (!adm || adm.error) {log.log(`[webrtc] broadcast refused (${adm && adm.error}) from ${socket.remoteAddress || '?'}`);-- socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nX-OpenRe-Error: ' + (adm && adm.error || 'refused') + '\r\n\r\n');++ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nX-OpenRestream-Error: ' + (adm && adm.error || 'refused') + '\r\n\r\n');socket.destroy();return true;}
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
-# OpenRe.Stream — Environment Configuration
+# OpenRestream — Environment Configuration
# Copy to .env for development. In production this is /etc/openvibe/openre.env (0600), read by
# Copy to .env for development. In production this is /etc/openvibe/openre.env (0600), read by
# every unit (openre-api, openre-session-coordinator, openre-rtmp-ingest@, openre-restream-worker@);
# every unit (openre-api, openre-session-coordinator, openre-rtmp-ingest@, openre-restream-worker@);
# the units set NODE_ENV, PORT and OPENRE_RELEASE themselves.
# the units set NODE_ENV, PORT and OPENRE_RELEASE themselves.
@@ -22,16 +22,16 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -22,16 +22,16 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# Pin the Network public key instead of fetching /api/.well-known/jwks (PEM, \n escapes allowed).
# Pin the Network public key instead of fetching /api/.well-known/jwks (PEM, \n escapes allowed).
#OV_NETWORK_PUBLIC_KEY=
#OV_NETWORK_PUBLIC_KEY=
# OAuth client `openre`: browser sign-in for the UI (redirect https://openre.stream/auth/callback)
# OAuth client `openre`: browser sign-in for the UI (redirect https://openre.stream/auth/callback)
-# and OpenRe's own client-credentials tokens (Events, Media).
+# and OpenRestream's own client-credentials tokens (Events, Media).
OV_OAUTH_CLIENT_ID=openre
OV_OAUTH_CLIENT_ID=openre
OV_OAUTH_CLIENT_SECRET=
OV_OAUTH_CLIENT_SECRET=
#OV_OAUTH_REDIRECT_URI=https://openre.stream/auth/callback
#OV_OAUTH_REDIRECT_URI=https://openre.stream/auth/callback
# A browser's Network JWT is accepted when its aud contains one of these.
# A browser's Network JWT is accepted when its aud contains one of these.
#OPENRE_USER_AUDIENCES=openvibe.openre,openvibe.network
#OPENRE_USER_AUDIENCES=openvibe.openre,openvibe.network
-# ── Store (PostgreSQL, shared by every OpenRe process) ────────
+# ── Store (PostgreSQL, shared by every OpenRestream process) ────────
# PostgreSQL (ADR-035): DATABASE_URL serves through PgBouncer, DATABASE_DIRECT_URL (the owner) runs migrations/ at boot;
# PostgreSQL (ADR-035): DATABASE_URL serves through PgBouncer, DATABASE_DIRECT_URL (the owner) runs migrations/ at boot;
-# OpenVibe.Host `roles/data/add-service.sh openre` writes both. Every OpenRe process shares the database. Without
+# OpenVibe.Host `roles/data/add-service.sh openre` writes both. Every OpenRestream process shares the database. Without
# DATABASE_URL, development runs on an embedded PGlite database in data/pglite (OPENRE_PGLITE_DIR): one process only.
# DATABASE_URL, development runs on an embedded PGlite database in data/pglite (OPENRE_PGLITE_DIR): one process only.
DATABASE_URL=
DATABASE_URL=
DATABASE_DIRECT_URL=
DATABASE_DIRECT_URL=
@@ -6,7 +6,7 @@ on:
@@ -6,7 +6,7 @@ on:
jobs:
jobs:
# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q). ADR-035: PostgreSQL
# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q). ADR-035: PostgreSQL
# 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk scripts/test-services.sh); npm test runs every
# 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk scripts/test-services.sh); npm test runs every
- # file on PGlite, test:pg runs them all through PgBouncer, with the tests that start several OpenRe processes.
+ # file on PGlite, test:pg runs them all through PgBouncer, with the tests that start several OpenRestream processes.
test:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-# OpenRe.Stream
+# OpenRestream
> Ingest and restream: stream definitions, keys, sessions, transport workers, outputs and output health.
> Ingest and restream: stream definitions, keys, sessions, transport workers, outputs and output health.
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
## Purpose
## Purpose
-The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live keeps the creator/channel product and observes sessions through OpenRe APIs and events; transport workers survive Live deploys **and OpenRe API deploys**. This is the permanent fix for "restarting Live drops live RTMP streams" (hazard H1).
+The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live keeps the creator/channel product and observes sessions through OpenRestream APIs and events; transport workers survive Live deploys **and OpenRestream API deploys**. This is the permanent fix for "restarting Live drops live RTMP streams" (hazard H1).
## Owns
## Owns
@@ -33,15 +33,15 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live
@@ -33,15 +33,15 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live
- OpenVibe.Events (event relay; optional: rows wait in the outbox)
- OpenVibe.Events (event relay; optional: rows wait in the outbox)
- OpenVibe.Media (recording requests; optional: sessions work without it)
- OpenVibe.Media (recording requests; optional: sessions work without it)
- OpenVibe.Contracts v0.76.0 (ids, problem+json, capability checks), OpenVibe.SDK v0.26.0 (`db`, PostgreSQL outbox, token client, per-actor limits, the `service` kit), OpenVibe.Shared v2.5.0 (Frame, readiness), pinned by release tarball
- OpenVibe.Contracts v0.76.0 (ids, problem+json, capability checks), OpenVibe.SDK v0.26.0 (`db`, PostgreSQL outbox, token client, per-actor limits, the `service` kit), OpenVibe.Shared v2.5.0 (Frame, readiness), pinned by release tarball
-- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): the store every OpenRe process shares, and the API's per-actor limit counters (optional)
+- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): the store every OpenRestream process shares, and the API's per-actor limit counters (optional)
- OpenVibe.Live (`live.lineage.resolve`: which channel a Live-linked stream belongs to)
- OpenVibe.Live (`live.lineage.resolve`: which channel a Live-linked stream belongs to)
- node-media-server 2.7.4 (the RTMP session code Live runs) and the system ffmpeg
- node-media-server 2.7.4 (the RTMP session code Live runs) and the system ffmpeg
## What is ported, per protocol
## What is ported, per protocol
-| Transport | Live source | In OpenRe | State |
+| Transport | Live source | In OpenRestream | State |
|---|---|---|---|
|---|---|---|---|
-| RTMP ingest | `server/streaming/rtmp-server.js` (node-media-server) | `workers/rtmp-ingest.js` — same NMS session code; OpenRe's own keys; publish renamed to `/live/<session id>` so no key reaches a play URL; loopback RTMP play + HTTP-FLV per generation; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
+| RTMP ingest | `server/streaming/rtmp-server.js` (node-media-server) | `workers/rtmp-ingest.js` — same NMS session code; OpenRestream's own keys; publish renamed to `/live/<session id>` so no key reaches a play URL; loopback RTMP play + HTTP-FLV per generation; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
| Restream (RTMP source) | `server/streaming/restream-manager.js` | `workers/restream/*` — same ffmpeg arguments (codec copy from HTTP-FLV), Twitch→RTMPS, Kick `/app`, SRT options, live ACK by `-progress`, backoff, rapid-crash circuit breaker, destination cooldown | **ported, tested with real ffmpeg** |
| Restream (RTMP source) | `server/streaming/restream-manager.js` | `workers/restream/*` — same ffmpeg arguments (codec copy from HTTP-FLV), Twitch→RTMPS, Kick `/app`, SRT options, live ACK by `-progress`, backoff, rapid-crash circuit breaker, destination cooldown | **ported, tested with real ffmpeg** |
| Restream from a JSMPEG source | same | `jsmpegArgs()` — re-encodes the worker's MPEG-TS data tap (`http://127.0.0.1:<tapPort>/tap/<session>.ts`) | **ported, tested with real ffmpeg** |
| Restream from a JSMPEG source | same | `jsmpegArgs()` — re-encodes the worker's MPEG-TS data tap (`http://127.0.0.1:<tapPort>/tap/<session>.ts`) | **ported, tested with real ffmpeg** |
| Restream from a WebRTC source | same | `webrtcArgs()`/`rtpInputArgs()` — re-encodes a mediasoup PlainRTP consumer the owning worker sends to ffmpeg's ports (SDP written by the worker's egress API) | **ported, tested with real ffmpeg + mediasoup** |
| Restream from a WebRTC source | same | `webrtcArgs()`/`rtpInputArgs()` — re-encodes a mediasoup PlainRTP consumer the owning worker sends to ffmpeg's ports (SDP written by the worker's egress API) | **ported, tested with real ffmpeg + mediasoup** |
@@ -49,7 +49,7 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live
@@ -49,7 +49,7 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live
| WebRTC (WHIP ingest + SFU + viewer signaling) | `whip-handler.js` + `webrtc-sfu.js` + `broadcast-server.js` (mediasoup; one worker owns all of it) | `workers/webrtc.js` + `workers/webrtc/{sfu,sdp,signaling}.js` — WHIP (RFC 9725) admission by key, one mediasoup Router per session, `/b/<key>` broadcaster + `/w/<id>` viewer signaling, loopback RTP/SDP egress for restream/recording/thumbnails; SO_REUSEPORT drain | **ported, tested with real mediasoup** |
| WebRTC (WHIP ingest + SFU + viewer signaling) | `whip-handler.js` + `webrtc-sfu.js` + `broadcast-server.js` (mediasoup; one worker owns all of it) | `workers/webrtc.js` + `workers/webrtc/{sfu,sdp,signaling}.js` — WHIP (RFC 9725) admission by key, one mediasoup Router per session, `/b/<key>` broadcaster + `/w/<id>` viewer signaling, loopback RTP/SDP egress for restream/recording/thumbnails; SO_REUSEPORT drain | **ported, tested with real mediasoup** |
| Live thumbnails | `media-proxy/live-thumbs.js` | `workers/thumbnails.js` — the owning worker grabs a frame from its own source (FLV, MPEG-TS tap or a PlainRTP consumer) and uploads it to Media (object ns `live`); the session carries `thumbnail_url` | **ported, tested against a stub Media** |
| Live thumbnails | `media-proxy/live-thumbs.js` | `workers/thumbnails.js` — the owning worker grabs a frame from its own source (FLV, MPEG-TS tap or a PlainRTP consumer) and uploads it to Media (object ns `live`); the session carries `thumbnail_url` | **ported, tested against a stub Media** |
| JSMPEG | `jsmpeg-relay.js` | `workers/jsmpeg.js` — MPEG-TS POST admission by key in path, WebSocket viewers by playback id, loopback MPEG-TS data tap for restream; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
| JSMPEG | `jsmpeg-relay.js` | `workers/jsmpeg.js` — MPEG-TS POST admission by key in path, WebSocket viewers by playback id, loopback MPEG-TS data tap for restream; SO_REUSEPORT drain | **ported, tested with real ffmpeg** |
-| OAuth-linked destinations (per go-live Twitch/YouTube key refresh, YouTube broadcast creation) | `restream-manager._refreshDestFromConnection` | no (Live owns platform OAuth); OpenRe pushes to the stored key | **not ported** |
+| OAuth-linked destinations (per go-live Twitch/YouTube key refresh, YouTube broadcast creation) | `restream-manager._refreshDestFromConnection` | no (Live owns platform OAuth); OpenRestream pushes to the stored key | **not ported** |
| Viewer counts from platforms, chat relay, PowerChat | restream-manager / integrations | no (product features, stay in Live) | not in scope |
| Viewer counts from platforms, chat relay, PowerChat | restream-manager / integrations | no (product features, stay in Live) | not in scope |
## Processes
## Processes
@@ -66,7 +66,7 @@ openre-api (4500) openre-session-coordinator openre-rtmp-inges
@@ -66,7 +66,7 @@ openre-api (4500) openre-session-coordinator openre-rtmp-inges
- **Generations.** Each worker process registers as generation N+1 of its kind. When a newer generation is `ready`, the coordinator marks older ones `draining` (deadline `OPENRE_DRAIN_MAX_MS`, default 24 h). A draining RTMP worker closes its public listener (the kernel sends new encoder connections to the new generation through the shared port) and keeps its publishers; a draining restream worker keeps its outputs. Idle → the process exits 0 by itself. At the deadline, remaining sessions are ended (encoders reconnect to the newest generation) and remaining outputs are handed over.
- **Generations.** Each worker process registers as generation N+1 of its kind. When a newer generation is `ready`, the coordinator marks older ones `draining` (deadline `OPENRE_DRAIN_MAX_MS`, default 24 h). A draining RTMP worker closes its public listener (the kernel sends new encoder connections to the new generation through the shared port) and keeps its publishers; a draining restream worker keeps its outputs. Idle → the process exits 0 by itself. At the deadline, remaining sessions are ended (encoders reconnect to the newest generation) and remaining outputs are handed over.
- **Leases.** Every heartbeat (2 s) renews the worker and the lease of every session it owns in one transaction. A worker silent for `OPENRE_WORKER_LEASE_MS` (15 s) is `lost`: its sessions fail (`openre.session.failed`), its outputs go back to `pending` for another worker. A lost worker that wakes up drops its transports and exits.
- **Leases.** Every heartbeat (2 s) renews the worker and the lease of every session it owns in one transaction. A worker silent for `OPENRE_WORKER_LEASE_MS` (15 s) is `lost`: its sessions fail (`openre.session.failed`), its outputs go back to `pending` for another worker. A lost worker that wakes up drops its transports and exits.
- **Isolation.** An output only ever changes its own row. A dead destination fails (`openre.output.failed`, destination cooldown 15 min → 24 h) and the session stays live (`test/rtmp-e2e.test.js`, `test/sessions.test.js`, `test/coordinator.test.js`).
- **Isolation.** An output only ever changes its own row. A dead destination fails (`openre.output.failed`, destination cooldown 15 min → 24 h) and the session stays live (`test/rtmp-e2e.test.js`, `test/sessions.test.js`, `test/coordinator.test.js`).
-- **Store.** PostgreSQL 18 through PgBouncer (`ov_openre` on the host's data role, ADR-035; schema in [migrations/](migrations/)), shared by every OpenRe process. Every transaction is SERIALIZABLE (`server/store/index.js`): an ingest worker admitting a publish and the coordinator failing a lost worker's session conflict, and PostgreSQL makes one of them retry. Admission is async, so the RTMP worker admits a publish before node-media-server's own publish handling runs. Valkey holds the API's per-actor limit counters.
+- **Store.** PostgreSQL 18 through PgBouncer (`ov_openre` on the host's data role, ADR-035; schema in [migrations/](migrations/)), shared by every OpenRestream process. Every transaction is SERIALIZABLE (`server/store/index.js`): an ingest worker admitting a publish and the coordinator failing a lost worker's session conflict, and PostgreSQL makes one of them retry. Admission is async, so the RTMP worker admits a publish before node-media-server's own publish handling runs. Valkey holds the API's per-actor limit counters.
## Running it
## Running it
@@ -96,7 +96,7 @@ Called elsewhere, as the service principal `openre` (client credentials from Net
@@ -96,7 +96,7 @@ Called elsewhere, as the service principal `openre` (client credentials from Net
| OpenVibe.Live | `live.lineage.resolve` | the channel a Live-linked stream definition belongs to |
| OpenVibe.Live | `live.lineage.resolve` | the channel a Live-linked stream definition belongs to |
| OpenVibe.Media | the `live` tenant key today (`MEDIA_API_KEY`); `media.object.upload` once `OPENRE_MEDIA_AUTH=service` | recording requests |
| OpenVibe.Media | the `live` tenant key today (`MEDIA_API_KEY`); `media.object.upload` once `OPENRE_MEDIA_AUTH=service` | recording requests |
-The full grant list, including what Live needs to call OpenRe, is under "Grants the lead adds in
+The full grant list, including what Live needs to call OpenRestream, is under "Grants the lead adds in
Network" below.
Network" below.
## Auth
## Auth
@@ -126,7 +126,7 @@ The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposa
@@ -126,7 +126,7 @@ The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposa
## Events
## Events
-Written to `event_outbox` in the same transaction as the change (openvibe-sdk `createPgOutbox`); the coordinator's relay publishes them with OpenRe's service token. Envelopes validate against `events.event-envelope@1`; `visibility: internal`; no payload ever carries an ingest key or a destination key.
+Written to `event_outbox` in the same transaction as the change (openvibe-sdk `createPgOutbox`); the coordinator's relay publishes them with OpenRestream's service token. Envelopes validate against `events.event-envelope@1`; `visibility: internal`; no payload ever carries an ingest key or a destination key.
| Type | Subject | When |
| Type | Subject | When |
|---|---|---|
|---|---|---|
@@ -140,7 +140,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c
@@ -140,7 +140,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c
## Playback
## Playback
-`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. A **webrtc** session's descriptor is `webrtc: { signaling_url, announced_ip }` (`signaling_url` is `ws(s)://<host>:<port>/w/<session id>`, the viewer signaling endpoint keyed by playback id, never the key; Live's watch page runs the mediasoup-client `sfu-viewer-*` flow against it). Every descriptor also carries `thumbnail_url` (the Media object the worker's latest grabbed frame was uploaded to, or `null`) and the session API reports `viewers` (connected consumers / relay sockets, from the worker heartbeat); `hls` is `null` (not produced). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRe session proxies `flv.internal_url` (see the Live patch). **AI taps** (decision 6: they stay in Live and read OpenRe's sources, never a Live-local ingest): an **rtmp** session's `flv.internal_url` (loopback HTTP-FLV) and a **webrtc** session's RTP descriptor on the owning worker's loopback egress API — `GET /rtp/<session id>/describe` (the codec facts, e.g. for Media's RTP ingest) and `GET /rtp/<session id>?vport=<p>&aport=<p>` (the SDP a pulling ffmpeg reads, a PlainRTP consumer per producer). Both are loopback-only, reachable by services on the worker's host.
+`GET /api/v1/sessions/:id/playback` returns a descriptor: `flv.internal_url` (loopback HTTP-FLV on the worker that holds the session — for services on this host), `flv.public_url` (`https://openre.stream/play/<session>.flv`, proxied by the API for non-private streams), `rtmp.internal_url` (loopback RTMP play, what Media records from). A **jsmpeg** session's descriptor is `jsmpeg: { ws_url, width, height }` (`ws_url` is the worker's public WebSocket path carrying the session's playback id, never the key; `width`/`height` come from the ffmpeg POST path), plus `jsmpeg.tap_internal_url`, the loopback MPEG-TS tap the restream worker reads. A **webrtc** session's descriptor is `webrtc: { signaling_url, announced_ip }` (`signaling_url` is `ws(s)://<host>:<port>/w/<session id>`, the viewer signaling endpoint keyed by playback id, never the key; Live's watch page runs the mediasoup-client `sfu-viewer-*` flow against it). Every descriptor also carries `thumbnail_url` (the Media object the worker's latest grabbed frame was uploaded to, or `null`) and the session API reports `viewers` (connected consumers / relay sockets, from the worker heartbeat); `hls` is `null` (not produced). Live's player keeps using its own `/api/streams/rtmp-proxy/:id.flv`, which for an OpenRestream session proxies `flv.internal_url` (see the Live patch). **AI taps** (decision 6: they stay in Live and read OpenRestream's sources, never a Live-local ingest): an **rtmp** session's `flv.internal_url` (loopback HTTP-FLV) and a **webrtc** session's RTP descriptor on the owning worker's loopback egress API — `GET /rtp/<session id>/describe` (the codec facts, e.g. for Media's RTP ingest) and `GET /rtp/<session id>?vport=<p>&aport=<p>` (the SDP a pulling ffmpeg reads, a PlainRTP consumer per producer). Both are loopback-only, reachable by services on the worker's host.
## Recording
## Recording
@@ -160,15 +160,15 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -160,15 +160,15 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
| Port | Owner | Until |
| Port | Owner | Until |
|---|---|---|
|---|---|---|
-| 1935/tcp | Live's in-process RTMP (node-media-server) | Live's RTMP ingest is retired (after the last slot has run on OpenRe for two weeks, ADR-009) |
+| 1935/tcp | Live's in-process RTMP (node-media-server) | Live's RTMP ingest is retired (after the last slot has run on OpenRestream for two weeks, ADR-009) |
| 9935/tcp (loopback) | Live's HTTP-FLV | same |
| 9935/tcp (loopback) | Live's HTTP-FLV | same |
-| **1936/tcp** | OpenRe `openre-rtmp-ingest` (all generations, SO_REUSEPORT) | permanent: URLs handed out as `rtmp://ingest.openre.stream:1936/live` keep working |
-| 19360–19399/tcp (loopback) | OpenRe per-generation RTMP play + HTTP-FLV | permanent |
-| **9736/tcp** | OpenRe `openre-jsmpeg` (all generations, SO_REUSEPORT): MPEG-TS POST + WS viewers | permanent: `http://ingest.openre.stream:9736/<key>/<w>/<h>/` and `ws://…/<session>` |
-| 19710–19749/tcp (loopback) | OpenRe per-generation JSMPEG MPEG-TS data tap | permanent |
-| **9936/tcp** | OpenRe `openre-webrtc` (all generations, SO_REUSEPORT): WHIP + `/b/<key>` and `/w/<id>` signaling (Live's WHIP is on its app port 3000) | permanent: `http(s)://ingest.openre.stream/whip/<key>` and `ws(s)://ingest.openre.stream/<b\|w>/…` |
-| **10200–10300/udp** | OpenRe `openre-webrtc` mediasoup RTC (never Live's 10000–10100) | permanent: open at the provider edge, `MEDIASOUP_ANNOUNCED_IP` must be reachable |
-| 19810–19849/tcp (loopback) | OpenRe per-generation WebRTC RTP/SDP egress API (restream, Media's RTP recorder, thumbnails) | permanent |
+| **1936/tcp** | OpenRestream `openre-rtmp-ingest` (all generations, SO_REUSEPORT) | permanent: URLs handed out as `rtmp://ingest.openre.stream:1936/live` keep working |
+| 19360–19399/tcp (loopback) | OpenRestream per-generation RTMP play + HTTP-FLV | permanent |
+| **9736/tcp** | OpenRestream `openre-jsmpeg` (all generations, SO_REUSEPORT): MPEG-TS POST + WS viewers | permanent: `http://ingest.openre.stream:9736/<key>/<w>/<h>/` and `ws://…/<session>` |
+| 19710–19749/tcp (loopback) | OpenRestream per-generation JSMPEG MPEG-TS data tap | permanent |
+| **9936/tcp** | OpenRestream `openre-webrtc` (all generations, SO_REUSEPORT): WHIP + `/b/<key>` and `/w/<id>` signaling (Live's WHIP is on its app port 3000) | permanent: `http(s)://ingest.openre.stream/whip/<key>` and `ws(s)://ingest.openre.stream/<b\|w>/…` |
+| **10200–10300/udp** | OpenRestream `openre-webrtc` mediasoup RTC (never Live's 10000–10100) | permanent: open at the provider edge, `MEDIASOUP_ANNOUNCED_IP` must be reachable |
+| 19810–19849/tcp (loopback) | OpenRestream per-generation WebRTC RTP/SDP egress API (restream, Media's RTP recorder, thumbnails) | permanent |
| 4500/tcp (loopback) | `openre-api` | permanent |
| 4500/tcp (loopback) | `openre-api` | permanent |
The switch to 1935: once Live no longer listens on 1935, set `OPENRE_RTMP_EXTRA_PORTS=1935` and deploy a worker generation (`deploy.sh workers`); from then on `rtmp://ingest.openre.stream/live` works too, and 1936 keeps working. `ingest.openre.stream` is a DNS-only record (RTMP cannot go through Cloudflare's proxy); open 1936/tcp at the host firewall and the provider edge.
The switch to 1935: once Live no longer listens on 1935, set `OPENRE_RTMP_EXTRA_PORTS=1935` and deploy a worker generation (`deploy.sh workers`); from then on `rtmp://ingest.openre.stream/live` works too, and 1936 keeps working. `ingest.openre.stream` is a DNS-only record (RTMP cannot go through Cloudflare's proxy); open 1936/tcp at the host firewall and the provider edge.
@@ -187,7 +187,7 @@ sudo deploy/scripts/deploy.sh workers # deploy-legacy.sh: starts a
@@ -187,7 +187,7 @@ sudo deploy/scripts/deploy.sh workers # deploy-legacy.sh: starts a
sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
```
```
-- ovhost refuses an API restart while an ingest session is open (`--wait-idle` holds it, `--force` goes ahead), records every attempt (`ovhost releases openre`), prunes releases beyond five but never one a worker generation runs from, and announces the release. When ovhost is missing, too old or does not deploy OpenRe with `release-layout`, the wrapper runs [deploy/scripts/deploy-legacy.sh](deploy/scripts/deploy-legacy.sh), the previous script, unchanged (`OVHOST_LEGACY=1` forces it). `workers`, `status` and `prune` always run it.
+- ovhost refuses an API restart while an ingest session is open (`--wait-idle` holds it, `--force` goes ahead), records every attempt (`ovhost releases openre`), prunes releases beyond five but never one a worker generation runs from, and announces the release. When ovhost is missing, too old or does not deploy OpenRestream with `release-layout`, the wrapper runs [deploy/scripts/deploy-legacy.sh](deploy/scripts/deploy-legacy.sh), the previous script, unchanged (`OVHOST_LEGACY=1` forces it). `workers`, `status` and `prune` always run it.
- An **API deploy** never restarts a worker unit (no unit depends on another). Viewers of `openre.stream/play/…` reconnect; encoders, restreams and recordings do not notice.
- An **API deploy** never restarts a worker unit (no unit depends on another). Viewers of `openre.stream/play/…` reconnect; encoders, restreams and recordings do not notice.
- A **worker deploy** starts `openre-rtmp-ingest@<sha>`, `openre-restream-worker@<sha>` and `openre-jsmpeg@<sha>`; older instances are disabled (not stopped) and exit on their own when drained. Never `systemctl restart` a worker instance during a broadcast; `systemctl stop` starts a drain and waits up to 30 min (`TimeoutStopSec`), then kills.
- A **worker deploy** starts `openre-rtmp-ingest@<sha>`, `openre-restream-worker@<sha>` and `openre-jsmpeg@<sha>`; older instances are disabled (not stopped) and exit on their own when drained. Never `systemctl restart` a worker instance during a broadcast; `systemctl stop` starts a drain and waits up to 30 min (`TimeoutStopSec`), then kills.
@@ -203,17 +203,17 @@ sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
@@ -203,17 +203,17 @@ sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
[docs/live-patch.diff](docs/live-patch.diff) is the Live side, made against OpenVibe.Live `f11f809` and verified there (applies with `git apply`; Live's `npm test` 63/63 on Node 22.22.1). It is applied in Live (`f0ca18b`) and deployed in production, switched off: `OPENRE_URL` is not set in Live's env. **Switch off (the default) changes nothing**: with `OPENRE_URL` unset, or a slot on `'live'`, every changed code path returns what it returned before (`test/openre-switch.test.js` in the patch). The only observable difference is additive: owner responses built from `managed_streams` rows carry the two new columns (`ingest_authority: 'live'`, `openre_stream_id: null`). It adds:
[docs/live-patch.diff](docs/live-patch.diff) is the Live side, made against OpenVibe.Live `f11f809` and verified there (applies with `git apply`; Live's `npm test` 63/63 on Node 22.22.1). It is applied in Live (`f0ca18b`) and deployed in production, switched off: `OPENRE_URL` is not set in Live's env. **Switch off (the default) changes nothing**: with `OPENRE_URL` unset, or a slot on `'live'`, every changed code path returns what it returned before (`test/openre-switch.test.js` in the patch). The only observable difference is additive: owner responses built from `managed_streams` rows carry the two new columns (`ingest_authority: 'live'`, `openre_stream_id: null`). It adds:
-- `server/openre/openre-client.js` — service-token client for OpenRe (streams by `external_ref`, create, rotate, sessions, playback descriptors).
+- `server/openre/openre-client.js` — service-token client for OpenRestream (streams by `external_ref`, create, rotate, sessions, playback descriptors).
- `managed_streams.ingest_authority` (`'live'` default | `'openre'`) + `openre_stream_id`, and an `openre_sessions` projection table (additive, in `initDb`).
- `managed_streams.ingest_authority` (`'live'` default | `'openre'`) + `openre_stream_id`, and an `openre_sessions` projection table (additive, in `initDb`).
-- Live's RTMP `prePublish` refuses a slot key when the slot is on `'openre'`, and a personal key (`users.stream_key`) when any of the user's slots is (a stream can never be ingested twice, and Live never pushes the same destinations as OpenRe). Only RTMP moves; WHIP/browser/JSMPEG for that slot stay on Live.
-- Go Live / stream-key UI for `'openre'` slots: `GET /api/streams/managed` returns the slot without its Live key; `GET /api/streams/managed/:id/profile` and `GET /api/streams/:id/endpoint` show OpenRe's RTMP URL and a key hint (never a key); `POST /api/streams/managed/:id/regenerate-key` rotates on OpenRe and shows the new key once; `GET /api/streams/:id/rtmp-status` reads the mirror. Small fallbacks in `broadcast.js` / `broadcast-workspace.js` display the hint.
-- `POST /internal/openre-events` — OpenVibe.Events delivery endpoint (SDK `parseDelivery` + `createInbox`, consumer `live-openre-mirror`): `openre.session.started` creates or attaches the slot's `streams` row (control config, go-live notifications, Live's own `live.stream.started`), `ended`/`failed` end it and stop the RobotStreamer bridge, chat relays and AI bots as Live's own unpublish does; revision-ordered, exactly once; only for switched slots whose definition has `mirror_to_live` (consent). A 30 s reconcile asks OpenRe about mirrored live sessions (heartbeat refresh; ends rows OpenRe ended). Live's stale-stream cleanup skips mirrored sessions OpenRe confirmed in the last 30 min, and Live's boot-time restream resume skips them.
-- Playback: `GET /api/streams/rtmp-proxy/:id.flv` proxies the session's `flv.internal_url` from OpenRe's playback descriptor (validated as a loopback `…/live/ses_….flv` URL), so Live's player is unchanged.
+- Live's RTMP `prePublish` refuses a slot key when the slot is on `'openre'`, and a personal key (`users.stream_key`) when any of the user's slots is (a stream can never be ingested twice, and Live never pushes the same destinations as OpenRestream). Only RTMP moves; WHIP/browser/JSMPEG for that slot stay on Live.
+- Go Live / stream-key UI for `'openre'` slots: `GET /api/streams/managed` returns the slot without its Live key; `GET /api/streams/managed/:id/profile` and `GET /api/streams/:id/endpoint` show OpenRestream's RTMP URL and a key hint (never a key); `POST /api/streams/managed/:id/regenerate-key` rotates on OpenRestream and shows the new key once; `GET /api/streams/:id/rtmp-status` reads the mirror. Small fallbacks in `broadcast.js` / `broadcast-workspace.js` display the hint.
+- `POST /internal/openre-events` — OpenVibe.Events delivery endpoint (SDK `parseDelivery` + `createInbox`, consumer `live-openre-mirror`): `openre.session.started` creates or attaches the slot's `streams` row (control config, go-live notifications, Live's own `live.stream.started`), `ended`/`failed` end it and stop the RobotStreamer bridge, chat relays and AI bots as Live's own unpublish does; revision-ordered, exactly once; only for switched slots whose definition has `mirror_to_live` (consent). A 30 s reconcile asks OpenRestream about mirrored live sessions (heartbeat refresh; ends rows OpenRestream ended). Live's stale-stream cleanup skips mirrored sessions OpenRestream confirmed in the last 30 min, and Live's boot-time restream resume skips them.
+- Playback: `GET /api/streams/rtmp-proxy/:id.flv` proxies the session's `flv.internal_url` from OpenRestream's playback descriptor (validated as a loopback `…/live/ses_….flv` URL), so Live's player is unchanged.
- Restream routes refuse (409 + `manage_url`) edits/start/stop for destinations of `'openre'` slots: those restreams run and are managed on openre.stream.
- Restream routes refuse (409 + `manage_url`) edits/start/stop for destinations of `'openre'` slots: those restreams run and are managed on openre.stream.
-- Admin: `GET /api/admin/openre/status`, `PUT /api/admin/openre/managed/:id/ingest-authority {authority, force}` — `'openre'` needs the slot offline, an RTMP slot (or `force`), the owner's canonical subject; it finds or creates the OpenRe definition and, in one transaction, flips the slot and rotates Live's own key for it. `'live'` flips back.
+- Admin: `GET /api/admin/openre/status`, `PUT /api/admin/openre/managed/:id/ingest-authority {authority, force}` — `'openre'` needs the slot offline, an RTMP slot (or `force`), the owner's canonical subject; it finds or creates the OpenRestream definition and, in one transaction, flips the slot and rotates Live's own key for it. `'live'` flips back.
- Env: `OPENRE_URL`, `OPENRE_PUBLIC_URL`, `OPENRE_EVENTS_SECRET` (in Live's `.env.example`). Unsetting `OPENRE_URL` is the emergency rollback for every switched slot at once.
- Env: `OPENRE_URL`, `OPENRE_PUBLIC_URL`, `OPENRE_EVENTS_SECRET` (in Live's `.env.example`). Unsetting `OPENRE_URL` is the emergency rollback for every switched slot at once.
-Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRe sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` for an rtmp session and the worker's loopback RTP descriptor — `GET /rtp/<session id>/describe` and `?vport=&aport=` — for a webrtc one); Stream Manager destination editing through OpenRe's API (today: a link to openre.stream); platform viewer counts for OpenRe outputs.
+Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the RobotStreamer publisher for OpenRestream sessions (they read Live's local HTTP-FLV by key; they can read `flv.internal_url` for an rtmp session and the worker's loopback RTP descriptor — `GET /rtp/<session id>/describe` and `?vport=&aport=` — for a webrtc one); Stream Manager destination editing through OpenRestream's API (today: a link to openre.stream); platform viewer counts for OpenRestream outputs.
## Migration
## Migration
@@ -221,7 +221,7 @@ Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the Rob
@@ -221,7 +221,7 @@ Not in the patch (follow-ups): live thumbnails, AI audio/vision taps and the Rob
## Cutover runbook
## Cutover runbook
-ADR-009: one protocol at a time (RTMP → WHIP → JSMPEG → SFU), per slot, behind the switch, in a maintenance window agreed with the broadcaster; every key that existed before is rotated at the RTMP cutover; Live's ingest code stays until the last protocol has run on OpenRe for two weeks.
+ADR-009: one protocol at a time (RTMP → WHIP → JSMPEG → SFU), per slot, behind the switch, in a maintenance window agreed with the broadcaster; every key that existed before is rotated at the RTMP cutover; Live's ingest code stays until the last protocol has run on OpenRestream for two weeks.
### RTMP (ready)
### RTMP (ready)
@@ -241,7 +241,7 @@ the checks and the rollback for every step. In short:
@@ -241,7 +241,7 @@ the checks and the rollback for every step. In short:
`scripts/cutover-preflight.js` checks, read-only, what each step needs: the release, the service,
`scripts/cutover-preflight.js` checks, read-only, what each step needs: the release, the service,
the env, the bind, DNS, the port from outside, the database, Live's env, the Events subscription and
the env, the bind, DNS, the port from outside, the database, Live's env, the Events subscription and
-one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is done. The release with
+one slot. Status on 2026-09-23: OpenRestream is deployed (`655b98a10aaa`) and DNS is done. The release with
`6dc78a5`, the public bind, the provider port, Live's settings and the subscription are not.
`6dc78a5`, the public bind, the provider port, Live's settings and the subscription are not.
### JSMPEG (worker ready)
### JSMPEG (worker ready)
@@ -256,13 +256,13 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d
@@ -256,13 +256,13 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d
| Criterion (ADR-009 / plan W7) | Evidence |
| Criterion (ADR-009 / plan W7) | Evidence |
|---|---|
|---|---|
-| OpenRe ingests and restreams without visiting Live | `test/rtmp-e2e.test.js`: real ffmpeg publish → OpenRe worker → ffmpeg restream to an RTMP sink; no Live process involved |
-| Deploying the OpenRe API does not end worker-owned transports | `test/api-restart.test.js` (API process SIGTERM + restart while a worker process holds a live session; lease keeps renewing) and `test/rtmp-e2e.test.js` (same with a real encoder, restream and FLV playback afterwards) |
+| OpenRestream ingests and restreams without visiting Live | `test/rtmp-e2e.test.js`: real ffmpeg publish → OpenRestream worker → ffmpeg restream to an RTMP sink; no Live process involved |
+| Deploying the OpenRestream API does not end worker-owned transports | `test/api-restart.test.js` (API process SIGTERM + restart while a worker process holds a live session; lease keeps renewing) and `test/rtmp-e2e.test.js` (same with a real encoder, restream and FLV playback afterwards) |
| New sessions route to the newest ready generation; old workers drain | `test/rtmp-e2e.test.js` (second RTMP worker process: new publish on generation 2, generation 1 keeps its session, closes its listener and exits 0 when it ends), `test/coordinator.test.js` |
| New sessions route to the newest ready generation; old workers drain | `test/rtmp-e2e.test.js` (second RTMP worker process: new publish on generation 2, generation 1 keeps its session, closes its listener and exits 0 when it ends), `test/coordinator.test.js` |
| A destination failure never terminates the source session | `test/rtmp-e2e.test.js` (dead destination fails, session and encoder stay), `test/sessions.test.js`, `test/coordinator.test.js` (lost restream worker) |
| A destination failure never terminates the source session | `test/rtmp-e2e.test.js` (dead destination fails, session and encoder stay), `test/sessions.test.js`, `test/coordinator.test.js` (lost restream worker) |
| Recording finalisation stays in Media | `test/recording-events.test.js`, `test/rtmp-e2e.test.js` (Media is asked to create/ingest/finalise; Media's ffmpeg pulls the loopback URL) |
| Recording finalisation stays in Media | `test/recording-events.test.js`, `test/rtmp-e2e.test.js` (Media is asked to create/ingest/finalise; Media's ffmpeg pulls the loopback URL) |
| Every stream key that existed before is rotated | `test/migration.test.js` (old keys never imported; new keys unseen), Live patch rotates Live's own slot key at the switch; checklist step for personal keys |
| Every stream key that existed before is rotated | `test/migration.test.js` (old keys never imported; new keys unseen), Live patch rotates Live's own slot key at the switch; checklist step for personal keys |
-| Deploying Live during a broadcast does not interrupt transport or recording | by construction (no OpenRe process talks to Live); proven for real only once a slot runs on OpenRe — not claimed here (every Live restart still stops Live's own restreams) |
+| Deploying Live during a broadcast does not interrupt transport or recording | by construction (no OpenRestream process talks to Live); proven for real only once a slot runs on OpenRestream — not claimed here (every Live restart still stops Live's own restreams) |
| Switch off = no Live behaviour change | Live's full `npm test` with the patch applied, plus `test/openre-switch.test.js` |
| Switch off = no Live behaviour change | Live's full `npm test` with the patch applied, plus `test/openre-switch.test.js` |
## Launch rule
## Launch rule
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
{
{
- "repository": "OpenVibers/OpenRe.Stream",
+ "repository": "OpenVibers/OpenRestream",
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W7",
"wave": "W7",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"openre.output.write"
"openre.output.write"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
- "livePatch": "applied in Live f0ca18b and deployed; Live wired on 2026-09-23 (OPENRE_URL and the openre.session.* subscription, docs/cutover.md phase A); every Live slot keeps ingest_authority=live until its per-slot cutover (phase B), so no broadcaster uses OpenRe yet",
+ "livePatch": "applied in Live f0ca18b and deployed; Live wired on 2026-09-23 (OPENRE_URL and the openre.session.* subscription, docs/cutover.md phase A); every Live slot keeps ingest_authority=live until its per-slot cutover (phase B), so no broadcaster uses OpenRestream yet",
"deployed": true,
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy openre` (OpenVibe.Host strategy release-layout since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper and keeps workers/status/prune): API + coordinator on 127.0.0.1:4500, RTMP ingest public on 0.0.0.0:1936 (ingest.openre.stream, DNS-only; reachable from outside since 2026-09-23), Live wired (OPENRE_URL, openre.session.* subscription), preflight all PASS, restore drill passed. No slot switched yet: every slot is still ingested by Live until the per-slot cutover (docs/cutover.md phase B).",
"deployment": "Deployed with `sudo ovhost deploy openre` (OpenVibe.Host strategy release-layout since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper and keeps workers/status/prune): API + coordinator on 127.0.0.1:4500, RTMP ingest public on 0.0.0.0:1936 (ingest.openre.stream, DNS-only; reachable from outside since 2026-09-23), Live wired (OPENRE_URL, openre.session.* subscription), preflight all PASS, restore drill passed. No slot switched yet: every slot is still ingested by Live until the per-slot cutover (docs/cutover.md phase B).",
"features": [
"features": [
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
-# OpenRe.Stream deploy (run on the host as root; the lead runs it, never CI).
+# OpenRestream deploy (run on the host as root; the lead runs it, never CI).
#
#
# deploy/scripts/deploy.sh release [<git-ref>] check out <ref> (default origin/main) into
# deploy/scripts/deploy.sh release [<git-ref>] check out <ref> (default origin/main) into
# /opt/openre.stream/releases/<sha>, npm ci
# /opt/openre.stream/releases/<sha>, npm ci
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
set -euo pipefail
set -euo pipefail
ROOT=/opt/openre.stream
ROOT=/opt/openre.stream
-REPO=https://github.com/OpenVibers/OpenRe.Stream.git
+REPO=https://github.com/OpenVibers/OpenRestream.git
API=http://127.0.0.1:4500
API=http://127.0.0.1:4500
NODE_BIN=${NODE_BIN:-node}
NODE_BIN=${NODE_BIN:-node}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
-# OpenRe.Stream deploy: a thin wrapper around `ovhost deploy openre` (OpenVibe.Host, strategy
+# OpenRestream deploy: a thin wrapper around `ovhost deploy openre` (OpenVibe.Host, strategy
# release-layout; roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md). Run on the host as root.
# release-layout; roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md). Run on the host as root.
#
#
# deploy/scripts/deploy.sh [deploy [<ref>]] ovhost deploy openre [--to <ref>]: release + api in one
# deploy/scripts/deploy.sh [deploy [<ref>]] ovhost deploy openre [--to <ref>]: release + api in one
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenRe.Stream API and UI (openre-api): definitions, keys, destinations, sessions, playback proxy
+Description=OpenRestream API and UI (openre-api): definitions, keys, destinations, sessions, playback proxy
# The API owns no transport. Restarting it (every API deploy) never touches the transport
# The API owns no transport. Restarting it (every API deploy) never touches the transport
# workers: they are separate units with no dependency on this one, in either direction.
# workers: they are separate units with no dependency on this one, in either direction.
After=network.target postgresql.service pgbouncer.service valkey-server.service
After=network.target postgresql.service pgbouncer.service valkey-server.service
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenRe.Stream JSMPEG ingest+relay worker, release %i
+Description=OpenRestream JSMPEG ingest+relay worker, release %i
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# instance; the coordinator then drains the older generation, which closes its public listener,
# instance; the coordinator then drains the older generation, which closes its public listener,
# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.
# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenRe.Stream restream worker (ffmpeg per destination), release %i
+Description=OpenRestream restream worker (ffmpeg per destination), release %i
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# instance; the coordinator gives it every new output and drains the older generation, whose
# instance; the coordinator gives it every new output and drains the older generation, whose
# outputs run until their sessions end (or OPENRE_DRAIN_MAX_MS: then they move over) before it exits 0.
# outputs run until their sessions end (or OPENRE_DRAIN_MAX_MS: then they move over) before it exits 0.
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenRe.Stream RTMP ingest worker, release %i
+Description=OpenRestream RTMP ingest worker, release %i
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# instance; the coordinator then drains the older generation, which closes its public listener,
# instance; the coordinator then drains the older generation, which closes its public listener,
# keeps its publishers until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.
# keeps its publishers until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenRe.Stream session coordinator: worker leases and generations, output assignment, recording requests, event relay
+Description=OpenRestream session coordinator: worker leases and generations, output assignment, recording requests, event relay
# Stateless between ticks (everything is in the database): restarting it never ends a session.
# Stateless between ticks (everything is in the database): restarting it never ends a session.
After=network.target postgresql.service pgbouncer.service valkey-server.service
After=network.target postgresql.service pgbouncer.service valkey-server.service
Wants=postgresql.service pgbouncer.service valkey-server.service
Wants=postgresql.service pgbouncer.service valkey-server.service
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenRe.Stream WebRTC worker (WHIP ingest + mediasoup SFU + viewer signaling), release %i
+Description=OpenRestream WebRTC worker (WHIP ingest + mediasoup SFU + viewer signaling), release %i
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# One instance per release (instance name = the release directory). A worker deploy STARTS a new
# instance; the coordinator then drains the older generation, which closes its public listener,
# instance; the coordinator then drains the older generation, which closes its public listener,
# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.
# keeps its sessions until they leave (or OPENRE_DRAIN_MAX_MS), and exits 0 by itself.
@@ -1,13 +1,13 @@
@@ -1,13 +1,13 @@
-# RTMP cutover runbook (OpenRe.Stream)
+# RTMP cutover runbook (OpenRestream)
-The order of work that moves RTMP ingest for Live's stream slots from Live to OpenRe.Stream, one slot
+The order of work that moves RTMP ingest for Live's stream slots from Live to OpenRestream, one slot
at a time (ADR-009). Every step says who does it, how to check it, and how to undo it.
at a time (ADR-009). Every step says who does it, how to check it, and how to undo it.
- **OWNER** steps need the owner: the provider firewall, a Live admin session in the browser, the
- **OWNER** steps need the owner: the provider firewall, a Live admin session in the browser, the
broadcasters, and a real restream destination.
broadcasters, and a real restream destination.
- **AGENT** steps are commands on the host (`ssh openvibe-ovh`, then `sudo`).
- **AGENT** steps are commands on the host (`ssh openvibe-ovh`, then `sudo`).
- Every command, path and env name below was checked against the code at the commit that added this
- Every command, path and env name below was checked against the code at the commit that added this
- file: OpenRe `deploy/scripts/deploy.sh`, `server/config.js`, `scripts/*`; Live `server/openre/*`,
+ file: OpenRestream `deploy/scripts/deploy.sh`, `server/config.js`, `scripts/*`; Live `server/openre/*`,
`deploy/scripts/deploy.sh`; Events `server/api/subscriptions.js`. The production facts (paths,
`deploy/scripts/deploy.sh`; Events `server/api/subscriptions.js`. The production facts (paths,
grants, subscriptions, listeners) were read on the host on 2026-09-23, without changing anything.
grants, subscriptions, listeners) were read on the host on 2026-09-23, without changing anything.
@@ -47,7 +47,7 @@ Everything below runs on the host. The scripts ship with the release (`scripts/`
@@ -47,7 +47,7 @@ Everything below runs on the host. The scripts ship with the release (`scripts/`
| `cutover-preflight.js` | read-only checks: `release service env bind dns port db live-env events [slot]` (`--only`, `--skip`, `--slot <id>`, `--json`, `--strict`, `--expect-release <sha>`, `--probe-url`, `--host-ip`) | nothing |
| `cutover-preflight.js` | read-only checks: `release service env bind dns port db live-env events [slot]` (`--only`, `--skip`, `--slot <id>`, `--json`, `--strict`, `--expect-release <sha>`, `--probe-url`, `--host-ip`) | nothing |
| `subscribe-live-events.js` | Live's Events subscription `openre.session.*` → Live, **as Live** (reads `/etc/openvibe/live.env`); `--dry-run`, `--disable`, `--enable` | one subscription in Events |
| `subscribe-live-events.js` | Live's Events subscription `openre.session.*` → Live, **as Live** (reads `/etc/openvibe/live.env`); `--dry-run`, `--disable`, `--enable` | one subscription in Events |
| `migrate-from-live.js` | imports slots and destinations from a Live **snapshot** (dry run unless `--apply`) | the PostgreSQL store (`ov_openre`) |
| `migrate-from-live.js` | imports slots and destinations from a Live **snapshot** (dry run unless `--apply`) | the PostgreSQL store (`ov_openre`) |
-| `set-definition-state.js` | disables or re-enables one slot's OpenRe definition, **as Live** (per-slot rollback) | the PostgreSQL store (`ov_openre`) via the API |
+| `set-definition-state.js` | disables or re-enables one slot's OpenRestream definition, **as Live** (per-slot rollback) | the PostgreSQL store (`ov_openre`) via the API |
Shell helpers for the session (paste once per SSH session):
Shell helpers for the session (paste once per SSH session):
@@ -55,7 +55,7 @@ Shell helpers for the session (paste once per SSH session):
@@ -55,7 +55,7 @@ Shell helpers for the session (paste once per SSH session):
SHA=<the 12-character release id printed by step A2>
SHA=<the 12-character release id printed by step A2>
# read-only preflight from that release
# read-only preflight from that release
pf() { sudo node /opt/openre.stream/releases/$SHA/scripts/cutover-preflight.js --expect-release "$SHA" "$@"; }
pf() { sudo node /opt/openre.stream/releases/$SHA/scripts/cutover-preflight.js --expect-release "$SHA" "$@"; }
-# run a command in the current release with OpenRe's production env, AS THE SERVICE USER (ubuntu).
+# run a command in the current release with OpenRestream's production env, AS THE SERVICE USER (ubuntu).
openre_as_service() { sudo bash -c "set -a; . /etc/openvibe/openre.env; set +a; export NODE_ENV=production OPENRE_ENV_FILE=/nonexistent; cd /opt/openre.stream/current && sudo -E -u ubuntu $1"; }
openre_as_service() { sudo bash -c "set -a; . /etc/openvibe/openre.env; set +a; export NODE_ENV=production OPENRE_ENV_FILE=/nonexistent; cd /opt/openre.stream/current && sudo -E -u ubuntu $1"; }
DEPLOY=/opt/openre.stream/releases/$SHA/deploy/scripts/deploy.sh
DEPLOY=/opt/openre.stream/releases/$SHA/deploy/scripts/deploy.sh
```
```
@@ -198,7 +198,7 @@ Then repeat the off-host port check from A4.
@@ -198,7 +198,7 @@ Then repeat the off-host port check from A4.
### A7. Rehearsal with a test slot and a real restream destination (OWNER + AGENT)
### A7. Rehearsal with a test slot and a real restream destination (OWNER + AGENT)
-Use a **dedicated test account**, not the owner's own. Once any of a user's slots is on OpenRe, Live
+Use a **dedicated test account**, not the owner's own. Once any of a user's slots is on OpenRestream, Live
refuses that user's personal RTMP key (`refusesLiveIngest`).
refuses that user's personal RTMP key (`refusesLiveIngest`).
1. **OWNER:** sign in to Live once with the test account, so it gets a canonical subject. Create an
1. **OWNER:** sign in to Live once with the test account, so it gets a canonical subject. Create an
@@ -211,7 +211,7 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
@@ -211,7 +211,7 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
agent the slot id.
agent the slot id.
2. **AGENT:** snapshot, then migrate that slot (commands in B2), then `pf --only slot --slot <id>`.
2. **AGENT:** snapshot, then migrate that slot (commands in B2), then `pf --only slot --slot <id>`.
3. **OWNER:** switch it with `await api('/admin/openre/managed/<id>/ingest-authority', { method: 'PUT', body: { authority: 'openre' } })`.
3. **OWNER:** switch it with `await api('/admin/openre/managed/<id>/ingest-authority', { method: 'PUT', body: { authority: 'openre' } })`.
- On the Go Live page, press **Regenerate stream key**. The OpenRe key is shown once. In OBS, set
+ On the Go Live page, press **Regenerate stream key**. The OpenRestream key is shown once. In OBS, set
the server to `rtmp://ingest.openre.stream:1936/live` and paste the key. Start streaming.
the server to `rtmp://ingest.openre.stream:1936/live` and paste the key. Start streaming.
4. **Check (AGENT and OWNER):**
4. **Check (AGENT and OWNER):**
- The channel page on openvibe.live shows the stream, and the player works (Live proxies
- The channel page on openvibe.live shows the stream, and the player works (Live proxies
@@ -225,10 +225,10 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
@@ -225,10 +225,10 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
`sudo -u ubuntu bash -c "set -a; . /etc/openvibe/events.env; set +a; psql \"\$DATABASE_URL\" -c \"SELECT d.status, count(*) FROM deliveries d JOIN subscriptions s ON s.id = d.subscription_id WHERE s.consumer = 'live' AND s.topic_pattern = 'openre.session.*' GROUP BY 1\""`
`sudo -u ubuntu bash -c "set -a; . /etc/openvibe/events.env; set +a; psql \"\$DATABASE_URL\" -c \"SELECT d.status, count(*) FROM deliveries d JOIN subscriptions s ON s.id = d.subscription_id WHERE s.consumer = 'live' AND s.topic_pattern = 'openre.session.*' GROUP BY 1\""`
should all be `delivered`.
should all be `delivered`.
5. **During the broadcast (AGENT):**
5. **During the broadcast (AGENT):**
- - `sudo $DEPLOY api $SHA`: the stream, the restream and the recording do not drop. OpenRe
+ - `sudo $DEPLOY api $SHA`: the stream, the restream and the recording do not drop. OpenRestream
playback viewers reconnect.
playback viewers reconnect.
- `sudo /opt/openvibe.live/deploy/scripts/deploy.sh --restart` (no `--wait-idle`: the rehearsal
- `sudo /opt/openvibe.live/deploy/scripts/deploy.sh --restart` (no `--wait-idle`: the rehearsal
- itself is live on Live). First check `/api/streams` for anyone else live. The OpenRe session,
+ itself is live on Live). First check `/api/streams` for anyone else live. The OpenRestream session,
the restream and the recording keep running. After the restart, Live's 30 s reconcile keeps
the restream and the recording keep running. After the restart, Live's 30 s reconcile keeps
the channel row live.
the channel row live.
- `deploy.sh workers` exercises a generation rollover only with a **newer** release (a new
- `deploy.sh workers` exercises a generation rollover only with a **newer** release (a new
@@ -254,7 +254,7 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
@@ -254,7 +254,7 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
the same change, before `nginx -t`.
the same change, before `nginx -t`.
- (b) Tell broadcasters that destinations are frozen at the cutover until (a).
- (b) Tell broadcasters that destinations are frozen at the cutover until (a).
- **Hazard H4, the leaked keys.** 97 of 97 Live stream keys are identical to the pre-leak backup.
- **Hazard H4, the leaked keys.** 97 of 97 Live stream keys are identical to the pre-leak backup.
- Each slot's cutover rotates that slot's Live key (at the switch) and issues a new OpenRe key that
+ Each slot's cutover rotates that slot's Live key (at the switch) and issues a new OpenRestream key that
only the broadcaster sees. It does not rotate the broadcaster's personal key (`users.stream_key`),
only the broadcaster sees. It does not rotate the broadcaster's personal key (`users.stream_key`),
so step B5 does that. For slots that will not be cut over within days, rotate on Live now: the
so step B5 does that. For slots that will not be cut over within days, rotate on Live now: the
broadcaster presses Regenerate on the Go Live page for each slot, and Regenerate on the dashboard
broadcaster presses Regenerate on the Go Live page for each slot, and Regenerate on the dashboard
@@ -278,7 +278,7 @@ sudo chown ubuntu:ubuntu "$SNAP" && sudo chmod 600 "$SNAP" # it holds every Liv
@@ -278,7 +278,7 @@ sudo chown ubuntu:ubuntu "$SNAP" && sudo chmod 600 "$SNAP" # it holds every Liv
openre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID" # dry run
openre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID" # dry run
openre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID --apply" # import + checklist
openre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID --apply" # import + checklist
sudo rm -f "$SNAP"
sudo rm -f "$SNAP"
-pf --only slot --slot $ID # OpenRe <id> (active, mirrored); N destination(s), held ones counted
+pf --only slot --slot $ID # OpenRestream <id> (active, mirrored); N destination(s), held ones counted
```
```
- The migration imports the slot as a definition with a **new key that nobody has seen**. Old keys
- The migration imports the slot as a definition with a **new key that nobody has seen**. Old keys
@@ -287,7 +287,7 @@ pf --only slot --slot $ID # OpenRe <id> (active, mirrored); N destination(
@@ -287,7 +287,7 @@ pf --only slot --slot $ID # OpenRe <id> (active, mirrored); N destination(
(disabled, with the reason recorded in `migration_map`). It is idempotent.
(disabled, with the reason recorded in `migration_map`). It is idempotent.
- The printed checklist (`--checklist <file>` writes it to a file) is the broadcaster-facing list
- The printed checklist (`--checklist <file>` writes it to a file) is the broadcaster-facing list
for B3 to B6.
for B3 to B6.
-- **Undo:** nothing to undo on Live. The OpenRe definition does nothing until the switch. To be
+- **Undo:** nothing to undo on Live. The OpenRestream definition does nothing until the switch. To be
tidy, `sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot $ID --state disabled`.
tidy, `sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot $ID --state disabled`.
### B3. Switch (OWNER, admin session)
### B3. Switch (OWNER, admin session)
@@ -297,15 +297,15 @@ await api('/admin/openre/managed/<ID>/ingest-authority', { method: 'PUT', body:
@@ -297,15 +297,15 @@ await api('/admin/openre/managed/<ID>/ingest-authority', { method: 'PUT', body:
// → { ingest_authority: 'openre', openre_stream_id, rtmp_url: 'rtmp://ingest.openre.stream:1936/live', live_key_rotated: true }
// → { ingest_authority: 'openre', openre_stream_id, rtmp_url: 'rtmp://ingest.openre.stream:1936/live', live_key_rotated: true }
```
```
-In one transaction, Live points the slot at the OpenRe definition that B2 created and rotates its
+In one transaction, Live points the slot at the OpenRestream definition that B2 created and rotates its
own copy of the slot key. From this moment Live refuses the old key. It also refuses the owner's
own copy of the slot key. From this moment Live refuses the old key. It also refuses the owner's
-personal key for RTMP, because the owner now has a slot on OpenRe. A 409 means one of: the slot is
+personal key for RTMP, because the owner now has a slot on OpenRestream. A 409 means one of: the slot is
live on Live, it is not an RTMP slot, or the owner has no subject. Fix the cause. Do not use `force`
live on Live, it is not an RTMP slot, or the owner has no subject. Fix the cause. Do not use `force`
for a non-RTMP slot.
for a non-RTMP slot.
### B4. New key (broadcaster)
### B4. New key (broadcaster)
-On the Go Live page for that slot, press **Regenerate stream key**. OpenRe rotates the key and shows
+On the Go Live page for that slot, press **Regenerate stream key**. OpenRestream rotates the key and shows
it once. In OBS, set Server to `rtmp://ingest.openre.stream:1936/live` and Stream Key to that key.
it once. In OBS, set Server to `rtmp://ingest.openre.stream:1936/live` and Stream Key to that key.
### B5. Personal key (broadcaster; H4)
### B5. Personal key (broadcaster; H4)
@@ -316,7 +316,7 @@ On the dashboard, press Regenerate for the personal stream key (`POST /api/auth/
@@ -316,7 +316,7 @@ On the dashboard, press Regenerate for the personal stream key (`POST /api/auth/
### B6. Test broadcast (broadcaster, AGENT, OWNER)
### B6. Test broadcast (broadcaster, AGENT, OWNER)
The broadcaster goes live. Check the same things as A7 step 4. `sudo $DEPLOY status` shows the
The broadcaster goes live. Check the same things as A7 step 4. `sudo $DEPLOY status` shows the
-session on the newest generation, and `await api('/admin/openre/status')` lists it. In the OpenRe DB,
+session on the newest generation, and `await api('/admin/openre/status')` lists it. In the OpenRestream DB,
the outputs are `live` and any held destinations are not running.
the outputs are `live` and any held destinations are not running.
### B-rollback (per slot)
### B-rollback (per slot)
@@ -324,12 +324,12 @@ the outputs are `live` and any held destinations are not running.
@@ -324,12 +324,12 @@ the outputs are `live` and any held destinations are not running.
1. The broadcaster stops streaming. The slot must be offline.
1. The broadcaster stops streaming. The slot must be offline.
2. **OWNER:** `await api('/admin/openre/managed/<ID>/ingest-authority', { method: 'PUT', body: { authority: 'live' } })`.
2. **OWNER:** `await api('/admin/openre/managed/<ID>/ingest-authority', { method: 'PUT', body: { authority: 'live' } })`.
3. **AGENT:** `sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot <ID> --state disabled`.
3. **AGENT:** `sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot <ID> --state disabled`.
- OpenRe then refuses the key the broadcaster got in B4, so the slot's destinations can never be
+ OpenRestream then refuses the key the broadcaster got in B4, so the slot's destinations can never be
pushed twice.
pushed twice.
4. **Broadcaster:** press Regenerate on the Go Live page. The Live key was rotated at the switch.
4. **Broadcaster:** press Regenerate on the Go Live page. The Live key was rotated at the switch.
Point OBS back at the Live RTMP URL shown there.
Point OBS back at the Live RTMP URL shown there.
-To switch the slot to OpenRe again later, run `set-definition-state.js --slot <ID> --state active`
+To switch the slot to OpenRestream again later, run `set-definition-state.js --slot <ID> --state active`
first, then B3 to B6.
first, then B3 to B6.
**All slots at once (emergency):** undo A5. Remove `OPENRE_URL`, or restore `live.env.pre-openre`,
**All slots at once (emergency):** undo A5. Remove `OPENRE_URL`, or restore `live.env.pre-openre`,
@@ -339,7 +339,7 @@ switch, so each broadcaster regenerates on the Go Live page.
@@ -339,7 +339,7 @@ switch, so each broadcaster regenerates on the Go Live page.
## JSMPEG protocol cutover (after RTMP)
## JSMPEG protocol cutover (after RTMP)
JSMPEG reuses the RTMP runbook above, with one **protocol precondition** (brief §4.5): the slot's
JSMPEG reuses the RTMP runbook above, with one **protocol precondition** (brief §4.5): the slot's
-`managed_streams.protocol` must be `jsmpeg` and its OpenRe definition carries the `jsmpeg` protocol.
+`managed_streams.protocol` must be `jsmpeg` and its OpenRestream definition carries the `jsmpeg` protocol.
Live keeps its own `jsmpeg-relay.js` (9710/9711) until the last JSMPEG slot is cut over.
Live keeps its own `jsmpeg-relay.js` (9710/9711) until the last JSMPEG slot is cut over.
### J-A. Once, before the first JSMPEG slot (AGENT)
### J-A. Once, before the first JSMPEG slot (AGENT)
@@ -401,9 +401,9 @@ done **once** (the worker generation and its ports), then per slot like every pr
@@ -401,9 +401,9 @@ done **once** (the worker generation and its ports), then per slot like every pr
generations drain and exit by themselves.
generations drain and exit by themselves.
- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant
- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant
`media.object.upload` ns `live`); RTP recording uses `ingestRtpStart`/`ingestRtpStop` (Media ports
`media.object.upload` ns `live`); RTP recording uses `ingestRtpStart`/`ingestRtpStop` (Media ports
- 12000–12199, already allocated by Media). Confirm both for OpenRe's token before switching a slot.
+ 12000–12199, already allocated by Media). Confirm both for OpenRestream's token before switching a slot.
- **Live:** add `webrtc` to `OPENRE_PROTOCOLS` in `server/openre/authority.js` **only when the
- **Live:** add `webrtc` to `OPENRE_PROTOCOLS` in `server/openre/authority.js` **only when the
- `openre-webrtc` generation is ready**, and point the broadcast/watch pages at the OpenRe signaling
+ `openre-webrtc` generation is ready**, and point the broadcast/watch pages at the OpenRestream signaling
endpoints (`sfu-*` messages are unchanged; the page reads `playback.webrtc.signaling_url`).
endpoints (`sfu-*` messages are unchanged; the page reads `playback.webrtc.signaling_url`).
### W-B1–W-B6 per slot (OWNER + broadcaster + AGENT)
### W-B1–W-B6 per slot (OWNER + broadcaster + AGENT)
@@ -465,7 +465,7 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCE
@@ -465,7 +465,7 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCE
themselves. Check `systemctl status 'openre-webrtc@*'` and
themselves. Check `systemctl status 'openre-webrtc@*'` and
`sudo journalctl -u 'openre-webrtc@<sha>.service' -n 50` (it logs the refusal if
`sudo journalctl -u 'openre-webrtc@<sha>.service' -n 50` (it logs the refusal if
`MEDIASOUP_ANNOUNCED_IP` is missing).
`MEDIASOUP_ANNOUNCED_IP` is missing).
-6. **Smoke (AGENT, from off the host too).** A WHIP POST with a key OpenRe never issued must be
+6. **Smoke (AGENT, from off the host too).** A WHIP POST with a key OpenRestream never issued must be
refused by the worker, not by nginx:
refused by the worker, not by nginx:
```
```
curl -sS -o /dev/null -D - -X POST -H 'content-type: application/sdp' --data-binary $'v=0\r\n' \
curl -sS -o /dev/null -D - -X POST -H 'content-type: application/sdp' --data-binary $'v=0\r\n' \
@@ -474,9 +474,9 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCE
@@ -474,9 +474,9 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCE
PASS: TLS verifies, `409` with `x-whip-error: unknown_key`. A `502`/`504` means the worker is not
PASS: TLS verifies, `409` with `x-whip-error: unknown_key`. A `502`/`504` means the worker is not
listening on 9936; `x-whip-error: generation not taking sessions` means it is not ready yet; a
listening on 9936; `x-whip-error: generation not taking sessions` means it is not ready yet; a
`404` means the vhost did not load.
`404` means the vhost did not load.
-7. **Prerequisite for robot video.** OpenVibe.Bot must publish with keys OpenRe issued
+7. **Prerequisite for robot video.** OpenVibe.Bot must publish with keys OpenRestream issued
(`POST /api/v1/streams`, rotated with `POST /api/v1/streams/:id/keys/rotate`): Bot mints its own
(`POST /api/v1/streams`, rotated with `POST /api/v1/streams/:id/keys/rotate`): Bot mints its own
- keys today and OpenRe refuses them exactly like the smoke key. That is Bot job R5
+ keys today and OpenRestream refuses them exactly like the smoke key. That is Bot job R5
(`bot-t15-r5-openvibe-bot-a`); until it ships, steps 1–6 work and a robot's camera is refused.
(`bot-t15-r5-openvibe-bot-a`); until it ships, steps 1–6 work and a robot's camera is refused.
**Rollback.** Nothing here touches RTMP, JSMPEG or the API.
**Rollback.** Nothing here touches RTMP, JSMPEG or the API.
@@ -500,14 +500,14 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCE
@@ -500,14 +500,14 @@ the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCE
and Media calls are off. Writes, `/play/` and sign-in answer 503.
and Media calls are off. Writes, `/play/` and sign-in answer 503.
- Update `STATUS.json` and README "Status" after each phase.
- Update `STATUS.json` and README "Status" after each phase.
- Each slot's Live RTMP key stays in Live's database until Live's RTMP ingest is retired. That
- Each slot's Live RTMP key stays in Live's database until Live's RTMP ingest is retired. That
- happens after the last slot has run on OpenRe for two weeks (ADR-009, register C-40). Port 1935
- moves to OpenRe then (`OPENRE_RTMP_EXTRA_PORTS=1935`, `deploy.sh workers`).
+ happens after the last slot has run on OpenRestream for two weeks (ADR-009, register C-40). Port 1935
+ moves to OpenRestream then (`OPENRE_RTMP_EXTRA_PORTS=1935`, `deploy.sh workers`).
## Order of actions
## Order of actions
| # | Who | Action | Check |
| # | Who | Action | Check |
|---|---|---|---|
|---|---|---|---|
-| A1 | OWNER | push OpenRe `main` (and OpenVibe.Host) | `git log origin/main` |
+| A1 | OWNER | push OpenRestream `main` (and OpenVibe.Host) | `git log origin/main` |
| A2 | AGENT | `deploy.sh release origin/main`, `deploy.sh api <sha>` | `pf --only release,env,db`, `/api/ready` |
| A2 | AGENT | `deploy.sh release origin/main`, `deploy.sh api <sha>` | `pf --only release,env,db`, `/api/ready` |
| A3 | AGENT | `OPENRE_RTMP_BIND=0.0.0.0`, `deploy.sh workers <sha>` | `pf --only service,env,bind` |
| A3 | AGENT | `OPENRE_RTMP_BIND=0.0.0.0`, `deploy.sh workers <sha>` | `pf --only service,env,bind` |
| A4 | **OWNER** | open 1936/tcp at the provider edge | off-host `--only port` PASS |
| A4 | **OWNER** | open 1936/tcp at the provider edge | off-host `--only port` PASS |
@@ -7,7 +7,7 @@ index 5b3d8ac..b30f33b 100644
@@ -7,7 +7,7 @@ index 5b3d8ac..b30f33b 100644
# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.
# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_URL=http://127.0.0.1:4300
+
+
-+# OpenRe.Stream (roadmap Wave 7): the ingest/restream authority. Unset = off, and every stream slot
++# OpenRestream (roadmap Wave 7): the ingest/restream authority. Unset = off, and every stream slot
+# is ingested by Live exactly as before. When set, an admin can switch one slot at a time
+# is ingested by Live exactly as before. When set, an admin can switch one slot at a time
+# (PUT /api/admin/openre/managed/:id/ingest-authority); only RTMP moves. Live's service principal
+# (PUT /api/admin/openre/managed/:id/ingest-authority); only RTMP moves. Live's service principal
+# (the OAuth client above) needs openre.stream.read, openre.stream.write, openre.key.rotate and
+# (the OAuth client above) needs openre.stream.read, openre.stream.write, openre.key.rotate and
@@ -28,7 +28,7 @@ index 5ecdf41..1ebe063 100644
@@ -28,7 +28,7 @@ index 5ecdf41..1ebe063 100644
_wsState.selectedMs = { ..._wsState.selectedMs, ...data.managed_stream };
_wsState.selectedMs = { ..._wsState.selectedMs, ...data.managed_stream };
}
}
_wsState.streamKey = data.stream_key || null;
_wsState.streamKey = data.stream_key || null;
-+ // OpenRe-ingested slot: no key in the profile, only a hint (Regenerate shows a new key once).
++ // OpenRestream-ingested slot: no key in the profile, only a hint (Regenerate shows a new key once).
+ _wsState.streamKeyHint = data.stream_key_hint || null;
+ _wsState.streamKeyHint = data.stream_key_hint || null;
_wsState.profile = data.broadcast_settings || {};
_wsState.profile = data.broadcast_settings || {};
_wsState.profile.vibeCoding = _wsNormalizeVibeSettings(_wsReadStoredVibeSettings(_wsState.profile));
_wsState.profile.vibeCoding = _wsNormalizeVibeSettings(_wsReadStoredVibeSettings(_wsState.profile));
@@ -59,7 +59,7 @@ index f0db572..4610588 100644
@@ -59,7 +59,7 @@ index f0db572..4610588 100644
const ep = data.endpoint || {};
const ep = data.endpoint || {};
const rtmpUrl = ep.rtmpUrl || `rtmp://${location.hostname}:1935/live`;
const rtmpUrl = ep.rtmpUrl || `rtmp://${location.hostname}:1935/live`;
- const streamKey = ep.streamKey || data.stream_key || 'N/A';
- const streamKey = ep.streamKey || data.stream_key || 'N/A';
-+ // An OpenRe-ingested slot never returns its key here, only a hint (Regenerate shows a new key once).
++ // An OpenRestream-ingested slot never returns its key here, only a hint (Regenerate shows a new key once).
+ const streamKey = ep.streamKey || data.stream_key || ep.streamKeyHint || 'N/A';
+ const streamKey = ep.streamKey || data.stream_key || ep.streamKeyHint || 'N/A';
document.getElementById('bc-rtmp-url').textContent = rtmpUrl;
document.getElementById('bc-rtmp-url').textContent = rtmpUrl;
document.getElementById('bc-rtmp-key').textContent = streamKey;
document.getElementById('bc-rtmp-key').textContent = streamKey;
@@ -72,7 +72,7 @@ index e8f5903..60c20bf 100644
@@ -72,7 +72,7 @@ index e8f5903..60c20bf 100644
database.exec('CREATE INDEX IF NOT EXISTS idx_vibe_events_stream ON vibe_coding_events(stream_id)');
database.exec('CREATE INDEX IF NOT EXISTS idx_vibe_events_stream ON vibe_coding_events(stream_id)');
} catch (e) { console.warn('[DB] vibe_coding migration:', e.message); }
} catch (e) { console.warn('[DB] vibe_coding migration:', e.message); }
-+ // OpenRe ingest switch (roadmap Wave 7): managed_streams.ingest_authority ('live' by default)
++ // OpenRestream ingest switch (roadmap Wave 7): managed_streams.ingest_authority ('live' by default)
+ // and the openre_sessions mirror. Additive only; see server/openre/schema.js.
+ // and the openre_sessions mirror. Additive only; see server/openre/schema.js.
+ try { require('../openre/schema').ensure(database); } catch (e) { console.warn('[DB] openre schema:', e.message); }
+ try { require('../openre/schema').ensure(database); } catch (e) { console.warn('[DB] openre schema:', e.message); }
+
+
@@ -87,7 +87,7 @@ index 63c0969..c88df08 100644
@@ -87,7 +87,7 @@ index 63c0969..c88df08 100644
function hasActiveLiveFeed(stream) {
function hasActiveLiveFeed(stream) {
if (!stream) return false;
if (!stream) return false;
-+ // OpenRe.Stream holds the ingest (a mirrored session it confirmed recently): not stale.
++ // OpenRestream holds the ingest (a mirrored session it confirmed recently): not stale.
+ if (require('./openre/mirror').hasLiveSession(stream.id)) return true;
+ if (require('./openre/mirror').hasLiveSession(stream.id)) return true;
const streamKey = getStreamKey(stream);
const streamKey = getStreamKey(stream);
if (stream.protocol === 'rtmp') {
if (stream.protocol === 'rtmp') {
@@ -96,7 +96,7 @@ index 63c0969..c88df08 100644
@@ -96,7 +96,7 @@ index 63c0969..c88df08 100644
// OpenVibe.Media → Live webhook (vod.ready / clip.ready …). Mounted BEFORE the
// OpenVibe.Media → Live webhook (vod.ready / clip.ready …). Mounted BEFORE the
// /internal router because it authenticates with an HMAC signature, not X-Internal-Key.
// /internal router because it authenticates with an HMAC signature, not X-Internal-Key.
app.post('/internal/media-webhook', require('./media-proxy/webhook'));
app.post('/internal/media-webhook', require('./media-proxy/webhook'));
-+// OpenVibe.Events → Live: OpenRe session lifecycle mirrored into `streams` (signed delivery,
++// OpenVibe.Events → Live: OpenRestream session lifecycle mirrored into `streams` (signed delivery,
+// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
+// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
+app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
+app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
@@ -114,7 +114,7 @@ index 63c0969..c88df08 100644
@@ -114,7 +114,7 @@ index 63c0969..c88df08 100644
// 5b. Resume enabled restreams for streams that survived the restart.
// 5b. Resume enabled restreams for streams that survived the restart.
// WHIP/RTMP broadcasters have no browser session to re-start them manually.
// WHIP/RTMP broadcasters have no browser session to re-start them manually.
for (const stream of db.getLiveStreams()) {
for (const stream of db.getLiveStreams()) {
-+ // OpenRe restreams its own sessions; Live must not start a second push for them.
++ // OpenRestream restreams its own sessions; Live must not start a second push for them.
+ if (require('./openre/mirror').ownsStream(stream.id)) continue;
+ if (require('./openre/mirror').ownsStream(stream.id)) continue;
restreamManager.resumeForStream(stream.id, stream.user_id, {
restreamManager.resumeForStream(stream.id, stream.user_id, {
protocol: stream.protocol,
protocol: stream.protocol,
@@ -123,8 +123,8 @@ index 63c0969..c88df08 100644
@@ -123,8 +123,8 @@ index 63c0969..c88df08 100644
// 6e. Start periodic viewer count polling for restream destinations
// 6e. Start periodic viewer count polling for restream destinations
restreamManager.startViewerCountPolling();
restreamManager.startViewerCountPolling();
-+ // 6g. OpenRe mirror reconcile (only runs when OPENRE_URL is set; idle with nothing mirrored).
-+ try { require('./openre/mirror').start(); } catch (e) { console.warn('[OpenRe] mirror reconcile not started:', e.message); }
++ // 6g. OpenRestream mirror reconcile (only runs when OPENRE_URL is set; idle with nothing mirrored).
++ try { require('./openre/mirror').start(); } catch (e) { console.warn('[OpenRestream] mirror reconcile not started:', e.message); }
+
+
// 6f. VOD storage/offload is owned by OpenVibe.Media now — nothing to start here.
// 6f. VOD storage/offload is owned by OpenVibe.Media now — nothing to start here.
@@ -139,14 +139,14 @@ index 0000000..fa4b4ab
@@ -139,14 +139,14 @@ index 0000000..fa4b4ab
+/**
+/**
+ * The per-slot ingest switch: managed_streams.ingest_authority = 'live' (default) | 'openre'.
+ * The per-slot ingest switch: managed_streams.ingest_authority = 'live' (default) | 'openre'.
+ *
+ *
-+ * 'openre' means OpenRe.Stream ingests this slot's RTMP: Live's RTMP server refuses the slot's
-+ * key (so a stream can never be ingested twice), the Go Live UI shows OpenRe's ingest URL and
-+ * rotates OpenRe's key, and the slot's sessions reach `streams` through the mirror (mirror.js).
++ * 'openre' means OpenRestream ingests this slot's RTMP: Live's RTMP server refuses the slot's
++ * key (so a stream can never be ingested twice), the Go Live UI shows OpenRestream's ingest URL and
++ * rotates OpenRestream's key, and the slot's sessions reach `streams` through the mirror (mirror.js).
+ * Only RTMP moves (OPENRE_PROTOCOLS); WHIP, browser/WebRTC and JSMPEG stay on Live until
+ * Only RTMP moves (OPENRE_PROTOCOLS); WHIP, browser/WebRTC and JSMPEG stay on Live until
-+ * OpenRe carries them.
++ * OpenRestream carries them.
+ *
+ *
+ * Switch off = zero behaviour change: every helper here returns what Live did before unless the
+ * Switch off = zero behaviour change: every helper here returns what Live did before unless the
-+ * slot says 'openre' AND OpenRe is configured (openre-client enabled()).
++ * slot says 'openre' AND OpenRestream is configured (openre-client enabled()).
+ */
+ */
+const crypto = require('crypto');
+const crypto = require('crypto');
+const db = require('../db/database');
+const db = require('../db/database');
@@ -163,7 +163,7 @@ index 0000000..fa4b4ab
@@ -163,7 +163,7 @@ index 0000000..fa4b4ab
+ return id ? db.getManagedStreamById(id) : null;
+ return id ? db.getManagedStreamById(id) : null;
+}
+}
+
+
-+/** authorityOf(slotById(id)) === 'openre', without reading the slot when OpenRe is not configured. */
++/** authorityOf(slotById(id)) === 'openre', without reading the slot when OpenRestream is not configured. */
+function slotIsOpenre(id) {
+function slotIsOpenre(id) {
+ if (!id || !client.enabled()) return false;
+ if (!id || !client.enabled()) return false;
+ return authorityOf(slotById(id)) === 'openre';
+ return authorityOf(slotById(id)) === 'openre';
@@ -171,7 +171,7 @@ index 0000000..fa4b4ab
@@ -171,7 +171,7 @@ index 0000000..fa4b4ab
+
+
+/**
+/**
+ * Live's RTMP publish handler asks this before accepting a key. A slot key is refused when its slot
+ * Live's RTMP publish handler asks this before accepting a key. A slot key is refused when its slot
-+ * is on OpenRe; a personal key (users.stream_key, no slot) is refused when any of the user's slots is,
++ * is on OpenRestream; a personal key (users.stream_key, no slot) is refused when any of the user's slots is,
+ * because Live would attach it to the user's live rows and push the user's destinations twice.
+ * because Live would attach it to the user's live rows and push the user's destinations twice.
+ */
+ */
+function refusesLiveIngest({ managedStream, user = null, protocol = 'rtmp' }) {
+function refusesLiveIngest({ managedStream, user = null, protocol = 'rtmp' }) {
@@ -197,7 +197,7 @@ index 0000000..fa4b4ab
@@ -197,7 +197,7 @@ index 0000000..fa4b4ab
+}
+}
+
+
+/**
+/**
-+ * What the Go Live UI shows for an OpenRe slot: OpenRe's RTMP URL and the hint of its current key
++ * What the Go Live UI shows for an OpenRestream slot: OpenRestream's RTMP URL and the hint of its current key
+ * (the key itself is only ever shown once, by a rotation).
+ * (the key itself is only ever shown once, by a rotation).
+ */
+ */
+async function ingestFor(slot, subject) {
+async function ingestFor(slot, subject) {
@@ -210,15 +210,15 @@ index 0000000..fa4b4ab
@@ -210,15 +210,15 @@ index 0000000..fa4b4ab
+ rtmp_url: rtmp ? rtmp.url : null,
+ rtmp_url: rtmp ? rtmp.url : null,
+ stream_key_hint: rtmp && rtmp.key_hint
+ stream_key_hint: rtmp && rtmp.key_hint
+ ? `Shown once: press Regenerate for a new key (current key ends in …${rtmp.key_hint})`
+ ? `Shown once: press Regenerate for a new key (current key ends in …${rtmp.key_hint})`
-+ : 'Press Regenerate to get your OpenRe stream key',
++ : 'Press Regenerate to get your OpenRestream stream key',
+ openre_manage_url: client.manageUrl(stream ? stream.id : slot.openre_stream_id),
+ openre_manage_url: client.manageUrl(stream ? stream.id : slot.openre_stream_id),
+ };
+ };
+}
+}
+
+
-+/** Regenerate on an OpenRe slot: OpenRe rotates, the new key is returned once. */
++/** Regenerate on an OpenRestream slot: OpenRestream rotates, the new key is returned once. */
+async function rotateFor(slot, subject) {
+async function rotateFor(slot, subject) {
+ const streamId = slot.openre_stream_id || (await client.streamForSlot(slot.id) || {}).id;
+ const streamId = slot.openre_stream_id || (await client.streamForSlot(slot.id) || {}).id;
-+ if (!streamId) throw new client.OpenReError('this slot has no OpenRe stream yet', 409);
++ if (!streamId) throw new client.OpenReError('this slot has no OpenRestream stream yet', 409);
+ const r = await client.rotateKey(streamId, { subject });
+ const r = await client.rotateKey(streamId, { subject });
+ return { stream_key: r.key.key, stream_key_managed_by: 'openre', rtmp_url: r.ingest && r.ingest.rtmp ? r.ingest.rtmp.url : null };
+ return { stream_key: r.key.key, stream_key_managed_by: 'openre', rtmp_url: r.ingest && r.ingest.rtmp ? r.ingest.rtmp.url : null };
+}
+}
@@ -231,8 +231,8 @@ index 0000000..fa4b4ab
@@ -231,8 +231,8 @@ index 0000000..fa4b4ab
+
+
+/**
+/**
+ * The admin switch (PUT /api/admin/openre/managed/:id/ingest-authority).
+ * The admin switch (PUT /api/admin/openre/managed/:id/ingest-authority).
-+ * 'openre': needs OpenRe configured, the slot offline on Live, the owner's canonical subject;
-+ * finds or creates the OpenRe definition, then (one transaction) flips the slot and
++ * 'openre': needs OpenRestream configured, the slot offline on Live, the owner's canonical subject;
++ * finds or creates the OpenRestream definition, then (one transaction) flips the slot and
+ * rotates Live's own key for it, so no key that existed before works anywhere.
+ * rotates Live's own key for it, so no key that existed before works anywhere.
+ * 'live': flips back (rollback). Live's key was rotated at the switch: the broadcaster
+ * 'live': flips back (rollback). Live's key was rotated at the switch: the broadcaster
+ * regenerates it on the Go Live page.
+ * regenerates it on the Go Live page.
@@ -243,15 +243,15 @@ index 0000000..fa4b4ab
@@ -243,15 +243,15 @@ index 0000000..fa4b4ab
+ if (!['live', 'openre'].includes(authority)) return { status: 400, error: "authority must be 'live' or 'openre'" };
+ if (!['live', 'openre'].includes(authority)) return { status: 400, error: "authority must be 'live' or 'openre'" };
+ if (authority === 'live') {
+ if (authority === 'live') {
+ db.run("UPDATE managed_streams SET ingest_authority = 'live', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [slot.id]);
+ db.run("UPDATE managed_streams SET ingest_authority = 'live', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [slot.id]);
-+ return { status: 200, body: { managed_stream_id: slot.id, ingest_authority: 'live', next: 'The broadcaster regenerates the stream key on the Go Live page (the Live key was rotated when the slot moved to OpenRe).' } };
++ return { status: 200, body: { managed_stream_id: slot.id, ingest_authority: 'live', next: 'The broadcaster regenerates the stream key on the Go Live page (the Live key was rotated when the slot moved to OpenRestream).' } };
+ }
+ }
-+ if (!client.enabled()) return { status: 409, error: 'OpenRe is not configured on this Live (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)' };
++ if (!client.enabled()) return { status: 409, error: 'OpenRestream is not configured on this Live (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)' };
+ if (slot.ingest_authority === 'openre') return { status: 200, body: { managed_stream_id: slot.id, ingest_authority: 'openre', openre_stream_id: slot.openre_stream_id, unchanged: true } };
+ if (slot.ingest_authority === 'openre') return { status: 200, body: { managed_stream_id: slot.id, ingest_authority: 'openre', openre_stream_id: slot.openre_stream_id, unchanged: true } };
+ const live = db.get('SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 LIMIT 1', [slot.id]);
+ const live = db.get('SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 LIMIT 1', [slot.id]);
+ if (live) return { status: 409, error: `Slot ${slot.id} is live on Live (stream ${live.id}); switch it in a maintenance window, while it is offline` };
+ if (live) return { status: 409, error: `Slot ${slot.id} is live on Live (stream ${live.id}); switch it in a maintenance window, while it is offline` };
+ const method = String(slot.streaming_method || '').toLowerCase();
+ const method = String(slot.streaming_method || '').toLowerCase();
+ if (!force && slot.protocol !== 'rtmp' && !['rtmp', 'obs'].includes(method)) {
+ if (!force && slot.protocol !== 'rtmp' && !['rtmp', 'obs'].includes(method)) {
-+ return { status: 409, error: `Slot ${slot.id} is a ${slot.protocol}${method ? `/${method}` : ''} slot; OpenRe only ingests RTMP today (pass force to switch anyway)` };
++ return { status: 409, error: `Slot ${slot.id} is a ${slot.protocol}${method ? `/${method}` : ''} slot; OpenRestream only ingests RTMP today (pass force to switch anyway)` };
+ }
+ }
+ const subject = require('../auth/identity-sync').subjectOf(slot.user_id);
+ const subject = require('../auth/identity-sync').subjectOf(slot.user_id);
+ if (!subject) return { status: 409, error: 'The owner has no canonical subject yet (they need to sign in to Live once)' };
+ if (!subject) return { status: 409, error: 'The owner has no canonical subject yet (they need to sign in to Live once)' };
@@ -263,7 +263,7 @@ index 0000000..fa4b4ab
@@ -263,7 +263,7 @@ index 0000000..fa4b4ab
+ }
+ }
+ const newLiveKey = crypto.randomBytes(20).toString('hex');
+ const newLiveKey = crypto.randomBytes(20).toString('hex');
+ const flipped = db.getDb().transaction(() => {
+ const flipped = db.getDb().transaction(() => {
-+ // Checked again here: a Live publish may have started while OpenRe was being asked.
++ // Checked again here: a Live publish may have started while OpenRestream was being asked.
+ if (db.get('SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 LIMIT 1', [slot.id])) return false;
+ if (db.get('SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 LIMIT 1', [slot.id])) return false;
+ db.run("UPDATE managed_streams SET ingest_authority = 'openre', openre_stream_id = ?, stream_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [stream.id, newLiveKey, slot.id]);
+ db.run("UPDATE managed_streams SET ingest_authority = 'openre', openre_stream_id = ?, stream_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [stream.id, newLiveKey, slot.id]);
+ return true;
+ return true;
@@ -277,7 +277,7 @@ index 0000000..fa4b4ab
@@ -277,7 +277,7 @@ index 0000000..fa4b4ab
+ openre_stream_id: stream.id,
+ openre_stream_id: stream.id,
+ rtmp_url: stream.ingest && stream.ingest.rtmp ? stream.ingest.rtmp.url : null,
+ rtmp_url: stream.ingest && stream.ingest.rtmp ? stream.ingest.rtmp.url : null,
+ live_key_rotated: true,
+ live_key_rotated: true,
-+ next: 'The broadcaster presses Regenerate on the Go Live page (OpenRe issues the key) and pastes the new server and key into OBS.',
++ next: 'The broadcaster presses Regenerate on the Go Live page (OpenRestream issues the key) and pastes the new server and key into OBS.',
+ },
+ },
+ };
+ };
+}
+}
@@ -291,19 +291,19 @@ index 0000000..5a32b1c
@@ -291,19 +291,19 @@ index 0000000..5a32b1c
@@ -0,0 +1,216 @@
@@ -0,0 +1,216 @@
+'use strict';
+'use strict';
+/**
+/**
-+ * OpenRe sessions → Live's `streams` table (roadmap Wave 7, ADR-009: Live observes sessions through
-+ * OpenRe events and APIs). Watch pages, discovery, go-live notifications and analytics keep working
-+ * because an OpenRe-ingested session becomes an ordinary live `streams` row.
++ * OpenRestream sessions → Live's `streams` table (roadmap Wave 7, ADR-009: Live observes sessions through
++ * OpenRestream events and APIs). Watch pages, discovery, go-live notifications and analytics keep working
++ * because an OpenRestream-ingested session becomes an ordinary live `streams` row.
+ *
+ *
+ * POST /internal/openre-events the endpoint of Live's OpenVibe.Events subscription (topic
+ * POST /internal/openre-events the endpoint of Live's OpenVibe.Events subscription (topic
+ * openre.session.*), signed with OPENRE_EVENTS_SECRET. Each event
+ * openre.session.*), signed with OPENRE_EVENTS_SECRET. Each event
+ * is applied exactly once (openvibe-sdk inbox, consumer
+ * is applied exactly once (openvibe-sdk inbox, consumer
+ * 'live-openre-mirror'), in revision order per session.
+ * 'live-openre-mirror'), in revision order per session.
-+ * reconcile (every 30 s) for mirrored live sessions only: asks OpenRe for the session,
-+ * keeps the row's heartbeat fresh, ends it if OpenRe says it ended
++ * reconcile (every 30 s) for mirrored live sessions only: asks OpenRestream for the session,
++ * keeps the row's heartbeat fresh, ends it if OpenRestream says it ended
+ * (covers a missed event). Does nothing when no row is mirrored.
+ * (covers a missed event). Does nothing when no row is mirrored.
+ *
+ *
-+ * Consent: a session is mirrored only when its slot is switched to OpenRe on Live AND the OpenRe
++ * Consent: a session is mirrored only when its slot is switched to OpenRestream on Live AND the OpenRestream
+ * definition says mirror_to_live (plan §15.10 "explicit visibility and consent").
+ * definition says mirror_to_live (plan §15.10 "explicit visibility and consent").
+ */
+ */
+const db = require('../db/database');
+const db = require('../db/database');
@@ -312,7 +312,7 @@ index 0000000..5a32b1c
@@ -312,7 +312,7 @@ index 0000000..5a32b1c
+
+
+const CONSUMER = 'live-openre-mirror';
+const CONSUMER = 'live-openre-mirror';
+const RECONCILE_MS = 30000;
+const RECONCILE_MS = 30000;
-+// A mirrored session OpenRe has not confirmed for this long is left to Live's stale cleanup.
++// A mirrored session OpenRestream has not confirmed for this long is left to Live's stale cleanup.
+const CONFIRM_WINDOW_MIN = 30;
+const CONFIRM_WINDOW_MIN = 30;
+let inbox = null;
+let inbox = null;
+let timer = null;
+let timer = null;
@@ -391,11 +391,11 @@ index 0000000..5a32b1c
@@ -391,11 +391,11 @@ index 0000000..5a32b1c
+ const channel = db.getChannelByUserId(user.id);
+ const channel = db.getChannelByUserId(user.id);
+ const configId = slot.control_config_id || (channel && channel.active_control_config_id);
+ const configId = slot.control_config_id || (channel && channel.active_control_config_id);
+ if (configId) db.applyConfigToStream(configId, streamId);
+ if (configId) db.applyConfigToStream(configId, streamId);
-+ } catch (e) { console.warn('[OpenRe] control config for mirrored stream failed:', e.message); }
++ } catch (e) { console.warn('[OpenRestream] control config for mirrored stream failed:', e.message); }
+ const stream = db.getStreamById(streamId) || { id: streamId };
+ const stream = db.getStreamById(streamId) || { id: streamId };
-+ try { require('../streaming/golive-notify').notifyFollowersGoLive(user, stream); } catch (e) { console.warn('[OpenRe] go-live notify failed:', e.message); }
++ try { require('../streaming/golive-notify').notifyFollowersGoLive(user, stream); } catch (e) { console.warn('[OpenRestream] go-live notify failed:', e.message); }
+ try { require('../streaming/live-events').announceGoLive(stream, user); } catch { /* */ }
+ try { require('../streaming/live-events').announceGoLive(stream, user); } catch { /* */ }
-+ console.log(`[OpenRe] session ${sessionId} mirrored into stream ${streamId} (${user.username}, slot ${slot.id})`);
++ console.log(`[OpenRestream] session ${sessionId} mirrored into stream ${streamId} (${user.username}, slot ${slot.id})`);
+ },
+ },
+ };
+ };
+ }
+ }
@@ -419,7 +419,7 @@ index 0000000..5a32b1c
@@ -419,7 +419,7 @@ index 0000000..5a32b1c
+ try { require('../integrations/robotstreamer-service').stopForStream(streamId); } catch { /* */ }
+ try { require('../integrations/robotstreamer-service').stopForStream(streamId); } catch { /* */ }
+ try { require('../integrations/chat-relay-service').stopForStream(streamId); } catch { /* */ }
+ try { require('../integrations/chat-relay-service').stopForStream(streamId); } catch { /* */ }
+ try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch { /* */ }
+ try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch { /* */ }
-+ console.log(`[OpenRe] session ${sessionId} ${state}: stream ${streamId} ended`);
++ console.log(`[OpenRestream] session ${sessionId} ${state}: stream ${streamId} ended`);
+ },
+ },
+ };
+ };
+}
+}
@@ -441,20 +441,20 @@ index 0000000..5a32b1c
@@ -441,20 +441,20 @@ index 0000000..5a32b1c
+ after = r.after || null;
+ after = r.after || null;
+ return r.outcome;
+ return r.outcome;
+ });
+ });
-+ if (!result.duplicate && after) { try { after(); } catch (e) { console.warn('[OpenRe] post-commit step failed:', e.message); } }
++ if (!result.duplicate && after) { try { after(); } catch (e) { console.warn('[OpenRestream] post-commit step failed:', e.message); } }
+ } catch (err) {
+ } catch (err) {
-+ console.error('[OpenRe] event apply failed:', err.message);
++ console.error('[OpenRestream] event apply failed:', err.message);
+ return res.status(500).json({ error: 'apply failed' }); // Events retries
+ return res.status(500).json({ error: 'apply failed' }); // Events retries
+ }
+ }
+ return res.status(204).end();
+ return res.status(204).end();
+}
+}
+
+
-+/** Stream id → the mirrored OpenRe session (or null). */
++/** Stream id → the mirrored OpenRestream session (or null). */
+function sessionForStream(streamId) {
+function sessionForStream(streamId) {
+ try { return db.get('SELECT * FROM openre_sessions WHERE stream_id = ? ORDER BY updated_at DESC LIMIT 1', [streamId]) || null; } catch { return null; }
+ try { return db.get('SELECT * FROM openre_sessions WHERE stream_id = ? ORDER BY updated_at DESC LIMIT 1', [streamId]) || null; } catch { return null; }
+}
+}
+
+
-+/** Does OpenRe hold a live ingest for this stream (confirmed recently)? Used by Live's stale cleanup. */
++/** Does OpenRestream hold a live ingest for this stream (confirmed recently)? Used by Live's stale cleanup. */
+function hasLiveSession(streamId) {
+function hasLiveSession(streamId) {
+ try {
+ try {
+ return Boolean(db.get(`SELECT 1 FROM openre_sessions WHERE stream_id = ? AND state = 'live'
+ return Boolean(db.get(`SELECT 1 FROM openre_sessions WHERE stream_id = ? AND state = 'live'
@@ -462,7 +462,7 @@ index 0000000..5a32b1c
@@ -462,7 +462,7 @@ index 0000000..5a32b1c
+ } catch { return false; }
+ } catch { return false; }
+}
+}
+
+
-+/** Does OpenRe own this stream at all (so Live must not start its own restream/recording for it)? */
++/** Does OpenRestream own this stream at all (so Live must not start its own restream/recording for it)? */
+function ownsStream(streamId) {
+function ownsStream(streamId) {
+ return Boolean(sessionForStream(streamId));
+ return Boolean(sessionForStream(streamId));
+}
+}
@@ -473,14 +473,14 @@ index 0000000..5a32b1c
@@ -473,14 +473,14 @@ index 0000000..5a32b1c
+ if (!rows.length || !client.enabled()) return 0;
+ if (!rows.length || !client.enabled()) return 0;
+ let n = 0;
+ let n = 0;
+ for (const r of rows) {
+ for (const r of rows) {
-+ // Live ended the row itself (End Stream, or stale cleanup while OpenRe was unreachable):
++ // Live ended the row itself (End Stream, or stale cleanup while OpenRestream was unreachable):
+ // stop tracking it rather than keeping a live mirror of an offline stream.
+ // stop tracking it rather than keeping a live mirror of an offline stream.
+ const row = r.stream_id ? db.getStreamById(r.stream_id) : null;
+ const row = r.stream_id ? db.getStreamById(r.stream_id) : null;
+ if (row && !row.is_live) { db.run("UPDATE openre_sessions SET state = 'detached', updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]); continue; }
+ if (row && !row.is_live) { db.run("UPDATE openre_sessions SET state = 'detached', updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]); continue; }
+ let s;
+ let s;
+ try { s = await client.getSession(r.session_id); } catch (err) {
+ try { s = await client.getSession(r.session_id); } catch (err) {
+ if (err.status === 404) s = { state: 'failed', revision: r.revision + 1, ended_at: null };
+ if (err.status === 404) s = { state: 'failed', revision: r.revision + 1, ended_at: null };
-+ else continue; // OpenRe unreachable: keep the last known state (bounded by CONFIRM_WINDOW_MIN)
++ else continue; // OpenRestream unreachable: keep the last known state (bounded by CONFIRM_WINDOW_MIN)
+ }
+ }
+ if (s.state === 'live' || s.state === 'starting') {
+ if (s.state === 'live' || s.state === 'starting') {
+ db.run("UPDATE openre_sessions SET confirmed_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]);
+ db.run("UPDATE openre_sessions SET confirmed_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]);
@@ -497,7 +497,7 @@ index 0000000..5a32b1c
@@ -497,7 +497,7 @@ index 0000000..5a32b1c
+
+
+function start() {
+function start() {
+ if (timer || !client.enabled()) return;
+ if (timer || !client.enabled()) return;
-+ timer = setInterval(() => { reconcileOnce().catch(err => console.warn('[OpenRe] reconcile failed:', err.message)); }, RECONCILE_MS);
++ timer = setInterval(() => { reconcileOnce().catch(err => console.warn('[OpenRestream] reconcile failed:', err.message)); }, RECONCILE_MS);
+ if (timer.unref) timer.unref();
+ if (timer.unref) timer.unref();
+}
+}
+
+
@@ -513,8 +513,8 @@ index 0000000..6862412
@@ -513,8 +513,8 @@ index 0000000..6862412
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+'use strict';
+/**
+/**
-+ * OpenRe.Stream client (roadmap Wave 7). Live asks OpenRe for stream definitions, ingest URLs,
-+ * key rotation, sessions and playback descriptors; it never touches an OpenRe worker.
++ * OpenRestream client (roadmap Wave 7). Live asks OpenRestream for stream definitions, ingest URLs,
++ * key rotation, sessions and playback descriptors; it never touches an OpenRestream worker.
+ *
+ *
+ * Auth: Live's Network service principal (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET),
+ * Auth: Live's Network service principal (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET),
+ * client-credentials tokens for audience openvibe.openre. Grants Live needs on openre:
+ * client-credentials tokens for audience openvibe.openre. Grants Live needs on openre:
@@ -522,7 +522,7 @@ index 0000000..6862412
@@ -522,7 +522,7 @@ index 0000000..6862412
+ * Calls made for a streamer carry X-OV-Subject: <their usr_ subject>.
+ * Calls made for a streamer carry X-OV-Subject: <their usr_ subject>.
+ *
+ *
+ * Env:
+ * Env:
-+ * OPENRE_URL OpenRe API, host-local (http://127.0.0.1:4500). Unset = integration off:
++ * OPENRE_URL OpenRestream API, host-local (http://127.0.0.1:4500). Unset = integration off:
+ * every slot behaves exactly as before, whatever its ingest_authority.
+ * every slot behaves exactly as before, whatever its ingest_authority.
+ * OPENRE_PUBLIC_URL for links to the standalone UI (default https://openre.stream)
+ * OPENRE_PUBLIC_URL for links to the standalone UI (default https://openre.stream)
+ * OPENRE_EVENTS_SECRET the signing secret of Live's OpenVibe.Events subscription to openre.*
+ * OPENRE_EVENTS_SECRET the signing secret of Live's OpenVibe.Events subscription to openre.*
@@ -543,7 +543,7 @@ index 0000000..6862412
@@ -543,7 +543,7 @@ index 0000000..6862412
+ };
+ };
+}
+}
+
+
-+/** True when Live is configured to talk to OpenRe at all. */
++/** True when Live is configured to talk to OpenRestream at all. */
+function enabled() {
+function enabled() {
+ const s = settings();
+ const s = settings();
+ return Boolean(s.url && s.clientSecret);
+ return Boolean(s.url && s.clientSecret);
@@ -563,7 +563,7 @@ index 0000000..6862412
@@ -563,7 +563,7 @@ index 0000000..6862412
+}
+}
+
+
+async function request(method, path, { body, subject, timeoutMs = TIMEOUT_MS } = {}) {
+async function request(method, path, { body, subject, timeoutMs = TIMEOUT_MS } = {}) {
-+ if (!enabled()) throw new OpenReError('OpenRe integration is not configured (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)', 0);
++ if (!enabled()) throw new OpenReError('OpenRestream integration is not configured (OPENRE_URL, OV_OAUTH_CLIENT_SECRET)', 0);
+ const s = settings();
+ const s = settings();
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${await tokenClient().getToken()}` };
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${await tokenClient().getToken()}` };
+ if (subject) headers['X-OV-Subject'] = subject;
+ if (subject) headers['X-OV-Subject'] = subject;
@@ -574,15 +574,15 @@ index 0000000..6862412
@@ -574,15 +574,15 @@ index 0000000..6862412
+ res = await fetch(`${s.url}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
+ res = await fetch(`${s.url}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
+ text = await res.text().catch(() => '');
+ text = await res.text().catch(() => '');
+ } catch (err) {
+ } catch (err) {
-+ throw new OpenReError(`OpenRe unreachable (${method} ${path}): ${err.message}`, 0);
++ throw new OpenReError(`OpenRestream unreachable (${method} ${path}): ${err.message}`, 0);
+ }
+ }
+ let json = null;
+ let json = null;
+ if (text) { try { json = JSON.parse(text); } catch { json = null; } }
+ if (text) { try { json = JSON.parse(text); } catch { json = null; } }
-+ if (!res.ok) throw new OpenReError((json && (json.detail || json.error)) || `OpenRe ${res.status} on ${method} ${path}`, res.status, json);
++ if (!res.ok) throw new OpenReError((json && (json.detail || json.error)) || `OpenRestream ${res.status} on ${method} ${path}`, res.status, json);
+ return json;
+ return json;
+}
+}
+
+
-+/** The OpenRe stream definition serving a Live slot, or null. */
++/** The OpenRestream stream definition serving a Live slot, or null. */
+async function streamForSlot(managedStreamId) {
+async function streamForSlot(managedStreamId) {
+ const r = await request('GET', `/api/v1/streams?external_ref=${encodeURIComponent(`live:managed_stream:${managedStreamId}`)}`);
+ const r = await request('GET', `/api/v1/streams?external_ref=${encodeURIComponent(`live:managed_stream:${managedStreamId}`)}`);
+ return (r && r.streams && r.streams[0]) || null;
+ return (r && r.streams && r.streams[0]) || null;
@@ -646,8 +646,8 @@ index 0000000..836fd8c
@@ -646,8 +646,8 @@ index 0000000..836fd8c
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+'use strict';
+/**
+/**
-+ * Admin routes for the OpenRe ingest switch (mounted at /api/admin/openre, admin only). The lead
-+ * flips a slot during a maintenance window agreed with the broadcaster (OpenRe.Stream README,
++ * Admin routes for the OpenRestream ingest switch (mounted at /api/admin/openre, admin only). The lead
++ * flips a slot during a maintenance window agreed with the broadcaster (OpenRestream README,
+ * "RTMP cutover runbook").
+ * "RTMP cutover runbook").
+ *
+ *
+ * GET /api/admin/openre/status configuration + switched slots
+ * GET /api/admin/openre/status configuration + switched slots
@@ -684,11 +684,11 @@ index 0000000..836fd8c
@@ -684,11 +684,11 @@ index 0000000..836fd8c
+ try {
+ try {
+ const r = await authority.setAuthority(id, String((req.body && req.body.authority) || ''), { force: Boolean(req.body && req.body.force) });
+ const r = await authority.setAuthority(id, String((req.body && req.body.authority) || ''), { force: Boolean(req.body && req.body.force) });
+ if (r.error) return res.status(r.status).json({ error: r.error });
+ if (r.error) return res.status(r.status).json({ error: r.error });
-+ console.log(`[OpenRe] admin ${req.user.username} set slot ${id} ingest authority to ${r.body.ingest_authority}`);
++ console.log(`[OpenRestream] admin ${req.user.username} set slot ${id} ingest authority to ${r.body.ingest_authority}`);
+ return res.status(r.status).json(r.body);
+ return res.status(r.status).json(r.body);
+ } catch (err) {
+ } catch (err) {
-+ console.error('[OpenRe] switch failed:', err.message);
-+ return res.status(err.status && err.status < 500 ? 409 : 502).json({ error: `OpenRe: ${err.message}` });
++ console.error('[OpenRestream] switch failed:', err.message);
++ return res.status(err.status && err.status < 500 ? 409 : 502).json({ error: `OpenRestream: ${err.message}` });
+ }
+ }
+});
+});
+
+
@@ -701,11 +701,11 @@ index 0000000..c0c0d85
@@ -701,11 +701,11 @@ index 0000000..c0c0d85
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+'use strict';
+/**
+/**
-+ * OpenRe ingest switch — Live-side schema (roadmap Wave 7, ADR-009). Additive and idempotent:
++ * OpenRestream ingest switch — Live-side schema (roadmap Wave 7, ADR-009). Additive and idempotent:
+ *
+ *
+ * managed_streams.ingest_authority 'live' (default) | 'openre' who ingests RTMP for this slot
+ * managed_streams.ingest_authority 'live' (default) | 'openre' who ingests RTMP for this slot
-+ * managed_streams.openre_stream_id the OpenRe stream definition serving the slot (std_…)
-+ * openre_sessions Live's projection of OpenRe ingest sessions it mirrors into
++ * managed_streams.openre_stream_id the OpenRestream stream definition serving the slot (std_…)
++ * openre_sessions Live's projection of OpenRestream ingest sessions it mirrors into
+ * `streams` (rebuilt from openre.session.* events; the
+ * `streams` (rebuilt from openre.session.* events; the
+ * revision guard makes out-of-order delivery harmless)
+ * revision guard makes out-of-order delivery harmless)
+ *
+ *
@@ -744,7 +744,7 @@ index 4b5f8e4..4ac081b 100644
@@ -744,7 +744,7 @@ index 4b5f8e4..4ac081b 100644
const router = express.Router();
const router = express.Router();
+/**
+/**
-+ * A slot ingested by OpenRe.Stream (ingest_authority = 'openre') restreams from OpenRe: its
++ * A slot ingested by OpenRestream (ingest_authority = 'openre') restreams from OpenRestream: its
+ * destinations are managed there (openre.stream), so Live refuses to edit or run them here rather
+ * destinations are managed there (openre.stream), so Live refuses to edit or run them here rather
+ * than letting two systems push the same stream. Slots on Live's own ingest are unaffected.
+ * than letting two systems push the same stream. Slots on Live's own ingest are unaffected.
+ */
+ */
@@ -753,7 +753,7 @@ index 4b5f8e4..4ac081b 100644
@@ -753,7 +753,7 @@ index 4b5f8e4..4ac081b 100644
+ if (!openre.slotIsOpenre(managedStreamId)) return false;
+ if (!openre.slotIsOpenre(managedStreamId)) return false;
+ const slot = openre.slotById(managedStreamId);
+ const slot = openre.slotById(managedStreamId);
+ res.status(409).json({
+ res.status(409).json({
-+ error: 'Restreaming for this stream slot is managed on OpenRe.Stream',
++ error: 'Restreaming for this stream slot is managed on OpenRestream',
+ managed_by: 'openre',
+ managed_by: 'openre',
+ manage_url: require('../openre/openre-client').manageUrl(slot && slot.openre_stream_id),
+ manage_url: require('../openre/openre-client').manageUrl(slot && slot.openre_stream_id),
+ });
+ });
@@ -849,12 +849,12 @@ index 70a0857..2c5d856 100644
@@ -849,12 +849,12 @@ index 70a0857..2c5d856 100644
restream_destinations: restreamDestinations,
restream_destinations: restreamDestinations,
- });
- });
+ };
+ };
-+ // OpenRe ingests this slot: its RTMP server and key come from OpenRe (the key is only
++ // OpenRestream ingests this slot: its RTMP server and key come from OpenRestream (the key is only
+ // ever shown by Regenerate). Slots on Live's own ingest get exactly the response above.
+ // ever shown by Regenerate). Slots on Live's own ingest get exactly the response above.
+ if (openreAuthority.authorityOf(ms) === 'openre') {
+ if (openreAuthority.authorityOf(ms) === 'openre') {
+ const subject = require('../auth/identity-sync').subjectOf(ms.user_id);
+ const subject = require('../auth/identity-sync').subjectOf(ms.user_id);
+ Object.assign(body, { stream_key: null }, await openreAuthority.ingestFor(ms, subject).catch((err) => ({
+ Object.assign(body, { stream_key: null }, await openreAuthority.ingestFor(ms, subject).catch((err) => ({
-+ ingest_authority: 'openre', stream_key_managed_by: 'openre', rtmp_url: null, stream_key_hint: `OpenRe is unreachable (${err.message})`,
++ ingest_authority: 'openre', stream_key_managed_by: 'openre', rtmp_url: null, stream_key_hint: `OpenRestream is unreachable (${err.message})`,
+ })));
+ })));
+ }
+ }
+ res.json(body);
+ res.json(body);
@@ -883,14 +883,14 @@ index 70a0857..2c5d856 100644
@@ -883,14 +883,14 @@ index 70a0857..2c5d856 100644
return res.status(403).json({ error: 'Not your managed stream' });
return res.status(403).json({ error: 'Not your managed stream' });
}
}
-+ // OpenRe ingests this slot: OpenRe rotates its key (the old one stops working at once) and
++ // OpenRestream ingests this slot: OpenRestream rotates its key (the old one stops working at once) and
+ // the new one is shown here, once.
+ // the new one is shown here, once.
+ if (openreAuthority.authorityOf(ms) === 'openre') {
+ if (openreAuthority.authorityOf(ms) === 'openre') {
+ try {
+ try {
+ const subject = require('../auth/identity-sync').subjectOf(ms.user_id);
+ const subject = require('../auth/identity-sync').subjectOf(ms.user_id);
+ return res.json(await openreAuthority.rotateFor(ms, subject));
+ return res.json(await openreAuthority.rotateFor(ms, subject));
+ } catch (err) {
+ } catch (err) {
-+ return res.status(502).json({ error: `Could not rotate the key on OpenRe: ${err.message}` });
++ return res.status(502).json({ error: `Could not rotate the key on OpenRestream: ${err.message}` });
+ }
+ }
+ }
+ }
+
+
@@ -911,13 +911,13 @@ index 70a0857..2c5d856 100644
@@ -911,13 +911,13 @@ index 70a0857..2c5d856 100644
...(whipUrlWarning ? { whipUrlWarning } : {}),
...(whipUrlWarning ? { whipUrlWarning } : {}),
};
};
+ } else if (stream.protocol === 'rtmp' && openreAuthority.slotIsOpenre(stream.managed_stream_id)) {
+ } else if (stream.protocol === 'rtmp' && openreAuthority.slotIsOpenre(stream.managed_stream_id)) {
-+ // OpenRe ingests this slot: its server URL, the hint of its key (never the key).
++ // OpenRestream ingests this slot: its server URL, the hint of its key (never the key).
+ const slot = openreAuthority.slotById(stream.managed_stream_id);
+ const slot = openreAuthority.slotById(stream.managed_stream_id);
+ const ingest = await openreAuthority.ingestFor(slot, require('../auth/identity-sync').subjectOf(slot.user_id)).catch(() => ({}));
+ const ingest = await openreAuthority.ingestFor(slot, require('../auth/identity-sync').subjectOf(slot.user_id)).catch(() => ({}));
+ endpoint = {
+ endpoint = {
+ rtmpUrl: ingest.rtmp_url || null,
+ rtmpUrl: ingest.rtmp_url || null,
+ streamKey: null,
+ streamKey: null,
-+ streamKeyHint: ingest.stream_key_hint || 'Press Regenerate to get your OpenRe stream key',
++ streamKeyHint: ingest.stream_key_hint || 'Press Regenerate to get your OpenRestream stream key',
+ keyManagedBy: 'openre',
+ keyManagedBy: 'openre',
+ flvUrl: `/api/streams/rtmp-proxy/${stream.id}.flv`,
+ flvUrl: `/api/streams/rtmp-proxy/${stream.id}.flv`,
+ };
+ };
@@ -955,8 +955,8 @@ index 70a0857..2c5d856 100644
@@ -955,8 +955,8 @@ index 70a0857..2c5d856 100644
+ let url;
+ let url;
+ const mirrored = openreMirror.sessionForStream(stream.id);
+ const mirrored = openreMirror.sessionForStream(stream.id);
+ if (mirrored) {
+ if (mirrored) {
-+ // An OpenRe session: pull HTTP-FLV from the OpenRe worker that holds it, as named by
-+ // OpenRe's playback descriptor (a loopback URL on this host; no key in it).
++ // An OpenRestream session: pull HTTP-FLV from the OpenRestream worker that holds it, as named by
++ // OpenRestream's playback descriptor (a loopback URL on this host; no key in it).
+ if (mirrored.state !== 'live') return res.status(404).end();
+ if (mirrored.state !== 'live') return res.status(404).end();
+ const pb = await require('../openre/openre-client').playback(mirrored.session_id).catch(() => null);
+ const pb = await require('../openre/openre-client').playback(mirrored.session_id).catch(() => null);
+ if (!pb || !pb.flv || !/^http:\/\/127\.0\.0\.1:\d+\/live\/ses_[0-9A-Z]+\.flv$/.test(pb.flv.internal_url)) return res.status(502).end();
+ if (!pb || !pb.flv || !/^http:\/\/127\.0\.0\.1:\d+\/live\/ses_[0-9A-Z]+\.flv$/.test(pb.flv.internal_url)) return res.status(502).end();
@@ -968,7 +968,7 @@ index 70a0857..2c5d856 100644
@@ -968,7 +968,7 @@ index 70a0857..2c5d856 100644
+ url = `http://127.0.0.1:${nmsPort}/live/${flvKey}.flv`;
+ url = `http://127.0.0.1:${nmsPort}/live/${flvKey}.flv`;
+ }
+ }
-+ if (req.destroyed) return undefined; // the viewer left while OpenRe was asked
++ if (req.destroyed) return undefined; // the viewer left while OpenRestream was asked
const http = require('http');
const http = require('http');
const upstream = http.get(url, (nmsRes) => {
const upstream = http.get(url, (nmsRes) => {
if (nmsRes.statusCode !== 200) {
if (nmsRes.statusCode !== 200) {
@@ -980,11 +980,11 @@ index fb8ba5c..c857f5b 100644
@@ -980,11 +980,11 @@ index fb8ba5c..c857f5b 100644
return;
return;
}
}
-+ // OpenRe.Stream ingests this slot (managed_streams.ingest_authority = 'openre'): refuse
++ // OpenRestream ingests this slot (managed_streams.ingest_authority = 'openre'): refuse
+ // here so one stream can never be ingested twice. Slots on 'live' (the default) are
+ // here so one stream can never be ingested twice. Slots on 'live' (the default) are
+ // untouched. See server/openre/authority.js.
+ // untouched. See server/openre/authority.js.
+ if (require('../openre/authority').refusesLiveIngest({ managedStream, user: managedStream ? null : resolvedUser, protocol: 'rtmp' })) {
+ if (require('../openre/authority').refusesLiveIngest({ managedStream, user: managedStream ? null : resolvedUser, protocol: 'rtmp' })) {
-+ console.log(`[RTMP] Rejected: ${managedStream ? `slot ${managedStream.id}` : `personal key of ${resolvedUser.username}`} is ingested by OpenRe`);
++ console.log(`[RTMP] Rejected: ${managedStream ? `slot ${managedStream.id}` : `personal key of ${resolvedUser.username}`} is ingested by OpenRestream`);
+ const session = this.nms.getSession(id);
+ const session = this.nms.getSession(id);
+ if (session) session.reject();
+ if (session) session.reject();
+ return;
+ return;
@@ -1001,9 +1001,9 @@ index 0000000..6ffdc33
@@ -1001,9 +1001,9 @@ index 0000000..6ffdc33
@@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
+'use strict';
+'use strict';
+
+
-+// OpenRe ingest switch (roadmap Wave 7, ADR-009). With OPENRE_URL unset, or a slot on 'live' (the
++// OpenRestream ingest switch (roadmap Wave 7, ADR-009). With OPENRE_URL unset, or a slot on 'live' (the
+// default), nothing changes. With a slot switched to 'openre': Live's RTMP refuses its key, the
+// default), nothing changes. With a slot switched to 'openre': Live's RTMP refuses its key, the
-+// Go Live helpers show OpenRe's URL and rotate OpenRe's key, and OpenRe session events (signed
++// Go Live helpers show OpenRestream's URL and rotate OpenRestream's key, and OpenRestream session events (signed
+// Events deliveries, applied once, in revision order) become ordinary `streams` rows.
+// Events deliveries, applied once, in revision order) become ordinary `streams` rows.
+
+
+const assert = require('assert');
+const assert = require('assert');
@@ -1100,7 +1100,7 @@ index 0000000..6ffdc33
@@ -1100,7 +1100,7 @@ index 0000000..6ffdc33
+ assert.strictEqual(authority.serializeSlot(slot701), slot701, 'the same object: responses unchanged');
+ assert.strictEqual(authority.serializeSlot(slot701), slot701, 'the same object: responses unchanged');
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: slot701 }), false);
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: slot701 }), false);
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 601 } }), false);
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 601 } }), false);
-+ // Even a slot marked 'openre' behaves as before while OpenRe is not configured.
++ // Even a slot marked 'openre' behaves as before while OpenRestream is not configured.
+ d.prepare("UPDATE managed_streams SET ingest_authority = 'openre' WHERE id = 701").run();
+ d.prepare("UPDATE managed_streams SET ingest_authority = 'openre' WHERE id = 701").run();
+ const marked = db.getManagedStreamById(701);
+ const marked = db.getManagedStreamById(701);
+ assert.strictEqual(authority.authorityOf(marked), 'live');
+ assert.strictEqual(authority.authorityOf(marked), 'live');
@@ -1150,7 +1150,7 @@ index 0000000..6ffdc33
@@ -1150,7 +1150,7 @@ index 0000000..6ffdc33
+ assert.strictEqual(db.getManagedStreamByStreamKey('livekey701aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), undefined, "Live's old key is rotated away");
+ assert.strictEqual(db.getManagedStreamByStreamKey('livekey701aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), undefined, "Live's old key is rotated away");
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: switched }), true, "Live's RTMP refuses the slot");
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: switched }), true, "Live's RTMP refuses the slot");
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: switched, protocol: 'whip' }), false, 'only RTMP moved');
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: switched, protocol: 'whip' }), false, 'only RTMP moved');
-+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 602 } }), false, 'personal keys of users without an OpenRe slot stay on Live');
++ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 602 } }), false, 'personal keys of users without an OpenRestream slot stay on Live');
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 601 } }), true, 'a personal key cannot double-ingest a switched channel');
+ assert.strictEqual(authority.refusesLiveIngest({ managedStream: null, user: { id: 601 } }), true, 'a personal key cannot double-ingest a switched channel');
+ const shown = authority.serializeSlot(switched);
+ const shown = authority.serializeSlot(switched);
+ assert.strictEqual(shown.stream_key, null);
+ assert.strictEqual(shown.stream_key, null);
@@ -1180,7 +1180,7 @@ index 0000000..6ffdc33
@@ -1180,7 +1180,7 @@ index 0000000..6ffdc33
+ assert.strictEqual(mirror.hasLiveSession(streamId), true, 'stale cleanup leaves it alone');
+ assert.strictEqual(mirror.hasLiveSession(streamId), true, 'stale cleanup leaves it alone');
+ assert.strictEqual(mirror.ownsStream(streamId), true, 'Live does not resume its own restreams for it');
+ assert.strictEqual(mirror.ownsStream(streamId), true, 'Live does not resume its own restreams for it');
+
+
-+ // Reconcile: OpenRe says live → heartbeat refreshed; says ended → row ended.
++ // Reconcile: OpenRestream says live → heartbeat refreshed; says ended → row ended.
+ openreSessions[SES(4)] = { id: SES(4), state: 'live', revision: 2 };
+ openreSessions[SES(4)] = { id: SES(4), state: 'live', revision: 2 };
+ d.prepare("UPDATE streams SET last_heartbeat = datetime('now', '-10 minutes') WHERE id = ?").run(streamId);
+ d.prepare("UPDATE streams SET last_heartbeat = datetime('now', '-10 minutes') WHERE id = ?").run(streamId);
+ await mirror.reconcileOnce();
+ await mirror.reconcileOnce();
@@ -4,9 +4,9 @@
@@ -4,9 +4,9 @@
"openvibe-contracts": ">=0.13.0 <1.0.0"
"openvibe-contracts": ">=0.13.0 <1.0.0"
},
},
"id": "openre",
"id": "openre",
- "name": "OpenRe.Stream",
+ "name": "OpenRestream",
"status": "alpha",
"status": "alpha",
- "repository": "OpenVibers/OpenRe.Stream",
+ "repository": "OpenVibers/OpenRestream",
"domains": [
"domains": [
"openre.stream",
"openre.stream",
"ingest.openre.stream"
"ingest.openre.stream"
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openre-stream",
"name": "openre-stream",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenRe.Stream: ingest and restream authority for the OpenVibe network. Stream definitions, hashed ingest keys, ingest sessions, transport workers with generations and leases, restream outputs with health and logs, recording requests to OpenVibe.Media.",
+ "description": "OpenRestream: ingest and restream authority for the OpenVibe network. Stream definitions, hashed ingest keys, ingest sessions, transport workers with generations and leases, restream outputs with health and logs, recording requests to OpenVibe.Media.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
],
],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
- "repository": "github:OpenVibers/OpenRe.Stream",
+ "repository": "github:OpenVibers/OpenRestream",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -15,13 +15,13 @@
@@ -15,13 +15,13 @@
* bind OPENRE_RTMP_BIND is public and the RTMP port really listens on a public address
* bind OPENRE_RTMP_BIND is public and the RTMP port really listens on a public address
* dns the ingest host resolves (A/AAAA) only to this host's addresses
* dns the ingest host resolves (A/AAAA) only to this host's addresses
* port the ingest port answers an RTMP handshake from outside (see "port" below)
* port the ingest port answers an RTMP handshake from outside (see "port" below)
- * db OpenRe's PostgreSQL database (DATABASE_URL of openre.env, one READ ONLY transaction): no data
+ * db OpenRestream's PostgreSQL database (DATABASE_URL of openre.env, one READ ONLY transaction): no data
* checksum failure, no foreign key left unvalidated, the main tables' counts
* checksum failure, no foreign key left unvalidated, the main tables' counts
* live-env /etc/openvibe/live.env has OPENRE_URL, OPENRE_EVENTS_SECRET (32+ chars) and
* live-env /etc/openvibe/live.env has OPENRE_URL, OPENRE_EVENTS_SECRET (32+ chars) and
* OV_OAUTH_CLIENT_SECRET, and the running Live process was started with them
* OV_OAUTH_CLIENT_SECRET, and the running Live process was started with them
* events Events has Live's openre.session.* subscription to /internal/openre-events, enabled,
* events Events has Live's openre.session.* subscription to /internal/openre-events, enabled,
* with the secret Live holds (compared by hash, never printed), and no dead deliveries
* with the secret Live holds (compared by hash, never printed), and no dead deliveries
- * slot only with --slot <id>: the Live slot and its OpenRe definition, for the per-slot step;
+ * slot only with --slot <id>: the Live slot and its OpenRestream definition, for the per-slot step;
* --slot all (or --all-slots): every Live slot, one slot:<id> line each, same rules
* --slot all (or --all-slots): every Live slot, one slot:<id> line each, same rules
*
*
* "port": from the host itself a connection to its own public address never crosses the provider
* "port": from the host itself a connection to its own public address never crosses the provider
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
* that is not the host (a checkout with `npm ci` is enough), or pass --probe-url with a probe you
* that is not the host (a checkout with `npm ci` is enough), or pass --probe-url with a probe you
* trust (GET, `{host}` and `{port}` replaced, JSON answer with a boolean `open` or `reachable`).
* trust (GET, `{host}` and `{port}` replaced, JSON answer with a boolean `open` or `reachable`).
*
*
- * OpenRe and Events serve from PostgreSQL (ADR-035): their databases are read through the DATABASE_URL of their env
+ * OpenRestream and Events serve from PostgreSQL (ADR-035): their databases are read through the DATABASE_URL of their env
* files (--openre-db / --events-db take env:<file>, a postgres:// URL or a SQLite file); Live's is still its SQLite file.
* files (--openre-db / --events-db take env:<file>, a postgres:// URL or a SQLite file); Live's is still its SQLite file.
*
*
* Nothing is written anywhere: files are opened read-only and PostgreSQL is read in READ ONLY transactions, no secret value is
* Nothing is written anywhere: files are opened read-only and PostgreSQL is read in READ ONLY transactions, no secret value is
@@ -379,7 +379,7 @@ async function checkLiveEnv(ctx) {
@@ -379,7 +379,7 @@ async function checkLiveEnv(ctx) {
else if (env.OPENRE_URL.replace(/\/+$/, '') !== opts.openreUrl) problems.push(`OPENRE_URL = ${env.OPENRE_URL}, expected ${opts.openreUrl}`);
else if (env.OPENRE_URL.replace(/\/+$/, '') !== opts.openreUrl) problems.push(`OPENRE_URL = ${env.OPENRE_URL}, expected ${opts.openreUrl}`);
if (!env.OPENRE_EVENTS_SECRET) problems.push('OPENRE_EVENTS_SECRET not set');
if (!env.OPENRE_EVENTS_SECRET) problems.push('OPENRE_EVENTS_SECRET not set');
else if (env.OPENRE_EVENTS_SECRET.length < 32) problems.push('OPENRE_EVENTS_SECRET shorter than 32 characters (Events refuses it)');
else if (env.OPENRE_EVENTS_SECRET.length < 32) problems.push('OPENRE_EVENTS_SECRET shorter than 32 characters (Events refuses it)');
- if (!env.OV_OAUTH_CLIENT_SECRET) problems.push('OV_OAUTH_CLIENT_SECRET not set (Live cannot get OpenRe tokens)');
+ if (!env.OV_OAUTH_CLIENT_SECRET) problems.push('OV_OAUTH_CLIENT_SECRET not set (Live cannot get OpenRestream tokens)');
const notes = [];
const notes = [];
if (!env.OPENRE_PUBLIC_URL) notes.push('OPENRE_PUBLIC_URL unset (default https://openre.stream)');
if (!env.OPENRE_PUBLIC_URL) notes.push('OPENRE_PUBLIC_URL unset (default https://openre.stream)');
// The running process: systemd loaded the env file when Live started. Names only.
// The running process: systemd loaded the env file when Live started. Names only.
@@ -447,15 +447,15 @@ async function checkSlot(ctx, id = Number(ctx.opts.slot)) {
@@ -447,15 +447,15 @@ async function checkSlot(ctx, id = Number(ctx.opts.slot)) {
if (live.user && live.user.is_banned) problems.push('owner is banned');
if (live.user && live.user.is_banned) problems.push('owner is banned');
if (!live.subject) problems.push('owner has no canonical subject (they sign in to Live once)');
if (!live.subject) problems.push('owner has no canonical subject (they sign in to Live once)');
if (live.liveNow) problems.push(`live on Live right now (stream ${live.liveNow.id}): wait for the window`);
if (live.liveNow) problems.push(`live on Live right now (stream ${live.liveNow.id}): wait for the window`);
- if (s.protocol !== 'rtmp' && !['rtmp', 'obs'].includes(String(s.streaming_method || '').toLowerCase())) problems.push('not an RTMP slot (OpenRe carries RTMP only)');
- if (openre.defs.length > 1) problems.push(`${openre.defs.length} OpenRe definitions reference it`);
+ if (s.protocol !== 'rtmp' && !['rtmp', 'obs'].includes(String(s.streaming_method || '').toLowerCase())) problems.push('not an RTMP slot (OpenRestream carries RTMP only)');
+ if (openre.defs.length > 1) problems.push(`${openre.defs.length} OpenRestream definitions reference it`);
const def = openre.defs[0];
const def = openre.defs[0];
if (def) {
if (def) {
- facts.push(`OpenRe ${def.id} (${def.state}${def.mirror_to_live ? ', mirrored' : ', NOT mirrored to Live'})`);
+ facts.push(`OpenRestream ${def.id} (${def.state}${def.mirror_to_live ? ', mirrored' : ', NOT mirrored to Live'})`);
const held = openre.dests.filter((d) => d.hold_reason).length;
const held = openre.dests.filter((d) => d.hold_reason).length;
facts.push(`${openre.dests.length} destination(s)${held ? `, ${held} held` : ''}`);
facts.push(`${openre.dests.length} destination(s)${held ? `, ${held} held` : ''}`);
- if (s.openre_stream_id && s.openre_stream_id !== def.id) problems.push(`Live points at ${s.openre_stream_id}, OpenRe has ${def.id}`);
- if (openre.openSessions) facts.push(`${openre.openSessions} open OpenRe session(s)`);
+ if (s.openre_stream_id && s.openre_stream_id !== def.id) problems.push(`Live points at ${s.openre_stream_id}, OpenRestream has ${def.id}`);
+ if (openre.openSessions) facts.push(`${openre.openSessions} open OpenRestream session(s)`);
} else facts.push(openre.slotMap ? `migration_map: ${openre.slotMap.status}${openre.slotMap.reason ? ` (${openre.slotMap.reason})` : ''}` : 'not migrated yet');
} else facts.push(openre.slotMap ? `migration_map: ${openre.slotMap.status}${openre.slotMap.reason ? ` (${openre.slotMap.reason})` : ''}` : 'not migrated yet');
if (problems.length) return ['FAIL', `${facts.join('; ')}: ${problems.join('; ')}`];
if (problems.length) return ['FAIL', `${facts.join('; ')}: ${problems.join('; ')}`];
return ['PASS', facts.join('; ')];
return ['PASS', facts.join('; ')];
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
#!/usr/bin/env node
#!/usr/bin/env node
'use strict';
'use strict';
/**
/**
- * Import Live's stream slots and restream destinations into OpenRe, from a read-only snapshot of
+ * Import Live's stream slots and restream destinations into OpenRestream, from a read-only snapshot of
* Live's database, and print the per-channel RTMP cutover checklist.
* Live's database, and print the per-channel RTMP cutover checklist.
*
*
* node scripts/migrate-from-live.js --live-db /path/to/live-snapshot.db # dry run
* node scripts/migrate-from-live.js --live-db /path/to/live-snapshot.db # dry run
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* ... [--slots 12,31] [--checklist /path/to/checklist.md]
* ... [--slots 12,31] [--checklist /path/to/checklist.md]
*
*
* Take the snapshot with `sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-snapshot.db"`
* Take the snapshot with `sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-snapshot.db"`
- * (Live's DB_PATH in production; never point this at the live file). Uses the OpenRe environment
+ * (Live's DB_PATH in production; never point this at the live file). Uses the OpenRestream environment
* (.env or OPENRE_ENV_FILE) for its own database and OPENRE_SECRETS_KEY; in production set
* (.env or OPENRE_ENV_FILE) for its own database and OPENRE_SECRETS_KEY; in production set
* OPENRE_ENV_FILE=/etc/openvibe/openre.env and NODE_ENV=production (a unit sets NODE_ENV itself)
* OPENRE_ENV_FILE=/etc/openvibe/openre.env and NODE_ENV=production (a unit sets NODE_ENV itself)
* — docs/cutover.md has the command. Prints no secret: old keys are not imported and the new
* — docs/cutover.md has the command. Prints no secret: old keys are not imported and the new
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
#!/usr/bin/env node
#!/usr/bin/env node
'use strict';
'use strict';
/**
/**
- * Disable (or re-enable) the OpenRe stream definition of one Live slot, AS LIVE (docs/cutover.md,
+ * Disable (or re-enable) the OpenRestream stream definition of one Live slot, AS LIVE (docs/cutover.md,
* per-slot rollback). A disabled definition's keys are refused at the RTMP handshake
* per-slot rollback). A disabled definition's keys are refused at the RTMP handshake
- * (`stream_disabled`), so after a slot goes back to Live nobody can publish to OpenRe with the key
- * the broadcaster got at the cutover, and OpenRe never pushes that slot's destinations. Running
+ * (`stream_disabled`), so after a slot goes back to Live nobody can publish to OpenRestream with the key
+ * the broadcaster got at the cutover, and OpenRestream never pushes that slot's destinations. Running
* sessions are not ended (Live holds no openre.session.end grant): do it with the slot offline.
* sessions are not ended (Live holds no openre.session.end grant): do it with the slot offline.
*
*
* sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot 12 --state disabled
* sudo node /opt/openre.stream/current/scripts/set-definition-state.js --slot 12 --state disabled
@@ -38,9 +38,9 @@ async function run({ slot, state, env, fetchImpl = globalThis.fetch, log = conso
@@ -38,9 +38,9 @@ async function run({ slot, state, env, fetchImpl = globalThis.fetch, log = conso
const ref = `live:managed_stream:${slot}`;
const ref = `live:managed_stream:${slot}`;
const found = await fetchImpl(`${openre}/api/v1/streams?external_ref=${encodeURIComponent(ref)}`, { headers, signal: AbortSignal.timeout(15000) });
const found = await fetchImpl(`${openre}/api/v1/streams?external_ref=${encodeURIComponent(ref)}`, { headers, signal: AbortSignal.timeout(15000) });
const list = await readJson(found);
const list = await readJson(found);
- if (!found.ok) throw new Error(`OpenRe answered ${found.status}: ${list.code || ''} ${list.detail || ''}`.trim());
+ if (!found.ok) throw new Error(`OpenRestream answered ${found.status}: ${list.code || ''} ${list.detail || ''}`.trim());
const d = (list.streams || [])[0];
const d = (list.streams || [])[0];
- if (!d) throw new Error(`OpenRe has no stream definition for ${ref}`);
+ if (!d) throw new Error(`OpenRestream has no stream definition for ${ref}`);
if (d.state === state) {
if (d.state === state) {
log(`${d.id} (${ref}) is already ${state}`);
log(`${d.id} (${ref}) is already ${state}`);
return { id: d.id, state, changed: false, open_session: d.session ? d.session.id : null };
return { id: d.id, state, changed: false, open_session: d.session ? d.session.id : null };
@@ -52,7 +52,7 @@ async function run({ slot, state, env, fetchImpl = globalThis.fetch, log = conso
@@ -52,7 +52,7 @@ async function run({ slot, state, env, fetchImpl = globalThis.fetch, log = conso
signal: AbortSignal.timeout(15000),
signal: AbortSignal.timeout(15000),
});
});
const b = await readJson(r);
const b = await readJson(r);
- if (!r.ok) throw new Error(`OpenRe answered ${r.status}: ${b.code || ''} ${b.detail || ''}`.trim());
+ if (!r.ok) throw new Error(`OpenRestream answered ${r.status}: ${b.code || ''} ${b.detail || ''}`.trim());
log(`${d.id} (${ref}): ${d.state} → ${b.stream ? b.stream.state : state}${d.session ? `; session ${d.session.id} is still ${d.session.state} (not ended by this)` : ''}`);
log(`${d.id} (${ref}): ${d.state} → ${b.stream ? b.stream.state : state}${d.session ? `; session ${d.session.id} is still ${d.session.state} (not ended by this)` : ''}`);
return { id: d.id, state: b.stream ? b.stream.state : state, changed: true, open_session: d.session ? d.session.id : null };
return { id: d.id, state: b.stream ? b.stream.state : state, changed: true, open_session: d.session ? d.session.id : null };
}
}
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
#!/usr/bin/env node
#!/usr/bin/env node
'use strict';
'use strict';
/**
/**
- * Live's OpenVibe.Events subscription to OpenRe's session events (docs/cutover.md step A5):
+ * Live's OpenVibe.Events subscription to OpenRestream's session events (docs/cutover.md step A5):
* topic `openre.session.*` → Live's `POST /internal/openre-events`, signed with the secret Live holds
* topic `openre.session.*` → Live's `POST /internal/openre-events`, signed with the secret Live holds
* as OPENRE_EVENTS_SECRET. Events names the consumer after the calling service, so this runs AS LIVE:
* as OPENRE_EVENTS_SECRET. Events names the consumer after the calling service, so this runs AS LIVE:
* it reads Live's env file (Live's OAuth client, grant [live, events.subscription.manage,
* it reads Live's env file (Live's OAuth client, grant [live, events.subscription.manage,
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * OpenRe API v1 (openre-api). Every route checks one capability for service callers; owners
+ * OpenRestream API v1 (openre-api). Every route checks one capability for service callers; owners
* (Network user JWT) act on their own streams. Errors are RFC 9457 problem+json.
* (Network user JWT) act on their own streams. Errors are RFC 9457 problem+json.
*
*
* GET /api/v1/streams openre.stream.read list (owner, ?external_ref=live:managed_stream:12)
* GET /api/v1/streams openre.stream.read list (owner, ?external_ref=live:managed_stream:12)
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * OpenRe.Stream configuration. Everything comes from the environment (.env in development,
+ * OpenRestream configuration. Everything comes from the environment (.env in development,
* /etc/openvibe/openre.env in production); .env.example documents every name.
* /etc/openvibe/openre.env in production); .env.example documents every name.
*
*
* load(env) is pure so tests (and every worker process) can build a config without touching
* load(env) is pure so tests (and every worker process) can build a config without touching
@@ -75,7 +75,7 @@ function load(env = process.env) {
@@ -75,7 +75,7 @@ function load(env = process.env) {
// ── Ingest (RTMP) ───────────────────────────────────────
// ── Ingest (RTMP) ───────────────────────────────────────
rtmp: {
rtmp: {
// Public ingest port. Live's in-process ingest owns 1935 until the RTMP cutover
// Public ingest port. Live's in-process ingest owns 1935 until the RTMP cutover
- // (README "Port plan"); OpenRe listens on 1936 until then.
+ // (README "Port plan"); OpenRestream listens on 1936 until then.
port: rtmpPort,
port: rtmpPort,
// Further public ports served by every worker, e.g. 1935 once Live's own RTMP ingest is
// Further public ports served by every worker, e.g. 1935 once Live's own RTMP ingest is
// retired (README "Port plan"): URLs handed out with :1936 keep working forever.
// retired (README "Port plan"): URLs handed out with :1936 keep working forever.
@@ -97,7 +97,7 @@ function load(env = process.env) {
@@ -97,7 +97,7 @@ function load(env = process.env) {
// ── Ingest (JSMPEG) ─────────────────────────────────────
// ── Ingest (JSMPEG) ─────────────────────────────────────
jsmpeg: {
jsmpeg: {
// Public port for both the MPEG-TS HTTP POST from the broadcaster's ffmpeg and the
// Public port for both the MPEG-TS HTTP POST from the broadcaster's ffmpeg and the
- // WebSocket viewers. Live's relay owns 9710/9711 until the JSMPEG cutover; OpenRe
+ // WebSocket viewers. Live's relay owns 9710/9711 until the JSMPEG cutover; OpenRestream
// listens on 9736 until then, SO_REUSEPORT so two generations can listen during a drain.
// listens on 9736 until then, SO_REUSEPORT so two generations can listen during a drain.
port: jsmpegPort,
port: jsmpegPort,
bindHost: env.OPENRE_JSMPEG_BIND || '0.0.0.0',
bindHost: env.OPENRE_JSMPEG_BIND || '0.0.0.0',
@@ -113,11 +113,11 @@ function load(env = process.env) {
@@ -113,11 +113,11 @@ function load(env = process.env) {
// ── Ingest (WebRTC: WHIP + the mediasoup SFU + viewer signaling) ──
// ── Ingest (WebRTC: WHIP + the mediasoup SFU + viewer signaling) ──
// One worker kind 'webrtc' owns WHIP ingest, the mediasoup producers/consumers and viewer
// One worker kind 'webrtc' owns WHIP ingest, the mediasoup producers/consumers and viewer
// signaling (T4 decision 1: mediasoup is single-process, so ingest and consumption cannot be
// signaling (T4 decision 1: mediasoup is single-process, so ingest and consumption cannot be
- // split). Live's own SFU keeps UDP 10000–10100 until the WebRTC cutover; OpenRe listens on
+ // split). Live's own SFU keeps UDP 10000–10100 until the WebRTC cutover; OpenRestream listens on
// 10200–10300 (decision 3) so both stacks run side by side.
// 10200–10300 (decision 3) so both stacks run side by side.
webrtc: {
webrtc: {
// Public HTTP port: WHIP (POST/PATCH/DELETE /whip/…) and the WS signaling endpoints
// Public HTTP port: WHIP (POST/PATCH/DELETE /whip/…) and the WS signaling endpoints
- // (broadcaster and viewer). Live's WHIP lives on its app port 3000; OpenRe brings its own.
+ // (broadcaster and viewer). Live's WHIP lives on its app port 3000; OpenRestream brings its own.
// SO_REUSEPORT so two generations can listen during a drain.
// SO_REUSEPORT so two generations can listen during a drain.
port: webrtcPort,
port: webrtcPort,
bindHost: env.OPENRE_WEBRTC_BIND || '0.0.0.0',
bindHost: env.OPENRE_WEBRTC_BIND || '0.0.0.0',
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * OpenRe's durable store: one PostgreSQL database (ov_openre, ADR-035) shared by every OpenRe process.
+ * OpenRestream's durable store: one PostgreSQL database (ov_openre, ADR-035) shared by every OpenRestream process.
*
*
* openre-api definitions, keys, destinations (owner writes)
* openre-api definitions, keys, destinations (owner writes)
* openre-session-coordinator worker liveness, drain, output assignment, recordings, event relay
* openre-session-coordinator worker liveness, drain, output assignment, recordings, event relay
@@ -21,7 +21,7 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -21,7 +21,7 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
/**
/**
* The serving handle: DATABASE_URL through PgBouncer, after the migrations ran as the owner (DATABASE_DIRECT_URL).
* The serving handle: DATABASE_URL through PgBouncer, after the migrations ran as the owner (DATABASE_DIRECT_URL).
* Without DATABASE_URL (development, tests), an embedded PGlite database: in OPENRE_PGLITE_DIR or data/pglite, or in
* Without DATABASE_URL (development, tests), an embedded PGlite database: in OPENRE_PGLITE_DIR or data/pglite, or in
- * memory with { memory: true }. PGlite is one process: run the other OpenRe processes against PostgreSQL.
+ * memory with { memory: true }. PGlite is one process: run the other OpenRestream processes against PostgreSQL.
*/
*/
async function openDb(config, { memory = false, log = console, registry } = {}) {
async function openDb(config, { memory = false, log = console, registry } = {}) {
const quiet = { ...log, info() {}, log() {} };
const quiet = { ...log, info() {}, log() {} };
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* user:password@, no whitespace or control characters, at most 2048 characters
* user:password@, no whitespace or control characters, at most 2048 characters
* - the host may not be (or resolve to) loopback, private, link-local, CGNAT, multicast or
* - the host may not be (or resolve to) loopback, private, link-local, CGNAT, multicast or
* unspecified addresses, unless OPENRE_DEST_ALLOW_PRIVATE is on (tests, a restream box on the
* unspecified addresses, unless OPENRE_DEST_ALLOW_PRIVATE is on (tests, a restream box on the
- * operator's LAN). Live allowed private hosts; OpenRe does not by default, and the migration
+ * operator's LAN). Live allowed private hosts; OpenRestream does not by default, and the migration
* holds such destinations with a recorded reason instead of importing them enabled.
* holds such destinations with a recorded reason instead of importing them enabled.
*/
*/
const dns = require('dns');
const dns = require('dns');
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* GET /robots.txt welcomes search and AI crawlers, keeps the signed-in owner pages, sign-in,
* GET /robots.txt welcomes search and AI crawlers, keeps the signed-in owner pages, sign-in,
* the API and playback out of the index, and always names the sitemap
* the API and playback out of the index, and always names the sitemap
* GET /sitemap.xml the public, server-rendered pages (server/ui/routes.js) only
* GET /sitemap.xml the public, server-rendered pages (server/ui/routes.js) only
- * GET /llms.txt what OpenRe.Stream does today, in plain language
+ * GET /llms.txt what OpenRestream does today, in plain language
*
*
* Read-only and viewer-independent: built from the config (the public base URL) and nothing else.
* Read-only and viewer-independent: built from the config (the public base URL) and nothing else.
*/
*/
@@ -31,25 +31,25 @@ function createDiscoveryRoutes({ config }) {
@@ -31,25 +31,25 @@ function createDiscoveryRoutes({ config }) {
router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders()).send(
seo.llmsTxt({
seo.llmsTxt({
- name: 'OpenRe.Stream',
- summary: 'Go live from your browser with no OBS and no follower minimum: on the OpenVibe network you open openvibe.live, press Go Live and allow your camera. OpenRe.Stream is the network\'s ingest and restream service behind it: it takes an RTMP feed from an encoder such as OBS and restreams the session to RTMP and SRT destinations.',
+ name: 'OpenRestream',
+ summary: 'Go live from your browser with no OBS and no follower minimum: on the OpenVibe network you open openvibe.live, press Go Live and allow your camera. OpenRestream is the network\'s ingest and restream service behind it: it takes an RTMP feed from an encoder such as OBS and restreams the session to RTMP and SRT destinations.',
details: [
details: [
- 'OpenRe.Stream is the ingest and restream authority behind OpenVibe.Live: channels, discovery and watch pages stay on openvibe.live. OpenRe itself serves a JSON/REST API and a small server-rendered owner UI at openre.stream. The service is alpha.',
+ 'OpenRestream is the ingest and restream authority behind OpenVibe.Live: channels, discovery and watch pages stay on openvibe.live. OpenRestream itself serves a JSON/REST API and a small server-rendered owner UI at openre.stream. The service is alpha.',
'',
'',
'Going live, as it runs today:',
'Going live, as it runs today:',
'- From a browser, with nothing to install: on OpenVibe.Live (https://openvibe.live, the Go Live button; guide at https://openvibe.live/docs/go-live-in-your-browser). There is no follower, subscriber or eligibility threshold.',
'- From a browser, with nothing to install: on OpenVibe.Live (https://openvibe.live, the Go Live button; guide at https://openvibe.live/docs/go-live-in-your-browser). There is no follower, subscriber or eligibility threshold.',
- '- With an encoder such as OBS: RTMP to rtmp://ingest.openre.stream:1936/live with the stream key OpenRe shows once when the stream is created. Any signed-in OpenVibe account can create a stream.',
- '- Browser WebRTC (WHIP, RFC 9725) and JSMPEG ingest are ported to OpenRe and tested, but their workers do not run in production yet; until they do, OpenVibe.Live serves them.',
+ '- With an encoder such as OBS: RTMP to rtmp://ingest.openre.stream:1936/live with the stream key OpenRestream shows once when the stream is created. Any signed-in OpenVibe account can create a stream.',
+ '- Browser WebRTC (WHIP, RFC 9725) and JSMPEG ingest are ported to OpenRestream and tested, but their workers do not run in production yet; until they do, OpenVibe.Live serves them.',
'',
'',
- 'Restream: a session can fan out to several RTMP and SRT destinations at once, with per-output health, logs, backoff and a rapid-crash circuit breaker. Recording: OpenRe asks OpenVibe.Media to record a VOD or clips of an RTMP session. Playback: a live, non-private session is served as HTTP-FLV at https://openre.stream/play/<session id>.flv.',
+ 'Restream: a session can fan out to several RTMP and SRT destinations at once, with per-output health, logs, backoff and a rapid-crash circuit breaker. Recording: OpenRestream asks OpenVibe.Media to record a VOD or clips of an RTMP session. Playback: a live, non-private session is served as HTTP-FLV at https://openre.stream/play/<session id>.flv.',
'',
'',
'Ingest keys are random, stored only as a SHA-256 hash and shown once at creation or rotation.',
'Ingest keys are random, stored only as a SHA-256 hash and shown once at creation or rotation.',
].join('\n'),
].join('\n'),
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
- { title: 'OpenRe.Stream home', url: abs('/') },
+ { title: 'OpenRestream home', url: abs('/') },
{ title: 'What shipped', url: abs('/updates') },
{ title: 'What shipped', url: abs('/updates') },
- { title: 'Source code', url: 'https://github.com/OpenVibers/OpenRe.Stream' },
+ { title: 'Source code', url: 'https://github.com/OpenVibers/OpenRestream' },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
'use strict';
'use strict';
/**
/**
- * OpenRe → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ * OpenRestream → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
*
*
- * Every OpenRe process can enqueue (the row commits in the same transaction as the change
- * it describes); only the session coordinator runs the relay that publishes rows with OpenRe's
+ * Every OpenRestream process can enqueue (the row commits in the same transaction as the change
+ * it describes); only the session coordinator runs the relay that publishes rows with OpenRestream's
* service token (audience openvibe.events, capability events.event.publish). Events down, Network
* service token (audience openvibe.events, capability events.event.publish). Events down, Network
* down or no credentials yet: rows wait and are retried; nothing in the transport path waits.
* down or no credentials yet: rows wait and are retried; nothing in the transport path waits.
*
*
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * OpenRe identifiers: <prefix>_<ULID> (the ULID from openvibe-contracts ids, so they sort by
- * creation time like every other OpenVibe id). Prefixes are OpenRe's own entity types.
+ * OpenRestream identifiers: <prefix>_<ULID> (the ULID from openvibe-contracts ids, so they sort by
+ * creation time like every other OpenVibe id). Prefixes are OpenRestream's own entity types.
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
@@ -20,7 +20,7 @@ const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new Reg
@@ -20,7 +20,7 @@ const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new Reg
function newId(kind, now) {
function newId(kind, now) {
const p = PREFIX[kind];
const p = PREFIX[kind];
- if (!p) throw new TypeError(`no OpenRe id prefix for "${kind}"`);
+ if (!p) throw new TypeError(`no OpenRestream id prefix for "${kind}"`);
return `${p}_${ids.ulid(now)}`;
return `${p}_${ids.ulid(now)}`;
}
}
@@ -48,7 +48,7 @@ async function start({ config, clock, fetchImpl = globalThis.fetch, log = consol
@@ -48,7 +48,7 @@ async function start({ config, clock, fetchImpl = globalThis.fetch, log = consol
if (require.main === module) {
if (require.main === module) {
require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
start().then((h) => {
start().then((h) => {
- // SIGTERM/SIGINT (openvibe-sdk/service, docs/service.md's OpenRe.Stream entry): the keys poller stops, requests
+ // SIGTERM/SIGINT (openvibe-sdk/service, docs/service.md's OpenRestream entry): the keys poller stops, requests
// in flight get 8 s, then Valkey and the database close in that order. A database close failure exits 1;
// in flight get 8 s, then Valkey and the database close in that order. A database close failure exits 1;
// past the 10 s deadline the process also exits 1. Workers are separate processes and keep running.
// past the 10 s deadline the process also exits 1. Workers are separate processes and keep running.
gracefulStop({
gracefulStop({
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
- * Live lineage for OpenRe definitions (roadmap D20): which OpenVibe.Live channel a stream belongs to,
+ * Live lineage for OpenRestream definitions (roadmap D20): which OpenVibe.Live channel a stream belongs to,
* answered by Live's canonical resolver (GET /internal/lineage/resolve, capability live.lineage.resolve,
* answered by Live's canonical resolver (GET /internal/lineage/resolve, capability live.lineage.resolve,
- * OpenRe's Network service token for audience openvibe.live) instead of a mapping of OpenRe's own.
+ * OpenRestream's Network service token for audience openvibe.live) instead of a mapping of OpenRestream's own.
* A definition is asked about when it mirrors into Live or points at a Live slot (external ref
* A definition is asked about when it mirrors into Live or points at a Live slot (external ref
* live:managed_stream), with its owner subject and that slot id; display names are never sent.
* live:managed_stream), with its owner subject and that slot id; display names are never sent.
*
*
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* DELETE /api/v1/:app/vods/:id → drop an empty shell / an ephemeral clips-only recording
* DELETE /api/v1/:app/vods/:id → drop an empty shell / an ephemeral clips-only recording
*
*
* The rtmp_url is the owning worker's loopback play URL (rtmp://127.0.0.1:<port>/live/<session id>):
* The rtmp_url is the owning worker's loopback play URL (rtmp://127.0.0.1:<port>/live/<session id>):
- * no ingest key ever leaves OpenRe. Media records and finalises; OpenRe only asks.
+ * no ingest key ever leaves OpenRestream. Media records and finalises; OpenRestream only asks.
*
*
* Auth: config.media.auth === 'key' sends the tenant app key (MEDIA_API_KEY), which Media's VOD
* Auth: config.media.auth === 'key' sends the tenant app key (MEDIA_API_KEY), which Media's VOD
* routes accept today. 'service' sends a Network service token (audience openvibe.media); Media's
* routes accept today. 'service' sends a Network service token (audience openvibe.media); Media's
@@ -1,13 +1,13 @@
@@ -1,13 +1,13 @@
'use strict';
'use strict';
/**
/**
* Import OpenVibe.Live stream slots (managed_streams) and their restream destinations from a
* Import OpenVibe.Live stream slots (managed_streams) and their restream destinations from a
- * read-only snapshot of Live's database into OpenRe (scripts/migrate-from-live.js is the CLI).
+ * read-only snapshot of Live's database into OpenRestream (scripts/migrate-from-live.js is the CLI).
*
*
* Rules (ADR-009, plan §8.1):
* Rules (ADR-009, plan §8.1):
* - Live's database is opened read-only; nothing is written to it.
* - Live's database is opened read-only; nothing is written to it.
- * - Stream keys are NEVER imported. Every imported slot gets a new OpenRe key that nobody has
+ * - Stream keys are NEVER imported. Every imported slot gets a new OpenRestream key that nobody has
* seen; the streamer gets a usable key by rotating (Live's "Regenerate" once the slot is
* seen; the streamer gets a usable key by rotating (Live's "Regenerate" once the slot is
- * switched to OpenRe, or openre.stream). Every key that existed before the cutover is dead
+ * switched to OpenRestream, or openre.stream). Every key that existed before the cutover is dead
* at the cutover.
* at the cutover.
* - Every source row is imported, held with a reason, or excluded with a reason, and recorded in
* - Every source row is imported, held with a reason, or excluded with a reason, and recorded in
* migration_map. Re-running is idempotent: imported rows are skipped, held rows are retried.
* migration_map. Re-running is idempotent: imported rows are skipped, held rows are retried.
@@ -165,7 +165,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
@@ -165,7 +165,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
continue;
continue;
}
}
const v = validateDestinationUrl(d.server_url, { allowPrivate: config.outputs.allowPrivateHosts });
const v = validateDestinationUrl(d.server_url, { allowPrivate: config.outputs.allowPrivateHosts });
- const hold = !v.ok ? `server URL refused by OpenRe's destination rules: ${v.error}` : null;
+ const hold = !v.ok ? `server URL refused by OpenRestream's destination rules: ${v.error}` : null;
const input = {
const input = {
platform: ['youtube', 'twitch', 'kick', 'custom'].includes(d.platform) ? d.platform : 'custom',
platform: ['youtube', 'twitch', 'kick', 'custom'].includes(d.platform) ? d.platform : 'custom',
name: d.name || null,
name: d.name || null,
@@ -198,7 +198,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
@@ -198,7 +198,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
count(hold ? 'held' : 'imported');
count(hold ? 'held' : 'imported');
}
}
if (own.some(d => d.connection_id) || unbound.some(d => d.connection_id)) {
if (own.some(d => d.connection_id) || unbound.some(d => d.connection_id)) {
- ch.notes.push('OAuth-linked destination(s): Live refreshed their ingest key (and created the YouTube broadcast) on every go-live; OpenRe pushes to the key stored at import. Check the platform key is a persistent one, or re-enter it on openre.stream after the cutover.');
+ ch.notes.push('OAuth-linked destination(s): Live refreshed their ingest key (and created the YouTube broadcast) on every go-live; OpenRestream pushes to the key stored at import. Check the platform key is a persistent one, or re-enter it on openre.stream after the cutover.');
}
}
}
}
return report;
return report;
@@ -207,7 +207,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
@@ -207,7 +207,7 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
/** Markdown checklist per channel, for the lead and the broadcaster. Contains no secret. */
/** Markdown checklist per channel, for the lead and the broadcaster. Contains no secret. */
function checklist(report, { openreUrl = 'https://openre.stream', rtmpUrl } = {}) {
function checklist(report, { openreUrl = 'https://openre.stream', rtmpUrl } = {}) {
const lines = [];
const lines = [];
- lines.push(`# OpenRe RTMP cutover checklist${report.apply ? '' : ' (dry run: nothing was written)'}`);
+ lines.push(`# OpenRestream RTMP cutover checklist${report.apply ? '' : ' (dry run: nothing was written)'}`);
lines.push('');
lines.push('');
lines.push(`Imported ${report.counts.imported}, held ${report.counts.held}, excluded ${report.counts.excluded}, already done ${report.counts.skipped}.`);
lines.push(`Imported ${report.counts.imported}, held ${report.counts.held}, excluded ${report.counts.excluded}, already done ${report.counts.skipped}.`);
lines.push('');
lines.push('');
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Two kinds of secret, handled differently:
* Two kinds of secret, handled differently:
*
*
- * Ingest keys (what a streamer pastes into OBS) are OpenRe's own credentials. Only their SHA-256
+ * Ingest keys (what a streamer pastes into OBS) are OpenRestream's own credentials. Only their SHA-256
* is stored; the plain key exists once, in the response that created or rotated it. 256 bits of
* is stored; the plain key exists once, in the response that created or rotated it. 256 bits of
* randomness make a plain digest safe (no dictionary to try), and lookup stays one indexed read,
* randomness make a plain digest safe (no dictionary to try), and lookup stays one indexed read,
* which matters because the RTMP worker authenticates synchronously inside the publish handshake.
* which matters because the RTMP worker authenticates synchronously inside the publish handshake.
@@ -234,7 +234,7 @@ function createDefinitions({ db, config, events, clock }) {
@@ -234,7 +234,7 @@ function createDefinitions({ db, config, events, clock }) {
/**
/**
* The RTMP (or later WHIP) worker's check, synchronous by design: a publish handshake is
* The RTMP (or later WHIP) worker's check, synchronous by design: a publish handshake is
- * answered inside node-media-server's prePublish handler. Only OpenRe's own hashed keys count:
+ * answered inside node-media-server's prePublish handler. Only OpenRestream's own hashed keys count:
* this never looks at any other service's database.
* this never looks at any other service's database.
*/
*/
async function resolveIngestKey(plain, protocol) {
async function resolveIngestKey(plain, protocol) {
@@ -14,7 +14,7 @@ const { createOutputs } = require('./outputs');
@@ -14,7 +14,7 @@ const { createOutputs } = require('./outputs');
const { createRecordings } = require('./recordings');
const { createRecordings } = require('./recordings');
/**
/**
- * The handle every store gets: its transactions are SERIALIZABLE. Several OpenRe processes change the same rows (a
+ * The handle every store gets: its transactions are SERIALIZABLE. Several OpenRestream processes change the same rows (a
* publish admitted by ingest while the coordinator fails a lost worker's session); PostgreSQL detects the conflict
* publish admitted by ingest while the coordinator fails a lost worker's session); PostgreSQL detects the conflict
* and the SDK runs the loser again (40001 is retried).
* and the SDK runs the loser again (40001 is retried).
*/
*/
@@ -191,7 +191,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
@@ -191,7 +191,7 @@ function createSessions({ db, config, events, clock, definitions, workers }) {
}
}
/**
/**
- * Where to watch a session. Internal URLs are loopback addresses on the OpenRe host (Live and
+ * Where to watch a session. Internal URLs are loopback addresses on the OpenRestream host (Live and
* Media run there too); public_url is served by openre-api's /play proxy.
* Media run there too); public_url is served by openre-api's /play proxy.
*/
*/
async function playback(session) {
async function playback(session) {
@@ -6,7 +6,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -6,7 +6,7 @@ const ovServe = require('openvibe-shared/serve');
* pinned openvibe-shared release) the way OpenVibe.Community renders it.
* pinned openvibe-shared release) the way OpenVibe.Community renders it.
*/
*/
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
-const SITE = 'OpenRe.Stream';
+const SITE = 'OpenRestream';
function esc(v) {
function esc(v) {
return String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
return String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -34,7 +34,7 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
@@ -34,7 +34,7 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
`;
`;
-const DESCRIPTION = 'OpenRe.Stream: stream definitions, ingest keys, sessions and restream outputs for the OpenVibe network.';
+const DESCRIPTION = 'OpenRestream: stream definitions, ingest keys, sessions and restream outputs for the OpenVibe network.';
function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [] }) {
function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [] }) {
const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — ingest and restream for OpenVibe`;
const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — ingest and restream for OpenVibe`;
@@ -51,7 +51,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
@@ -51,7 +51,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
};
};
const foot = { service: 'openre', variant: 'compact', mount: '#ov-footer', brandName: SITE, legalBase: config.liveUrl, updates: '/updates',
const foot = { service: 'openre', variant: 'compact', mount: '#ov-footer', brandName: SITE, legalBase: config.liveUrl, updates: '/updates',
- links: [{ heading: SITE, items: [{ name: 'Streams', url: '/streams' }, { name: 'Source code', url: 'https://github.com/OpenVibers/OpenRe.Stream' }] }] };
+ links: [{ heading: SITE, items: [{ name: 'Streams', url: '/streams' }, { name: 'Source code', url: 'https://github.com/OpenVibers/OpenRestream' }] }] };
let footerSsr = '';
let footerSsr = '';
try { footerSsr = require('openvibe-shared/footer').ssr({ service: 'openre', variant: 'compact', updates: '/updates' }); } catch { footerSsr = '<footer id="ov-footer"></footer>'; }
try { footerSsr = require('openvibe-shared/footer').ssr({ service: 'openre', variant: 'compact', updates: '/updates' }); } catch { footerSsr = '<footer id="ov-footer"></footer>'; }
let icon = '';
let icon = '';
@@ -74,7 +74,7 @@ ${styles.map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">
@@ -74,7 +74,7 @@ ${styles.map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">
</head>
</head>
<body>
<body>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
-<noscript><nav class="ore" style="padding:8px 16px"><a href="/">OpenRe.Stream</a> · <a href="/streams">Streams</a> · <a href="/sessions">Sessions</a> · ${user ? '<a href="/auth/logout">Sign out</a>' : '<a href="/auth/login">Sign in</a>'}</nav></noscript>
+<noscript><nav class="ore" style="padding:8px 16px"><a href="/">OpenRestream</a> · <a href="/streams">Streams</a> · <a href="/sessions">Sessions</a> · ${user ? '<a href="/auth/logout">Sign out</a>' : '<a href="/auth/login">Sign in</a>'}</nav></noscript>
<main id="main" class="ore">
<main id="main" class="ore">
${body}
${body}
</main>
</main>
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
* The standalone UI (openre.stream). Server-rendered, useful without JavaScript, forms post back
* The standalone UI (openre.stream). Server-rendered, useful without JavaScript, forms post back
* to the same process and go through the same store rules as the API.
* to the same process and go through the same store rules as the API.
*
*
- * / what OpenRe is, and the signed-in owner's streams
+ * / what OpenRestream is, and the signed-in owner's streams
* /streams definitions + create
* /streams definitions + create
* /streams/:id ingest URL, keys (rotate: the new key is shown once), settings,
* /streams/:id ingest URL, keys (rotate: the new key is shown once), settings,
* destinations (add, edit, delete, test, start, stop), sessions
* destinations (add, edit, delete, test, start, stop), sessions
@@ -43,7 +43,7 @@ function createUiRouter({ rt, auth, limits = null }) {
@@ -43,7 +43,7 @@ function createUiRouter({ rt, auth, limits = null }) {
if (req.caller.kind === 'user' && req.caller.subject) return true;
if (req.caller.kind === 'user' && req.caller.subject) return true;
page(req, res, {
page(req, res, {
title: 'Sign in', canonicalPath: req.path,
title: 'Sign in', canonicalPath: req.path,
- body: `<h1>Sign in</h1><p>OpenRe.Stream uses your OpenVibe account.</p><p><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
+ body: `<h1>Sign in</h1><p>OpenRestream uses your OpenVibe account.</p><p><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
}, 401);
}, 401);
return false;
return false;
}
}
@@ -84,17 +84,17 @@ function createUiRouter({ rt, auth, limits = null }) {
@@ -84,17 +84,17 @@ function createUiRouter({ rt, auth, limits = null }) {
// ── Pages ─────────────────────────────────────────────────
// ── Pages ─────────────────────────────────────────────────
- // What shipped on OpenRe.Stream: the shared update log every OpenVibe site has.
- router.get('/updates', (req, res) => page(req, res, { canonicalPath: '/updates', robots: 'index,follow', title: 'What shipped on OpenRe.Stream', body: frame.updatesBody({ service: 'openre', siteName: 'OpenRe.Stream' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));
+ // What shipped on OpenRestream: the shared update log every OpenVibe site has.
+ router.get('/updates', (req, res) => page(req, res, { canonicalPath: '/updates', robots: 'index,follow', title: 'What shipped on OpenRestream', body: frame.updatesBody({ service: 'openre', siteName: 'OpenRestream' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
const signedIn = req.caller.kind === 'user' && req.caller.subject;
const signedIn = req.caller.kind === 'user' && req.caller.subject;
const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];
const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];
page(req, res, {
page(req, res, {
canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],
canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],
- description: 'Go live from your browser with no OBS and no follower minimum on OpenVibe.Live, or restream an OBS feed to your other channels with OpenRe.Stream.',
+ description: 'Go live from your browser with no OBS and no follower minimum on OpenVibe.Live, or restream an OBS feed to your other channels with OpenRestream.',
body: `${frontPage(config)}
body: `${frontPage(config)}
${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
-${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream' })}`,
+${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
});
});
});
});
@@ -106,9 +106,9 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'
@@ -106,9 +106,9 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'
function frontPage(cfg) {
function frontPage(cfg) {
const live = cfg.liveUrl;
const live = cfg.liveUrl;
return showcase.hero({
return showcase.hero({
- eyebrow: 'OpenRe.Stream · alpha',
+ eyebrow: 'OpenRestream · alpha',
title: 'Go live from your browser.', accent: 'No OBS, no follower minimum.',
title: 'Go live from your browser.', accent: 'No OBS, no follower minimum.',
- lede: 'Open OpenVibe.Live, press Go Live and allow your camera: nothing to install, and no follower or subscriber threshold. OpenRe.Stream is the ingest and restream service behind it. With an encoder such as OBS, it takes your RTMP feed and sends the session on to your other channels.',
+ lede: 'Open OpenVibe.Live, press Go Live and allow your camera: nothing to install, and no follower or subscriber threshold. OpenRestream is the ingest and restream service behind it. With an encoder such as OBS, it takes your RTMP feed and sends the session on to your other channels.',
actions: [
actions: [
{ label: 'Go live in your browser', href: live, primary: true },
{ label: 'Go live in your browser', href: live, primary: true },
{ label: 'Restream with OBS', href: '/streams' },
{ label: 'Restream with OBS', href: '/streams' },
@@ -116,7 +116,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'
@@ -116,7 +116,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'
],
],
note: 'Alpha. RTMP ingest and RTMP/SRT restreaming run here today; browser (WHIP) and JSMPEG ingest are still served by OpenVibe.Live. Channels, discovery and watch pages stay on openvibe.live.',
note: 'Alpha. RTMP ingest and RTMP/SRT restreaming run here today; browser (WHIP) and JSMPEG ingest are still served by OpenVibe.Live. Channels, discovery and watch pages stay on openvibe.live.',
}) + showcase.features({
}) + showcase.features({
- title: 'What OpenRe does',
+ title: 'What OpenRestream does',
items: [
items: [
{ icon: 'ov:stream', title: 'Ingest keys you control', text: 'Create a stream and get its RTMP server and key. The key is shown once, stored only as a hash, and can be rotated any time.' },
{ icon: 'ov:stream', title: 'Ingest keys you control', text: 'Create a stream and get its RTMP server and key. The key is shown once, stored only as a hash, and can be rotated any time.' },
{ icon: 'ov:live', title: 'One stream, several channels', text: 'Send a session to several RTMP or SRT destinations at once, each with its own health, logs and retry with backoff.' },
{ icon: 'ov:live', title: 'One stream, several channels', text: 'Send a session to several RTMP or SRT destinations at once, each with its own health, logs and retry with backoff.' },
@@ -162,7 +162,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'
@@ -162,7 +162,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRe.Stream'
page(req, res, {
page(req, res, {
title: heading, canonicalPath: `/streams/${definition.id}`,
title: heading, canonicalPath: `/streams/${definition.id}`,
body: `<h1>${esc(heading)}</h1>
body: `<h1>${esc(heading)}</h1>
-<div class="card"><p><strong>Copy the key now.</strong> OpenRe stores only a hash of it; this page is the only time it is shown.</p>
+<div class="card"><p><strong>Copy the key now.</strong> OpenRestream stores only a hash of it; this page is the only time it is shown.</p>
<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>
<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>
<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>
<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>
<p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,
<p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,
@@ -217,7 +217,7 @@ ${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><
@@ -217,7 +217,7 @@ ${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><
<div><label>Recording visibility</label><select name="recording_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.recording_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
<div><label>Recording visibility</label><select name="recording_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.recording_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
<div><label>Playback</label><select name="playback_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.playback_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
<div><label>Playback</label><select name="playback_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.playback_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>
<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>
-<label><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Mirror sessions into my OpenVibe.Live channel (consent; Live also has to switch this slot to OpenRe)</label>
+<label><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Mirror sessions into my OpenVibe.Live channel (consent; Live also has to switch this slot to OpenRestream)</label>
<button type="submit">Save</button>`)}</div>
<button type="submit">Save</button>`)}</div>
<h2>Sessions</h2>${sessionTable(sessions)}`,
<h2>Sessions</h2>${sessionTable(sessions)}`,
});
});
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// ADR-009 acceptance: "Deploying the OpenRe API does not end worker-owned transports."
+// ADR-009 acceptance: "Deploying the OpenRestream API does not end worker-owned transports."
// The API runs as its own process; a fake worker process owns a live session. The API is stopped
// The API runs as its own process; a fake worker process owns a live session. The API is stopped
// (SIGTERM, as systemd does on a deploy), the session stays live and its lease keeps being renewed
// (SIGTERM, as systemd does on a deploy), the session stays live and its lease keeps being renewed
// by the worker; a new API process starts and serves the same live session.
// by the worker; a new API process starts and serves the same live session.
@@ -10,7 +10,7 @@ const { load } = require('../server/config');
@@ -10,7 +10,7 @@ const { load } = require('../server/config');
const { openRuntime } = require('../server/store');
const { openRuntime } = require('../server/store');
if (!multiProcess()) {
if (!multiProcess()) {
- console.log('api-restart: skipped (OpenRe processes sharing one database need PostgreSQL: npm run test:pg)');
+ console.log('api-restart: skipped (OpenRestream processes sharing one database need PostgreSQL: npm run test:pg)');
process.exit(0);
process.exit(0);
}
}
const t = suite('api-restart');
const t = suite('api-restart');
@@ -12,7 +12,7 @@ const { localAddresses, rtmpProbe } = require('../scripts/cutover-preflight');
@@ -12,7 +12,7 @@ const { localAddresses, rtmpProbe } = require('../scripts/cutover-preflight');
const { tmpDir, testEnv, child, waitFor, freePort, suite, silent, multiProcess } = require('./helpers');
const { tmpDir, testEnv, child, waitFor, freePort, suite, silent, multiProcess } = require('./helpers');
if (!multiProcess()) {
if (!multiProcess()) {
- console.log('bind-switch: skipped (OpenRe processes sharing one database need PostgreSQL: npm run test:pg)');
+ console.log('bind-switch: skipped (OpenRestream processes sharing one database need PostgreSQL: npm run test:pg)');
process.exit(0);
process.exit(0);
}
}
const t = suite('bind-switch');
const t = suite('bind-switch');
@@ -53,7 +53,7 @@ t('the UI works without JavaScript: landing, sign-in prompt, streams', async ()
@@ -53,7 +53,7 @@ t('the UI works without JavaScript: landing, sign-in prompt, streams', async ()
api = await bootApi();
api = await bootApi();
const home = await request(api.base, 'GET', '/');
const home = await request(api.base, 'GET', '/');
assert.strictEqual(home.status, 200);
assert.strictEqual(home.status, 200);
- assert.match(home.text, /OpenRe\.Stream/);
+ assert.match(home.text, /OpenRestream/);
assert.match(home.text, /theme-loader\.js/);
assert.match(home.text, /theme-loader\.js/);
assert.match(home.text, /<noscript><nav/);
assert.match(home.text, /<noscript><nav/);
const signin = await request(api.base, 'GET', '/streams');
const signin = await request(api.base, 'GET', '/streams');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The discovery routes (server/discovery.js) answer with the right content types: robots.txt
// The discovery routes (server/discovery.js) answer with the right content types: robots.txt
// names the sitemap, sitemap.xml lists the public pages including /, and llms.txt describes what
// names the sitemap, sitemap.xml lists the public pages including /, and llms.txt describes what
-// OpenRe.Stream does today. All three come from openvibe-shared/seo, the shared toolkit.
+// OpenRestream does today. All three come from openvibe-shared/seo, the shared toolkit.
const assert = require('assert');
const assert = require('assert');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { bootApi, request, suite } = require('./helpers');
const { bootApi, request, suite } = require('./helpers');
@@ -41,10 +41,10 @@ t('llms.txt is plain text, leads with browser go-live and says what runs today',
@@ -41,10 +41,10 @@ t('llms.txt is plain text, leads with browser go-live and says what runs today',
const r = await request(api.base, 'GET', '/llms.txt');
const r = await request(api.base, 'GET', '/llms.txt');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /^text\/plain/);
assert.match(r.headers.get('content-type'), /^text\/plain/);
- assert.ok(r.text.startsWith('# OpenRe.Stream\n'), 'starts with the site heading');
+ assert.ok(r.text.startsWith('# OpenRestream\n'), 'starts with the site heading');
assert.match(r.text.split('\n').slice(0, 4).join(' '), /Go live from your browser with no OBS and no follower minimum/, 'browser go-live leads');
assert.match(r.text.split('\n').slice(0, 4).join(' '), /Go live from your browser with no OBS and no follower minimum/, 'browser go-live leads');
// Only the RTMP ingest and restream workers run in production: WHIP and JSMPEG are ported, not served yet.
// Only the RTMP ingest and restream workers run in production: WHIP and JSMPEG are ported, not served yet.
- assert.match(r.text, /WHIP, RFC 9725\) and JSMPEG ingest are ported to OpenRe and tested, but their workers do not run in production yet/);
+ assert.match(r.text, /WHIP, RFC 9725\) and JSMPEG ingest are ported to OpenRestream and tested, but their workers do not run in production yet/);
} finally {
} finally {
await api.close();
await api.close();
}
}
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* manual clock, runtime/API boot helpers, child-process helpers and a tiny sequential runner.
* manual clock, runtime/API boot helpers, child-process helpers and a tiny sequential runner.
*
*
* Databases (ADR-035): one per test directory, migrated. npm test: PGlite in memory, so one process (a test that
* Databases (ADR-035): one per test directory, migrated. npm test: PGlite in memory, so one process (a test that
- * spawns OpenRe processes sharing it says it is skipped); npm run test:pg (OPENRE_TEST_STORE=pg): a schema of its own
+ * spawns OpenRestream processes sharing it says it is skipped); npm run test:pg (OPENRE_TEST_STORE=pg): a schema of its own
* on the PostgreSQL + PgBouncer containers, whose URLs every spawned process of the directory gets.
* on the PostgreSQL + PgBouncer containers, whose URLs every spawned process of the directory gets.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
@@ -68,7 +68,7 @@ async function testDb(dir) {
@@ -68,7 +68,7 @@ async function testDb(dir) {
return await databases.get(dir);
return await databases.get(dir);
}
}
-/** Several OpenRe processes on one database need PostgreSQL (PGlite is one process). */
+/** Several OpenRestream processes on one database need PostgreSQL (PGlite is one process). */
const multiProcess = () => STORE === 'pg';
const multiProcess = () => STORE === 'pg';
/** A handle the test keeps: a runtime's or an API's close() leaves it open for the next one on the directory. */
/** A handle the test keeps: a runtime's or an API's close() leaves it open for the next one on the directory. */
@@ -22,7 +22,7 @@ if (!hasFfmpeg()) {
@@ -22,7 +22,7 @@ if (!hasFfmpeg()) {
process.exit(0);
process.exit(0);
}
}
if (!multiProcess()) {
if (!multiProcess()) {
- console.log('jsmpeg-e2e: skipped (the OpenRe processes share one database: PostgreSQL only, npm run test:pg)');
+ console.log('jsmpeg-e2e: skipped (the OpenRestream processes share one database: PostgreSQL only, npm run test:pg)');
process.exit(0);
process.exit(0);
}
}
const t = suite('jsmpeg-e2e');
const t = suite('jsmpeg-e2e');
@@ -90,7 +90,7 @@ let pubA;
@@ -90,7 +90,7 @@ let pubA;
let sink;
let sink;
let viewer;
let viewer;
-t('setup: stubs and the four OpenRe processes', async () => {
+t('setup: stubs and the four OpenRestream processes', async () => {
const network = await stubServer((req, res, body) => {
const network = await stubServer((req, res, body) => {
if (req.url === '/oauth/token') {
if (req.url === '/oauth/token') {
const p = new URLSearchParams(body);
const p = new URLSearchParams(body);
@@ -249,7 +249,7 @@ t('a killed worker fails its session after the lease expires', async () => {
@@ -249,7 +249,7 @@ t('a killed worker fails its session after the lease expires', async () => {
assert.match(s.failure_reason, /lease|lost|expired/i);
assert.match(s.failure_reason, /lease|lost|expired/i);
});
});
-t('no OpenRe process crashed along the way, and no key reached a log', () => {
+t('no OpenRestream process crashed along the way, and no key reached a log', () => {
assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);
assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);
assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);
assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);
for (const p of procs) {
for (const p of procs) {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// Live lineage (D20): the coordinator asks OpenVibe.Live's canonical resolver which channel a Live-linked
// Live lineage (D20): the coordinator asks OpenVibe.Live's canonical resolver which channel a Live-linked
-// definition belongs to, with OpenRe's service token; session events carry the answer.
+// definition belongs to, with OpenRestream's service token; session events carry the answer.
const assert = require('assert');
const assert = require('assert');
const { runtime, manualClock, suite, OWNER, outboxEnvelopes, silent } = require('./helpers');
const { runtime, manualClock, suite, OWNER, outboxEnvelopes, silent } = require('./helpers');
const { createCoordinator } = require('../server/coordinator');
const { createCoordinator } = require('../server/coordinator');
@@ -31,7 +31,7 @@ const RESOLVED = { status: 'resolved', channel: { id: '17', slug: 'goosely', own
@@ -31,7 +31,7 @@ const RESOLVED = { status: 'resolved', channel: { id: '17', slug: 'goosely', own
t('a Live-linked definition is resolved with its owner and slot; an unlinked one is never asked about', async () => {
t('a Live-linked definition is resolved with its owner and slot; an unlinked one is never asked about', async () => {
const { rt, lineage, asked } = await setup(RESOLVED);
const { rt, lineage, asked } = await setup(RESOLVED);
const linked = (await rt.store.definitions.create({ owner_subject: OWNER, mirror_to_live: true, external_refs: [{ service: 'live', type: 'managed_stream', id: '12' }] })).definition;
const linked = (await rt.store.definitions.create({ owner_subject: OWNER, mirror_to_live: true, external_refs: [{ service: 'live', type: 'managed_stream', id: '12' }] })).definition;
- await rt.store.definitions.create({ owner_subject: OWNER, title: 'OpenRe only' });
+ await rt.store.definitions.create({ owner_subject: OWNER, title: 'OpenRestream only' });
const out = await lineage.refresh();
const out = await lineage.refresh();
assert.deepStrictEqual(out, { checked: 1, resolved: 1 });
assert.deepStrictEqual(out, { checked: 1, resolved: 1 });
assert.deepStrictEqual(asked, [{ owner_subject: OWNER, slot_id: '12' }], 'owner subject and slot id, never a display name');
assert.deepStrictEqual(asked, [{ owner_subject: OWNER, slot_id: '12' }], 'owner subject and slot id, never a display name');
@@ -148,7 +148,7 @@ t('the CLI runs a dry run against a snapshot and prints the checklist', async ()
@@ -148,7 +148,7 @@ t('the CLI runs a dry run against a snapshot and prints the checklist', async ()
const out = execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'migrate-from-live.js'), '--live-db', file], {
const out = execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'migrate-from-live.js'), '--live-db', file], {
env: { ...process.env, ...env, OPENRE_ENV_FILE: '/nonexistent' }, cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env, OPENRE_ENV_FILE: '/nonexistent' }, cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
});
});
- assert.match(out, /OpenRe RTMP cutover checklist \(dry run/);
+ assert.match(out, /OpenRestream RTMP cutover checklist \(dry run/);
assert.match(out, /@twoslots/);
assert.match(out, /@twoslots/);
});
});
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// scripts/cutover-preflight.js against a mocked host: files, commands, DNS, interfaces, the port
// scripts/cutover-preflight.js against a mocked host: files, commands, DNS, interfaces, the port
-// probe and /api/ready are fakes; OpenRe's database is a real PostgreSQL one (PGlite) read in READ ONLY transactions,
+// probe and /api/ready are fakes; OpenRestream's database is a real PostgreSQL one (PGlite) read in READ ONLY transactions,
// Events' and Live's are real SQLite files opened read-only (Events' production PostgreSQL has the same tables).
// Events' and Live's are real SQLite files opened read-only (Events' production PostgreSQL has the same tables).
// Covers the host as it is today (2026-09-23: everything the cutover still needs fails), a host
// Covers the host as it is today (2026-09-23: everything the cutover still needs fails), a host
// ready for the first slot, each failure on its own, the off-host port check, the probe URL, the
// ready for the first slot, each failure on its own, the off-host port check, the probe URL, the
@@ -27,7 +27,7 @@ const LIVE_PID = 4242;
@@ -27,7 +27,7 @@ const LIVE_PID = 4242;
async function makeDbs() {
async function makeDbs() {
const dir = tmpDir();
const dir = tmpDir();
- // OpenRe's database: PGlite in memory, reached as the target 'pglite:openre' (host() below hands it out).
+ // OpenRestream's database: PGlite in memory, reached as the target 'pglite:openre' (host() below hands it out).
const openrePath = 'pglite:openre';
const openrePath = 'pglite:openre';
const o = await openDb(load({ NODE_ENV: 'test' }), { memory: true });
const o = await openDb(load({ NODE_ENV: 'test' }), { memory: true });
const now = Date.now();
const now = Date.now();
@@ -380,7 +380,7 @@ t('slot: offline RTMP slot with a subject and a migrated definition passes; live
@@ -380,7 +380,7 @@ t('slot: offline RTMP slot with a subject and a migrated definition passes; live
const dbs = await makeDbs();
const dbs = await makeDbs();
let r = byId(await preflight(opts(dbs, ['--only', 'slot', '--slot', '12']), host(dbs)));
let r = byId(await preflight(opts(dbs, ['--only', 'slot', '--slot', '12']), host(dbs)));
assert.strictEqual(r.slot.status, 'PASS', r.slot.detail);
assert.strictEqual(r.slot.status, 'PASS', r.slot.detail);
- assert.match(r.slot.detail, /slot 12 \(@rehearsal, rtmp\/obs\) authority live; OpenRe str_1 \(active, mirrored\); 0 destination\(s\)/);
+ assert.match(r.slot.detail, /slot 12 \(@rehearsal, rtmp\/obs\) authority live; OpenRestream str_1 \(active, mirrored\); 0 destination\(s\)/);
const l = new Database(dbs.livePath);
const l = new Database(dbs.livePath);
await l.prepare('INSERT INTO streams (managed_stream_id, is_live) VALUES (12, 1)').run();
await l.prepare('INSERT INTO streams (managed_stream_id, is_live) VALUES (12, 1)').run();
await l.prepare('DELETE FROM linked_accounts').run();
await l.prepare('DELETE FROM linked_accounts').run();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// Recording requests (Media contract as Live's recorder uses it) and the event relay (outbox →
// Recording requests (Media contract as Live's recorder uses it) and the event relay (outbox →
-// OpenVibe.Events with OpenRe's service token), with stub Media / Network / Events servers.
+// OpenVibe.Events with OpenRestream's service token), with stub Media / Network / Events servers.
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
const { runtime, manualClock, suite, OWNER, silent, outboxTypes } = require('./helpers');
const { runtime, manualClock, suite, OWNER, silent, outboxTypes } = require('./helpers');
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// openre-restream-worker and openre-session-coordinator as separate child processes, a stub
// openre-restream-worker and openre-session-coordinator as separate child processes, a stub
// OpenVibe.Network (token endpoint), a stub OpenVibe.Events and a stub OpenVibe.Media.
// OpenVibe.Network (token endpoint), a stub OpenVibe.Events and a stub OpenVibe.Media.
//
//
-// Proves: ingest with OpenRe's own keys (wrong and duplicate publishers refused), restream to a
+// Proves: ingest with OpenRestream's own keys (wrong and duplicate publishers refused), restream to a
// working destination while a dead destination fails without touching the session, recording
// working destination while a dead destination fails without touching the session, recording
// requested from Media with a key-free loopback URL that Media can actually pull, an API restart
// requested from Media with a key-free loopback URL that Media can actually pull, an API restart
// during the broadcast that ends nothing, HTTP-FLV playback through the new API, a worker
// during the broadcast that ends nothing, HTTP-FLV playback through the new API, a worker
@@ -22,7 +22,7 @@ if (!hasFfmpeg()) {
@@ -22,7 +22,7 @@ if (!hasFfmpeg()) {
}
}
if (!multiProcess()) {
if (!multiProcess()) {
- console.log('rtmp-e2e: skipped (the four OpenRe processes share one database: PostgreSQL only, npm run test:pg)');
+ console.log('rtmp-e2e: skipped (the four OpenRestream processes share one database: PostgreSQL only, npm run test:pg)');
process.exit(0);
process.exit(0);
}
}
const t = suite('rtmp-e2e');
const t = suite('rtmp-e2e');
@@ -74,7 +74,7 @@ let pubA;
@@ -74,7 +74,7 @@ let pubA;
let sessionA;
let sessionA;
let sink;
let sink;
-t('setup: stubs and the four OpenRe processes', async () => {
+t('setup: stubs and the four OpenRestream processes', async () => {
const network = await stubServer((req, res, body) => {
const network = await stubServer((req, res, body) => {
if (req.url === '/oauth/token') {
if (req.url === '/oauth/token') {
const p = new URLSearchParams(body);
const p = new URLSearchParams(body);
@@ -263,10 +263,10 @@ t('events reached OpenVibe.Events through the outbox relay, without keys', async
@@ -263,10 +263,10 @@ t('events reached OpenVibe.Events through the outbox relay, without keys', async
assert.deepStrictEqual(started.payload.external_refs.map(r => `${r.service}:${r.type}:${r.id}`), ['live:managed_stream:5', 'live:user:42']);
assert.deepStrictEqual(started.payload.external_refs.map(r => `${r.service}:${r.type}:${r.id}`), ['live:managed_stream:5', 'live:user:42']);
});
});
-t('no OpenRe process crashed along the way', () => {
+t('no OpenRestream process crashed along the way', () => {
assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);
assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);
assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);
assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);
- assert.strictEqual(ingest2.exitCode, null, `generation 2 exited:\n${ingest2.output}`); // No ingest or destination key in any OpenRe process log.
+ assert.strictEqual(ingest2.exitCode, null, `generation 2 exited:\n${ingest2.output}`); // No ingest or destination key in any OpenRestream process log.
for (const p of procs) {
for (const p of procs) {
if (!p.output) continue;
if (!p.output) continue;
assert.ok(!p.output.includes(keyA), 'no ingest key in logs');
assert.ok(!p.output.includes(keyA), 'no ingest key in logs');
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
'use strict';
'use strict';
/**
/**
- * openvibe-sdk/service in OpenRe.Stream (plan T1, lane A): the API's SIGTERM/SIGINT shutdown is the kit's
+ * openvibe-sdk/service in OpenRestream (plan T1, lane A): the API's SIGTERM/SIGINT shutdown is the kit's
* gracefulStop, not a hand-written handler. The keys poller stops, requests in flight drain for 8 s, then the
* gracefulStop, not a hand-written handler. The keys poller stops, requests in flight drain for 8 s, then the
* store closes in today's order (Valkey, then the database); a database close failure exits 1,
* store closes in today's order (Valkey, then the database); a database close failure exits 1,
* a clean stop exits 0, and past the 10 s deadline the process exits 1. The static half reads server/index.js;
* a clean stop exits 0, and past the 10 s deadline the process exits 1. The static half reads server/index.js;
- * the behavioural half drives gracefulStop with the same options OpenRe passes, exits stubbed.
+ * the behavioural half drives gracefulStop with the same options OpenRestream passes, exits stubbed.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -28,7 +28,7 @@ function fixtureOffer() {
@@ -28,7 +28,7 @@ function fixtureOffer() {
].join('\r\n');
].join('\r\n');
}
}
-t('config: the WebRTC ports never collide with Live and the RTC range is OpenRe\'s own', () => {
+t('config: the WebRTC ports never collide with Live and the RTC range is OpenRestream\'s own', () => {
const c = load({});
const c = load({});
assert.strictEqual(c.webrtc.port, 9936);
assert.strictEqual(c.webrtc.port, 9936);
assert.strictEqual(c.webrtc.media.minPort, 10200);
assert.strictEqual(c.webrtc.media.minPort, 10200);
@@ -58,7 +58,7 @@ t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {
@@ -58,7 +58,7 @@ t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {
assert.strictEqual(rtp.mid, '0');
assert.strictEqual(rtp.mid, '0');
const transport = { iceParameters: { usernameFragment: 'uf', password: 'pw' }, iceCandidates: [{ foundation: '1', protocol: 'udp', priority: 1, ip: '203.0.113.7', port: 10200, type: 'host' }], dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB' }] } };
const transport = { iceParameters: { usernameFragment: 'uf', password: 'pw' }, iceCandidates: [{ foundation: '1', protocol: 'udp', priority: 1, ip: '203.0.113.7', port: 10200, type: 'host' }], dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB' }] } };
- const answer = sdp.buildSdpAnswer(transport, offer, { video: { rtpParameters: rtp } }, { serverName: 'OpenRe' });
+ const answer = sdp.buildSdpAnswer(transport, offer, { video: { rtpParameters: rtp } }, { serverName: 'OpenRestream' });
const parsedAnswer = sdp.parse(answer);
const parsedAnswer = sdp.parse(answer);
assert.strictEqual(parsedAnswer.media[0].direction, 'recvonly');
assert.strictEqual(parsedAnswer.media[0].direction, 'recvonly');
assert.strictEqual(parsedAnswer.media[0].setup, 'passive');
assert.strictEqual(parsedAnswer.media[0].setup, 'passive');
@@ -31,7 +31,7 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit
@@ -31,7 +31,7 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit
},
},
});
});
- /** The loopback source URL for a session's protocol, or null when OpenRe cannot restream it yet. */
+ /** The loopback source URL for a session's protocol, or null when OpenRestream cannot restream it yet. */
async function inputUrlFor(session) {
async function inputUrlFor(session) {
if (session.protocol === 'rtmp') {
if (session.protocol === 'rtmp') {
const pb = await store.sessions.playback(session);
const pb = await store.sessions.playback(session);
@@ -73,7 +73,7 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit
@@ -73,7 +73,7 @@ function createRestreamWorker({ rt, log = console, exit = (code) => process.exit
egressBase = `http://127.0.0.1:${ep.egressPort}`;
egressBase = `http://127.0.0.1:${ep.egressPort}`;
} else {
} else {
input = await inputUrlFor(session);
input = await inputUrlFor(session);
- if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }
+ if (!input) { await store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRestream yet`, ended_at: Date.now() }, { workerId: runtime.me.id }); continue; }
}
}
// Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).
// Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).
if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;
if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
* FFmpeg argument building for restream outputs, ported from OpenVibe.Live
* FFmpeg argument building for restream outputs, ported from OpenVibe.Live
* server/streaming/restream-manager.js (quality presets, destination URL normalisation, SRT,
* server/streaming/restream-manager.js (quality presets, destination URL normalisation, SRT,
* output flags, friendly errors). Kept byte-for-byte equivalent where it matters so a stream that
* output flags, friendly errors). Kept byte-for-byte equivalent where it matters so a stream that
- * restreams correctly from Live restreams the same way from OpenRe.
+ * restreams correctly from Live restreams the same way from OpenRestream.
*
*
* RTMP → destination is a codec copy (zero CPU); a JSMPEG source (the worker's MPEG-TS data tap,
* RTMP → destination is a codec copy (zero CPU); a JSMPEG source (the worker's MPEG-TS data tap,
* read over HTTP) is re-encoded with encodingArgs(). A WebRTC source is mediasoup PlainRTP read
* read over HTTP) is re-encoded with encodingArgs(). A WebRTC source is mediasoup PlainRTP read
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* published in its worker row, so consumers always reach the process that holds the
* published in its worker row, so consumers always reach the process that holds the
* publisher.
* publisher.
*
*
- * Authentication happens inside the publish handshake, synchronously, against OpenRe's hashed
+ * Authentication happens inside the publish handshake, synchronously, against OpenRestream's hashed
* keys only. An accepted publish is renamed from /live/<key> to /live/<session id> before
* keys only. An accepted publish is renamed from /live/<key> to /live/<session id> before
* node-media-server registers it, so the key never appears in any play URL, log or descriptor.
* node-media-server registers it, so the key never appears in any play URL, log or descriptor.
*
*
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * The worker interface every OpenRe transport worker implements (RTMP ingest, restream, and the
+ * The worker interface every OpenRestream transport worker implements (RTMP ingest, restream, and the
* WHIP/WebRTC/SFU/JSMPEG kinds as they are ported):
* WHIP/WebRTC/SFU/JSMPEG kinds as they are ported):
*
*
* const w = createWorkerRuntime({ rt, kind, log, hooks });
* const w = createWorkerRuntime({ rt, kind, log, hooks });
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* and grabs one frame from it every interval, then uploads it to Media as an object under the `live`
* and grabs one frame from it every interval, then uploads it to Media as an object under the `live`
* namespace (grant media.object.upload ns live); the object's public URL becomes the session's
* namespace (grant media.object.upload ns live); the object's public URL becomes the session's
* thumbnail_url and travels on the session state and its openre.session.* events. Live can no longer
* thumbnail_url and travels on the session state and its openre.session.* events. Live can no longer
- * read a local FLV/SFU/relay for OpenRe-owned sessions, so the frame has to come from here.
+ * read a local FLV/SFU/relay for OpenRestream-owned sessions, so the frame has to come from here.
*
*
* The grabber is protocol-agnostic: the caller passes the ffmpeg *input* arguments for its source
* The grabber is protocol-agnostic: the caller passes the ffmpeg *input* arguments for its source
* (an RTMP/HTTP-FLV URL, the JSMPEG data tap, or an SDP file for a PlainRTP consumer) and this module
* (an RTMP/HTTP-FLV URL, the JSMPEG data tap, or an SDP file for a PlainRTP consumer) and this module
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* WHIP SDP <-> mediasoup bridging, ported from OpenVibe.Live server/streaming/whip-handler.js
* WHIP SDP <-> mediasoup bridging, ported from OpenVibe.Live server/streaming/whip-handler.js
* (offer validation, DTLS parameter extraction, RTP parameter extraction against the router's
* (offer validation, DTLS parameter extraction, RTP parameter extraction against the router's
- * capabilities, and the answer builder). The Live-specific stream-row code is not ported: OpenRe
+ * capabilities, and the answer builder). The Live-specific stream-row code is not ported: OpenRestream
* validates the offer, negotiates codecs and builds the answer, and nothing here touches a database.
* validates the offer, negotiates codecs and builds the answer, and nothing here touches a database.
*
*
* `sdp-transform` is required lazily so the module (and the whole worker) can be loaded — and its
* `sdp-transform` is required lazily so the module (and the whole worker) can be loaded — and its
@@ -187,7 +187,7 @@ function extractRtpParameters(media, routerCapabilities, mediaIndex = 0) {
@@ -187,7 +187,7 @@ function extractRtpParameters(media, routerCapabilities, mediaIndex = 0) {
* BUNDLE group (RFC 8843 §7.3.3), so a browser publisher whose extra section (a data channel, a
* BUNDLE group (RFC 8843 §7.3.3), so a browser publisher whose extra section (a data channel, a
* codec the router lacks) is rejected is not refused by its own SDP parser.
* codec the router lacks) is rejected is not refused by its own SDP parser.
*/
*/
-function buildSdpAnswer(transport, offerSdp, producersByKind, { serverName = 'OpenRe.Stream', fallbackAddress = '127.0.0.1' } = {}) {
+function buildSdpAnswer(transport, offerSdp, producersByKind, { serverName = 'OpenRestream', fallbackAddress = '127.0.0.1' } = {}) {
const { iceParameters, iceCandidates, dtlsParameters } = transport;
const { iceParameters, iceCandidates, dtlsParameters } = transport;
const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);
const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);
const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';
const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';
@@ -316,7 +316,7 @@ function buildIceFragment(iceParameters, mirror = {}) {
@@ -316,7 +316,7 @@ function buildIceFragment(iceParameters, mirror = {}) {
* listens on; the worker's PlainRTP consumer is already sending to it.
* listens on; the worker's PlainRTP consumer is already sending to it.
*/
*/
function buildEgressSdp({ video, audio } = {}) {
function buildEgressSdp({ video, audio } = {}) {
- const lines = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenRe.Stream WebRTC egress', 'c=IN IP4 127.0.0.1', 't=0 0'];
+ const lines = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenRestream WebRTC egress', 'c=IN IP4 127.0.0.1', 't=0 0'];
const section = (type, c, port) => {
const section = (type, c, port) => {
const pt = c.payloadType;
const pt = c.payloadType;
const codecName = (c.mimeType || `${type}/VP8`).split('/')[1];
const codecName = (c.mimeType || `${type}/VP8`).split('/')[1];
@@ -1,14 +1,14 @@
@@ -1,14 +1,14 @@
'use strict';
'use strict';
/**
/**
- * The mediasoup SFU, one Router per OpenRe session, ported from OpenVibe.Live
+ * The mediasoup SFU, one Router per OpenRestream session, ported from OpenVibe.Live
* server/streaming/webrtc-sfu.js. The differences that matter:
* server/streaming/webrtc-sfu.js. The differences that matter:
*
*
- * - a "room" is an OpenRe ingest session id (not Live's `stream-<id>`), so a router's lifetime
+ * - a "room" is an OpenRestream ingest session id (not Live's `stream-<id>`), so a router's lifetime
* is exactly the session's;
* is exactly the session's;
* - consumer/producer/transport bookkeeping is unchanged, so the viewer signaling and the
* - consumer/producer/transport bookkeeping is unchanged, so the viewer signaling and the
* PlainRTP egress paths behave like Live's;
* PlainRTP egress paths behave like Live's;
* - PlainRTP consumers (restream, Media's RTP recording, thumbnails) send to an explicit remote
* - PlainRTP consumers (restream, Media's RTP recording, thumbnails) send to an explicit remote
- * ip:port the caller chose, because on OpenRe the consumer side (ffmpeg) runs in another process.
+ * ip:port the caller chose, because on OpenRestream the consumer side (ffmpeg) runs in another process.
*
*
* `mediasoup` is required lazily: the module loads (and its turn-free helpers run) on a host where
* `mediasoup` is required lazily: the module loads (and its turn-free helpers run) on a host where
* the native dependency is not installed, and `createSfu().available` is false there. The worker
* the native dependency is not installed, and `createSfu().available` is false there. The worker
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* WS signaling for WebRTC, modelled on the producer and viewer halves of OpenVibe.Live's
* WS signaling for WebRTC, modelled on the producer and viewer halves of OpenVibe.Live's
* server/streaming/broadcast-server.js so Live's broadcast and watch pages can point their
* server/streaming/broadcast-server.js so Live's broadcast and watch pages can point their
- * mediasoup-client at OpenRe:
+ * mediasoup-client at OpenRestream:
*
*
* ws://<host>:<port>/b/<ingest key> the broadcaster (Live's role: produce into the SFU)
* ws://<host>:<port>/b/<ingest key> the broadcaster (Live's role: produce into the SFU)
* ws://<host>:<port>/w/<session playback id> a viewer (consume; never carries the key)
* ws://<host>:<port>/w/<session playback id> a viewer (consume; never carries the key)
@@ -129,7 +129,7 @@ function createSignaling({ sfu, iceServers, admit, endSession, log = console })
@@ -129,7 +129,7 @@ function createSignaling({ sfu, iceServers, admit, endSession, log = console })
try { adm = await admit(decodeURIComponent(bMatch[1])); } catch (err) { adm = { error: err.message }; }
try { adm = await admit(decodeURIComponent(bMatch[1])); } catch (err) { adm = { error: err.message }; }
if (!adm || adm.error) {
if (!adm || adm.error) {
log.log(`[webrtc] broadcast refused (${adm && adm.error}) from ${socket.remoteAddress || '?'}`);
log.log(`[webrtc] broadcast refused (${adm && adm.error}) from ${socket.remoteAddress || '?'}`);
- socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nX-OpenRe-Error: ' + (adm && adm.error || 'refused') + '\r\n\r\n');
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nX-OpenRestream-Error: ' + (adm && adm.error || 'refused') + '\r\n\r\n');
socket.destroy();
socket.destroy();
return true;
return true;
}
}