openvibers/openvibe.media Commits

Media: no app-key seeding; every first-party tenant is token-only (C-71) (#86)

OP OpenVibers committed 11 Oct 2026 · 4d5bd72899 · 12 files, +74 −109

This closes compatibility register C-71. Media no longer seeds app keys, every first-party tenant is token-only, and the key hashes still stored are emptied.

**No tenant uses a key any more (verified in production today):**
- Live: a service token since C-70.
- OpenRestream: a service token since C-42 (OpenRestream#54).
- Inventory and MediaHub: service tokens since Inventory#31 and MediaHub#23, with grants from Network#145. Each service's own token, sent from its own checkout with its production env, was accepted by Media (`DELETE` of a missing object answered `404 media.object.not_found`, past the credential and verb checks).
- Community, Tools, Games, Wiki and Blog were token-only already.
- No service's env holds a Media app key now. Live's and OpenRestream's dead `MEDIA_API_KEY` lines and Inventory's and MediaHub's keys were removed, with backups.

**What changes:**
- **Seeding:** `seedApps()` is gone. Media no longer reads `MEDIA_APPS_SEED` or `MEDIA_APP_KEYS` (`server/auth.js`, `server/config.js`, `server/index.js`, `.env.example`, README).
- **Tenants:** `ensureTokenOnlyApps()` covers all eight first-party tenants: `live`, `community`, `tools`, `games`, `wiki`, `blog`, `inventory`, `media-hub`. A missing one is created with no key, its quota and its browser origins. An existing row is left as it is, so production's quotas and origins stand.
- **Migration `0006_first_party_tenants_token_only.sql`** (phase migrate) runs `UPDATE apps SET api_key_hash = '' WHERE project_id IS NULL AND api_key_hash <> ''`:
  - In production it touches five rows: `games`, `inventory`, `live`, `media-hub`, `tools`.
  - An empty hash never matches a presented key (`checkAppKey`), exactly like the tenants that never had one.
  - Developer-project tenants carry no hash already, and re-running it is a no-op.

**Tests:**
- **`app-tenants`:** boot creates the tenants keyless, with the games origins, and leaves an existing row alone.
- **`security-secrets`:** writes its app-key fixtures with `db.upsertApp` after boot instead of through the env, so the redaction checks still cover a key and a webhook secret. Its sanity floor drops from 10 to 9 secret variables.
- **`drill-mode`, `trust-proxy`:** drop the seeding env.
- **`security-ssrf`:** asserts no server code calls `upsertApp`.

**Smoke script:** `scripts/smoke-test.sh` writes its two keyed test tenants into the run's own PGlite before boot. It passes 43 checks here; 5 checks about thumbnail URLs, a paste route and the admin listing fail identically on main.

`ov test`: 91 covering files pass, with `security-secrets` fixed and rerun. After this deploys, `MEDIA_APPS_SEED` is removed from `media.env`.

Changes

ddiff --git a/.env.example b/.env.example
iindex b046137..720cfb8 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex de75b5a..190a86a 100644
---- a/README.md
++++ b/README.md
@@@ -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's
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`)
@@@ -698,8 +697,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
`test/rtmp-allowlist.test.js`); avatar
ingestion fetches through the SSRF guard; other calls go to the configured Network, Live, Events, B2
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
secret visitor hashes are derived from; it falls back to `MEDIA_SECRET`) live in
`/etc/openvibe/media.env` (0600), by name only.
ddiff --git a/migrations/0006_first_party_tenants_token_only.sql b/migrations/0006_first_party_tenants_token_only.sql
nnew file mode 100644
iindex 0000000..9d51e57
---- /dev/null
++++ b/migrations/0006_first_party_tenants_token_only.sql
@@@ -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 <> '';
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
iindex 63ff03c..51fd819 100755
---- a/scripts/smoke-test.sh
++++ b/scripts/smoke-test.sh
@@@ -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=$!
ddiff --git a/server/auth.js b/server/auth.js
iindex 5bc1a74..78396aa 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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,
ddiff --git a/server/config.js b/server/config.js
iindex a302f91..3607671 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)),
ddiff --git a/server/index.js b/server/index.js
iindex e9f10a3..4befe22 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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)
}
ddiff --git a/test/app-tenants.test.js b/test/app-tenants.test.js
iindex fae032b..81e409a 100644
---- a/test/app-tenants.test.js
++++ b/test/app-tenants.test.js
@@@ -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()}`;
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex 60712ad..7d9ad50 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -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);
});
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 22db5e3..609da1c 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -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 ──
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5f57805..16b1a5c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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.
ddiff --git a/test/trust-proxy.test.js b/test/trust-proxy.test.js
iindex 83b8d30..be942a4 100644
---- a/test/trust-proxy.test.js
++++ b/test/trust-proxy.test.js
@@@ -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; });