openvibers/openvibe.media Commits
Media: no app-key seeding; every first-party tenant is token-only (C-71) (#86)
Changes
@@@ -56,13 +56,9 @@ MEDIA_OWNER_SUBJECT_INTERVAL_MIN=10# openvibe.live, OV_LIVE_INTERNAL_URL) instead of a local username mapping. MEDIA_LINEAGE=off disables it.MEDIA_LINEAGE=--# ── Tenant (app) seeding ─────────────────────────────────────--# JSON array, upserted into the `apps` table on every boot (api_key is hashed,--# never stored in plaintext). allowed_origins guards browser (user-JWT) calls.--# MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"change-me","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"change-me-too","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'--MEDIA_APPS_SEED=--# deprecated: use MEDIA_APPS_SEED (fallback short form, no webhooks/origins): "live:key1,games:key2"--MEDIA_APP_KEYS=++# ── First-party tenants ──────────────────────────────────────++# Created at boot (server/auth.js ensureTokenOnlyApps) and reached only with each service's Network service token.++# No app key is issued or seeded (MEDIA_APPS_SEED and MEDIA_APP_KEYS are gone, compatibility register C-71).# ── Developer-project tenants (ADR-014) ──────────────────────# Created on first use by a developer app token (Network, audience openvibe.media) for its own@@@ -14,7 +14,7 @@ Implements **Media API v1** from `../CONTRACTS.md`.```npm install--cp .env.example .env # set MEDIA_APPS_SEED at minimum++cp .env.example .env # the first-party tenants are created at boot (token-only)npm start # node server/index.js```@@@ -219,15 +219,14 @@ namespaces are `app.<project_id>` (production) and `app.<project_id>.sandbox`, w- Not yet: reading quotas set in Network (`network.project.read`), and removing a project'stenants when the project is archived.--### App seeding++### First-party tenants--On every boot the service upserts apps from env (keys are hashed):----```bash--MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"…","webhook_url":"http://127.0.0.1:3000/internal/media-webhook","webhook_secret":"…","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'--# deprecated: use MEDIA_APPS_SEED (fallback short form, no webhooks/origins)--MEDIA_APP_KEYS="live:key1,games:key2"--```++Boot creates any missing first-party tenant: `live`, `community`, `tools`, `games`, `wiki`, `blog`, `inventory` and++`media-hub` (`server/auth.js` `ensureTokenOnlyApps`), with its quota and browser origins, and leaves an existing row as++it is. Each is reached only with its service's Network service token, with grants per namespace in Network. No app++key is issued or seeded: `MEDIA_APPS_SEED` and `MEDIA_APP_KEYS` are gone (compatibility register C-71), and migration++`0006` emptied the key hashes the tenants still carried. App keys now exist only as test fixtures (`db.upsertApp`)++and in `scripts/smoke-test.sh`.## API (all under `/api/v1/:app`)@@@ -698,8 +697,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:`test/rtmp-allowlist.test.js`); avataringestion fetches through the SSRF guard; other calls go to the configured Network, Live, Events, B2and R2 endpoints.--- **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `MEDIA_SIGNING_SECRET`, `MEDIA_SECRET`, `MEDIA_APPS_SEED`-- (and the deprecated `MEDIA_APP_KEYS`), `MEDIA_B2_*`, `MEDIA_R2_*`, `MEDIA_INBOUND_EVENTS_SECRET` and++- **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `MEDIA_SIGNING_SECRET`, `MEDIA_SECRET`, `MEDIA_B2_*`, `MEDIA_R2_*`, `MEDIA_INBOUND_EVENTS_SECRET` and`VIEW_HASH_SECRET` (thesecret visitor hashes are derived from; it falls back to `MEDIA_SECRET`) live in`/etc/openvibe/media.env` (0600), by name only.nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: migrate++-- Compatibility register C-71: every first-party tenant is reached only with its service's Network service token++-- (Live since C-70, OpenRestream since C-42, Inventory and MediaHub since their 2026-10-11 releases). App keys were++-- only ever seeded from MEDIA_APPS_SEED / MEDIA_APP_KEYS, which this release stops reading, so the key hashes still++-- stored for those tenants are emptied: an empty hash never matches a presented key (server/auth.js checkAppKey),++-- exactly like the tenants that never had one. Developer-project tenants never carry a hash (ADR-014). Re-running it++-- is a no-op.++UPDATE apps SET api_key_hash = '' WHERE project_id IS NULL AND api_key_hash <> '';@@@ -34,14 +34,24 @@ ffmpeg -y -f lavfi -i "testsrc=duration=5:size=640x360:rate=30" \"$WORK/test.mp4" >/dev/null 2>&1[ -s "$WORK/test.mp4" ] && ok "test.mp4 generated" || { bad "test.mp4 generation"; exit 1; }++echo "-- test tenants with app keys (only tests and this script write keys; production tenants are token-only, C-71)"++MEDIA_PGLITE_DIR="$WORK/data/pglite" LIVE_KEY="$LIVE_KEY" GAMES_KEY="$GAMES_KEY" node -e '++const db = require(process.argv[1] + "/server/db/database");++(async () => {++ await db.initDb();++ await db.upsertApp({ app_id: "live", name: "Live", api_key: process.env.LIVE_KEY, allowed_origins: ["http://localhost:3000"] });++ await db.upsertApp({ app_id: "games", name: "Games", api_key: process.env.GAMES_KEY, quota_bytes: 10485760 });++ await db.close();++})().catch((e) => { console.error(e.message); process.exit(1); });++' "$ROOT" || { bad "test tenants"; exit 1; }++echo "-- booting server on :$PORT (temp data dir)"--SEED="[{\"app_id\":\"live\",\"name\":\"Live\",\"api_key\":\"$LIVE_KEY\",\"allowed_origins\":[\"http://localhost:3000\"]},{\"app_id\":\"games\",\"name\":\"Games\",\"api_key\":\"$GAMES_KEY\",\"quota_bytes\":10485760}]"env PORT="$PORT" HOST=127.0.0.1 \++ MEDIA_PGLITE_DIR="$WORK/data/pglite" \MEDIA_DATA_DIR="$WORK/data" \VOD_PATH="$WORK/data/vods" CLIPS_PATH="$WORK/data/clips" \PASTES_PATH="$WORK/data/pastes" THUMBNAILS_PATH="$WORK/data/thumbnails" \FILES_PATH="$WORK/data/files" \-- MEDIA_APPS_SEED="$SEED" \OV_NETWORK_URL="http://127.0.0.1:1" \node "$ROOT/server/index.js" > "$WORK/server.log" 2>&1 &SRV=$!@@@ -12,9 +12,9 @@* Browser endpoints additionally check the Origin header against the* app's allowed_origins list.*-- * App seeding: MEDIA_APPS_SEED (JSON array) and/or the deprecated MEDIA_APP_KEYS-- * ("id:key,…") short form are upserted on boot; keys are stored as sha256 hashes.-- * MEDIA_APP_KEYS is deprecated — move each app to MEDIA_APPS_SEED.++ * Tenants: the first-party ones are created at boot by ensureTokenOnlyApps and reached only with Network service++ * tokens; developer-project tenants are created on first use (ADR-014). No app key is seeded or issued (C-71): an++ * app key (stored as a sha256 hash) exists only where a test or the smoke script writes one with db.upsertApp.** Network principal tokens (service and developer app tokens) are checked per namespace for one of* five verbs: read, list, write, delete, transform (VERBS below; objects/namespaces.js has the rows).@@@ -196,19 +196,25 @@ function tenantPath(app) {}/**-- * Tenants that exist only to be reached with service tokens (no API key is ever issued):-- * OpenVibe.Community stores screenshot bytes here under its own namespace.++ * The first-party tenants. Every one is reached only with its service's Network service token: no app key is ever++ * issued, and none is seeded (MEDIA_APPS_SEED / MEDIA_APP_KEYS are gone, compatibility register C-71). A missing++ * tenant is created with its quota (0 = no tenant quota) and browser origins; an existing row is left as it is, so an++ * operator's quota or origins stand. Developer-project tenants are created on first use instead (ADR-014).*/async function ensureTokenOnlyApps() {-- // Services that reach Media only with Network service tokens (no app key). Quotas are per tenant.-- for (const [appId, name, quotaGb] of [-- ['community', 'OpenVibe.Community', 10],-- ['tools', 'OpenVibe.Tools', 20], // Wave 11 job results (TOOLS_JOB_RESULTS=media)-- ['games', 'OpenVibe.Games', 10], // Wave 12 map-editor assets-- ['wiki', 'OpenVibe.Wiki', 10], // Wave 16 page attachments-- ['blog', 'OpenVibe.Blog', 10], // Wave 16 post attachments++ for (const [appId, name, quotaGb, origins] of [++ ['live', 'OpenVibe.Live', 0, ['https://openvibe.live']], // VODs, clips, thumbnails, avatars (C-70)++ ['community', 'OpenVibe.Community', 10, []],++ ['tools', 'OpenVibe.Tools', 20, ['https://openvibe.tools']], // Wave 11 job results (TOOLS_JOB_RESULTS=media)++ ['games', 'OpenVibe.Games', 10, ['https://openvibe.games', 'https://play.openvibe.games']], // Wave 12 map-editor assets++ ['wiki', 'OpenVibe.Wiki', 10, []], // Wave 16 page attachments++ ['blog', 'OpenVibe.Blog', 10, []], // Wave 16 post attachments++ ['inventory', 'OpenVibe.Inventory', 1, []], // Workshop badge images++ ['media-hub', 'OpenVibe.MediaHub', 100, []], // the OpenVibe.Download drive]) {-- if (!await db.getApp(appId)) await db.run("INSERT INTO apps (app_id, name, api_key_hash, quota_bytes) VALUES (?, ?, '', ?)", [appId, name, quotaGb * 1024 ** 3]);++ if (!await db.getApp(appId)) {++ await db.run("INSERT INTO apps (app_id, name, api_key_hash, quota_bytes, allowed_origins) VALUES (?, ?, '', ?, ?)", [appId, name, quotaGb * 1024 ** 3, JSON.stringify(origins)]);++ }await db.ensureRootNamespace(await db.getApp(appId));}}@@@ -412,54 +418,6 @@ function tenantCors(req, res, next) {next();}--// ── App seeding ──────────────────────────────────────────────----async function seedApps() {-- let seeded = 0;---- if (config.apps.seedJson) {-- try {-- const list = JSON.parse(config.apps.seedJson);-- for (const entry of Array.isArray(list) ? list : []) {-- if (!entry || !entry.app_id || !entry.api_key) continue;-- if (/^prj_/.test(String(entry.app_id))) { console.warn(`[Auth] MEDIA_APPS_SEED: ${entry.app_id} skipped (developer-project tenants never get an API key)`); continue; }-- await db.upsertApp(entry);-- seeded++;-- }-- } catch (err) {-- console.error('[Auth] MEDIA_APPS_SEED is not valid JSON:', err.message);-- }-- }---- if (config.apps.seedKeys) {-- console.warn('[Auth] MEDIA_APP_KEYS is deprecated: move each app to MEDIA_APPS_SEED (JSON with app_id, api_key, webhook_url, allowed_origins)');-- for (const pair of config.apps.seedKeys.split(',')) {-- const idx = pair.indexOf(':');-- if (idx < 1) continue;-- const app_id = pair.slice(0, idx).trim();-- const api_key = pair.slice(idx + 1).trim();-- if (!app_id || !api_key) continue;-- if (/^prj_/.test(app_id)) { console.warn(`[Auth] MEDIA_APP_KEYS: ${app_id} skipped (developer-project tenants never get an API key)`); continue; }-- // Don't clobber a richer MEDIA_APPS_SEED entry for the same app.-- const existing = await db.getApp(app_id);-- if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;-- await db.upsertApp({-- app_id,-- name: existing?.name || app_id,-- api_key,-- webhook_url: existing?.webhook_url || null,-- webhook_secret: existing?.webhook_secret || null,-- allowed_origins: existing ? db.appAllowedOrigins(existing) : [],-- quota_bytes: existing?.quota_bytes || 0,-- });-- seeded++;-- }-- }---- if (seeded) console.log(`[Auth] Seeded/updated ${seeded} app(s): ${(await db.listApps()).map(a => a.app_id).join(', ')}`);-- else if (!(await db.listApps()).length) console.warn('[Auth] No apps configured — set MEDIA_APPS_SEED (all API calls will 404)');--}--module.exports = {tenantAuth,tenantPath,@@@ -481,7 +439,6 @@ module.exports = {optionalIdentity,// verifyUserJwt stays internal: it proves a token is a genuine Network credential,// but its subject is a Network id and must never be used as an app-local user id.-- seedApps,startJwksRefresh,stopJwksRefresh,jwksLoaded,@@@ -170,10 +170,6 @@ const config = {indexnow: { key: String(process.env.INDEXNOW_KEY || '').trim() },apps: {-- // JSON array: [{app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes}]-- seedJson: process.env.MEDIA_APPS_SEED || '',-- // Fallback short form: "live:key1,games:key2"-- seedKeys: process.env.MEDIA_APP_KEYS || '',// Developer-project tenants (ADR-014), created on first use by an app token: default quotas.// Applied when the tenant is created; an operator can change apps.quota_bytes afterwards.projectQuotaMb: Math.max(1, intEnv('MEDIA_APP_TENANT_QUOTA_MB', 1024)),@@@ -64,8 +64,7 @@ const ready = (async () => {try { require('./placement/providers').startHealthLoop(); } catch (err) { console.warn('[Placement] health loop did not start:', err.message); }}if (!drill.enabled) {-- await auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS-- await auth.ensureTokenOnlyApps(); // tenants reached only with Network service tokens (community)++ await auth.ensureTokenOnlyApps(); // the first-party tenants, reached only with Network service tokens (C-71)auth.startJwksRefresh(); // a drill asks Network for nothing (app keys still verify against the copy)}@@@ -34,6 +34,15 @@ const crypto = require('crypto');auth._setNetworkPublicKeyForTests(keys.publicKey);await db.upsertApp({ app_id: 'live', api_key: 'live-key' });await auth.ensureTokenOnlyApps();++ // C-71: every first-party tenant exists without seeding, and one boot created carries no app key. An existing row++ // (live, written with a test key just above) is left exactly as it was.++ for (const id of ['community', 'tools', 'games', 'wiki', 'blog', 'inventory', 'media-hub']) {++ const row = await db.getApp(id);++ assert.ok(row, `${id} exists without seeding`);++ assert.strictEqual(row.api_key_hash, '', `${id} has no app key`);++ }++ assert.deepStrictEqual(JSON.parse((await db.getApp('games')).allowed_origins), ['https://openvibe.games', 'https://play.openvibe.games']);++ assert.notStrictEqual((await db.getApp('live')).api_key_hash, '', 'an existing row is not rewritten');const PA = `prj_${ids.ulid()}`;const PB = `prj_${ids.ulid()}`;@@@ -123,7 +123,6 @@ const { spawnSync, execFileSync } = require('child_process');MEDIA_DRILL: '1', DATABASE_URL: DRILL_URL, DATABASE_DIRECT_URL: '', HOST: '127.0.0.1', PORT: String(port), NODE_ENV: 'production',// What the production env file sets; a normal boot would act on all of it.EVENTS_URL: 'http://127.0.0.1:9', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40),-- MEDIA_APP_KEYS: `live:${'n'.repeat(40)},games:${'g'.repeat(40)}`,MEDIA_B2_ENDPOINT: 'https://s3.example.invalid', MEDIA_B2_BUCKET: 'b', MEDIA_B2_KEY_ID: 'id', MEDIA_B2_APP_KEY: 'key',MEDIA_R2_ENDPOINT: 'https://r2.example.invalid', MEDIA_R2_BUCKET: 'r', MEDIA_R2_ACCESS_KEY_ID: 'id', MEDIA_R2_SECRET_ACCESS_KEY: 'key',// The Host inventory's storage: nothing but the thumbnail listing points at production.@@@ -212,7 +211,7 @@ const { spawnSync, execFileSync } = require('child_process');assert.deepStrictEqual(drill.blocked, []);assert.deepStrictEqual(sinkHits, []);});-- await check('no app seeding: the copy\'s apps are production\'s, untouched by MEDIA_APP_KEYS', async () => {++ await check('the copy\'s apps are production\'s: a drill boot creates and changes no tenant', async () => {assert.deepStrictEqual(await counts(), countsBefore);});@@@ -37,11 +37,9 @@ const crypto = require('crypto');MEDIA_B2_APP_KEY: S('b2-app-key'),MEDIA_R2_ACCESS_KEY_ID: S('r2-access-key-id'),MEDIA_R2_SECRET_ACCESS_KEY: S('r2-secret-access-key'),-- MEDIA_APP_KEYS: `live:${LIVE_KEY}`,-- MEDIA_APPS_SEED: JSON.stringify([{ app_id: 'games', name: 'Games', api_key: GAMES_KEY, webhook_url: `${CLOSED}/hook`, webhook_secret: S('games-webhook-secret'), allowed_origins: ['https://games.test'] }]),};const sentinels = new Map([-- ...Object.entries(SECRET_ENV).filter(([k]) => !['MEDIA_APP_KEYS', 'MEDIA_APPS_SEED'].includes(k)),++ ...Object.entries(SECRET_ENV),['live app key', LIVE_KEY], ['games app key', GAMES_KEY], ['games webhook secret', S('games-webhook-secret')],['stream key', S('stream-key')], ['rtmp path key', S('rtmp-path-key')],]);@@@ -55,7 +53,7 @@ const crypto = require('crypto');// Public by protocol, not secrets: an IndexNow key is served at /<key>.txt for search engines to verify.const PUBLIC_BY_DESIGN = new Set(['INDEXNOW_KEY']);const secretish = [...names].filter(n => /SECRET|_KEY$|_KEYS$|KEY_ID$|TOKEN|PASSWORD|PASSWD|CREDENTIAL|_SEED$|PRIVATE/.test(n) && !/_(S|MS|MB|GB|MIN|HOURS|DAYS)$/.test(n) && !PUBLIC_BY_DESIGN.has(n)); // not the numeric settings (MEDIA_UPLOAD_TOKEN_TTL_S)-- assert.ok(secretish.length >= 10, `found the secret variables (${secretish})`);++ assert.ok(secretish.length >= 9, `found the secret variables (${secretish})`); // 9 since the seeding variables went (C-71)for (const n of secretish) assert.ok(Object.prototype.hasOwnProperty.call(SECRET_ENV, n), `${n} is read by the service: give it a sentinel in test/security-secrets.test.js`);}@@@ -97,20 +95,16 @@ const crypto = require('crypto');});for (const k of ['MEDIA_DRILL', 'PASTES_MOVED_TO', 'EVENTS_PUBLISH']) delete process.env[k];-- // MEDIA_APP_KEYS is deprecated (T4): it still seeds, but must warn once per boot and never name the key.-- const warnLines = [];-- const realWarn = console.warn;-- console.warn = (...args) => { warnLines.push(args.join(' ')); return realWarn.apply(console, args); };const { app, server } = await require('../server/index.js').ready;if (!server.listening) await new Promise((r) => server.once('listening', r));-- console.warn = realWarn;-- const depr = warnLines.filter((l) => l.includes('MEDIA_APP_KEYS is deprecated'));-- assert.strictEqual(depr.length, 1, `MEDIA_APP_KEYS warns once per boot (saw ${depr.length})`);-- assert.ok(depr[0].includes('MEDIA_APPS_SEED') && !depr[0].includes(LIVE_KEY), 'the warning points at MEDIA_APPS_SEED and never names a key');const auth = require('../server/auth');auth._setNetworkPublicKeyForTests(keys.publicKey);const db = require('../server/db/database');-- assert.strictEqual((await db.getApp('live')).app_id, 'live', 'MEDIA_APP_KEYS still seeds its app (deprecated, not removed)');++ // Boot made the first-party tenants token-only (C-71: nothing seeds a key). The app keys these checks use are++ // fixtures written here, so the redaction checks still cover a key and a webhook secret.++ assert.strictEqual((await db.getApp('live')).api_key_hash, '', 'boot creates live with no app key');++ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: LIVE_KEY, allowed_origins: ['https://openvibe.live'] });++ await db.upsertApp({ app_id: 'games', name: 'Games', api_key: GAMES_KEY, webhook_url: `${CLOSED}/hook`, webhook_secret: S('games-webhook-secret'), allowed_origins: ['https://games.test'] });const base = `http://127.0.0.1:${server.address().port}`;// ── Credentials ──@@@ -6,7 +6,7 @@// - POST /api/v1/:app/vods/:id/ingest/rtmp makes ffmpeg pull an app-supplied rtmp_url, which must name an// allow-listed host:port (MEDIA_RTMP_PULL_ALLOW; test/rtmp-allowlist.test.js has the URL grammar).// Every other outbound request goes to an operator-configured address (Network, Events, Live's internal--// URL, B2/R2, an app's webhook_url from MEDIA_APPS_SEED, IndexNow's fixed api.indexnow.org from++// URL, B2/R2, an app's operator-set webhook_url row, IndexNow's fixed api.indexnow.org from// INDEXNOW_KEY). The inventory below fails when a new outbound call appears in server/ until it is// classified here, and no route lets a tenant set a webhook URL.// No test here needs the network: literals and internal names are refused before DNS, and DNS answers@@@ -31,7 +31,7 @@ const https = require('https');'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below','server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations','server/user-auth.js': 'Network OAuth (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL); the signing keys come from openvibe-sdk/auth',-- 'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',++ 'server/webhooks.js': 'an app\'s webhook_url, an operator-set row (no seeding since C-71)','server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path','server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path','server/jobs/pack.js': 'a presigned GET on B2/R2 (operator-configured buckets) for a segment\'s durable copy',@@@ -53,7 +53,7 @@ const https = require('https');for (const f of found) assert.ok(OUTBOUND[f], `${f} makes an outbound request: classify it in test/security-ssrf.test.js (a user-chosen address must go through a guard)`);for (const f of Object.keys(OUTBOUND)) assert.ok(found.has(f), `${f} no longer makes an outbound request: drop it from the inventory`);// Webhook targets are operator configuration: the only SQL that writes an app's webhook_url is-- // db.upsertApp, and the only caller of that is app seeding (MEDIA_APPS_SEED / MEDIA_APP_KEYS).++ // db.upsertApp, which no server code calls since app seeding went (C-71): tests and the smoke script only.const sqlWriters = [], upserters = [];const scan = (d) => {for (const e of fs.readdirSync(d, { withFileTypes: true })) {@@@ -67,8 +67,7 @@ const https = require('https');};scan(path.join(ROOT, 'server'));assert.deepStrictEqual(sqlWriters, ['server/db/database.js'], 'only db.upsertApp writes an app\'s webhook_url');-- assert.deepStrictEqual(upserters, ['server/auth.js'], 'and only app seeding calls it');-- assert.ok(/function seedApps[\s\S]*upsertApp\(/.test(fs.readFileSync(path.join(ROOT, 'server/auth.js'), 'utf8')), 'from seedApps');++ assert.deepStrictEqual(upserters, [], 'and no server code calls it: app seeding is gone (C-71), so a webhook URL is an operator-set row');// IndexNow (openvibe-shared/indexnow, wired in server/index.js): the only outbound request made// from a dependency. Its endpoint is the fixed api.indexnow.org and host/key are operator config// (MEDIA_PUBLIC_URL / INDEXNOW_KEY) — never a tenant or a user-supplied URL, and no route reaches it.@@@ -59,7 +59,7 @@ const express = require('express');const freePort = await new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });const env = { ...process.env, MEDIA_PGLITE_DIR: path.join(tmp, 'pglite'), DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', VOD_PATH: path.join(tmp, 'vods'), CLIPS_PATH: path.join(tmp, 'clips'), PASTES_PATH: path.join(tmp, 'pastes'),THUMBNAILS_PATH: path.join(tmp, 'thumbs'), FILES_PATH: path.join(tmp, 'files'), OBJECTS_PATH: path.join(tmp, 'objects'), PORT: String(freePort), HOST: ext ? '0.0.0.0' : '127.0.0.1',-- NODE_ENV: 'test', MEDIA_JOBS_ENABLED: '0', MEDIA_VERIFY_ENABLED: '0', MEDIA_OWNER_SUBJECT_SYNC: '0', EVENTS_URL: '', MEDIA_APP_KEYS: 'live:live-key-for-trust-test' };++ NODE_ENV: 'test', MEDIA_JOBS_ENABLED: '0', MEDIA_VERIFY_ENABLED: '0', MEDIA_OWNER_SUBJECT_SYNC: '0', EVENTS_URL: '' };const child = spawn(process.execPath, [path.join(__dirname, '../server/index.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });let log = '';child.stdout.on('data', (d) => { log += d; });
@@ -56,13 +56,9 @@ MEDIA_OWNER_SUBJECT_INTERVAL_MIN=10
@@ -56,13 +56,9 @@ MEDIA_OWNER_SUBJECT_INTERVAL_MIN=10
# openvibe.live, OV_LIVE_INTERNAL_URL) instead of a local username mapping. MEDIA_LINEAGE=off disables it.
# openvibe.live, OV_LIVE_INTERNAL_URL) instead of a local username mapping. MEDIA_LINEAGE=off disables it.
MEDIA_LINEAGE=
MEDIA_LINEAGE=
-# ── Tenant (app) seeding ─────────────────────────────────────
-# JSON array, upserted into the `apps` table on every boot (api_key is hashed,
-# never stored in plaintext). allowed_origins guards browser (user-JWT) calls.
-# MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"change-me","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"change-me-too","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
-MEDIA_APPS_SEED=
-# deprecated: use MEDIA_APPS_SEED (fallback short form, no webhooks/origins): "live:key1,games:key2"
-MEDIA_APP_KEYS=
+# ── First-party tenants ──────────────────────────────────────
+# Created at boot (server/auth.js ensureTokenOnlyApps) and reached only with each service's Network service token.
+# No app key is issued or seeded (MEDIA_APPS_SEED and MEDIA_APP_KEYS are gone, compatibility register C-71).
# ── Developer-project tenants (ADR-014) ──────────────────────
# ── Developer-project tenants (ADR-014) ──────────────────────
# Created on first use by a developer app token (Network, audience openvibe.media) for its own
# Created on first use by a developer app token (Network, audience openvibe.media) for its own
@@ -14,7 +14,7 @@ Implements **Media API v1** from `../CONTRACTS.md`.
@@ -14,7 +14,7 @@ Implements **Media API v1** from `../CONTRACTS.md`.
```
```
npm install
npm install
-cp .env.example .env # set MEDIA_APPS_SEED at minimum
+cp .env.example .env # the first-party tenants are created at boot (token-only)
npm start # node server/index.js
npm start # node server/index.js
```
```
@@ -219,15 +219,14 @@ namespaces are `app.<project_id>` (production) and `app.<project_id>.sandbox`, w
@@ -219,15 +219,14 @@ namespaces are `app.<project_id>` (production) and `app.<project_id>.sandbox`, w
- Not yet: reading quotas set in Network (`network.project.read`), and removing a project's
- Not yet: reading quotas set in Network (`network.project.read`), and removing a project's
tenants when the project is archived.
tenants when the project is archived.
-### App seeding
+### First-party tenants
-On every boot the service upserts apps from env (keys are hashed):
-
-```bash
-MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"…","webhook_url":"http://127.0.0.1:3000/internal/media-webhook","webhook_secret":"…","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
-# deprecated: use MEDIA_APPS_SEED (fallback short form, no webhooks/origins)
-MEDIA_APP_KEYS="live:key1,games:key2"
-```
+Boot creates any missing first-party tenant: `live`, `community`, `tools`, `games`, `wiki`, `blog`, `inventory` and
+`media-hub` (`server/auth.js` `ensureTokenOnlyApps`), with its quota and browser origins, and leaves an existing row as
+it is. Each is reached only with its service's Network service token, with grants per namespace in Network. No app
+key is issued or seeded: `MEDIA_APPS_SEED` and `MEDIA_APP_KEYS` are gone (compatibility register C-71), and migration
+`0006` emptied the key hashes the tenants still carried. App keys now exist only as test fixtures (`db.upsertApp`)
+and in `scripts/smoke-test.sh`.
## API (all under `/api/v1/:app`)
## API (all under `/api/v1/:app`)
@@ -698,8 +697,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -698,8 +697,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
`test/rtmp-allowlist.test.js`); avatar
`test/rtmp-allowlist.test.js`); avatar
ingestion fetches through the SSRF guard; other calls go to the configured Network, Live, Events, B2
ingestion fetches through the SSRF guard; other calls go to the configured Network, Live, Events, B2
and R2 endpoints.
and R2 endpoints.
-- **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `MEDIA_SIGNING_SECRET`, `MEDIA_SECRET`, `MEDIA_APPS_SEED`
- (and the deprecated `MEDIA_APP_KEYS`), `MEDIA_B2_*`, `MEDIA_R2_*`, `MEDIA_INBOUND_EVENTS_SECRET` and
+- **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `MEDIA_SIGNING_SECRET`, `MEDIA_SECRET`, `MEDIA_B2_*`, `MEDIA_R2_*`, `MEDIA_INBOUND_EVENTS_SECRET` and
`VIEW_HASH_SECRET` (the
`VIEW_HASH_SECRET` (the
secret visitor hashes are derived from; it falls back to `MEDIA_SECRET`) live in
secret visitor hashes are derived from; it falls back to `MEDIA_SECRET`) live in
`/etc/openvibe/media.env` (0600), by name only.
`/etc/openvibe/media.env` (0600), by name only.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: migrate
+-- Compatibility register C-71: every first-party tenant is reached only with its service's Network service token
+-- (Live since C-70, OpenRestream since C-42, Inventory and MediaHub since their 2026-10-11 releases). App keys were
+-- only ever seeded from MEDIA_APPS_SEED / MEDIA_APP_KEYS, which this release stops reading, so the key hashes still
+-- stored for those tenants are emptied: an empty hash never matches a presented key (server/auth.js checkAppKey),
+-- exactly like the tenants that never had one. Developer-project tenants never carry a hash (ADR-014). Re-running it
+-- is a no-op.
+UPDATE apps SET api_key_hash = '' WHERE project_id IS NULL AND api_key_hash <> '';
@@ -34,14 +34,24 @@ ffmpeg -y -f lavfi -i "testsrc=duration=5:size=640x360:rate=30" \
@@ -34,14 +34,24 @@ ffmpeg -y -f lavfi -i "testsrc=duration=5:size=640x360:rate=30" \
"$WORK/test.mp4" >/dev/null 2>&1
"$WORK/test.mp4" >/dev/null 2>&1
[ -s "$WORK/test.mp4" ] && ok "test.mp4 generated" || { bad "test.mp4 generation"; exit 1; }
[ -s "$WORK/test.mp4" ] && ok "test.mp4 generated" || { bad "test.mp4 generation"; exit 1; }
+echo "-- test tenants with app keys (only tests and this script write keys; production tenants are token-only, C-71)"
+MEDIA_PGLITE_DIR="$WORK/data/pglite" LIVE_KEY="$LIVE_KEY" GAMES_KEY="$GAMES_KEY" node -e '
+const db = require(process.argv[1] + "/server/db/database");
+(async () => {
+ await db.initDb();
+ await db.upsertApp({ app_id: "live", name: "Live", api_key: process.env.LIVE_KEY, allowed_origins: ["http://localhost:3000"] });
+ await db.upsertApp({ app_id: "games", name: "Games", api_key: process.env.GAMES_KEY, quota_bytes: 10485760 });
+ await db.close();
+})().catch((e) => { console.error(e.message); process.exit(1); });
+' "$ROOT" || { bad "test tenants"; exit 1; }
+
echo "-- booting server on :$PORT (temp data dir)"
echo "-- booting server on :$PORT (temp data dir)"
-SEED="[{\"app_id\":\"live\",\"name\":\"Live\",\"api_key\":\"$LIVE_KEY\",\"allowed_origins\":[\"http://localhost:3000\"]},{\"app_id\":\"games\",\"name\":\"Games\",\"api_key\":\"$GAMES_KEY\",\"quota_bytes\":10485760}]"
env PORT="$PORT" HOST=127.0.0.1 \
env PORT="$PORT" HOST=127.0.0.1 \
+ MEDIA_PGLITE_DIR="$WORK/data/pglite" \
MEDIA_DATA_DIR="$WORK/data" \
MEDIA_DATA_DIR="$WORK/data" \
VOD_PATH="$WORK/data/vods" CLIPS_PATH="$WORK/data/clips" \
VOD_PATH="$WORK/data/vods" CLIPS_PATH="$WORK/data/clips" \
PASTES_PATH="$WORK/data/pastes" THUMBNAILS_PATH="$WORK/data/thumbnails" \
PASTES_PATH="$WORK/data/pastes" THUMBNAILS_PATH="$WORK/data/thumbnails" \
FILES_PATH="$WORK/data/files" \
FILES_PATH="$WORK/data/files" \
- MEDIA_APPS_SEED="$SEED" \
OV_NETWORK_URL="http://127.0.0.1:1" \
OV_NETWORK_URL="http://127.0.0.1:1" \
node "$ROOT/server/index.js" > "$WORK/server.log" 2>&1 &
node "$ROOT/server/index.js" > "$WORK/server.log" 2>&1 &
SRV=$!
SRV=$!
@@ -12,9 +12,9 @@
@@ -12,9 +12,9 @@
* Browser endpoints additionally check the Origin header against the
* Browser endpoints additionally check the Origin header against the
* app's allowed_origins list.
* app's allowed_origins list.
*
*
- * App seeding: MEDIA_APPS_SEED (JSON array) and/or the deprecated MEDIA_APP_KEYS
- * ("id:key,…") short form are upserted on boot; keys are stored as sha256 hashes.
- * MEDIA_APP_KEYS is deprecated — move each app to MEDIA_APPS_SEED.
+ * Tenants: the first-party ones are created at boot by ensureTokenOnlyApps and reached only with Network service
+ * tokens; developer-project tenants are created on first use (ADR-014). No app key is seeded or issued (C-71): an
+ * app key (stored as a sha256 hash) exists only where a test or the smoke script writes one with db.upsertApp.
*
*
* Network principal tokens (service and developer app tokens) are checked per namespace for one of
* Network principal tokens (service and developer app tokens) are checked per namespace for one of
* five verbs: read, list, write, delete, transform (VERBS below; objects/namespaces.js has the rows).
* five verbs: read, list, write, delete, transform (VERBS below; objects/namespaces.js has the rows).
@@ -196,19 +196,25 @@ function tenantPath(app) {
@@ -196,19 +196,25 @@ function tenantPath(app) {
}
}
/**
/**
- * Tenants that exist only to be reached with service tokens (no API key is ever issued):
- * OpenVibe.Community stores screenshot bytes here under its own namespace.
+ * The first-party tenants. Every one is reached only with its service's Network service token: no app key is ever
+ * issued, and none is seeded (MEDIA_APPS_SEED / MEDIA_APP_KEYS are gone, compatibility register C-71). A missing
+ * tenant is created with its quota (0 = no tenant quota) and browser origins; an existing row is left as it is, so an
+ * operator's quota or origins stand. Developer-project tenants are created on first use instead (ADR-014).
*/
*/
async function ensureTokenOnlyApps() {
async function ensureTokenOnlyApps() {
- // Services that reach Media only with Network service tokens (no app key). Quotas are per tenant.
- for (const [appId, name, quotaGb] of [
- ['community', 'OpenVibe.Community', 10],
- ['tools', 'OpenVibe.Tools', 20], // Wave 11 job results (TOOLS_JOB_RESULTS=media)
- ['games', 'OpenVibe.Games', 10], // Wave 12 map-editor assets
- ['wiki', 'OpenVibe.Wiki', 10], // Wave 16 page attachments
- ['blog', 'OpenVibe.Blog', 10], // Wave 16 post attachments
+ for (const [appId, name, quotaGb, origins] of [
+ ['live', 'OpenVibe.Live', 0, ['https://openvibe.live']], // VODs, clips, thumbnails, avatars (C-70)
+ ['community', 'OpenVibe.Community', 10, []],
+ ['tools', 'OpenVibe.Tools', 20, ['https://openvibe.tools']], // Wave 11 job results (TOOLS_JOB_RESULTS=media)
+ ['games', 'OpenVibe.Games', 10, ['https://openvibe.games', 'https://play.openvibe.games']], // Wave 12 map-editor assets
+ ['wiki', 'OpenVibe.Wiki', 10, []], // Wave 16 page attachments
+ ['blog', 'OpenVibe.Blog', 10, []], // Wave 16 post attachments
+ ['inventory', 'OpenVibe.Inventory', 1, []], // Workshop badge images
+ ['media-hub', 'OpenVibe.MediaHub', 100, []], // the OpenVibe.Download drive
]) {
]) {
- if (!await db.getApp(appId)) await db.run("INSERT INTO apps (app_id, name, api_key_hash, quota_bytes) VALUES (?, ?, '', ?)", [appId, name, quotaGb * 1024 ** 3]);
+ if (!await db.getApp(appId)) {
+ await db.run("INSERT INTO apps (app_id, name, api_key_hash, quota_bytes, allowed_origins) VALUES (?, ?, '', ?, ?)", [appId, name, quotaGb * 1024 ** 3, JSON.stringify(origins)]);
+ }
await db.ensureRootNamespace(await db.getApp(appId));
await db.ensureRootNamespace(await db.getApp(appId));
}
}
}
}
@@ -412,54 +418,6 @@ function tenantCors(req, res, next) {
@@ -412,54 +418,6 @@ function tenantCors(req, res, next) {
next();
next();
}
}
-// ── App seeding ──────────────────────────────────────────────
-
-async function seedApps() {
- let seeded = 0;
-
- if (config.apps.seedJson) {
- try {
- const list = JSON.parse(config.apps.seedJson);
- for (const entry of Array.isArray(list) ? list : []) {
- if (!entry || !entry.app_id || !entry.api_key) continue;
- if (/^prj_/.test(String(entry.app_id))) { console.warn(`[Auth] MEDIA_APPS_SEED: ${entry.app_id} skipped (developer-project tenants never get an API key)`); continue; }
- await db.upsertApp(entry);
- seeded++;
- }
- } catch (err) {
- console.error('[Auth] MEDIA_APPS_SEED is not valid JSON:', err.message);
- }
- }
-
- if (config.apps.seedKeys) {
- console.warn('[Auth] MEDIA_APP_KEYS is deprecated: move each app to MEDIA_APPS_SEED (JSON with app_id, api_key, webhook_url, allowed_origins)');
- for (const pair of config.apps.seedKeys.split(',')) {
- const idx = pair.indexOf(':');
- if (idx < 1) continue;
- const app_id = pair.slice(0, idx).trim();
- const api_key = pair.slice(idx + 1).trim();
- if (!app_id || !api_key) continue;
- if (/^prj_/.test(app_id)) { console.warn(`[Auth] MEDIA_APP_KEYS: ${app_id} skipped (developer-project tenants never get an API key)`); continue; }
- // Don't clobber a richer MEDIA_APPS_SEED entry for the same app.
- const existing = await db.getApp(app_id);
- if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;
- await db.upsertApp({
- app_id,
- name: existing?.name || app_id,
- api_key,
- webhook_url: existing?.webhook_url || null,
- webhook_secret: existing?.webhook_secret || null,
- allowed_origins: existing ? db.appAllowedOrigins(existing) : [],
- quota_bytes: existing?.quota_bytes || 0,
- });
- seeded++;
- }
- }
-
- if (seeded) console.log(`[Auth] Seeded/updated ${seeded} app(s): ${(await db.listApps()).map(a => a.app_id).join(', ')}`);
- else if (!(await db.listApps()).length) console.warn('[Auth] No apps configured — set MEDIA_APPS_SEED (all API calls will 404)');
-}
-
module.exports = {
module.exports = {
tenantAuth,
tenantAuth,
tenantPath,
tenantPath,
@@ -481,7 +439,6 @@ module.exports = {
@@ -481,7 +439,6 @@ module.exports = {
optionalIdentity,
optionalIdentity,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
// but its subject is a Network id and must never be used as an app-local user id.
// but its subject is a Network id and must never be used as an app-local user id.
- seedApps,
startJwksRefresh,
startJwksRefresh,
stopJwksRefresh,
stopJwksRefresh,
jwksLoaded,
jwksLoaded,
@@ -170,10 +170,6 @@ const config = {
@@ -170,10 +170,6 @@ const config = {
indexnow: { key: String(process.env.INDEXNOW_KEY || '').trim() },
indexnow: { key: String(process.env.INDEXNOW_KEY || '').trim() },
apps: {
apps: {
- // JSON array: [{app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes}]
- seedJson: process.env.MEDIA_APPS_SEED || '',
- // Fallback short form: "live:key1,games:key2"
- seedKeys: process.env.MEDIA_APP_KEYS || '',
// Developer-project tenants (ADR-014), created on first use by an app token: default quotas.
// Developer-project tenants (ADR-014), created on first use by an app token: default quotas.
// Applied when the tenant is created; an operator can change apps.quota_bytes afterwards.
// Applied when the tenant is created; an operator can change apps.quota_bytes afterwards.
projectQuotaMb: Math.max(1, intEnv('MEDIA_APP_TENANT_QUOTA_MB', 1024)),
projectQuotaMb: Math.max(1, intEnv('MEDIA_APP_TENANT_QUOTA_MB', 1024)),
@@ -64,8 +64,7 @@ const ready = (async () => {
@@ -64,8 +64,7 @@ const ready = (async () => {
try { require('./placement/providers').startHealthLoop(); } catch (err) { console.warn('[Placement] health loop did not start:', err.message); }
try { require('./placement/providers').startHealthLoop(); } catch (err) { console.warn('[Placement] health loop did not start:', err.message); }
}
}
if (!drill.enabled) {
if (!drill.enabled) {
- await auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS
- await auth.ensureTokenOnlyApps(); // tenants reached only with Network service tokens (community)
+ await auth.ensureTokenOnlyApps(); // the first-party tenants, reached only with Network service tokens (C-71)
auth.startJwksRefresh(); // a drill asks Network for nothing (app keys still verify against the copy)
auth.startJwksRefresh(); // a drill asks Network for nothing (app keys still verify against the copy)
}
}
@@ -34,6 +34,15 @@ const crypto = require('crypto');
@@ -34,6 +34,15 @@ const crypto = require('crypto');
auth._setNetworkPublicKeyForTests(keys.publicKey);
auth._setNetworkPublicKeyForTests(keys.publicKey);
await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
await auth.ensureTokenOnlyApps();
await auth.ensureTokenOnlyApps();
+ // C-71: every first-party tenant exists without seeding, and one boot created carries no app key. An existing row
+ // (live, written with a test key just above) is left exactly as it was.
+ for (const id of ['community', 'tools', 'games', 'wiki', 'blog', 'inventory', 'media-hub']) {
+ const row = await db.getApp(id);
+ assert.ok(row, `${id} exists without seeding`);
+ assert.strictEqual(row.api_key_hash, '', `${id} has no app key`);
+ }
+ assert.deepStrictEqual(JSON.parse((await db.getApp('games')).allowed_origins), ['https://openvibe.games', 'https://play.openvibe.games']);
+ assert.notStrictEqual((await db.getApp('live')).api_key_hash, '', 'an existing row is not rewritten');
const PA = `prj_${ids.ulid()}`;
const PA = `prj_${ids.ulid()}`;
const PB = `prj_${ids.ulid()}`;
const PB = `prj_${ids.ulid()}`;
@@ -123,7 +123,6 @@ const { spawnSync, execFileSync } = require('child_process');
@@ -123,7 +123,6 @@ const { spawnSync, execFileSync } = require('child_process');
MEDIA_DRILL: '1', DATABASE_URL: DRILL_URL, DATABASE_DIRECT_URL: '', HOST: '127.0.0.1', PORT: String(port), NODE_ENV: 'production',
MEDIA_DRILL: '1', DATABASE_URL: DRILL_URL, DATABASE_DIRECT_URL: '', HOST: '127.0.0.1', PORT: String(port), NODE_ENV: 'production',
// What the production env file sets; a normal boot would act on all of it.
// What the production env file sets; a normal boot would act on all of it.
EVENTS_URL: 'http://127.0.0.1:9', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40),
EVENTS_URL: 'http://127.0.0.1:9', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40),
- MEDIA_APP_KEYS: `live:${'n'.repeat(40)},games:${'g'.repeat(40)}`,
MEDIA_B2_ENDPOINT: 'https://s3.example.invalid', MEDIA_B2_BUCKET: 'b', MEDIA_B2_KEY_ID: 'id', MEDIA_B2_APP_KEY: 'key',
MEDIA_B2_ENDPOINT: 'https://s3.example.invalid', MEDIA_B2_BUCKET: 'b', MEDIA_B2_KEY_ID: 'id', MEDIA_B2_APP_KEY: 'key',
MEDIA_R2_ENDPOINT: 'https://r2.example.invalid', MEDIA_R2_BUCKET: 'r', MEDIA_R2_ACCESS_KEY_ID: 'id', MEDIA_R2_SECRET_ACCESS_KEY: 'key',
MEDIA_R2_ENDPOINT: 'https://r2.example.invalid', MEDIA_R2_BUCKET: 'r', MEDIA_R2_ACCESS_KEY_ID: 'id', MEDIA_R2_SECRET_ACCESS_KEY: 'key',
// The Host inventory's storage: nothing but the thumbnail listing points at production.
// The Host inventory's storage: nothing but the thumbnail listing points at production.
@@ -212,7 +211,7 @@ const { spawnSync, execFileSync } = require('child_process');
@@ -212,7 +211,7 @@ const { spawnSync, execFileSync } = require('child_process');
assert.deepStrictEqual(drill.blocked, []);
assert.deepStrictEqual(drill.blocked, []);
assert.deepStrictEqual(sinkHits, []);
assert.deepStrictEqual(sinkHits, []);
});
});
- await check('no app seeding: the copy\'s apps are production\'s, untouched by MEDIA_APP_KEYS', async () => {
+ await check('the copy\'s apps are production\'s: a drill boot creates and changes no tenant', async () => {
assert.deepStrictEqual(await counts(), countsBefore);
assert.deepStrictEqual(await counts(), countsBefore);
});
});
@@ -37,11 +37,9 @@ const crypto = require('crypto');
@@ -37,11 +37,9 @@ const crypto = require('crypto');
MEDIA_B2_APP_KEY: S('b2-app-key'),
MEDIA_B2_APP_KEY: S('b2-app-key'),
MEDIA_R2_ACCESS_KEY_ID: S('r2-access-key-id'),
MEDIA_R2_ACCESS_KEY_ID: S('r2-access-key-id'),
MEDIA_R2_SECRET_ACCESS_KEY: S('r2-secret-access-key'),
MEDIA_R2_SECRET_ACCESS_KEY: S('r2-secret-access-key'),
- MEDIA_APP_KEYS: `live:${LIVE_KEY}`,
- MEDIA_APPS_SEED: JSON.stringify([{ app_id: 'games', name: 'Games', api_key: GAMES_KEY, webhook_url: `${CLOSED}/hook`, webhook_secret: S('games-webhook-secret'), allowed_origins: ['https://games.test'] }]),
};
};
const sentinels = new Map([
const sentinels = new Map([
- ...Object.entries(SECRET_ENV).filter(([k]) => !['MEDIA_APP_KEYS', 'MEDIA_APPS_SEED'].includes(k)),
+ ...Object.entries(SECRET_ENV),
['live app key', LIVE_KEY], ['games app key', GAMES_KEY], ['games webhook secret', S('games-webhook-secret')],
['live app key', LIVE_KEY], ['games app key', GAMES_KEY], ['games webhook secret', S('games-webhook-secret')],
['stream key', S('stream-key')], ['rtmp path key', S('rtmp-path-key')],
['stream key', S('stream-key')], ['rtmp path key', S('rtmp-path-key')],
]);
]);
@@ -55,7 +53,7 @@ const crypto = require('crypto');
@@ -55,7 +53,7 @@ const crypto = require('crypto');
// Public by protocol, not secrets: an IndexNow key is served at /<key>.txt for search engines to verify.
// Public by protocol, not secrets: an IndexNow key is served at /<key>.txt for search engines to verify.
const PUBLIC_BY_DESIGN = new Set(['INDEXNOW_KEY']);
const PUBLIC_BY_DESIGN = new Set(['INDEXNOW_KEY']);
const secretish = [...names].filter(n => /SECRET|_KEY$|_KEYS$|KEY_ID$|TOKEN|PASSWORD|PASSWD|CREDENTIAL|_SEED$|PRIVATE/.test(n) && !/_(S|MS|MB|GB|MIN|HOURS|DAYS)$/.test(n) && !PUBLIC_BY_DESIGN.has(n)); // not the numeric settings (MEDIA_UPLOAD_TOKEN_TTL_S)
const secretish = [...names].filter(n => /SECRET|_KEY$|_KEYS$|KEY_ID$|TOKEN|PASSWORD|PASSWD|CREDENTIAL|_SEED$|PRIVATE/.test(n) && !/_(S|MS|MB|GB|MIN|HOURS|DAYS)$/.test(n) && !PUBLIC_BY_DESIGN.has(n)); // not the numeric settings (MEDIA_UPLOAD_TOKEN_TTL_S)
- assert.ok(secretish.length >= 10, `found the secret variables (${secretish})`);
+ assert.ok(secretish.length >= 9, `found the secret variables (${secretish})`); // 9 since the seeding variables went (C-71)
for (const n of secretish) assert.ok(Object.prototype.hasOwnProperty.call(SECRET_ENV, n), `${n} is read by the service: give it a sentinel in test/security-secrets.test.js`);
for (const n of secretish) assert.ok(Object.prototype.hasOwnProperty.call(SECRET_ENV, n), `${n} is read by the service: give it a sentinel in test/security-secrets.test.js`);
}
}
@@ -97,20 +95,16 @@ const crypto = require('crypto');
@@ -97,20 +95,16 @@ const crypto = require('crypto');
});
});
for (const k of ['MEDIA_DRILL', 'PASTES_MOVED_TO', 'EVENTS_PUBLISH']) delete process.env[k];
for (const k of ['MEDIA_DRILL', 'PASTES_MOVED_TO', 'EVENTS_PUBLISH']) delete process.env[k];
- // MEDIA_APP_KEYS is deprecated (T4): it still seeds, but must warn once per boot and never name the key.
- const warnLines = [];
- const realWarn = console.warn;
- console.warn = (...args) => { warnLines.push(args.join(' ')); return realWarn.apply(console, args); };
const { app, server } = await require('../server/index.js').ready;
const { app, server } = await require('../server/index.js').ready;
if (!server.listening) await new Promise((r) => server.once('listening', r));
if (!server.listening) await new Promise((r) => server.once('listening', r));
- console.warn = realWarn;
- const depr = warnLines.filter((l) => l.includes('MEDIA_APP_KEYS is deprecated'));
- assert.strictEqual(depr.length, 1, `MEDIA_APP_KEYS warns once per boot (saw ${depr.length})`);
- assert.ok(depr[0].includes('MEDIA_APPS_SEED') && !depr[0].includes(LIVE_KEY), 'the warning points at MEDIA_APPS_SEED and never names a key');
const auth = require('../server/auth');
const auth = require('../server/auth');
auth._setNetworkPublicKeyForTests(keys.publicKey);
auth._setNetworkPublicKeyForTests(keys.publicKey);
const db = require('../server/db/database');
const db = require('../server/db/database');
- assert.strictEqual((await db.getApp('live')).app_id, 'live', 'MEDIA_APP_KEYS still seeds its app (deprecated, not removed)');
+ // Boot made the first-party tenants token-only (C-71: nothing seeds a key). The app keys these checks use are
+ // fixtures written here, so the redaction checks still cover a key and a webhook secret.
+ assert.strictEqual((await db.getApp('live')).api_key_hash, '', 'boot creates live with no app key');
+ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: LIVE_KEY, allowed_origins: ['https://openvibe.live'] });
+ await db.upsertApp({ app_id: 'games', name: 'Games', api_key: GAMES_KEY, webhook_url: `${CLOSED}/hook`, webhook_secret: S('games-webhook-secret'), allowed_origins: ['https://games.test'] });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
// ── Credentials ──
// ── Credentials ──
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
// - POST /api/v1/:app/vods/:id/ingest/rtmp makes ffmpeg pull an app-supplied rtmp_url, which must name an
// - POST /api/v1/:app/vods/:id/ingest/rtmp makes ffmpeg pull an app-supplied rtmp_url, which must name an
// allow-listed host:port (MEDIA_RTMP_PULL_ALLOW; test/rtmp-allowlist.test.js has the URL grammar).
// allow-listed host:port (MEDIA_RTMP_PULL_ALLOW; test/rtmp-allowlist.test.js has the URL grammar).
// Every other outbound request goes to an operator-configured address (Network, Events, Live's internal
// Every other outbound request goes to an operator-configured address (Network, Events, Live's internal
-// URL, B2/R2, an app's webhook_url from MEDIA_APPS_SEED, IndexNow's fixed api.indexnow.org from
+// URL, B2/R2, an app's operator-set webhook_url row, IndexNow's fixed api.indexnow.org from
// INDEXNOW_KEY). The inventory below fails when a new outbound call appears in server/ until it is
// INDEXNOW_KEY). The inventory below fails when a new outbound call appears in server/ until it is
// classified here, and no route lets a tenant set a webhook URL.
// classified here, and no route lets a tenant set a webhook URL.
// No test here needs the network: literals and internal names are refused before DNS, and DNS answers
// No test here needs the network: literals and internal names are refused before DNS, and DNS answers
@@ -31,7 +31,7 @@ const https = require('https');
@@ -31,7 +31,7 @@ const https = require('https');
'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
'server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations',
'server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations',
'server/user-auth.js': 'Network OAuth (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL); the signing keys come from openvibe-sdk/auth',
'server/user-auth.js': 'Network OAuth (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL); the signing keys come from openvibe-sdk/auth',
- 'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',
+ 'server/webhooks.js': 'an app\'s webhook_url, an operator-set row (no seeding since C-71)',
'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
'server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path',
'server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path',
'server/jobs/pack.js': 'a presigned GET on B2/R2 (operator-configured buckets) for a segment\'s durable copy',
'server/jobs/pack.js': 'a presigned GET on B2/R2 (operator-configured buckets) for a segment\'s durable copy',
@@ -53,7 +53,7 @@ const https = require('https');
@@ -53,7 +53,7 @@ const https = require('https');
for (const f of found) assert.ok(OUTBOUND[f], `${f} makes an outbound request: classify it in test/security-ssrf.test.js (a user-chosen address must go through a guard)`);
for (const f of found) assert.ok(OUTBOUND[f], `${f} makes an outbound request: classify it in test/security-ssrf.test.js (a user-chosen address must go through a guard)`);
for (const f of Object.keys(OUTBOUND)) assert.ok(found.has(f), `${f} no longer makes an outbound request: drop it from the inventory`);
for (const f of Object.keys(OUTBOUND)) assert.ok(found.has(f), `${f} no longer makes an outbound request: drop it from the inventory`);
// Webhook targets are operator configuration: the only SQL that writes an app's webhook_url is
// Webhook targets are operator configuration: the only SQL that writes an app's webhook_url is
- // db.upsertApp, and the only caller of that is app seeding (MEDIA_APPS_SEED / MEDIA_APP_KEYS).
+ // db.upsertApp, which no server code calls since app seeding went (C-71): tests and the smoke script only.
const sqlWriters = [], upserters = [];
const sqlWriters = [], upserters = [];
const scan = (d) => {
const scan = (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
@@ -67,8 +67,7 @@ const https = require('https');
@@ -67,8 +67,7 @@ const https = require('https');
};
};
scan(path.join(ROOT, 'server'));
scan(path.join(ROOT, 'server'));
assert.deepStrictEqual(sqlWriters, ['server/db/database.js'], 'only db.upsertApp writes an app\'s webhook_url');
assert.deepStrictEqual(sqlWriters, ['server/db/database.js'], 'only db.upsertApp writes an app\'s webhook_url');
- assert.deepStrictEqual(upserters, ['server/auth.js'], 'and only app seeding calls it');
- assert.ok(/function seedApps[\s\S]*upsertApp\(/.test(fs.readFileSync(path.join(ROOT, 'server/auth.js'), 'utf8')), 'from seedApps');
+ assert.deepStrictEqual(upserters, [], 'and no server code calls it: app seeding is gone (C-71), so a webhook URL is an operator-set row');
// IndexNow (openvibe-shared/indexnow, wired in server/index.js): the only outbound request made
// IndexNow (openvibe-shared/indexnow, wired in server/index.js): the only outbound request made
// from a dependency. Its endpoint is the fixed api.indexnow.org and host/key are operator config
// from a dependency. Its endpoint is the fixed api.indexnow.org and host/key are operator config
// (MEDIA_PUBLIC_URL / INDEXNOW_KEY) — never a tenant or a user-supplied URL, and no route reaches it.
// (MEDIA_PUBLIC_URL / INDEXNOW_KEY) — never a tenant or a user-supplied URL, and no route reaches it.
@@ -59,7 +59,7 @@ const express = require('express');
@@ -59,7 +59,7 @@ const express = require('express');
const freePort = await new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
const freePort = await new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
const env = { ...process.env, MEDIA_PGLITE_DIR: path.join(tmp, 'pglite'), DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', VOD_PATH: path.join(tmp, 'vods'), CLIPS_PATH: path.join(tmp, 'clips'), PASTES_PATH: path.join(tmp, 'pastes'),
const env = { ...process.env, MEDIA_PGLITE_DIR: path.join(tmp, 'pglite'), DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', VOD_PATH: path.join(tmp, 'vods'), CLIPS_PATH: path.join(tmp, 'clips'), PASTES_PATH: path.join(tmp, 'pastes'),
THUMBNAILS_PATH: path.join(tmp, 'thumbs'), FILES_PATH: path.join(tmp, 'files'), OBJECTS_PATH: path.join(tmp, 'objects'), PORT: String(freePort), HOST: ext ? '0.0.0.0' : '127.0.0.1',
THUMBNAILS_PATH: path.join(tmp, 'thumbs'), FILES_PATH: path.join(tmp, 'files'), OBJECTS_PATH: path.join(tmp, 'objects'), PORT: String(freePort), HOST: ext ? '0.0.0.0' : '127.0.0.1',
- NODE_ENV: 'test', MEDIA_JOBS_ENABLED: '0', MEDIA_VERIFY_ENABLED: '0', MEDIA_OWNER_SUBJECT_SYNC: '0', EVENTS_URL: '', MEDIA_APP_KEYS: 'live:live-key-for-trust-test' };
+ NODE_ENV: 'test', MEDIA_JOBS_ENABLED: '0', MEDIA_VERIFY_ENABLED: '0', MEDIA_OWNER_SUBJECT_SYNC: '0', EVENTS_URL: '' };
const child = spawn(process.execPath, [path.join(__dirname, '../server/index.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [path.join(__dirname, '../server/index.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let log = '';
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stdout.on('data', (d) => { log += d; });