openvibers/openrestream Commits
OpenRestream: Media calls use the service token only; the tenant key and capability bridge go (C-42) (#54)
Changes
@@@ -150,10 +150,8 @@ OPENRE_RTMP_PORT=1936MEDIA_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@@@ -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 inNetwork" 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@@@ -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 };@@@ -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),},@@@ -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 } = {}) {@@@ -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', () => {@@@ -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',@@@ -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 liveEnvss: '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',@@@ -24,8 +24,14 @@ const reply = (res, status, obj) => { res.statusCode = status; res.setHeader('Coasync 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 finalawait 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}`);@@@ -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 livet('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);@@@ -187,18 +187,21 @@ t('a grabber captures a frame (ffmpeg stdout) and uploads it to Media as an objet('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', () => {
@@ -150,10 +150,8 @@ OPENRE_RTMP_PORT=1936
@@ -150,10 +150,8 @@ OPENRE_RTMP_PORT=1936
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
# The Media tenant recordings are filed under ('live' keeps them in the Live channel's gallery).
# The Media tenant recordings are filed under ('live' keeps them in the Live channel's gallery).
MEDIA_APP_ID=live
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=off disables recording requests.
#OPENRE_RECORDING=on
#OPENRE_RECORDING=on
#OPENRE_RECORDING_START_DELAY_MS=2000
#OPENRE_RECORDING_START_DELAY_MS=2000
@@ -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.Events | `events.event.publish` | the coordinator's outbox relay (`openre.*` events) |
| 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.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
The full grant list, including what Live needs to call OpenRestream, is under "Grants the lead adds in
Network" below.
Network" below.
@@ -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
- `[openre, events.event.publish, openvibe.events]`
- `[openre, events.event.publish, openvibe.events]`
- `[openre, live.lineage.resolve, openvibe.live]` (the channel lineage of Live-linked streams)
- `[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)
- `[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`.
- OAuth client `openre` (authorization code + client credentials) with redirect `https://openre.stream/auth/callback`.
## Events
## Events
@@ -149,7 +149,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c
@@ -149,7 +149,7 @@ Written to `event_outbox` in the same transaction as the change (openvibe-sdk `c
## Recording
## 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
## Security notes
@@ -16,10 +16,9 @@
@@ -16,10 +16,9 @@
* everything, end sessions).
* everything, end sessions).
* { kind: 'anonymous' }
* { 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
* 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.
* 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']);
@@ -34,18 +33,6 @@ const STAFF_ROLES = new Set(['admin']);
// ── Capabilities ───────────────────────────────────────────────
// ── 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 ─────────────────────────────────────────────────────
// ── Tokens ─────────────────────────────────────────────────────
const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
@@ -146,7 +133,7 @@ function createAuth({ config, keys }) {
@@ -146,7 +133,7 @@ function createAuth({ config, keys }) {
return function capGuard(req, res, next) {
return function capGuard(req, res, next) {
const c = req.caller || { kind: 'anonymous' };
const c = req.caller || { kind: 'anonymous' };
if (c.kind === 'service') {
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 });
if (!r.allowed) return http.sendProblem(res, 403, r.code, { detail: r.reason, ctx: req.ov });
req.capability = capabilityId;
req.capability = capabilityId;
return next();
return next();
@@ -179,4 +166,4 @@ function createAuth({ config, keys }) {
@@ -179,4 +166,4 @@ function createAuth({ config, keys }) {
return { resolve, middleware, guard, canAccess, actorOf, verifyService, verifyUser };
return { resolve, middleware, guard, canAccess, actorOf, verifyService, verifyUser };
}
}
-module.exports = { createAuth, allows, hasCap, bearer, cookie, AuthError };
+module.exports = { createAuth, bearer, cookie, AuthError };
@@ -209,11 +209,8 @@ function load(env = process.env) {
@@ -209,11 +209,8 @@ function load(env = process.env) {
// The Media tenant the recording is filed under. 'live' keeps VODs in the Live
// 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).
// channel's gallery (Media still finalises and owns the object, ADR-006).
appId: env.MEDIA_APP_ID || 'live',
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',
enabled: env.OPENRE_RECORDING !== 'off',
startDelayMs: int(env.OPENRE_RECORDING_START_DELAY_MS, 2000),
startDelayMs: int(env.OPENRE_RECORDING_START_DELAY_MS, 2000),
},
},
@@ -12,9 +12,9 @@
@@ -12,9 +12,9 @@
* 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 OpenRestream. Media records and finalises; OpenRestream 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
- * 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');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
@@ -24,16 +24,15 @@ class MediaError extends Error {
@@ -24,16 +24,15 @@ class MediaError extends Error {
function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
const base = `${config.media.url}/api/v1/${encodeURIComponent(config.media.appId)}`;
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 })
? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.media', fetch: fetchImpl })
: null;
: null;
// A restore drill (OPENRE_DRILL) never asks Media for anything.
// 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() {
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 } = {}) {
async function request(method, path, { body, timeoutMs = 15000 } = {}) {
@@ -18,7 +18,7 @@ const { runtime, bootApi, request, serviceToken, tmpDir, testEnv, child, suite,
@@ -18,7 +18,7 @@ const { runtime, bootApi, request, serviceToken, tmpDir, testEnv, child, suite,
const t = suite('drill-mode');
const t = suite('drill-mode');
// What the production env file sets: with these, a non-drill process would publish and record.
// 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']);
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', () => {
t('OPENRE_DRILL parses like the other switches and is off by default', () => {
@@ -118,7 +118,6 @@ t('setup: stubs and the four OpenRestream processes', async () => {
@@ -118,7 +118,6 @@ t('setup: stubs and the four OpenRestream processes', async () => {
EVENTS_URL: events,
EVENTS_URL: events,
EVENTS_RELAY_INTERVAL_MS: '200',
EVENTS_RELAY_INTERVAL_MS: '200',
MEDIA_URL: media,
MEDIA_URL: media,
- MEDIA_API_KEY: 'media-app-key',
OPENRE_RECORDING_START_DELAY_MS: '200',
OPENRE_RECORDING_START_DELAY_MS: '200',
OPENRE_WORKER_HEARTBEAT_MS: '250',
OPENRE_WORKER_HEARTBEAT_MS: '250',
OPENRE_WORKER_LEASE_MS: '4000',
OPENRE_WORKER_LEASE_MS: '4000',
@@ -77,7 +77,7 @@ function host(dbs, over = {}) {
@@ -77,7 +77,7 @@ function host(dbs, over = {}) {
release: RELEASE,
release: RELEASE,
originMain: RELEASE,
originMain: RELEASE,
containsCommit: true,
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 },
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
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',
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',
@@ -24,8 +24,14 @@ const reply = (res, status, obj) => { res.statusCode = status; res.setHeader('Co
@@ -24,8 +24,14 @@ const reply = (res, status, obj) => { res.statusCode = status; res.setHeader('Co
async function setup({ media: mediaHandler, recording_mode = 'vod' }) {
async function setup({ media: mediaHandler, recording_mode = 'vod' }) {
const calls = [];
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); });
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 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 client = createMediaClient({ config: rt.config });
const coordinator = createCoordinator({ rt, media: client, log: silent });
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' }] });
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
@@ -48,7 +54,7 @@ t('a live session gets one recording request; finalize when it ends (Media final
await coordinator.tick();
await coordinator.tick();
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.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.user_id, 8);
assert.strictEqual(calls[0].body.visibility, 'public');
assert.strictEqual(calls[0].body.visibility, 'public');
assert.strictEqual(calls[1].body.rtmp_url, `rtmp://127.0.0.1:19390/live/${session.id}`);
assert.strictEqual(calls[1].body.rtmp_url, `rtmp://127.0.0.1:19390/live/${session.id}`);
@@ -117,7 +117,6 @@ t('setup: stubs and the four OpenRestream processes', async () => {
@@ -117,7 +117,6 @@ t('setup: stubs and the four OpenRestream processes', async () => {
EVENTS_URL: events,
EVENTS_URL: events,
EVENTS_RELAY_INTERVAL_MS: '200',
EVENTS_RELAY_INTERVAL_MS: '200',
MEDIA_URL: media,
MEDIA_URL: media,
- MEDIA_API_KEY: 'media-app-key',
OPENRE_RECORDING_START_DELAY_MS: '300',
OPENRE_RECORDING_START_DELAY_MS: '300',
OPENRE_WORKER_HEARTBEAT_MS: '250',
OPENRE_WORKER_HEARTBEAT_MS: '250',
OPENRE_WORKER_LEASE_MS: '4000',
OPENRE_WORKER_LEASE_MS: '4000',
@@ -184,7 +183,7 @@ t('the working destination goes live, the dead one fails, the session stays live
@@ -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 () => {
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 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');
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.user_id, 42);
assert.strictEqual(create.body.managed_stream_id, 5);
assert.strictEqual(create.body.managed_stream_id, 5);
assert.strictEqual(create.body.meta.openre_session_id, sessionA.id);
assert.strictEqual(create.body.meta.openre_session_id, sessionA.id);
@@ -187,18 +187,21 @@ t('a grabber captures a frame (ffmpeg stdout) and uploads it to Media as an obje
@@ -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 () => {
t('media-client.uploadObject does init → PUT → complete', async () => {
const calls = [];
const calls = [];
const fetchImpl = async (url, opts = {}) => {
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('/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/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' } }) };
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 () => '{}' };
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 media = createMediaClient({ config, fetchImpl });
const r = await media.uploadObject({ namespace: 'live', mimeType: 'image/jpeg', filename: 'x.jpg', bytes: Buffer.alloc(10, 1) });
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.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', () => {
t('restream args: webrtcArgs reads the worker\'s SDP file and re-encodes', () => {