openvibers/openrestream Commits

OpenRestream: Media calls use the service token only; the tenant key and capability bridge go (C-42) (#54)

OP OpenVibers committed 11 Oct 2026 · aedcb9b3ce · 11 files, +36 −48

This closes compatibility register C-42. OpenRestream calls Media only with its Network service token. The shared `live` tenant key (`MEDIA_API_KEY`), the `OPENRE_MEDIA_AUTH` switch and the capability bridge go.

**Production is already on service auth:**
1. **Probe:** OpenRestream's own Media client from the deployed release, forced to `service`, sent `DELETE /api/v1/live/vods/987654321`. Media answered `404 VOD not found`, from the handler past the credential and verb checks. Media's VOD routes take the write verb, and `media.object.upload` covers write and delete. The grant `[openre, media.object.upload, openvibe.media]` with namespace `live` has been active since 2026-09-29.
2. **Switch:** `OPENRE_MEDIA_AUTH=service` was set in `openre.env` (backup kept) and all six openre units restarted. There were no ingest sessions; OpenRestream has had one session ever, now ended. `ovhost status openre` reports ready.
3. **After this deploy:** `MEDIA_API_KEY` and `OPENRE_MEDIA_AUTH` are removed from the env; nothing reads them any more.

**Code:**
- **Media client:** `server/media-client.js` always uses the service token (client credentials, audience `openvibe.media`). With no client secret there is no token, and recording is off (`configured` false), as with a missing key before.
- **Config:** `server/config.js` drops `media.auth` and `media.apiKey`.
- **Capability bridge:** removed from `server/auth/index.js`, along with `allows()`/`hasCap()`. Contracts v0.131.0 (pinned) defines all eight `openre.*` ids the routes guard, so `capabilities.check()` decides alone. It already refuses an ungranted id (`capability.denied`) and honours `family.*` grants.
- **Docs:** `.env.example` and the README's grant table, grant list and Recording section.

**Tests:**
- **Recording requests:** `recording-events` and `rtmp-e2e` assert that Media sees OpenRestream's service token (from a stub Network), never a tenant key; `jsmpeg-e2e` drops the key from its env.
- **Thumbnails:** `webrtc`'s thumbnail upload answers the token endpoint and asserts the token on the object init.
- **Env samples:** `drill-mode` and `preflight` drop the key.

**Results:** `ov test` on the covering files passed, including `rtmp-e2e` and `jsmpeg-e2e` run directly. `webrtc-e2e` fails here and on main because the local dependency cache lacks the mediasoup worker binary (`spawn … mediasoup-worker ENOENT`); CI builds it.

Changes

ddiff --git a/.env.example b/.env.example
iindex 3b8685a..e7fd25b 100644
---- a/.env.example
++++ b/.env.example
@@@ -150,10 +150,8 @@ OPENRE_RTMP_PORT=1936
MEDIA_URL=http://127.0.0.1:4100
# The Media tenant recordings are filed under ('live' keeps them in the Live channel's gallery).
MEDIA_APP_ID=live
--# 'key' = the tenant's app key below (what Media's VOD routes accept today); 'service' = a Network
--# service token (needs Media to name a capability on its VOD ingest routes first).
--#OPENRE_MEDIA_AUTH=key
--MEDIA_API_KEY=
++# Requests carry OpenRestream's Network service token (OV_OAUTH_CLIENT_SECRET; grant
++# [openre, media.object.upload, openvibe.media] with namespace live).
# OPENRE_RECORDING=off disables recording requests.
#OPENRE_RECORDING=on
#OPENRE_RECORDING_START_DELAY_MS=2000
ddiff --git a/README.md b/README.md
iindex 38e8217..28050f5 100644
---- a/README.md
++++ b/README.md
@@@ -96,7 +96,7 @@ Called elsewhere, as the service principal `openre` (client credentials from Net
|---|---|---|
| OpenVibe.Events | `events.event.publish` | the coordinator's outbox relay (`openre.*` events) |
| 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 | `media.object.upload` (namespace `live`) | recording requests and live thumbnails |
The full grant list, including what Live needs to call OpenRestream, is under "Grants the lead adds in
Network" below.
@@@ -126,7 +126,7 @@ The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposa
- `[openre, events.event.publish, openvibe.events]`
- `[openre, live.lineage.resolve, openvibe.live]` (the channel lineage of Live-linked streams)
- `[live, events.subscription.manage, openvibe.events]` if Live creates its own `openre.session.*` subscription (or an operator creates it)
--- Media, only when `OPENRE_MEDIA_AUTH=service`: `[openre, media.object.upload, openvibe.media]` with namespace `live` — and Media must first name that capability on its VOD create/ingest/finalize/delete routes (today they accept only the tenant app key; see "Recording").
++- `[openre, media.object.upload, openvibe.media]` with namespace `live` (recording requests: VOD create, ingest, finalize and the delete of an empty shell; live thumbnails).
- OAuth client `openre` (authorization code + client credentials) with redirect `https://openre.stream/auth/callback`.
## Events
@@@ -149,7 +149,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c
## Recording
--When a session is live and its definition says `recording_mode` `vod` or `clips`, the coordinator asks Media, exactly as Live's recorder does: `POST /api/v1/<MEDIA_APP_ID>/vods` (title, Live `user_id`/`managed_stream_id` from the typed references so the VOD lands in the Live channel's gallery, visibility, `meta.openre_session_id`), then `POST …/vods/:id/ingest/rtmp { rtmp_url: rtmp://127.0.0.1:<play port>/live/<session id> }`. When the session ends: `POST …/finalize` (clips-only recordings are then deleted, as Live does). A disk-space refusal deletes the empty VOD shell and retries every 5 min while the session is live. Media finalises. Auth today is the Media tenant key (`MEDIA_API_KEY`, the `live` app's key — the interim compromise, as Live holds it too); the target is `OPENRE_MEDIA_AUTH=service` once Media accepts service tokens on those routes. Known gap: the VOD's `stream_id` (a Live id) is not set, because Live creates its `streams` row from the event; Live links the VOD by slot, user and time.
++When a session is live and its definition says `recording_mode` `vod` or `clips`, the coordinator asks Media, exactly as Live's recorder does: `POST /api/v1/<MEDIA_APP_ID>/vods` (title, Live `user_id`/`managed_stream_id` from the typed references so the VOD lands in the Live channel's gallery, visibility, `meta.openre_session_id`), then `POST …/vods/:id/ingest/rtmp { rtmp_url: rtmp://127.0.0.1:<play port>/live/<session id> }`. When the session ends: `POST …/finalize` (clips-only recordings are then deleted, as Live does). A disk-space refusal deletes the empty VOD shell and retries every 5 min while the session is live. Media finalises. Auth is OpenRestream's Network service token (audience `openvibe.media`, grant `media.object.upload` in namespace `live`); the shared tenant key it used until 2026-10-11 is gone. Known gap: the VOD's `stream_id` (a Live id) is not set, because Live creates its `streams` row from the event; Live links the VOD by slot, user and time.
## Security notes
ddiff --git a/server/auth/index.js b/server/auth/index.js
iindex 2900894..ca0fd1f 100644
---- a/server/auth/index.js
++++ b/server/auth/index.js
@@@ -16,10 +16,9 @@
* everything, end sessions).
* { kind: 'anonymous' }
*
-- * The capability ids are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts
-- * yet. Until the release that defines them, allows() grants them with the contracts rule (exact id
-- * or a `family.*` grant) and hands the decision to capabilities.check() once contracts know the id
-- * (the same bridge OpenVibe.Events used).
++ * Every openre.* capability id is defined in openvibe-contracts, so a service's grant is decided by
++ * capabilities.check() alone (exact id or a `family.*` grant). The bridge that let an id unknown to
++ * contracts through on the grant alone is gone (compatibility register C-42).
*
* The keys are openvibe-sdk/auth createNetworkKeys (server/index.js): a pinned OV_NETWORK_PUBLIC_KEY, or Network's
* JWKS with a rotation honoured on an unknown kid and the last good keys kept through an outage.
@@@ -34,18 +33,6 @@ const STAFF_ROLES = new Set(['admin']);
// ── Capabilities ───────────────────────────────────────────────
--function hasCap(claims, id) {
-- const granted = claims && Array.isArray(claims.cap) ? claims.cap : [];
-- return granted.some(g => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
--}
--
--function allows(claims, id) {
-- if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
-- const c = capabilities.check(claims, id);
-- if (c.code === 'capability.unknown' && !capabilities.get(id)) return { allowed: true, code: null, reason: null };
-- return c;
--}
--
// ── Tokens ─────────────────────────────────────────────────────
const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
@@@ -146,7 +133,7 @@ function createAuth({ config, keys }) {
return function capGuard(req, res, next) {
const c = req.caller || { kind: 'anonymous' };
if (c.kind === 'service') {
-- const r = allows(c.claims, capabilityId);
++ const r = capabilities.check(c.claims, capabilityId);
if (!r.allowed) return http.sendProblem(res, 403, r.code, { detail: r.reason, ctx: req.ov });
req.capability = capabilityId;
return next();
@@@ -179,4 +166,4 @@ function createAuth({ config, keys }) {
return { resolve, middleware, guard, canAccess, actorOf, verifyService, verifyUser };
}
--module.exports = { createAuth, allows, hasCap, bearer, cookie, AuthError };
++module.exports = { createAuth, bearer, cookie, AuthError };
ddiff --git a/server/config.js b/server/config.js
iindex e4a440b..43e76ff 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -209,11 +209,8 @@ function load(env = process.env) {
// The Media tenant the recording is filed under. 'live' keeps VODs in the Live
// channel's gallery (Media still finalises and owns the object, ADR-006).
appId: env.MEDIA_APP_ID || 'live',
-- // 'key': the tenant's app key (MEDIA_API_KEY), which Media's VOD routes accept today.
-- // 'service': a Network service token (audience openvibe.media) once Media names a
-- // capability on its VOD ingest routes (README "Recording").
-- auth: env.OPENRE_MEDIA_AUTH === 'service' ? 'service' : 'key',
-- apiKey: env.MEDIA_API_KEY || '',
++ // Requests carry OpenRestream's Network service token (audience openvibe.media; grant
++ // media.object.upload, namespace live). The shared tenant key is gone (C-42).
enabled: env.OPENRE_RECORDING !== 'off',
startDelayMs: int(env.OPENRE_RECORDING_START_DELAY_MS, 2000),
},
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 452ae04..bbb65be 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -12,9 +12,9 @@
* 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 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
-- * VOD routes do not accept service tokens yet (they name no capability) — see README "Recording".
++ * Auth: OpenRestream's Network service token (client credentials, audience openvibe.media). Media's VOD
++ * routes take it as the write verb (media.object.upload, which also covers the delete of an empty
++ * shell) in namespace live. With no client secret configured there is no token, and recording is off.
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
@@@ -24,16 +24,15 @@ class MediaError extends Error {
function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
const base = `${config.media.url}/api/v1/${encodeURIComponent(config.media.appId)}`;
-- const tokens = config.media.auth === 'service' && config.oauth.clientSecret
++ const tokens = config.oauth.clientSecret
? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.media', fetch: fetchImpl })
: null;
// A restore drill (OPENRE_DRILL) never asks Media for anything.
-- const configured = config.media.enabled && !config.drill && Boolean(config.media.auth === 'service' ? tokens : config.media.apiKey);
++ const configured = config.media.enabled && !config.drill && Boolean(tokens);
async function authHeader() {
-- if (tokens) return { Authorization: `Bearer ${await tokens.getToken()}` };
-- return config.media.apiKey ? { Authorization: `Bearer ${config.media.apiKey}` } : {};
++ return tokens ? { Authorization: `Bearer ${await tokens.getToken()}` } : {};
}
async function request(method, path, { body, timeoutMs = 15000 } = {}) {
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex d5ce4e1..035c6bd 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -18,7 +18,7 @@ const { runtime, bootApi, request, serviceToken, tmpDir, testEnv, child, suite,
const t = suite('drill-mode');
// What the production env file sets: with these, a non-drill process would publish and record.
--const PROD_LIKE = { EVENTS_URL: 'http://127.0.0.1:9', OV_OAUTH_CLIENT_SECRET: 'x'.repeat(40), MEDIA_URL: 'http://127.0.0.1:9', MEDIA_API_KEY: 'k'.repeat(40) };
++const PROD_LIKE = { EVENTS_URL: 'http://127.0.0.1:9', OV_OAUTH_CLIENT_SECRET: 'x'.repeat(40), MEDIA_URL: 'http://127.0.0.1:9' };
const reader = serviceToken('live', ['openre.stream.read', 'openre.stream.write', 'openre.session.read']);
t('OPENRE_DRILL parses like the other switches and is off by default', () => {
ddiff --git a/test/jsmpeg-e2e.test.js b/test/jsmpeg-e2e.test.js
iindex f888ac1..ed7ea75 100644
---- a/test/jsmpeg-e2e.test.js
++++ b/test/jsmpeg-e2e.test.js
@@@ -118,7 +118,6 @@ t('setup: stubs and the four OpenRestream processes', async () => {
EVENTS_URL: events,
EVENTS_RELAY_INTERVAL_MS: '200',
MEDIA_URL: media,
-- MEDIA_API_KEY: 'media-app-key',
OPENRE_RECORDING_START_DELAY_MS: '200',
OPENRE_WORKER_HEARTBEAT_MS: '250',
OPENRE_WORKER_LEASE_MS: '4000',
ddiff --git a/test/preflight.test.js b/test/preflight.test.js
iindex e2cf9ea..6ca2d50 100644
---- a/test/preflight.test.js
++++ b/test/preflight.test.js
@@@ -77,7 +77,7 @@ function host(dbs, over = {}) {
release: RELEASE,
originMain: RELEASE,
containsCommit: true,
-- openreEnv: { OV_OAUTH_CLIENT_ID: 'openre', OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, OPENRE_SECRETS_KEY: SECRETS_KEY, OPENRE_RTMP_PORT: '1936', OPENRE_RTMP_BIND: '0.0.0.0', OPENRE_RTMP_PUBLIC_HOST: 'ingest.openre.stream', EVENTS_URL: 'http://127.0.0.1:4300', MEDIA_API_KEY: 'm'.repeat(40) },
++ openreEnv: { OV_OAUTH_CLIENT_ID: 'openre', OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, OPENRE_SECRETS_KEY: SECRETS_KEY, OPENRE_RTMP_PORT: '1936', OPENRE_RTMP_BIND: '0.0.0.0', OPENRE_RTMP_PUBLIC_HOST: 'ingest.openre.stream', EVENTS_URL: 'http://127.0.0.1:4300' },
liveEnv: { OV_OAUTH_CLIENT_ID: 'live', OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, OPENRE_URL: 'http://127.0.0.1:4500', OPENRE_PUBLIC_URL: 'https://openre.stream', OPENRE_EVENTS_SECRET: EVENTS_SECRET },
runningLiveEnv: null, // default: same as liveEnv
ss: 'LISTEN 0 511 0.0.0.0:1936 0.0.0.0:*\nLISTEN 0 511 127.0.0.1:19360 0.0.0.0:*\n',
ddiff --git a/test/recording-events.test.js b/test/recording-events.test.js
iindex 642c2fe..239511e 100644
---- a/test/recording-events.test.js
++++ b/test/recording-events.test.js
@@@ -24,8 +24,14 @@ const reply = (res, status, obj) => { res.statusCode = status; res.setHeader('Co
async function setup({ media: mediaHandler, recording_mode = 'vod' }) {
const calls = [];
const media = await stub((req, res, body) => { calls.push({ method: req.method, url: req.url, body, auth: req.headers.authorization }); mediaHandler(req, res, body, calls); });
++ // Network issues OpenRestream's service token for audience openvibe.media.
++ const network = await stub((req, res, body, raw) => {
++ const p = new URLSearchParams(raw);
++ assert.strictEqual(p.get('grant_type'), 'client_credentials');
++ reply(res, 200, { access_token: `svc-${p.get('audience')}`, token_type: 'Bearer', expires_in: 300 });
++ });
const clock = manualClock();
-- const rt = await runtime({ clock, env: { MEDIA_URL: media.url, MEDIA_API_KEY: 'k-live', OPENRE_RECORDING_START_DELAY_MS: '0' } });
++ const rt = await runtime({ clock, env: { MEDIA_URL: media.url, OV_NETWORK_INTERNAL_URL: network.url, OV_OAUTH_CLIENT_SECRET: 's3cret', OPENRE_RECORDING_START_DELAY_MS: '0' } });
const client = createMediaClient({ config: rt.config });
const coordinator = createCoordinator({ rt, media: client, log: silent });
const { definition, key } = await rt.store.definitions.create({ owner_subject: OWNER, title: 'Rec', recording_mode, external_refs: [{ service: 'live', type: 'user', id: '8' }] });
@@@ -48,7 +54,7 @@ t('a live session gets one recording request; finalize when it ends (Media final
await coordinator.tick();
await coordinator.tick();
assert.deepStrictEqual(calls.map(c => `${c.method} ${c.url}`), ['POST /api/v1/live/vods', 'POST /api/v1/live/vods/501/ingest/rtmp']);
-- assert.strictEqual(calls[0].auth, 'Bearer k-live');
++ assert.strictEqual(calls[0].auth, 'Bearer svc-openvibe.media', 'OpenRestream\'s service token for Media');
assert.strictEqual(calls[0].body.user_id, 8);
assert.strictEqual(calls[0].body.visibility, 'public');
assert.strictEqual(calls[1].body.rtmp_url, `rtmp://127.0.0.1:19390/live/${session.id}`);
ddiff --git a/test/rtmp-e2e.test.js b/test/rtmp-e2e.test.js
iindex 0541f0a..5eb7f89 100644
---- a/test/rtmp-e2e.test.js
++++ b/test/rtmp-e2e.test.js
@@@ -117,7 +117,6 @@ t('setup: stubs and the four OpenRestream processes', async () => {
EVENTS_URL: events,
EVENTS_RELAY_INTERVAL_MS: '200',
MEDIA_URL: media,
-- MEDIA_API_KEY: 'media-app-key',
OPENRE_RECORDING_START_DELAY_MS: '300',
OPENRE_WORKER_HEARTBEAT_MS: '250',
OPENRE_WORKER_LEASE_MS: '4000',
@@@ -184,7 +183,7 @@ t('the working destination goes live, the dead one fails, the session stays live
t('recording is requested from Media with a loopback URL that carries no key, and Media can pull it', async () => {
const ingestCall = await waitFor(() => mediaCalls.find(c => c.url.endsWith('/ingest/rtmp')), { what: 'media ingest call', timeoutMs: 10000 });
const create = mediaCalls.find(c => c.url === '/api/v1/live/vods');
-- assert.strictEqual(create.auth, 'Bearer media-app-key');
++ assert.strictEqual(create.auth, 'Bearer tok-openvibe.media', 'OpenRestream\'s service token, never a tenant key');
assert.strictEqual(create.body.user_id, 42);
assert.strictEqual(create.body.managed_stream_id, 5);
assert.strictEqual(create.body.meta.openre_session_id, sessionA.id);
ddiff --git a/test/webrtc.test.js b/test/webrtc.test.js
iindex 82390ae..75aa82e 100644
---- a/test/webrtc.test.js
++++ b/test/webrtc.test.js
@@@ -187,18 +187,21 @@ t('a grabber captures a frame (ffmpeg stdout) and uploads it to Media as an obje
t('media-client.uploadObject does init → PUT → complete', async () => {
const calls = [];
const fetchImpl = async (url, opts = {}) => {
-- calls.push({ url: String(url), method: opts.method || 'GET' });
++ calls.push({ url: String(url), method: opts.method || 'GET', auth: opts.headers && opts.headers.Authorization });
++ if (String(url).endsWith('/oauth/token')) return { ok: true, status: 200, json: async () => ({ access_token: 'svc-media', token_type: 'Bearer', expires_in: 300 }) };
if (String(url).endsWith('/objects')) return { ok: true, status: 201, text: async () => JSON.stringify({ id: 'o1', upload: { url: 'https://m/o1/content', complete_url: 'https://m/o1/complete', method: 'PUT' } }) };
if (String(url).endsWith('/o1/content')) return { ok: true, status: 200, text: async () => '' };
if (String(url).endsWith('/o1/complete')) return { ok: true, status: 200, text: async () => JSON.stringify({ object: { id: 'o1', public_url: 'https://media.test/o/o1' } }) };
return { ok: false, status: 404, text: async () => '{}' };
};
-- const config = load({ MEDIA_URL: 'http://127.0.0.1:4100', MEDIA_API_KEY: 'k'.repeat(40), MEDIA_APP_ID: 'live' });
++ const config = load({ MEDIA_URL: 'http://127.0.0.1:4100', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40), MEDIA_APP_ID: 'live' });
const media = createMediaClient({ config, fetchImpl });
const r = await media.uploadObject({ namespace: 'live', mimeType: 'image/jpeg', filename: 'x.jpg', bytes: Buffer.alloc(10, 1) });
assert.strictEqual(r.url, 'https://media.test/o/o1');
-- assert.deepStrictEqual(calls.map(c => c.method), ['POST', 'PUT', 'POST']);
-- assert.ok(calls[0].url.includes('/api/v2/live/objects'));
++ const toMedia = calls.filter(c => !c.url.endsWith('/oauth/token'));
++ assert.deepStrictEqual(toMedia.map(c => c.method), ['POST', 'PUT', 'POST']);
++ assert.ok(toMedia[0].url.includes('/api/v2/live/objects'));
++ assert.strictEqual(toMedia[0].auth, 'Bearer svc-media', 'the object init carries the service token');
});
t('restream args: webrtcArgs reads the worker\'s SDP file and re-encodes', () => {