openvibers/openvibe.live Commits
X-Internal-Key is gone from Live (plan T2, step 3 of the sweep): every internal route takes only a Network service token with the capability it performs (live.avatar.write, live.url_registry.refresh, live.analytics.read, live.chat_mirror.write for Chat's staged-table handoff), loopback only. server/net/internal-key.js and config.internalApiKey are deleted; the proxy rule lives in service-guard.js. Network already sends its token on every call to Live (79a4eea). Tests: the retired key opens nothing (alone or beside a bad token), a token through the public edge is refused, and no server file reads or sends the key. SETUP, README, .env.example and the API docs describe the service token. npm test 151/152, 1 skipped.
Changes
@@@ -55,8 +55,6 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000# OAuth client credentials (client id `live` is seeded in the Network DB)OV_OAUTH_CLIENT_ID=liveOV_OAUTH_CLIENT_SECRET=--# Per-service key for Network's /internal/* endpoints (header X-Internal-Key)--INTERNAL_API_KEY=change-me-to-a-random-secret# OpenVibe.Events (durable events): stream lifecycle is published through Live's outbox with the# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.#EVENTS_URL=http://127.0.0.1:4300@@@ -142,7 +142,7 @@ npm run init-dbnpm run dev```--Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `INTERNAL_API_KEY`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_API_KEY`, `MEDIA_WEBHOOK_SECRET`.++Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_API_KEY`, `MEDIA_WEBHOOK_SECRET`.---@@@ -6,7 +6,7 @@ This document describes the OpenVibe.Live runtime setup, local development, andOpenVibe.Live (port **3000**) is the streaming front — ingest (RTMP / WHIP / WebRTC / JSMPEG), chat, channels, monetization (Vibes), moderation, and the SPA. Two sibling services do heavy lifting:--- **OpenVibe.Network** (`openvibe.network`, port **4000**) — SSO/OAuth2 identity provider (client id `live`), RS256 JWTs, URL registry, notifications, and the network-wide **OpenCoins wallet**. Live calls the wallet server-to-server via `OV_NETWORK_INTERNAL_URL` with `X-Internal-Key`.++- **OpenVibe.Network** (`openvibe.network`, port **4000**) — SSO/OAuth2 identity provider (client id `live`), RS256 JWTs, URL registry, notifications, and the network-wide **OpenCoins wallet**. Live calls the wallet server-to-server via `OV_NETWORK_INTERNAL_URL` with its service token (client credentials).- **OpenVibe.Media** (`openvibe.media`, port **4100**) — owns **VODs, clips, pastes, thumbnails, and file storage** (Media API v1). Live records streams by pointing Media at its ingest (RTMP pull / RTP ports 12000-12199), proxies the SPA's `/api/vods`, `/api/clips`, `/api/pastes`, `/api/thumbnails` calls to it (`server/media-client.js`), 302-redirects file payloads to `MEDIA_PUBLIC_URL`, and receives `vod.ready` / `clip.ready` webhooks at `POST /internal/media-webhook`.Live keeps locally: live.db (users/streams/chat/channel state), Vibes (PayPal tipping/cashout), channel points, comments, stream memories + AI state for Media-hosted vods/clips (`vod_ai_state` / `clip_ai_state`), ephemeral live thumbnails, and the song-request queue.@@@ -43,8 +43,7 @@ cp .env.example .env- `JWT_SECRET` — app-specific secret for Live's own JWT operations.- `OV_NETWORK_URL` — public SSO issuer (default `https://openvibe.network`).- `OV_NETWORK_INTERNAL_URL` — internal Network base (default `http://127.0.0.1:4000`).--- `INTERNAL_API_KEY` — key for Network's `/internal/*` endpoints (header `X-Internal-Key`).--- `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` — OAuth client (`live`).++- `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` — OAuth client (`live`). The same client mints Live's service tokens for Network's `/internal/*` endpoints (client credentials; the shared `INTERNAL_API_KEY` is retired).- `OV_NETWORK_PUBLIC_KEY` — path to the Network RS256 public key (offline JWT verification).- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).@@@ -94,7 +93,6 @@ NODE_ENV=developmentBASE_URL=http://localhost:3000OV_NETWORK_URL=http://localhost:4000OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000--INTERNAL_API_KEY=your-shared-secretOV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pemMEDIA_URL=http://127.0.0.1:4100MEDIA_PUBLIC_URL=http://127.0.0.1:4100@@@ -111,7 +109,7 @@ Network's `local-dev` bootstrap seeds the `live` OAuth client with `http://localOn startup, the server:--- refreshes URL registry values from OpenVibe.Network (needs `OV_NETWORK_INTERNAL_URL` + `INTERNAL_API_KEY`).++- refreshes URL registry values from OpenVibe.Network (needs `OV_NETWORK_INTERNAL_URL` + `OV_OAUTH_CLIENT_SECRET`).- initializes the database and schema.- seeds/refreshes built-in themes (default theme slug: `vibe`).- creates an admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD` if no admin exists (dev defaults `admin` / `changeme123`).@@@ -160,7 +158,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down or `MEDIA_URL`/`MEDIA_API_KEY` are wrong.- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.--- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`INTERNAL_API_KEY` wrong, or the user has no linked Network account.++- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.- **WebRTC fails**: `mediasoup` could not initialize or TURN is not configured.- **RTMP fails to start**: port conflict on `RTMP_PORT`.@@@ -156,7 +156,7 @@<pre><code>openvibe.network /api/admin/streamer/* → OpenVibe.Live /api/admin/*openvibe.network /api/admin/streamer-tts/* → OpenVibe.Live /api/tts/*openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code></pre>--<p>Protected by X-Internal-Key header for server-to-server calls.</p>++<p>Server-to-server calls carry a short-lived service token from openvibe.network, scoped to the one capability each route performs.</p></div><!-- ═══════════════════════════════════════════════════════════ -->@@@ -1073,7 +1073,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre></table><h3>Internal Events API (server-to-server)</h3>--<p>OpenVibe.Live emits events to openvibe.network via internal API. Protected by X-Internal-Key header.</p>++<p>OpenVibe.Live emits events to openvibe.network through its internal API, with a service token scoped to the capability of each call.</p><table class="doc-table"><thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead><tbody>@@@ -160,7 +160,6 @@ function buildConfig(registryValues) {baseUrl,openvibeToolsUrl, // public-facing URL of the SSO provider (e.g. https://openvibe.network)nodeEnv: process.env.NODE_ENV || 'development',-- internalApiKey: process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '',webrtc: {publicUrl: webRtcPublicUrl,},@@@ -607,7 +607,7 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);// Internal (server-to-server) routes — allow openvibe.network to call into this service// Summary numbers for the Network's navigation service (ordering sites by real use).// A service token with live.analytics.read (loopback only); returns totals, never rows.--app.get('/internal/analytics-summary', require('./net/service-guard').guardOrKey('live.analytics.read'), (req, res) => {++app.get('/internal/analytics-summary', require('./net/service-guard').guard('live.analytics.read'), (req, res) => {try {const days = Math.min(parseInt(req.query.days, 10) || 7, 90);const st = analytics.getStats({ days }) || {};@@@ -5,12 +5,11 @@ const router = express.Router();const config = require('../config');const db = require('../db/database');--const { guardOrKey } = require('../net/service-guard');++const { guard } = require('../net/service-guard');// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it--// performs on a Network service token (server/net/service-guard.js). The X-Internal-Key path is kept only while its--// callers move to tokens (plan T2).--const chatTablesGuard = guardOrKey('live.chat_mirror.write'); // OpenVibe.Chat's staged-table handoff (T3 deletes)++// performs on a Network service token (server/net/service-guard.js); nothing else gets in (X-Internal-Key: plan T2).++const chatTablesGuard = guard('live.chat_mirror.write'); // OpenVibe.Chat's staged-table handoff (T3 deletes)// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.function notifyChat(userId) {@@@ -55,7 +54,7 @@ router.post('/chat-tables/:table', chatTablesGuard, async (req, res) => {// Footer site copy is written by OpenVibe.AI for the Network directly (network.site_copy); the// /internal/ai/site-copy fallback that used to live here was retired on 2026-09-23.--router.post('/url-registry/refresh', guardOrKey('live.url_registry.refresh'), async (req, res) => {++router.post('/url-registry/refresh', guard('live.url_registry.refresh'), async (req, res) => {try {await config.refreshRegistry();console.log('[Internal] URL registry refresh requested');@@@ -73,7 +72,7 @@ router.post('/url-registry/refresh', guardOrKey('live.url_registry.refresh'), as// ── The account's avatar changed on the Network (or on another site) ─────────// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its// own user row because every stream card, chat line and profile reads it locally.--router.post('/user-avatar', guardOrKey('live.avatar.write'), (req, res) => {++router.post('/user-avatar', guard('live.avatar.write'), (req, res) => {try {const { username, openvibenetwork_id, avatar_url } = req.body || {};let url = null;ddeleted file mode 100644@@@ -1,33 +0,0 @@--'use strict';--/**-- * The trust rule for Live's internal (server-to-server) routes, in one place.-- *-- * viaProxy(req) the request came through nginx or Cloudflare: nginx adds X-Forwarded-For /-- * X-Real-IP to everything from outside (Cloudflare adds CF-Connecting-IP), and-- * loopback callers never send them. Internal routes refuse such a request.-- * internalKeyMatches(k) timing-safe comparison with INTERNAL_API_KEY (both sides hashed first, so-- * neither the content nor the length leaks through timing).-- * internalKeyOk(req) both: loopback and the right X-Internal-Key. Only service-guard.js guardOrKey-- * uses it, for callers still moving to service tokens (plan T2 deletes it).-- */--const crypto = require('crypto');----function viaProxy(req) {-- const h = (req && req.headers) || {};-- return !!(h['x-forwarded-for'] || h['x-real-ip'] || h['cf-connecting-ip']);--}----const digest = (v) => crypto.createHash('sha256').update(String(v)).digest();----function internalKeyMatches(given) {-- const key = String(require('../config').internalApiKey || '');-- const presented = String(given || '');-- if (!key || !presented) return false;-- return crypto.timingSafeEqual(digest(presented), digest(key));--}----function internalKeyOk(req) {-- return !viaProxy(req) && internalKeyMatches(req && req.headers && req.headers['x-internal-key']);--}----module.exports = { viaProxy, internalKeyMatches, internalKeyOk };@@@ -9,12 +9,18 @@** The capability ids are registered in openvibe-contracts (checked with capabilities.check()); an* id the pinned registry does not know yet is checked as a plain grant with capabilities.grants()-- * until the pin moves. Like X-Internal-Key routes, these are loopback-only: anything that came-- * through nginx is refused.++ * until the pin moves. Loopback only: anything that came through nginx is refused. X-Internal-Key is++ * gone (plan T2): every internal route is guard(capability).*/const { serviceAuth, capabilities, http } = require('openvibe-contracts');const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');--const { viaProxy } = require('./internal-key');++++/** The request came through nginx or Cloudflare: nginx adds X-Forwarded-For / X-Real-IP to everything from outside++ * (Cloudflare adds CF-Connecting-IP), and loopback callers never send them. Internal routes refuse such a request. */++function viaProxy(req) {++ const h = (req && req.headers) || {};++ return !!(h['x-forwarded-for'] || h['x-real-ip'] || h['cf-connecting-ip']);++}const AUDIENCE = 'openvibe.live';@@@ -38,18 +44,4 @@ function guard(capability) {};}--/**-- * While X-Internal-Key is retired (plan T2): a request that presents a Bearer is judged on the token alone (never-- * downgraded to the key); one without a Bearer still passes with the key from loopback. Callers move to tokens, then-- * every route becomes guard(capability) and the key goes.-- */--function guardOrKey(capability) {-- const tokenGuard = guard(capability);-- return function liveServiceGuardOrKey(req, res, next) {-- if (String(req.headers.authorization || '').startsWith('Bearer ')) return tokenGuard(req, res, next);-- if (require('./internal-key').internalKeyOk(req)) return next();-- return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: http.requestContext(req.headers) });-- };--}----module.exports = { guard, guardOrKey, AUDIENCE };++module.exports = { guard, viaProxy, AUDIENCE };@@@ -28,7 +28,6 @@ process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');process.env.OV_NETWORK_URL = 'https://openvibe.network';process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--process.env.INTERNAL_API_KEY = 'internal-test-key';process.env.CHAT_AUTHORITY = 'chat';const quiet = console.log;console.log = () => {};@@@ -183,8 +182,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));let json = null; try { json = JSON.parse(text); } catch { /* */ }return { status: res.status, body: json, text };};-- const KEY = { 'X-Internal-Key': 'internal-test-key' };-- const internal = (method, p, body) => call(method, `/internal${p}`, { body, headers: KEY });++ // OpenVibe.Chat's token for the staged-table handoff (live.chat_mirror.write); X-Internal-Key is gone (plan T2).++ const CHAT = () => ({ Authorization: `Bearer ${serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.live'], cap: ['live.chat_mirror.write'], iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey)}` });++ const internal = (method, p, body) => call(method, `/internal${p}`, { body, headers: CHAT() });const MIRROR = serviceToken(['live.chat_mirror.write'], 'openvibe.live');const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { body: { changes }, headers: { Authorization: `Bearer ${MIRROR}` } });@@@ -285,9 +285,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual(stats('channel_moderation_settings').compared, 2);assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);-- // 6. The status route: loopback + internal key; both sides' view of every table.++ // 6. The status route: loopback + a service token (live.chat_mirror.write); both sides' view of every table.assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 401, 'no credentials');-- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...KEY, 'X-Forwarded-For': '203.0.113.1' } })).status, 401, 'the key through the public edge');++ assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...CHAT(), 'X-Forwarded-For': '203.0.113.1' } })).status, 403, 'a token through the public edge');++ assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { 'X-Internal-Key': 'k'.repeat(40) } })).status, 401, 'the retired key opens nothing');let s = (await internal('GET', '/chat-tables')).body;assert.strictEqual(s.chat_authority, 'chat');assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);@@@ -1,11 +1,11 @@'use strict';--// Live's internal (server-to-server) routes, during the X-Internal-Key retirement (plan T2):++// Live's internal (server-to-server) routes after the X-Internal-Key retirement (plan T2):// - each route checks the one capability it performs on a Network service token (server/net/service-guard.js);--// - a request that presents a Bearer is judged on the token alone, never downgraded to the key;--// - without a Bearer, the key still passes from loopback while callers move (guardOrKey), compared in constant time;--// - nothing that came through nginx or Cloudflare gets in, with a token or the key;--// - POST /api/cosmetics/internal-unlock (the openvibe-quest bridge) is gone: the quest game runs nowhere.++// - the retired key opens nothing, alone or next to a bad token;++// - nothing that came through nginx or Cloudflare gets in;++// - POST /api/cosmetics/internal-unlock (the openvibe-quest bridge) is gone: the quest game runs nowhere;++// - no server file reads INTERNAL_API_KEY any more.const assert = require('assert');const crypto = require('crypto');@@@ -21,7 +21,7 @@ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);process.env.DB_PATH = path.join(tmp, 'live.db');process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');process.env.OV_NETWORK_URL = 'https://openvibe.network';--const KEY = 'k'.repeat(40);++const KEY = 'k'.repeat(40); // what an old caller would send: it must open nothingprocess.env.INTERNAL_API_KEY = KEY;console.log = () => {};console.warn = () => {};@@@ -58,15 +58,13 @@ const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) =>assert.strictEqual((await avatar(bearer(token(['live.url_registry.refresh'])))).status, 403, 'another capability is not enough');assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { aud: 'openvibe.network' })))).status, 401, 'a token for another audience');assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { env: 'sandbox' })))).status, 401, 'a sandbox token');-- assert.strictEqual((await avatar({ ...bearer('not.a.token'), 'x-internal-key': KEY })).status, 401, 'a Bearer is judged on the token alone, never downgraded to the key');++ assert.strictEqual((await avatar({ ...bearer('not.a.token'), 'x-internal-key': KEY })).status, 401, 'a bad token is not rescued by the key');assert.strictEqual((await avatar({ ...bearer(token(['live.avatar.write'])), 'x-forwarded-for': '203.0.113.9' })).status, 403, 'a token through the public edge is refused');assert.strictEqual((await avatar({})).status, 401);-- // The key while callers move: loopback only, constant time.-- assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 404, 'the key from loopback still reaches the handler');-- assert.strictEqual((await avatar({ 'x-internal-key': 'j'.repeat(40) })).status, 401, 'a wrong key of the right length');-- assert.strictEqual((await avatar({ 'x-internal-key': KEY + 'x' })).status, 401, 'a longer key that starts right');++ // The retired key opens nothing.++ assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 401, 'the key alone is refused');for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {-- assert.strictEqual((await avatar({ 'x-internal-key': KEY, [h]: '203.0.113.9' })).status, 401, `the right key through the public edge (${h}) is refused`);++ assert.strictEqual((await avatar({ ...bearer(token(['live.avatar.write'])), [h]: '203.0.113.9' })).status, 403, `a token through the public edge (${h}) is refused`);}// ── the other internal routes: each has its own capability ───────────────────@@@ -77,25 +75,20 @@ const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) =>assert.notStrictEqual((await tables(bearer(token(['live.chat_mirror.write'], { sub: 'svc:chat' })))).status, 403, 'Chat opens the staged-table handoff with live.chat_mirror.write');assert.strictEqual((await tables({})).status, 401);-- const ik = require('../server/net/internal-key');-- assert.strictEqual(ik.internalKeyMatches(KEY), true);-- assert.strictEqual(ik.internalKeyMatches(''), false);-- assert.strictEqual(ik.internalKeyMatches(KEY.slice(0, 39)), false);---- // No internal-key compare with === / !== anywhere in the server.++ // No server file reads or sends the retired key.const offenders = [];const walk = (dir) => {for (const e of fs.readdirSync(dir, { withFileTypes: true })) {const p = path.join(dir, e.name);if (e.isDirectory()) walk(p);else if (e.name.endsWith('.js')) {-- const src = fs.readFileSync(p, 'utf8');-- if (/[!=]==\s*(config\.internalApiKey|INTERNAL_API_KEY)\b|\b(config\.internalApiKey|INTERNAL_API_KEY)\s*[!=]==/.test(src)) offenders.push(path.relative(path.join(__dirname, '..'), p));++ const src = fs.readFileSync(p, 'utf8').replace(/^\s*(\/\/|\*).*$/gm, ''); // code, not comments++ if (/INTERNAL_API_KEY|OV_INTERNAL_KEY|internalApiKey|x-internal-key/i.test(src)) offenders.push(path.relative(path.join(__dirname, '..'), p));}}};walk(path.join(__dirname, '..', 'server'));-- assert.deepStrictEqual(offenders, [], 'internal keys are compared with internalKeyMatches (constant time)');++ assert.deepStrictEqual(offenders, [], 'no server file reads or sends X-Internal-Key');process.stdout.write('live internal routes: all checks passed\n');} finally {server.close();
@@ -55,8 +55,6 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -55,8 +55,6 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# OAuth client credentials (client id `live` is seeded in the Network DB)
# OAuth client credentials (client id `live` is seeded in the Network DB)
OV_OAUTH_CLIENT_ID=live
OV_OAUTH_CLIENT_ID=live
OV_OAUTH_CLIENT_SECRET=
OV_OAUTH_CLIENT_SECRET=
-# Per-service key for Network's /internal/* endpoints (header X-Internal-Key)
-INTERNAL_API_KEY=change-me-to-a-random-secret
# OpenVibe.Events (durable events): stream lifecycle is published through Live's outbox with the
# OpenVibe.Events (durable events): stream lifecycle is published through Live's outbox with the
# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.
# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_URL=http://127.0.0.1:4300
@@ -142,7 +142,7 @@ npm run init-db
@@ -142,7 +142,7 @@ npm run init-db
npm run dev
npm run dev
```
```
-Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `INTERNAL_API_KEY`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_API_KEY`, `MEDIA_WEBHOOK_SECRET`.
+Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_API_KEY`, `MEDIA_WEBHOOK_SECRET`.
---
---
@@ -6,7 +6,7 @@ This document describes the OpenVibe.Live runtime setup, local development, and
@@ -6,7 +6,7 @@ This document describes the OpenVibe.Live runtime setup, local development, and
OpenVibe.Live (port **3000**) is the streaming front — ingest (RTMP / WHIP / WebRTC / JSMPEG), chat, channels, monetization (Vibes), moderation, and the SPA. Two sibling services do heavy lifting:
OpenVibe.Live (port **3000**) is the streaming front — ingest (RTMP / WHIP / WebRTC / JSMPEG), chat, channels, monetization (Vibes), moderation, and the SPA. Two sibling services do heavy lifting:
-- **OpenVibe.Network** (`openvibe.network`, port **4000**) — SSO/OAuth2 identity provider (client id `live`), RS256 JWTs, URL registry, notifications, and the network-wide **OpenCoins wallet**. Live calls the wallet server-to-server via `OV_NETWORK_INTERNAL_URL` with `X-Internal-Key`.
+- **OpenVibe.Network** (`openvibe.network`, port **4000**) — SSO/OAuth2 identity provider (client id `live`), RS256 JWTs, URL registry, notifications, and the network-wide **OpenCoins wallet**. Live calls the wallet server-to-server via `OV_NETWORK_INTERNAL_URL` with its service token (client credentials).
- **OpenVibe.Media** (`openvibe.media`, port **4100**) — owns **VODs, clips, pastes, thumbnails, and file storage** (Media API v1). Live records streams by pointing Media at its ingest (RTMP pull / RTP ports 12000-12199), proxies the SPA's `/api/vods`, `/api/clips`, `/api/pastes`, `/api/thumbnails` calls to it (`server/media-client.js`), 302-redirects file payloads to `MEDIA_PUBLIC_URL`, and receives `vod.ready` / `clip.ready` webhooks at `POST /internal/media-webhook`.
- **OpenVibe.Media** (`openvibe.media`, port **4100**) — owns **VODs, clips, pastes, thumbnails, and file storage** (Media API v1). Live records streams by pointing Media at its ingest (RTMP pull / RTP ports 12000-12199), proxies the SPA's `/api/vods`, `/api/clips`, `/api/pastes`, `/api/thumbnails` calls to it (`server/media-client.js`), 302-redirects file payloads to `MEDIA_PUBLIC_URL`, and receives `vod.ready` / `clip.ready` webhooks at `POST /internal/media-webhook`.
Live keeps locally: live.db (users/streams/chat/channel state), Vibes (PayPal tipping/cashout), channel points, comments, stream memories + AI state for Media-hosted vods/clips (`vod_ai_state` / `clip_ai_state`), ephemeral live thumbnails, and the song-request queue.
Live keeps locally: live.db (users/streams/chat/channel state), Vibes (PayPal tipping/cashout), channel points, comments, stream memories + AI state for Media-hosted vods/clips (`vod_ai_state` / `clip_ai_state`), ephemeral live thumbnails, and the song-request queue.
@@ -43,8 +43,7 @@ cp .env.example .env
@@ -43,8 +43,7 @@ cp .env.example .env
- `JWT_SECRET` — app-specific secret for Live's own JWT operations.
- `JWT_SECRET` — app-specific secret for Live's own JWT operations.
- `OV_NETWORK_URL` — public SSO issuer (default `https://openvibe.network`).
- `OV_NETWORK_URL` — public SSO issuer (default `https://openvibe.network`).
- `OV_NETWORK_INTERNAL_URL` — internal Network base (default `http://127.0.0.1:4000`).
- `OV_NETWORK_INTERNAL_URL` — internal Network base (default `http://127.0.0.1:4000`).
-- `INTERNAL_API_KEY` — key for Network's `/internal/*` endpoints (header `X-Internal-Key`).
-- `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` — OAuth client (`live`).
+- `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` — OAuth client (`live`). The same client mints Live's service tokens for Network's `/internal/*` endpoints (client credentials; the shared `INTERNAL_API_KEY` is retired).
- `OV_NETWORK_PUBLIC_KEY` — path to the Network RS256 public key (offline JWT verification).
- `OV_NETWORK_PUBLIC_KEY` — path to the Network RS256 public key (offline JWT verification).
- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).
- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).
- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).
- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).
@@ -94,7 +93,6 @@ NODE_ENV=development
@@ -94,7 +93,6 @@ NODE_ENV=development
BASE_URL=http://localhost:3000
BASE_URL=http://localhost:3000
OV_NETWORK_URL=http://localhost:4000
OV_NETWORK_URL=http://localhost:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
-INTERNAL_API_KEY=your-shared-secret
OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
@@ -111,7 +109,7 @@ Network's `local-dev` bootstrap seeds the `live` OAuth client with `http://local
@@ -111,7 +109,7 @@ Network's `local-dev` bootstrap seeds the `live` OAuth client with `http://local
On startup, the server:
On startup, the server:
-- refreshes URL registry values from OpenVibe.Network (needs `OV_NETWORK_INTERNAL_URL` + `INTERNAL_API_KEY`).
+- refreshes URL registry values from OpenVibe.Network (needs `OV_NETWORK_INTERNAL_URL` + `OV_OAUTH_CLIENT_SECRET`).
- initializes the database and schema.
- initializes the database and schema.
- seeds/refreshes built-in themes (default theme slug: `vibe`).
- seeds/refreshes built-in themes (default theme slug: `vibe`).
- creates an admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD` if no admin exists (dev defaults `admin` / `changeme123`).
- creates an admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD` if no admin exists (dev defaults `admin` / `changeme123`).
@@ -160,7 +158,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
@@ -160,7 +158,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.
- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.
- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down or `MEDIA_URL`/`MEDIA_API_KEY` are wrong.
- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down or `MEDIA_URL`/`MEDIA_API_KEY` are wrong.
- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.
- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.
-- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`INTERNAL_API_KEY` wrong, or the user has no linked Network account.
+- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.
- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.
- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.
- **WebRTC fails**: `mediasoup` could not initialize or TURN is not configured.
- **WebRTC fails**: `mediasoup` could not initialize or TURN is not configured.
- **RTMP fails to start**: port conflict on `RTMP_PORT`.
- **RTMP fails to start**: port conflict on `RTMP_PORT`.
@@ -156,7 +156,7 @@
@@ -156,7 +156,7 @@
<pre><code>openvibe.network /api/admin/streamer/* → OpenVibe.Live /api/admin/*
<pre><code>openvibe.network /api/admin/streamer/* → OpenVibe.Live /api/admin/*
openvibe.network /api/admin/streamer-tts/* → OpenVibe.Live /api/tts/*
openvibe.network /api/admin/streamer-tts/* → OpenVibe.Live /api/tts/*
openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code></pre>
openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code></pre>
-<p>Protected by X-Internal-Key header for server-to-server calls.</p>
+<p>Server-to-server calls carry a short-lived service token from openvibe.network, scoped to the one capability each route performs.</p>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
@@ -1073,7 +1073,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -1073,7 +1073,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
</table>
</table>
<h3>Internal Events API (server-to-server)</h3>
<h3>Internal Events API (server-to-server)</h3>
-<p>OpenVibe.Live emits events to openvibe.network via internal API. Protected by X-Internal-Key header.</p>
+<p>OpenVibe.Live emits events to openvibe.network through its internal API, with a service token scoped to the capability of each call.</p>
<table class="doc-table">
<table class="doc-table">
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
<tbody>
<tbody>
@@ -160,7 +160,6 @@ function buildConfig(registryValues) {
@@ -160,7 +160,6 @@ function buildConfig(registryValues) {
baseUrl,
baseUrl,
openvibeToolsUrl, // public-facing URL of the SSO provider (e.g. https://openvibe.network)
openvibeToolsUrl, // public-facing URL of the SSO provider (e.g. https://openvibe.network)
nodeEnv: process.env.NODE_ENV || 'development',
nodeEnv: process.env.NODE_ENV || 'development',
- internalApiKey: process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '',
webrtc: {
webrtc: {
publicUrl: webRtcPublicUrl,
publicUrl: webRtcPublicUrl,
},
},
@@ -607,7 +607,7 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
@@ -607,7 +607,7 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
// Internal (server-to-server) routes — allow openvibe.network to call into this service
// Internal (server-to-server) routes — allow openvibe.network to call into this service
// Summary numbers for the Network's navigation service (ordering sites by real use).
// Summary numbers for the Network's navigation service (ordering sites by real use).
// A service token with live.analytics.read (loopback only); returns totals, never rows.
// A service token with live.analytics.read (loopback only); returns totals, never rows.
-app.get('/internal/analytics-summary', require('./net/service-guard').guardOrKey('live.analytics.read'), (req, res) => {
+app.get('/internal/analytics-summary', require('./net/service-guard').guard('live.analytics.read'), (req, res) => {
try {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const st = analytics.getStats({ days }) || {};
const st = analytics.getStats({ days }) || {};
@@ -5,12 +5,11 @@ const router = express.Router();
@@ -5,12 +5,11 @@ const router = express.Router();
const config = require('../config');
const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
-const { guardOrKey } = require('../net/service-guard');
+const { guard } = require('../net/service-guard');
// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it
// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it
-// performs on a Network service token (server/net/service-guard.js). The X-Internal-Key path is kept only while its
-// callers move to tokens (plan T2).
-const chatTablesGuard = guardOrKey('live.chat_mirror.write'); // OpenVibe.Chat's staged-table handoff (T3 deletes)
+// performs on a Network service token (server/net/service-guard.js); nothing else gets in (X-Internal-Key: plan T2).
+const chatTablesGuard = guard('live.chat_mirror.write'); // OpenVibe.Chat's staged-table handoff (T3 deletes)
// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
function notifyChat(userId) {
function notifyChat(userId) {
@@ -55,7 +54,7 @@ router.post('/chat-tables/:table', chatTablesGuard, async (req, res) => {
@@ -55,7 +54,7 @@ router.post('/chat-tables/:table', chatTablesGuard, async (req, res) => {
// Footer site copy is written by OpenVibe.AI for the Network directly (network.site_copy); the
// Footer site copy is written by OpenVibe.AI for the Network directly (network.site_copy); the
// /internal/ai/site-copy fallback that used to live here was retired on 2026-09-23.
// /internal/ai/site-copy fallback that used to live here was retired on 2026-09-23.
-router.post('/url-registry/refresh', guardOrKey('live.url_registry.refresh'), async (req, res) => {
+router.post('/url-registry/refresh', guard('live.url_registry.refresh'), async (req, res) => {
try {
try {
await config.refreshRegistry();
await config.refreshRegistry();
console.log('[Internal] URL registry refresh requested');
console.log('[Internal] URL registry refresh requested');
@@ -73,7 +72,7 @@ router.post('/url-registry/refresh', guardOrKey('live.url_registry.refresh'), as
@@ -73,7 +72,7 @@ router.post('/url-registry/refresh', guardOrKey('live.url_registry.refresh'), as
// ── The account's avatar changed on the Network (or on another site) ─────────
// ── The account's avatar changed on the Network (or on another site) ─────────
// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its
// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its
// own user row because every stream card, chat line and profile reads it locally.
// own user row because every stream card, chat line and profile reads it locally.
-router.post('/user-avatar', guardOrKey('live.avatar.write'), (req, res) => {
+router.post('/user-avatar', guard('live.avatar.write'), (req, res) => {
try {
try {
const { username, openvibenetwork_id, avatar_url } = req.body || {};
const { username, openvibenetwork_id, avatar_url } = req.body || {};
let url = null;
let url = null;
deleted file mode 100644
deleted file mode 100644
@@ -1,33 +0,0 @@
@@ -1,33 +0,0 @@
-'use strict';
-/**
- * The trust rule for Live's internal (server-to-server) routes, in one place.
- *
- * viaProxy(req) the request came through nginx or Cloudflare: nginx adds X-Forwarded-For /
- * X-Real-IP to everything from outside (Cloudflare adds CF-Connecting-IP), and
- * loopback callers never send them. Internal routes refuse such a request.
- * internalKeyMatches(k) timing-safe comparison with INTERNAL_API_KEY (both sides hashed first, so
- * neither the content nor the length leaks through timing).
- * internalKeyOk(req) both: loopback and the right X-Internal-Key. Only service-guard.js guardOrKey
- * uses it, for callers still moving to service tokens (plan T2 deletes it).
- */
-const crypto = require('crypto');
-
-function viaProxy(req) {
- const h = (req && req.headers) || {};
- return !!(h['x-forwarded-for'] || h['x-real-ip'] || h['cf-connecting-ip']);
-}
-
-const digest = (v) => crypto.createHash('sha256').update(String(v)).digest();
-
-function internalKeyMatches(given) {
- const key = String(require('../config').internalApiKey || '');
- const presented = String(given || '');
- if (!key || !presented) return false;
- return crypto.timingSafeEqual(digest(presented), digest(key));
-}
-
-function internalKeyOk(req) {
- return !viaProxy(req) && internalKeyMatches(req && req.headers && req.headers['x-internal-key']);
-}
-
-module.exports = { viaProxy, internalKeyMatches, internalKeyOk };
@@ -9,12 +9,18 @@
@@ -9,12 +9,18 @@
*
*
* The capability ids are registered in openvibe-contracts (checked with capabilities.check()); an
* The capability ids are registered in openvibe-contracts (checked with capabilities.check()); an
* id the pinned registry does not know yet is checked as a plain grant with capabilities.grants()
* id the pinned registry does not know yet is checked as a plain grant with capabilities.grants()
- * until the pin moves. Like X-Internal-Key routes, these are loopback-only: anything that came
- * through nginx is refused.
+ * until the pin moves. Loopback only: anything that came through nginx is refused. X-Internal-Key is
+ * gone (plan T2): every internal route is guard(capability).
*/
*/
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
-const { viaProxy } = require('./internal-key');
+
+/** The request came through nginx or Cloudflare: nginx adds X-Forwarded-For / X-Real-IP to everything from outside
+ * (Cloudflare adds CF-Connecting-IP), and loopback callers never send them. Internal routes refuse such a request. */
+function viaProxy(req) {
+ const h = (req && req.headers) || {};
+ return !!(h['x-forwarded-for'] || h['x-real-ip'] || h['cf-connecting-ip']);
+}
const AUDIENCE = 'openvibe.live';
const AUDIENCE = 'openvibe.live';
@@ -38,18 +44,4 @@ function guard(capability) {
@@ -38,18 +44,4 @@ function guard(capability) {
};
};
}
}
-/**
- * While X-Internal-Key is retired (plan T2): a request that presents a Bearer is judged on the token alone (never
- * downgraded to the key); one without a Bearer still passes with the key from loopback. Callers move to tokens, then
- * every route becomes guard(capability) and the key goes.
- */
-function guardOrKey(capability) {
- const tokenGuard = guard(capability);
- return function liveServiceGuardOrKey(req, res, next) {
- if (String(req.headers.authorization || '').startsWith('Bearer ')) return tokenGuard(req, res, next);
- if (require('./internal-key').internalKeyOk(req)) return next();
- return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: http.requestContext(req.headers) });
- };
-}
-
-module.exports = { guard, guardOrKey, AUDIENCE };
+module.exports = { guard, viaProxy, AUDIENCE };
@@ -28,7 +28,6 @@ process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
@@ -28,7 +28,6 @@ process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-process.env.INTERNAL_API_KEY = 'internal-test-key';
process.env.CHAT_AUTHORITY = 'chat';
process.env.CHAT_AUTHORITY = 'chat';
const quiet = console.log;
const quiet = console.log;
console.log = () => {};
console.log = () => {};
@@ -183,8 +182,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -183,8 +182,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let json = null; try { json = JSON.parse(text); } catch { /* */ }
let json = null; try { json = JSON.parse(text); } catch { /* */ }
return { status: res.status, body: json, text };
return { status: res.status, body: json, text };
};
};
- const KEY = { 'X-Internal-Key': 'internal-test-key' };
- const internal = (method, p, body) => call(method, `/internal${p}`, { body, headers: KEY });
+ // OpenVibe.Chat's token for the staged-table handoff (live.chat_mirror.write); X-Internal-Key is gone (plan T2).
+ const CHAT = () => ({ Authorization: `Bearer ${serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.live'], cap: ['live.chat_mirror.write'], iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey)}` });
+ const internal = (method, p, body) => call(method, `/internal${p}`, { body, headers: CHAT() });
const MIRROR = serviceToken(['live.chat_mirror.write'], 'openvibe.live');
const MIRROR = serviceToken(['live.chat_mirror.write'], 'openvibe.live');
const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { body: { changes }, headers: { Authorization: `Bearer ${MIRROR}` } });
const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { body: { changes }, headers: { Authorization: `Bearer ${MIRROR}` } });
@@ -285,9 +285,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -285,9 +285,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual(stats('channel_moderation_settings').compared, 2);
assert.strictEqual(stats('channel_moderation_settings').compared, 2);
assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);
assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);
- // 6. The status route: loopback + internal key; both sides' view of every table.
+ // 6. The status route: loopback + a service token (live.chat_mirror.write); both sides' view of every table.
assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 401, 'no credentials');
assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 401, 'no credentials');
- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...KEY, 'X-Forwarded-For': '203.0.113.1' } })).status, 401, 'the key through the public edge');
+ assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...CHAT(), 'X-Forwarded-For': '203.0.113.1' } })).status, 403, 'a token through the public edge');
+ assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { 'X-Internal-Key': 'k'.repeat(40) } })).status, 401, 'the retired key opens nothing');
let s = (await internal('GET', '/chat-tables')).body;
let s = (await internal('GET', '/chat-tables')).body;
assert.strictEqual(s.chat_authority, 'chat');
assert.strictEqual(s.chat_authority, 'chat');
assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);
assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
'use strict';
'use strict';
-// Live's internal (server-to-server) routes, during the X-Internal-Key retirement (plan T2):
+// Live's internal (server-to-server) routes after the X-Internal-Key retirement (plan T2):
// - each route checks the one capability it performs on a Network service token (server/net/service-guard.js);
// - each route checks the one capability it performs on a Network service token (server/net/service-guard.js);
-// - a request that presents a Bearer is judged on the token alone, never downgraded to the key;
-// - without a Bearer, the key still passes from loopback while callers move (guardOrKey), compared in constant time;
-// - nothing that came through nginx or Cloudflare gets in, with a token or the key;
-// - POST /api/cosmetics/internal-unlock (the openvibe-quest bridge) is gone: the quest game runs nowhere.
+// - the retired key opens nothing, alone or next to a bad token;
+// - nothing that came through nginx or Cloudflare gets in;
+// - POST /api/cosmetics/internal-unlock (the openvibe-quest bridge) is gone: the quest game runs nowhere;
+// - no server file reads INTERNAL_API_KEY any more.
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -21,7 +21,7 @@ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
@@ -21,7 +21,7 @@ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_NETWORK_URL = 'https://openvibe.network';
-const KEY = 'k'.repeat(40);
+const KEY = 'k'.repeat(40); // what an old caller would send: it must open nothing
process.env.INTERNAL_API_KEY = KEY;
process.env.INTERNAL_API_KEY = KEY;
console.log = () => {};
console.log = () => {};
console.warn = () => {};
console.warn = () => {};
@@ -58,15 +58,13 @@ const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) =>
@@ -58,15 +58,13 @@ const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) =>
assert.strictEqual((await avatar(bearer(token(['live.url_registry.refresh'])))).status, 403, 'another capability is not enough');
assert.strictEqual((await avatar(bearer(token(['live.url_registry.refresh'])))).status, 403, 'another capability is not enough');
assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { aud: 'openvibe.network' })))).status, 401, 'a token for another audience');
assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { aud: 'openvibe.network' })))).status, 401, 'a token for another audience');
assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { env: 'sandbox' })))).status, 401, 'a sandbox token');
assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { env: 'sandbox' })))).status, 401, 'a sandbox token');
- assert.strictEqual((await avatar({ ...bearer('not.a.token'), 'x-internal-key': KEY })).status, 401, 'a Bearer is judged on the token alone, never downgraded to the key');
+ assert.strictEqual((await avatar({ ...bearer('not.a.token'), 'x-internal-key': KEY })).status, 401, 'a bad token is not rescued by the key');
assert.strictEqual((await avatar({ ...bearer(token(['live.avatar.write'])), 'x-forwarded-for': '203.0.113.9' })).status, 403, 'a token through the public edge is refused');
assert.strictEqual((await avatar({ ...bearer(token(['live.avatar.write'])), 'x-forwarded-for': '203.0.113.9' })).status, 403, 'a token through the public edge is refused');
assert.strictEqual((await avatar({})).status, 401);
assert.strictEqual((await avatar({})).status, 401);
- // The key while callers move: loopback only, constant time.
- assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 404, 'the key from loopback still reaches the handler');
- assert.strictEqual((await avatar({ 'x-internal-key': 'j'.repeat(40) })).status, 401, 'a wrong key of the right length');
- assert.strictEqual((await avatar({ 'x-internal-key': KEY + 'x' })).status, 401, 'a longer key that starts right');
+ // The retired key opens nothing.
+ assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 401, 'the key alone is refused');
for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {
for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {
- assert.strictEqual((await avatar({ 'x-internal-key': KEY, [h]: '203.0.113.9' })).status, 401, `the right key through the public edge (${h}) is refused`);
+ assert.strictEqual((await avatar({ ...bearer(token(['live.avatar.write'])), [h]: '203.0.113.9' })).status, 403, `a token through the public edge (${h}) is refused`);
}
}
// ── the other internal routes: each has its own capability ───────────────────
// ── the other internal routes: each has its own capability ───────────────────
@@ -77,25 +75,20 @@ const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) =>
@@ -77,25 +75,20 @@ const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) =>
assert.notStrictEqual((await tables(bearer(token(['live.chat_mirror.write'], { sub: 'svc:chat' })))).status, 403, 'Chat opens the staged-table handoff with live.chat_mirror.write');
assert.notStrictEqual((await tables(bearer(token(['live.chat_mirror.write'], { sub: 'svc:chat' })))).status, 403, 'Chat opens the staged-table handoff with live.chat_mirror.write');
assert.strictEqual((await tables({})).status, 401);
assert.strictEqual((await tables({})).status, 401);
- const ik = require('../server/net/internal-key');
- assert.strictEqual(ik.internalKeyMatches(KEY), true);
- assert.strictEqual(ik.internalKeyMatches(''), false);
- assert.strictEqual(ik.internalKeyMatches(KEY.slice(0, 39)), false);
-
- // No internal-key compare with === / !== anywhere in the server.
+ // No server file reads or sends the retired key.
const offenders = [];
const offenders = [];
const walk = (dir) => {
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.js')) {
else if (e.name.endsWith('.js')) {
- const src = fs.readFileSync(p, 'utf8');
- if (/[!=]==\s*(config\.internalApiKey|INTERNAL_API_KEY)\b|\b(config\.internalApiKey|INTERNAL_API_KEY)\s*[!=]==/.test(src)) offenders.push(path.relative(path.join(__dirname, '..'), p));
+ const src = fs.readFileSync(p, 'utf8').replace(/^\s*(\/\/|\*).*$/gm, ''); // code, not comments
+ if (/INTERNAL_API_KEY|OV_INTERNAL_KEY|internalApiKey|x-internal-key/i.test(src)) offenders.push(path.relative(path.join(__dirname, '..'), p));
}
}
}
}
};
};
walk(path.join(__dirname, '..', 'server'));
walk(path.join(__dirname, '..', 'server'));
- assert.deepStrictEqual(offenders, [], 'internal keys are compared with internalKeyMatches (constant time)');
+ assert.deepStrictEqual(offenders, [], 'no server file reads or sends X-Internal-Key');
process.stdout.write('live internal routes: all checks passed\n');
process.stdout.write('live internal routes: all checks passed\n');
} finally {
} finally {
server.close();
server.close();