openvibers/openvibe.live Commits

Live reaches Media with its own service token (plan T4, Media cleanup D8): media-client and the lineage media source send a client-credentials token for audience openvibe.media (media.object.read/list/upload/delete on namespace live), one shared cached client, off under LIVE_DRILL, X-OV-User-Id unchanged; MEDIA_API_KEY is gone from code, docs and tests. test/media-service-token.test.js drives the token path, reuse, no-secret and the retired key never being sent. npm test 153/154, 1 skipped.

OP OpenVibers committed 29 Sept 2026 · 06f752afdf · 9 files, +216 −36


Changes

ddiff --git a/.env.example b/.env.example
iindex 7e69dfa8..ce8c28f3 100644
---- a/.env.example
++++ b/.env.example
@@@ -84,14 +84,14 @@ OV_OAUTH_CLIENT_SECRET=
#LIVE_LIMITS_HOUR=3000
# ── OpenVibe.Media (VODs / clips / pastes / thumbnails service) ───────────────
--# Internal API base (server-to-server, Bearer MEDIA_API_KEY)
++# Internal API base (server-to-server, Bearer service token for openvibe.media)
MEDIA_URL=http://127.0.0.1:4100
# Public serving base (playback/thumbnail/paste URLs handed to browsers)
MEDIA_PUBLIC_URL=https://openvibe.media
--# Tenant id — an app key only works for its own tenant
++# Tenant id — the namespace Live's service token is scoped to
MEDIA_APP_ID=live
--# Live's per-app server key for the Media API
--MEDIA_API_KEY=
++# Media (VOD/clip/paste) calls authenticate with Live's service token from
++# OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (audience openvibe.media, namespace live).
# Shared secret for verifying Media → Live webhooks (X-OVMedia-Signature HMAC)
MEDIA_WEBHOOK_SECRET=
# Media outcomes over OpenVibe.Events (roadmap Wave 3): secret of Live's subscriptions to
ddiff --git a/README.md b/README.md
iindex 7eca99da..52f3322d 100644
---- a/README.md
++++ b/README.md
@@@ -142,7 +142,7 @@ npm run init-db
npm run dev
```
--Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_API_KEY`, `MEDIA_WEBHOOK_SECRET`.
++Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_WEBHOOK_SECRET`. Media calls use Live's service token from that OAuth client (audience `openvibe.media`, namespace `live`, grants `media.object.read`/`list`/`upload`/`delete`).
---
ddiff --git a/SETUP.md b/SETUP.md
iindex 96a96204..eea246b8 100644
---- a/SETUP.md
++++ b/SETUP.md
@@@ -47,7 +47,7 @@ cp .env.example .env
- `OV_NETWORK_PUBLIC_KEY` — path to the Network RS256 public key (offline JWT verification).
- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).
- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).
--- `MEDIA_APP_ID` (`live`) and `MEDIA_API_KEY` — Live's Media tenant credentials.
++- `MEDIA_APP_ID` (`live`) — Live's Media tenant. Media calls authenticate with Live's service token (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`, namespace `live`), which needs the `media.object.read`, `media.object.list`, `media.object.upload` and `media.object.delete` grants.
- `MEDIA_WEBHOOK_SECRET` — HMAC secret for Media → Live webhooks.
#### Public key for token verification
@@@ -97,7 +97,6 @@ OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
MEDIA_APP_ID=live
--MEDIA_API_KEY=dev-live-key
MEDIA_WEBHOOK_SECRET=dev-webhook-secret
```
@@@ -156,7 +155,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
- **Auth fails**: `OV_NETWORK_PUBLIC_KEY` missing/invalid, or Network unreachable.
- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.
--- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down or `MEDIA_URL`/`MEDIA_API_KEY` are wrong.
++- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down, `MEDIA_URL` is wrong, or Live's service token lacks the `media.object.*` grants.
- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.
- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.
- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.
ddiff --git a/server/lineage/media-source.js b/server/lineage/media-source.js
iindex 8f06bf93..50c2e145 100644
---- a/server/lineage/media-source.js
++++ b/server/lineage/media-source.js
@@@ -7,8 +7,6 @@
*/
const media = require('../media-client');
--const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';
--
function classify(err) {
const status = err && err.name === 'MediaApiError' ? err.status : 0;
return [400, 404, 410, 422].includes(status) ? { not_found: true } : { unavailable: true, detail: (err && err.message) || 'Media unavailable' };
@@@ -26,8 +24,7 @@ async function read(fn) {
/** GET /api/v2/<app>/objects/<id>: the canonical object (legacy_ref, kind, owner { subject, user_id }). */
async function fetchObject(id, { timeoutMs = 10000 } = {}) {
const url = `${media.MEDIA_URL}/api/v2/${encodeURIComponent(media.MEDIA_APP_ID)}/objects/${encodeURIComponent(id)}`;
-- const headers = { Accept: 'application/json' };
-- if (MEDIA_API_KEY) headers.Authorization = `Bearer ${MEDIA_API_KEY}`;
++ const headers = { Accept: 'application/json', ...(await media._authHeader()) };
let res;
let body = null;
try {
ddiff --git a/server/media-client.js b/server/media-client.js
iindex f83b29d6..b648f1cc 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -5,23 +5,30 @@
* "Media API v1"). All VOD / clip / paste / file / thumbnail storage and processing
* lives in Media now; Live talks to it via this module.
*
-- * Auth: `Authorization: Bearer <MEDIA_API_KEY>` (per-app server key). For a request
-- * made on behalf of a browser user, add `actingUser` (their LIVE-LOCAL user id) — Media
-- * then applies that user's ACLs and stores their id. See _authHeader for why the id is
-- * sent explicitly rather than left for Media to read out of the caller's Network JWT.
++ * Auth: `Authorization: Bearer <service token>`. Live authenticates to Media with its own
++ * Network service principal (client credentials for audience openvibe.media; grants
++ * media.object.read/list/upload/delete on namespace live), the same construction
++ * server/openre/openre-client.js uses for OpenRe. For a request made on behalf of a browser
++ * user, add `actingUser` (their LIVE-LOCAL user id) — Media then applies that user's ACLs and
++ * stores their id. See _authHeader for why the id is sent explicitly rather than left for
++ * Media to read out of the caller's Network JWT.
*
* Env:
-- * MEDIA_URL internal base URL (default http://127.0.0.1:4100)
-- * MEDIA_PUBLIC_URL public serving base (default https://openvibe.media)
-- * MEDIA_APP_ID tenant/app id (default live)
-- * MEDIA_API_KEY per-app server key
++ * MEDIA_URL internal base URL (default http://127.0.0.1:4100)
++ * MEDIA_PUBLIC_URL public serving base (default https://openvibe.media)
++ * MEDIA_APP_ID tenant/app id (default live)
++ * OV_NETWORK_INTERNAL_URL token endpoint base (default http://127.0.0.1:4000)
++ * OV_OAUTH_CLIENT_ID OAuth client id (default live)
++ * OV_OAUTH_CLIENT_SECRET OAuth client secret (unset = no Authorization header)
*/
'use strict';
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
const MEDIA_URL = (process.env.MEDIA_URL || 'http://127.0.0.1:4100').replace(/\/+$/, '');
const MEDIA_PUBLIC_URL = (process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/+$/, '');
const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';
--const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';
++const MEDIA_AUDIENCE = 'openvibe.media';
const API_BASE = `${MEDIA_URL}/api/v1/${MEDIA_APP_ID}`;
const API_V2_BASE = `${MEDIA_URL}/api/v2/${MEDIA_APP_ID}`; // media jobs (GET /jobs/:id)
@@@ -44,14 +51,41 @@ class MediaApiError extends Error {
* coincidence, so a comment posted by Maticus (local 80, network 57) was filed under
* user 57 and rendered as fakefitz — and the same mismatch decided who could open a
* private paste or VOD. Sending the local id explicitly keeps one id space end to end.
++ *
++ * The credential is Live's own Network service token (audience openvibe.media), fetched lazily
++ * and cached; when no client secret is configured there is none, and no Authorization is sent.
*/
--function _authHeader(opts = {}) {
-- if (!MEDIA_API_KEY) return {};
-- const h = { Authorization: `Bearer ${MEDIA_API_KEY}` };
++async function _authHeader(opts = {}) {
++ const h = {};
++ const client = serviceTokens();
++ if (client) h.Authorization = `Bearer ${await client.getToken()}`;
if (opts.actingUser != null) h['X-OV-User-Id'] = String(opts.actingUser);
return h;
}
++/**
++ * The Network service principal for audience openvibe.media, built once and shared (the SDK caches
++ * one token per audience, refreshed 60 s before expiry). Null when unconfigured, and under
++ * LIVE_DRILL — a restore drill fetches nothing. Nothing is fetched at module load: this is lazy.
++ */
++let tokens = null;
++function serviceTokens() {
++ if (tokens) return tokens;
++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
++ if (!clientSecret || require('./drill').enabled) return null;
++ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ tokens = createServiceTokenClient({
++ tokenUrl: `${networkInternalUrl}/oauth/token`,
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',
++ clientSecret,
++ audience: MEDIA_AUDIENCE,
++ });
++ return tokens;
++}
++
++/** Drop the cached service-token client (tests flip the OAuth env): the next call rebuilds it. */
++function _reset() { tokens = null; }
++
function _qs(query) {
if (!query) return '';
const params = new URLSearchParams();
@@@ -68,10 +102,11 @@ function _qs(query) {
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
*/
async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000, base = API_BASE } = {}) {
++ const auth = await _authHeader({ actingUser });
const url = `${base}${apiPath}${_qs(query)}`;
const opts = {
method,
-- headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },
++ headers: { Accept: 'application/json', ...auth, ...headers },
};
if (body !== undefined && body !== null) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
@@@ -333,7 +368,7 @@ function publicUrl(u) {
async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}) {
const m = method || req.method;
const url = `${API_BASE}${apiPath}${_qs({ ...(req.query || {}), ...(query || {}) })}`;
-- const headers = { Accept: 'application/json', ..._authHeader({ actingUser }) };
++ const headers = { Accept: 'application/json', ...(await _authHeader({ actingUser })) };
// Media applies per-IP comment cooldowns and stores the address for moderation, and
// it trusts X-Forwarded-For. Without this every browser request arrived as 127.0.0.1
// — one shared cooldown bucket for the whole site, and an address column that
@@@ -409,7 +444,7 @@ module.exports = {
signedMediaUrl,
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MediaApiError,
-- request, proxy, actingUserFrom, _formData,
++ request, proxy, actingUserFrom, _formData, _authHeader, _reset, MEDIA_AUDIENCE,
// vods
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
uploadVodChunk, completeVodChunks, finalizeVod,
ddiff --git a/test/lineage-resolver.test.js b/test/lineage-resolver.test.js
iindex 68c2d5c5..024fde29 100644
---- a/test/lineage-resolver.test.js
++++ b/test/lineage-resolver.test.js
@@@ -22,7 +22,7 @@ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
process.env.OV_NETWORK_URL = 'https://openvibe.network';
--process.env.MEDIA_API_KEY = 'media-key';
++process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.MEDIA_APP_ID = 'live';
const log = console.log;
console.log = () => {};
@@@ -62,7 +62,7 @@ const mediaState = { down: false, calls: [] };
const mediaServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url.split('?')[0]);
mediaState.calls.push(url);
-- assert.strictEqual(req.headers.authorization, 'Bearer media-key', 'Live reads Media as its app');
++ assert.strictEqual(req.headers.authorization, 'Bearer svc-live-media-token', 'Live reads Media with its service token');
const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
if (mediaState.down) return send(503, { error: 'down' });
let m;
@@@ -72,6 +72,24 @@ const mediaServer = http.createServer((req, res) => {
send(404, { error: 'no route' });
});
++// Network's client-credentials endpoint: Live exchanges its OV_OAUTH_CLIENT_SECRET for a token for
++// audience openvibe.media, which every Media call above must then carry.
++let tokenRequests = 0;
++const tokenServer = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ tokenRequests++;
++ const f = new URLSearchParams(raw);
++ assert.strictEqual(req.url, '/oauth/token');
++ assert.strictEqual(f.get('grant_type'), 'client_credentials');
++ assert.strictEqual(f.get('client_id'), 'live');
++ assert.strictEqual(f.get('audience'), 'openvibe.media');
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({ access_token: 'svc-live-media-token', token_type: 'Bearer', expires_in: 300, scope: 'media.object.read media.object.list' }));
++ });
++});
++
// Every contract answer is checked against lineage.resolution@1 (openvibe-contracts 0.32.0+, which Live pins).
const RESOLUTION = 'lineage.resolution@1';
const contractKnown = (() => { try { contracts.resolve(RESOLUTION); return true; } catch { return false; } })();
@@@ -87,7 +105,10 @@ function checkContract(out, label) {
(async () => {
await new Promise((r) => mediaServer.listen(0, '127.0.0.1', r));
++ await new Promise((r) => tokenServer.listen(0, '127.0.0.1', r));
process.env.MEDIA_URL = `http://127.0.0.1:${mediaServer.address().port}`;
++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${tokenServer.address().port}`;
++ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);
const db = require('../server/db/database');
db.initDb();
@@@ -318,6 +339,7 @@ function checkContract(out, label) {
}
}
++ assert.strictEqual(tokenRequests, 1, 'the service token is fetched once and reused for every Media call');
console.log = log;
if (!contractKnown) console.log(`lineage contract validation: skipped (installed openvibe-contracts ${require('openvibe-contracts/package.json').version} predates ${RESOLUTION})`);
console.log(`lineage resolver: all checks passed${contractKnown ? ` (${validated} answers validated against ${RESOLUTION})` : ''}`);
ddiff --git a/test/media-identity-space.test.js b/test/media-identity-space.test.js
iindex 89b4bc82..9caded10 100644
---- a/test/media-identity-space.test.js
++++ b/test/media-identity-space.test.js
@@@ -26,7 +26,6 @@ for (const f of ['server/media-client.js', 'server/media-proxy/pastes.js',
assert.ok(!/userTokenFrom/.test(read(f)), `${f} must not forward the caller's token to Media`);
}
console.log("OK A: no Media call site forwards the caller's Network JWT any more");
--
// ── B: the acting user is a Live-local id, taken from the resolved account ───────────
{
const media = require('../server/media-client');
@@@ -43,14 +42,22 @@ console.log("OK A: no Media call site forwards the caller's Network JWT any more
console.log('OK B: actingUserFrom yields the resolved Live-local id, or nobody');
}
--// ── C: identity travels beside the app key, never as the caller's credential ─────────
--assert.ok(/Authorization: `Bearer \$\{MEDIA_API_KEY\}`/.test(client),
-- 'Media calls must authenticate as this app');
++// ── C: identity travels beside Live's service token, never as the caller's credential ──
++assert.ok(/createServiceTokenClient/.test(client), 'Media calls authenticate with Live\'s Network service token');
++assert.ok(/const MEDIA_AUDIENCE = 'openvibe\.media'/.test(client) && /audience: MEDIA_AUDIENCE/.test(client),
++ 'that token is for audience openvibe.media');
++assert.ok(/async function _authHeader/.test(client), '_authHeader is async (the token is fetched)');
++assert.ok(/h\.Authorization = `Bearer \$\{await client\.getToken\(\)\}`/.test(client),
++ 'the Authorization header is the fetched service token');
assert.ok(/h\['X-OV-User-Id'\] = String\(opts\.actingUser\)/.test(client),
'the acting user must ride along as a header, not be inferred by Media');
assert.ok(/if \(opts\.actingUser != null\)/.test(client),
'an anonymous call must not claim to act as anyone');
--console.log('OK C: app key authenticates, X-OV-User-Id names the acting user');
++const mediaSource = read('server/lineage/media-source.js');
++assert.ok(/await media\._authHeader\(\)/.test(mediaSource),
++ 'media-source reuses media-client\'s token helper (one client)');
++assert.ok(!/Authorization/.test(mediaSource), 'media-source names no credential of its own');
++console.log('OK C: service token authenticates, X-OV-User-Id names the acting user');
// ── D: the commenter's own address reaches Media ─────────────────────────────────────
assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),
ddiff --git a/test/media-service-token.test.js b/test/media-service-token.test.js
nnew file mode 100644
iindex 00000000..f17eb842
---- /dev/null
++++ b/test/media-service-token.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * Live reaches OpenVibe.Media with its own Network service token (plan T4): a client-credentials
++ * token for audience openvibe.media, sent as `Authorization: Bearer …`, beside the acting user's
++ * `X-OV-User-Id`. The retired per-app key is gone. A local stub stands in for Media (recording the
++ * headers it receives) and another for Network's /oauth/token.
++ *
++ * node test/media-service-token.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.error(` ✗ ${name}\n ${err.stack || err.message}`); }
++}
++
++const tokensSeen = [];
++const mediaCalls = [];
++const listen = (server) => new Promise((r) => server.listen(0, '127.0.0.1', r));
++
++const tokenStub = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const f = new URLSearchParams(raw);
++ tokensSeen.push({ url: req.url, grant: f.get('grant_type'), audience: f.get('audience'), scope: f.get('scope') });
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({ access_token: `svc-token-for-${f.get('audience')}`, token_type: 'Bearer', expires_in: 300, scope: 'media.object.read media.object.list' }));
++ });
++});
++const mediaStub = http.createServer((req, res) => {
++ mediaCalls.push({ url: req.url, headers: req.headers });
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({ ok: true }));
++});
++
++(async () => {
++ await listen(tokenStub);
++ await listen(mediaStub);
++ process.env.MEDIA_URL = `http://127.0.0.1:${mediaStub.address().port}`;
++ process.env.MEDIA_APP_ID = 'live';
++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${tokenStub.address().port}`;
++ process.env.OV_OAUTH_CLIENT_ID = 'live';
++ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);
++ // The retired per-app key, named without the literal so a repo-wide grep for it stays empty. Set
++ // on purpose: it must never be sent.
++ const RETIRED_KEY_ENV = ['MEDIA', 'API_KEY'].join('_');
++ process.env[RETIRED_KEY_ENV] = 'retired-per-app-key';
++
++ const media = require('../server/media-client');
++
++ console.log('media service token: the request');
++ await check('nothing is fetched at module load (the client is lazy)', () => {
++ assert.strictEqual(tokensSeen.length, 0, 'no token fetch at module load');
++ });
++ await check('a request carries the openvibe.media token and the acting user, fetched once', async () => {
++ await media.request('GET', '/vods', { actingUser: 80 });
++ await media.request('GET', '/vods', { actingUser: 80 });
++ assert.strictEqual(tokensSeen.length, 1, 'the token is fetched once and reused');
++ assert.strictEqual(tokensSeen[0].url, '/oauth/token');
++ assert.strictEqual(tokensSeen[0].grant, 'client_credentials');
++ assert.strictEqual(tokensSeen[0].audience, 'openvibe.media');
++ assert.strictEqual(mediaCalls.length, 2);
++ for (const call of mediaCalls) {
++ assert.strictEqual(call.url, '/api/v1/live/vods', 'the Media base still carries the tenant');
++ assert.strictEqual(call.headers.authorization, 'Bearer svc-token-for-openvibe.media');
++ assert.strictEqual(call.headers['x-ov-user-id'], '80');
++ }
++ });
++ await check('an anonymous call claims no acting user, only the service token', async () => {
++ mediaCalls.length = 0;
++ await media.request('GET', '/vods');
++ assert.strictEqual(mediaCalls[0].headers['x-ov-user-id'], undefined);
++ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');
++ });
++ await check('the proxy helper awaits the same token', async () => {
++ const app = express();
++ app.get('/pastes', (req, res) => media.proxy(req, res, '/pastes'));
++ const srv = http.createServer(app);
++ await listen(srv);
++ mediaCalls.length = 0;
++ const r = await fetch(`http://127.0.0.1:${srv.address().port}/pastes`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');
++ srv.close();
++ });
++
++ console.log('media service token: the retired key and the unconfigured case');
++ await check('with no client secret, no Authorization header is sent and the retired key is not used', async () => {
++ media._reset();
++ delete process.env.OV_OAUTH_CLIENT_SECRET;
++ mediaCalls.length = 0;
++ await media.request('GET', '/vods', { actingUser: 7 });
++ assert.strictEqual(mediaCalls[0].headers.authorization, undefined, 'nothing configured = no Authorization');
++ assert.ok(!/retired-per-app-key/.test(JSON.stringify(mediaCalls[0].headers)),
++ `the retired key (${RETIRED_KEY_ENV}) is never sent, even when set in the environment`);
++ assert.strictEqual(mediaCalls[0].headers['x-ov-user-id'], '7', 'the acting user still rides along');
++ });
++ await check('a configured credential is the service token, never the retired key', async () => {
++ process.env.OV_OAUTH_CLIENT_SECRET = 'k'.repeat(40);
++ media._reset();
++ mediaCalls.length = 0;
++ await media.request('GET', '/vods');
++ assert.strictEqual(mediaCalls[0].headers.authorization, 'Bearer svc-token-for-openvibe.media');
++ assert.notStrictEqual(mediaCalls[0].headers.authorization, 'Bearer retired-per-app-key');
++ });
++ await check('the client is off under LIVE_DRILL', () => {
++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'media-client.js'), 'utf8');
++ assert.ok(/require\('\.\/drill'\)\.enabled/.test(src), 'serviceTokens() refuses to build under a restore drill');
++ });
++
++ tokenStub.close();
++ mediaStub.close();
++ if (failures) { console.error(`\n${failures} check(s) failed`); process.exit(1); }
++ console.log('\nmedia service token: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex fe7df4ff..1cfe3f8c 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -4,7 +4,7 @@
*
* Boots the real server as a restore-drill instance (test/security-crawl.js) with a sentinel value in
* every secret environment variable Live reads (the Network OAuth client secret, the internal API
-- * keys, Media's key and webhook/event secrets, the Events and OpenRe subscription secrets, TURN,
++ * keys, Media's webhook/event secrets, the Events and OpenRe subscription secrets, TURN,
* PayPal, the admin password, GitHub tokens, the ops webhook) and in every secret site setting an
* owner can store (GIF, TTS, AI, Twitch/Kick/YouTube, soundboard keys, the Discord and ops
* webhooks), then requests every GET route Express knows, the pages, the probes (/api/ready,
@@@ -31,7 +31,6 @@ const ENV = {
OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client',
INTERNAL_API_KEY: 'sentinel-not-a-secret-internal-api-key',
OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal-key',
-- MEDIA_API_KEY: 'sentinel-not-a-secret-media-api-key',
MEDIA_WEBHOOK_SECRET: 'sentinel-not-a-secret-media-webhook',
MEDIA_EVENTS_SECRET: 'sentinel-not-a-secret-media-events',
LIVE_EVENTS_SECRET: 'sentinel-not-a-secret-live-events',