openvibers/openvibe.mediahub Commits

MediaHub: Media calls use the service token; the app key goes (C-71) (#23)

OP OpenVibers committed 11 Oct 2026 · a7a9df1728 · 11 files, +108 −31

Compatibility register C-71 groundwork. MediaHub reaches OpenVibe.Media with its Network service token instead of the `media-hub` tenant's app key.

- **Client:** `server/media/client.js` builds one SDK service-token client (client credentials, audience `openvibe.media`), lazily. Every object API call carries `Authorization: Bearer <token>`: init, content, multipart, complete, get, the download URL and delete. `X-OV-Subject` still names the person.
- **Not configured:** "configured" means `OV_OAUTH_CLIENT_SECRET` is set. Without it, calls answer `503 media.not_configured`, as before without the key.
- **Token failure:** if Network cannot issue a token, the call answers `502 media.unreachable` and nothing is thrown.
- **Removed:** `MEDIAHUB_MEDIA_APP_KEY` from config, `.env.example` and the README, along with the instruction to add the tenant to Media's `MEDIA_APPS_SEED`, which C-71 retires. The readiness check's message notes that Media calls need the client secret.
- **Grants:** `[media-hub, media.object.upload, openvibe.media]` and `[media-hub, media.object.read, openvibe.media]` on namespace `media-hub`, added by Network#145, which deploys first.
- **Tests:**
  - The stub Media requires `Bearer svc-media` on everything under `/api/v2`, and the stub Network issues it for audience `openvibe.media`.
  - `drive-upload` asserts every Media call carried the token and that the token request was `client_credentials` for `openvibe.media`.
  - The new `media-client.test.js` checks the no-secret case.

The change was drafted by a Codex job and reviewed here; the review fixed the namespace in the docs. `ov test`: 18 covering files pass.

After deploy, `MEDIAHUB_MEDIA_APP_KEY` is removed from `media-hub.env`.

Changes

ddiff --git a/.env.example b/.env.example
iindex 032c5c6..e6992a9 100644
---- a/.env.example
++++ b/.env.example
@@@ -31,11 +31,11 @@ MEDIAHUB_EVENTS_SECRET=
MEDIAHUB_EVENTS_URL=
# ── OpenVibe.Media: where the bytes live ────────────────────
--# The host-internal address of the Media object API, and the app key of Media's `media-hub` tenant. The key never
--# reaches a browser: MediaHub is the only client. Add `media-hub` to Media's MEDIA_APPS_SEED before deploying.
++# The host-internal address of the Media object API. MediaHub calls it with its Network service token.
++# Grant [media-hub, media.object.upload, openvibe.media] and [media-hub, media.object.read, openvibe.media]
++# on namespace media-hub. Media's `media-hub` tenant holds the bytes.
MEDIAHUB_MEDIA_URL=http://127.0.0.1:4100
MEDIAHUB_MEDIA_APP=media-hub
--MEDIAHUB_MEDIA_APP_KEY=
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
# ── Per person (Media's quotas are per tenant, so these are enforced here) ──
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8f8192d..bd71a9e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,9 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
++- MediaHub reaches OpenVibe.Media with its Network service token (audience `openvibe.media`, grants
++ `media.object.upload` and `media.object.read` on namespace `media-hub`). `MEDIAHUB_MEDIA_APP_KEY` is gone
++ (compatibility register C-71).
- openvibe.pics and openvibe.video are parked: their vhosts answer 302 to https://openvibe.network/ and proxy
nothing. The app no longer has "coming" pages or host-based brand handling.
ddiff --git a/README.md b/README.md
iindex 4c0bb8b..639ecee 100644
---- a/README.md
++++ b/README.md
@@@ -47,8 +47,10 @@ creates none):
- **OpenVibe.Network** — SSO (OAuth 2 with PKCE S256, client id `media-hub`) and its JWKS: `OV_NETWORK_URL`,
`OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`,
`MEDIAHUB_AUDIENCE`. It calls `identity.subject.resolve` on the internal URL with a service token of its own.
--- **OpenVibe.Media** — where the bytes live: `MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`, `MEDIAHUB_MEDIA_APP_KEY`
-- (and `MEDIAHUB_MEDIA_TIMEOUT_MS`).
++- **OpenVibe.Media** — where the bytes live: `MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`
++ (and `MEDIAHUB_MEDIA_TIMEOUT_MS`). Media calls use the service's Network service token. Grant
++ `[media-hub, media.object.upload, openvibe.media]` and `[media-hub, media.object.read, openvibe.media]`
++ on namespace `media-hub`.
- **OpenVibe.Events** — `MEDIAHUB_EVENTS_URL` (the two subscriptions are created at boot) and
`MEDIAHUB_EVENTS_SECRET` (the v2 signature on `POST /internal/events`).
- **PostgreSQL** and **Valkey** — `DATABASE_URL` / `DATABASE_DIRECT_URL`, `VALKEY_URL` / `VALKEY_PREFIX`, read
@@@ -91,7 +93,7 @@ OpenVibe.Download v1 is a **private drive**, not a public host:
## How it works
--Bytes live in **OpenVibe.Media** (`MEDIAHUB_MEDIA_URL`, app `media-hub`, app key `MEDIAHUB_MEDIA_APP_KEY`). MediaHub
++Bytes live in **OpenVibe.Media** (`MEDIAHUB_MEDIA_URL`, app `media-hub`). MediaHub uses its Network service token and
calls Media server-side only, naming the person with `X-OV-Subject`, and never hands a browser a Media URL:
- **Small files:** the plain form (`POST /files/upload`, no JavaScript needed) streams the file to a temp file with
@@@ -138,8 +140,8 @@ takes `network.account.export_requested` and `network.account.deleted`, and the
## Configuration
Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal
--URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`,
--`MEDIAHUB_MEDIA_APP_KEY`), Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,
++URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`),
++Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,
`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).
## Development
ddiff --git a/server/config.js b/server/config.js
iindex dec9d61..c9422c3 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -44,13 +44,12 @@ function load(env = process.env) {
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.MEDIAHUB_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:media-hub:' },
-- // OpenVibe.Media: the only place bytes live. MediaHub talks to it server-side with its own app key
-- // (Media's `:app` segment `media-hub`); no browser ever holds that key. MEDIAHUB_MEDIA_URL is the
++ // OpenVibe.Media: the only place bytes live. MediaHub talks to it server-side with its Network service
++ // token (Media's `:app` segment `media-hub`); no browser ever holds it. MEDIAHUB_MEDIA_URL is the
// host-internal address (127.0.0.1:4100 in production), never the public origin.
media: {
url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),
app: env.MEDIAHUB_MEDIA_APP || 'media-hub',
-- appKey: env.MEDIAHUB_MEDIA_APP_KEY || '',
timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
},
ddiff --git a/server/media/client.js b/server/media/client.js
iindex 274b54f..077bc14 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -2,7 +2,8 @@
/**
* OpenVibe.Media: where the bytes live. MediaHub calls Media's object API v2 **server-side only**, with its own
-- * app key (audience `media-hub`), and never hands that key, an upload token or a signed URL to a browser.
++ * Network service token (audience `openvibe.media`), and never hands that token, an upload token or a signed URL
++ * to a browser.
*
* init({ size, contentType, name, ownerSubject, multipart }) POST /api/v2/media-hub/objects
* putContent(objectId, buffer) PUT …/objects/:id/content
@@@ -22,6 +23,7 @@
* a refusal: it answers { ok: false, status, code, detail } so the caller can show the problem as it came.
*/
const { ids } = require('openvibe-contracts');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { headerSafeName } = require('../drive/rules');
/** A user subject as Media wants it in X-OV-Subject: the bare usr_…, or null when it cannot be one. */
@@@ -35,12 +37,31 @@ function subjectHeader(requester) {
function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console }) {
const media = config.media;
const base = () => `${media.url}/api/v2/${encodeURIComponent(media.app)}/objects`;
-- const enabled = Boolean(media.appKey);
++ const enabled = Boolean(config.oauth.clientSecret);
++ let tokens;
++
++ function tokenClient() {
++ if (!tokens) tokens = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.media',
++ fetch: fetchImpl,
++ });
++ return tokens;
++ }
/** One JSON call. Never throws for a Media refusal; throws only when Media cannot be reached at all. */
async function call(path, { method = 'GET', body, json, headers = {}, raw = false } = {}) {
-- if (!enabled) return { ok: false, status: 503, code: 'media.not_configured', detail: 'MEDIAHUB_MEDIA_APP_KEY is not set' };
-- const h = { Accept: 'application/json', Authorization: `Bearer ${media.appKey}`, ...headers };
++ if (!enabled) return { ok: false, status: 503, code: 'media.not_configured', detail: 'OV_OAUTH_CLIENT_SECRET is not set' };
++ let token;
++ try {
++ token = await tokenClient().getToken();
++ } catch {
++ log.warn('[MediaHub] Media service token unavailable');
++ return { ok: false, status: 502, code: 'media.unreachable', detail: 'OpenVibe.Network could not issue a Media token' };
++ }
++ const h = { Accept: 'application/json', ...headers, Authorization: `Bearer ${token}` };
let payload = body;
if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
let res;
ddiff --git a/server/observability.js b/server/observability.js
iindex ef0e787..d1d9a5c 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -5,7 +5,7 @@
*
* db required a real round trip to OpenVibe.MediaHub's PostgreSQL store
* network_jwks optional the Network signing key is loaded; without it nobody can sign in or call the API
-- * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in needs it)
++ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in and Media calls need it)
* valkey optional shared per-caller limit counters
*
* The product adds its own required checks here (a queue, a model provider, a worker) with a real round trip,
@@@ -37,7 +37,7 @@ function createServiceReadiness({ s, config, valkey = null, release = null }) {
return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
},
},
-- { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in and Media calls cannot complete') },
],
});
}
ddiff --git a/test/drive-upload.test.js b/test/drive-upload.test.js
iindex 8080934..d2efb07 100644
---- a/test/drive-upload.test.js
++++ b/test/drive-upload.test.js
@@@ -41,6 +41,10 @@ const MB = 1024 * 1024;
const dl = await t.get(`/files/${id}/download`, { as: kim });
assert.strictEqual(dl.status, 200);
assert.strictEqual(sha256(dl.buffer), sha256(data), 'the bytes round-trip');
++ const mediaCalls = t.media.requests.filter((request) => request.path.startsWith('/api/v2/'));
++ assert.ok(mediaCalls.length > 0);
++ assert.ok(mediaCalls.every((request) => request.authorization === 'Bearer svc-media'));
++ assert.ok(t.network.tokenRequests.some((request) => request.grant_type === 'client_credentials' && request.audience === 'openvibe.media'));
});
await check('the chunked upload the page script uses: start, parts, complete', async () => {
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex d44f3b1..150edf5 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -14,7 +14,7 @@ process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN = process.env.MEDIAHUB_API_RATE_LIMI
*/
const http = require('http');
const { startNetwork } = require('./mocks');
--const { startMedia, KEY } = require('./media');
++const { startMedia } = require('./media');
const captured = [];
for (const m of ['log', 'info', 'warn', 'error']) {
@@@ -30,7 +30,7 @@ async function boot(opts = {}) {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.download', TRUST_PROXY: '1',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'media-hub', OV_OAUTH_CLIENT_SECRET: 'media-hub-secret', COOKIE_SECURE: 'false',
-- ...(media ? { MEDIAHUB_MEDIA_URL: media.url, MEDIAHUB_MEDIA_APP_KEY: KEY } : {}),
++ ...(media ? { MEDIAHUB_MEDIA_URL: media.url } : {}),
...(opts.env || {}),
};
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
iindex c37eebd..745bcd6 100644
---- a/test/helpers/media.js
++++ b/test/helpers/media.js
@@@ -16,28 +16,28 @@
* DELETE /api/v2/:app/objects/:id soft delete
* GET /o/:id?exp&sig the bytes, with the signature checked
*
-- * The app key is required on everything under /api/v2 (x-ov-subject names the owner at init), exactly as Media
-- * requires it, so a test can prove MediaHub never asks Media for anything with the wrong credential. `.requests`
++ * The Network service token is required on everything under /api/v2 (x-ov-subject names the owner at init), so a
++ * test can prove MediaHub never asks Media for anything with the wrong credential. `.requests`
* records every call ({ method, path, query, authorization, subject, bytes }) for the assertions that need it.
*
-- * Behaviour knobs: `setDown(true)` makes every call answer 503; `setKeyRejects(true)` answers 401 as a bad key
++ * Behaviour knobs: `setDown(true)` makes every call answer 503; `setTokenRejects(true)` answers 401 as a bad token
* would; `objects`/`bytesOf(id)` are what the store holds; `deleted` lists the ids Media was asked to delete.
*/
const http = require('http');
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
--const KEY = 'media-hub-test-key';
++const SERVICE_TOKEN = 'svc-media';
const SIGN_SECRET = 'stand-in-media-signing-secret';
const sign = (objectId, exp) => crypto.createHmac('sha256', SIGN_SECRET).update(`get\n${objectId}\n${exp}`).digest('base64url');
--async function startMedia({ key = KEY, maxSingle = 256 * 1024 * 1024 } = {}) {
++async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const objects = new Map(); // id → { id, size_bytes, mime_type, content_hash, lifecycle_status, visibility, owner_subject, filename, bytes }
const sessions = new Map(); // id → { id, object_id, part_size, total_size, parts_expected, status, parts: Map }
const requests = [];
const deleted = [];
-- const state = { down: false, keyRejects: false };
++ const state = { down: false, tokenRejects: false };
let seq = 0;
const json = (res, status, body) => {
@@@ -119,9 +119,8 @@ async function startMedia({ key = KEY, maxSingle = 256 * 1024 * 1024 } = {}) {
return res.end(obj.bytes);
}
-- // ── Everything else is the tenant API, and needs the app key ──
-- const keyed = String(req.headers.authorization || '').startsWith('Bearer ') ? String(req.headers.authorization).slice(7) : null;
-- if (state.keyRejects || keyed !== key) return problem(res, 401, 'auth.required', 'Authentication required');
++ // ── Everything else is the tenant API, and needs the Network service token ──
++ if (state.tokenRejects || req.headers.authorization !== `Bearer ${SERVICE_TOKEN}`) return problem(res, 401, 'auth.required', 'Authentication required');
const rest = path.replace(/^\/api\/v2\//, '').split('/').filter(Boolean).map(decodeURIComponent);
if (rest.length < 2 || rest[1] !== 'objects') return problem(res, 404, 'route.not_found', 'no such route');
@@@ -254,14 +253,14 @@ async function startMedia({ key = KEY, maxSingle = 256 * 1024 * 1024 } = {}) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}`;
return {
-- url, key, objects, sessions, requests, deleted, state,
++ url, objects, sessions, requests, deleted, state,
bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,
objectOf: (id) => objects.get(id) || null,
setDown: (v) => { state.down = v; },
-- setKeyRejects: (v) => { state.keyRejects = v; },
++ setTokenRejects: (v) => { state.tokenRejects = v; },
reset: () => { requests.length = 0; },
close: () => new Promise((r) => server.close(r)),
};
}
--module.exports = { startMedia, KEY, sign };
++module.exports = { startMedia, SERVICE_TOKEN, sign };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 47cacf5..23ba240 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -298,6 +298,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
}
if (body.grant_type === 'client_credentials') {
++ if (body.audience === 'openvibe.media') return json(200, { access_token: 'svc-media', token_type: 'Bearer', expires_in: 300 });
const now = Math.floor(Date.now() / 1000);
return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:media-hub', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });
}
ddiff --git a/test/media-client.test.js b/test/media-client.test.js
nnew file mode 100644
iindex 0000000..34d04fd
---- /dev/null
++++ b/test/media-client.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++/** The Media client gets one Network service token and never calls Media without it. */
++const assert = require('assert');
++const { load } = require('../server/config');
++const { createMediaClient } = require('../server/media/client');
++
++async function main() {
++ const requests = [];
++ const fetchImpl = async (url, options) => {
++ requests.push({ url, options });
++ if (url.endsWith('/oauth/token')) return { ok: true, status: 200, json: async () => ({ access_token: 'svc-media', token_type: 'Bearer', expires_in: 300 }) };
++ return { ok: true, status: 200, json: async () => ({ id: 'med_test' }) };
++ };
++ const config = load({
++ OV_NETWORK_INTERNAL_URL: 'http://network.test', OV_OAUTH_CLIENT_ID: 'media-hub',
++ OV_OAUTH_CLIENT_SECRET: 'test-secret', MEDIAHUB_MEDIA_URL: 'http://media.test',
++ });
++ const client = createMediaClient({ config, fetchImpl });
++ assert.strictEqual(client.enabled, true);
++ assert.strictEqual(requests.length, 0, 'token client is lazy');
++ assert.strictEqual((await client.get('med_first')).ok, true);
++ assert.strictEqual((await client.get('med_second')).ok, true);
++ assert.strictEqual(requests.length, 3, 'one cached token for two Media requests');
++ const tokenRequest = requests[0];
++ assert.strictEqual(tokenRequest.url, 'http://network.test/oauth/token');
++ assert.strictEqual(tokenRequest.options.method, 'POST');
++ const form = new URLSearchParams(tokenRequest.options.body);
++ assert.strictEqual(form.get('grant_type'), 'client_credentials');
++ assert.strictEqual(form.get('audience'), 'openvibe.media');
++ assert.strictEqual(form.get('client_id'), 'media-hub');
++ assert.strictEqual(form.get('client_secret'), 'test-secret');
++ assert.ok(requests.slice(1).every((request) => request.options.headers.Authorization === 'Bearer svc-media'));
++
++ const missing = createMediaClient({ config: load({ OV_OAUTH_CLIENT_SECRET: '' }), fetchImpl });
++ assert.strictEqual(missing.enabled, false);
++ assert.deepStrictEqual(await missing.get('med_test'), {
++ ok: false, status: 503, code: 'media.not_configured', detail: 'OV_OAUTH_CLIENT_SECRET is not set',
++ });
++ assert.strictEqual(requests.length, 3, 'an unconfigured client makes no request');
++
++ const failing = createMediaClient({ config, fetchImpl: async () => { throw new Error('secret must stay private'); }, log: { warn() {} } });
++ const refused = await failing.get('med_test');
++ assert.strictEqual(refused.status, 502);
++ assert.strictEqual(refused.code, 'media.unreachable');
++ assert.ok(!refused.detail.includes('secret'));
++}
++
++main().then(() => console.log('media client passed'), (err) => { console.error(err); process.exitCode = 1; });