openvibers/openvibe.mediahub Commits
MediaHub: Media calls use the service token; the app key goes (C-71) (#23)
Changes
@@@ -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:4100MEDIAHUB_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) ──@@@ -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 proxynothing. The app no longer has "coming" pages or host-based brand handling.@@@ -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 andcalls 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## ConfigurationEvery 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@@@ -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),},@@@ -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;@@@ -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') },],});}@@@ -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 () => {@@@ -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];@@@ -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 };@@@ -298,6 +298,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mreturn 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 });}nnew file mode 100644@@@ -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; });
@@ -31,11 +31,11 @@ MEDIAHUB_EVENTS_SECRET=
@@ -31,11 +31,11 @@ MEDIAHUB_EVENTS_SECRET=
MEDIAHUB_EVENTS_URL=
MEDIAHUB_EVENTS_URL=
# ── OpenVibe.Media: where the bytes live ────────────────────
# ── 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_URL=http://127.0.0.1:4100
MEDIAHUB_MEDIA_APP=media-hub
MEDIAHUB_MEDIA_APP=media-hub
-MEDIAHUB_MEDIA_APP_KEY=
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
# ── Per person (Media's quotas are per tenant, so these are enforced here) ──
# ── Per person (Media's quotas are per tenant, so these are enforced here) ──
@@ -4,6 +4,9 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,9 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## 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
- 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.
nothing. The app no longer has "coming" pages or host-based brand handling.
@@ -47,8 +47,10 @@ creates none):
@@ -47,8 +47,10 @@ creates none):
- **OpenVibe.Network** — SSO (OAuth 2 with PKCE S256, client id `media-hub`) and its JWKS: `OV_NETWORK_URL`,
- **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`,
`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.
`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
- **OpenVibe.Events** — `MEDIAHUB_EVENTS_URL` (the two subscriptions are created at boot) and
`MEDIAHUB_EVENTS_SECRET` (the v2 signature on `POST /internal/events`).
`MEDIAHUB_EVENTS_SECRET` (the v2 signature on `POST /internal/events`).
- **PostgreSQL** and **Valkey** — `DATABASE_URL` / `DATABASE_DIRECT_URL`, `VALKEY_URL` / `VALKEY_PREFIX`, read
- **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:
@@ -91,7 +93,7 @@ OpenVibe.Download v1 is a **private drive**, not a public host:
## How it works
## 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:
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
- **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
@@ -138,8 +140,8 @@ takes `network.account.export_requested` and `network.account.deleted`, and the
## Configuration
## Configuration
Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal
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`).
`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).
## Development
## Development
@@ -44,13 +44,12 @@ function load(env = process.env) {
@@ -44,13 +44,12 @@ function load(env = process.env) {
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.MEDIAHUB_PGLITE_DIR || '' },
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:' },
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.
// host-internal address (127.0.0.1:4100 in production), never the public origin.
media: {
media: {
url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),
url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),
app: env.MEDIAHUB_MEDIA_APP || 'media-hub',
app: env.MEDIAHUB_MEDIA_APP || 'media-hub',
- appKey: env.MEDIAHUB_MEDIA_APP_KEY || '',
timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
},
},
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
/**
/**
* OpenVibe.Media: where the bytes live. MediaHub calls Media's object API v2 **server-side only**, with its own
* 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
* init({ size, contentType, name, ownerSubject, multipart }) POST /api/v2/media-hub/objects
* putContent(objectId, buffer) PUT …/objects/:id/content
* putContent(objectId, buffer) PUT …/objects/:id/content
@@ -22,6 +23,7 @@
@@ -22,6 +23,7 @@
* a refusal: it answers { ok: false, status, code, detail } so the caller can show the problem as it came.
* 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 { ids } = require('openvibe-contracts');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { headerSafeName } = require('../drive/rules');
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. */
/** 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) {
@@ -35,12 +37,31 @@ function subjectHeader(requester) {
function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console }) {
function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console }) {
const media = config.media;
const media = config.media;
const base = () => `${media.url}/api/v2/${encodeURIComponent(media.app)}/objects`;
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. */
/** 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 } = {}) {
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;
let payload = body;
if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
let res;
let res;
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*
*
* db required a real round trip to OpenVibe.MediaHub's PostgreSQL store
* 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
* 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
* 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,
* 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 }) {
@@ -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 } };
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') },
],
],
});
});
}
}
@@ -41,6 +41,10 @@ const MB = 1024 * 1024;
@@ -41,6 +41,10 @@ const MB = 1024 * 1024;
const dl = await t.get(`/files/${id}/download`, { as: kim });
const dl = await t.get(`/files/${id}/download`, { as: kim });
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.status, 200);
assert.strictEqual(sha256(dl.buffer), sha256(data), 'the bytes round-trip');
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 () => {
await check('the chunked upload the page script uses: start, parts, complete', async () => {
@@ -14,7 +14,7 @@ process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN = process.env.MEDIAHUB_API_RATE_LIMI
@@ -14,7 +14,7 @@ process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN = process.env.MEDIAHUB_API_RATE_LIMI
*/
*/
const http = require('http');
const http = require('http');
const { startNetwork } = require('./mocks');
const { startNetwork } = require('./mocks');
-const { startMedia, KEY } = require('./media');
+const { startMedia } = require('./media');
const captured = [];
const captured = [];
for (const m of ['log', 'info', 'warn', 'error']) {
for (const m of ['log', 'info', 'warn', 'error']) {
@@ -30,7 +30,7 @@ async function boot(opts = {}) {
@@ -30,7 +30,7 @@ async function boot(opts = {}) {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.download', TRUST_PROXY: '1',
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_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',
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 || {}),
...(opts.env || {}),
};
};
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
@@ -16,28 +16,28 @@
@@ -16,28 +16,28 @@
* DELETE /api/v2/:app/objects/:id soft delete
* DELETE /api/v2/:app/objects/:id soft delete
* GET /o/:id?exp&sig the bytes, with the signature checked
* 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.
* 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.
* would; `objects`/`bytesOf(id)` are what the store holds; `deleted` lists the ids Media was asked to delete.
*/
*/
const http = require('http');
const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
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_SECRET = 'stand-in-media-signing-secret';
const sign = (objectId, exp) => crypto.createHmac('sha256', SIGN_SECRET).update(`get\n${objectId}\n${exp}`).digest('base64url');
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 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 sessions = new Map(); // id → { id, object_id, part_size, total_size, parts_expected, status, parts: Map }
const requests = [];
const requests = [];
const deleted = [];
const deleted = [];
- const state = { down: false, keyRejects: false };
+ const state = { down: false, tokenRejects: false };
let seq = 0;
let seq = 0;
const json = (res, status, body) => {
const json = (res, status, body) => {
@@ -119,9 +119,8 @@ async function startMedia({ key = KEY, maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -119,9 +119,8 @@ async function startMedia({ key = KEY, maxSingle = 256 * 1024 * 1024 } = {}) {
return res.end(obj.bytes);
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);
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');
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 } = {}) {
@@ -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));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}`;
const url = `http://127.0.0.1:${server.address().port}`;
return {
return {
- url, key, objects, sessions, requests, deleted, state,
+ url, objects, sessions, requests, deleted, state,
bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,
bytesOf: (id) => (objects.get(id) && objects.get(id).bytes) || null,
objectOf: (id) => objects.get(id) || null,
objectOf: (id) => objects.get(id) || null,
setDown: (v) => { state.down = v; },
setDown: (v) => { state.down = v; },
- setKeyRejects: (v) => { state.keyRejects = v; },
+ setTokenRejects: (v) => { state.tokenRejects = v; },
reset: () => { requests.length = 0; },
reset: () => { requests.length = 0; },
close: () => new Promise((r) => server.close(r)),
close: () => new Promise((r) => server.close(r)),
};
};
}
}
-module.exports = { startMedia, KEY, sign };
+module.exports = { startMedia, SERVICE_TOKEN, sign };
@@ -298,6 +298,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -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' });
return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
}
}
if (body.grant_type === 'client_credentials') {
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);
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 });
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 });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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; });