openvibers/openvibe.live Commits
Live never sends X-Internal-Key again (plan T2, step 2 of the sweep): every call to Network's internal API goes with Live's client-credentials service token, including the avatar report and mark-read-by-type now that Network guards them (network.avatar.write, network.notifications.push). A refused token is retried once with a freshly minted one; with no token at all the call fails like an unreachable Network (the wallet answers a WalletError, notifications log and skip). The dead key reads in chat-server.js and streaming/routes.js are gone; the registry refresh, identity sync and go-live path check OV_OAUTH_CLIENT_SECRET instead of the key. Live's own internal routes still accept the key until Network and Chat stop sending it. test/helpers/network-token-stub.js gives stub Networks a token endpoint; the principal test asserts the key is never sent. npm test 151/152, 1 skipped.
Changes
@@@ -15,7 +15,8 @@* resolver all key on it). Only an answer naming the same Network user id is stored.*/const db = require('../db/database');--const { OV_NETWORK_INTERNAL_URL, INTERNAL_API_KEY } = require('../utils/notify');++const { OV_NETWORK_INTERNAL_URL } = require('../utils/notify');++const notConfigured = () => (require('../net/network-principal').configured() ? null : { skipped: 'no OV_OAUTH_CLIENT_SECRET' });const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const BATCH = 500;@@@ -55,7 +56,7 @@ async function networkPost(path, body) {}async function syncLegacyMap() {-- if (!INTERNAL_API_KEY) return { skipped: 'no INTERNAL_API_KEY' };++ const off = notConfigured(); if (off) return off;const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();const total = { sent: rows.length, inserted: 0, unchanged: 0, conflicts: 0, rejected: 0 };for (let i = 0; i < rows.length; i += BATCH) {@@@ -75,7 +76,7 @@ async function syncLegacyMap() {}async function backfillSubjects() {-- if (!INTERNAL_API_KEY) return { skipped: 'no INTERNAL_API_KEY' };++ const off = notConfigured(); if (off) return off;const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NULL AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();const total = { asked: rows.length, stored: 0, unknown: 0 };const store = db.getDb().prepare("UPDATE linked_accounts SET subject_id = ? WHERE service = 'network' AND user_id = ? AND service_user_id = ? AND subject_id IS NULL");@@@ -82,7 +82,6 @@ class ChatServer {// ── Unified anon resolution via openvibe.network internal API ──this._openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';-- this._internalKey = process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';/** @type {Map<string, Promise<number>>} IP → pending resolve promise (dedup concurrent) */this._pendingResolves = new Map();}@@@ -285,7 +285,7 @@ const initialRegistry = resolveRegistryValues(process.env, {}, {}, URL_DEFINITIOconst config = buildConfig(initialRegistry);async function refreshRegistry() {-- if (!config.openvibeToolsInternalUrl || (!config.internalApiKey && !process.env.OV_OAUTH_CLIENT_SECRET)) {++ if (!config.openvibeToolsInternalUrl || !process.env.OV_OAUTH_CLIENT_SECRET) {console.warn('[Config] Skipping registry refresh: missing internal URL, or neither a service identity nor an internal API key');return config;}@@@ -6,7 +6,7 @@* (users.openvibe_coins_balance) is frozen for the migration script; every earn/* spend goes through this client now.*-- * POST /internal/coins/credit|debit|transfer (X-Internal-Key, server-to-server)++ * POST /internal/coins/credit|debit|transfer (Live's service token, server-to-server)* GET /api/coins/me, /api/coins/me/history (Bearer user JWT, read-side)** user ids: the wallet is keyed by the NETWORK (SSO) user id, never Live's local@@@ -17,7 +17,6 @@ const db = require('../db/database');const principal = require('../net/network-principal');const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');--const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';class WalletError extends Error {constructor(message, status, body) {@@@ -45,7 +44,10 @@ function networkUserId(localUserId) {async function _post(apiPath, body, retried = false) {let res;-- const auth = await principal.headersFor(apiPath); // scoped service token, or X-Internal-Key++ let auth;++ try { auth = await principal.headersFor(apiPath); } catch (err) { // Live's scoped service token++ throw new WalletError(`Network wallet unavailable: no service token (${err.message})`, 0);++ }try {res = await fetch(`${NETWORK_INTERNAL_URL}${apiPath}`, {method: 'POST',@@@ -56,7 +58,7 @@ async function _post(apiPath, body, retried = false) {throw new WalletError(`Network wallet unreachable: ${err.message}`, 0);}const json = await res.json().catch(() => null);-- // A rejected token (expired key rotation, revoked grant) is retried once with the internal key.++ // A rejected token (a key rotation, a revoked grant) is retried once with a freshly minted token.// Idempotency keys make the retry safe: a credit that did land is not applied twice.if (res.status === 401 && auth.Authorization && !retried) {principal.tokenRejected(json && json.code);@@@ -1,28 +1,19 @@'use strict';/**-- * Live as a service principal toward OpenVibe.Network (roadmap Wave 1, ADR-003).++ * Live as a service principal toward OpenVibe.Network (ADR-003).*-- * headersFor(path) returns the auth headers for one internal call: a short-lived, capability-scoped-- * token from Network's /oauth/token (client_credentials, using Live's existing OAuth client id and-- * secret) for the routes Network guards by capability, and the shared X-Internal-Key for every other-- * route. If a token can't be had (Network not upgraded yet, no grant, secret missing) the key is used-- * and token attempts pause for a few minutes, so a Network outage never breaks coins or notifications.-- * After a 401 on a token call, call tokenRejected() and retry once; the retry uses the key.++ * headersFor(path) returns the auth headers for one internal call: a short-lived, capability-scoped token from++ * Network's /oauth/token (client_credentials with Live's OAuth client id and secret; the token carries every capability++ * Network grants Live for audience openvibe.network). Every /internal route Live calls takes a token; the shared++ * X-Internal-Key is no longer sent (plan T2). Without a token (no OV_OAUTH_CLIENT_SECRET, Network down) the call++ * throws, and callers treat it like Network being unreachable. After a 401, call tokenRejected() and retry once: the++ * retry mints a fresh token.*/const { serviceAuth } = require('openvibe-contracts');const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');--const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'live';const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';--const PAUSE_MS = 5 * 60 * 1000;----// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).--const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk', '/internal/events/stream-live',-- // Network accepts a service token on these since 2026-09-24 (identity.subject.resolve / network.coins.credit).-- '/internal/link-account', '/internal/identity/legacy-map', '/internal/identity/resolve-batch', '/internal/coins/stats', '/internal/resolve-anon',-- // identity.subject.resolve, which Network already guards it with (WS-B task 3, C-54).-- '/internal/url-registry/resolved']);const _clients = new Map();/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */@@@ -33,43 +24,39 @@ function clientFor(audience) {}return _clients.get(audience);}--const tokens = clientFor('openvibe.network');--/** Bearer headers for another service. Throws when no token can be had (no fallback: new APIs are token-only). */++/** True when Live can call Network's internal API at all (it has its OAuth client secret). */++function configured() { return Boolean(CLIENT_SECRET); }++++/** Bearer headers for another service. Throws when no token can be had. */async function serviceHeaders(audience) {const c = clientFor(audience);if (!c) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');return c.authHeaders();}function invalidate(audience) { const c = _clients.get(audience); if (c) c.invalidate(); }--let pausedUntil = 0;--let lastFailure = null;--const stats = { token: 0, key: 0, tokenFailures: 0 };--const legacyHeaders = () => (INTERNAL_API_KEY ? { 'X-Internal-Key': INTERNAL_API_KEY } : {});++const stats = { token: 0, tokenFailures: 0 };++let lastFailure = null;++/** Bearer headers for one call to Network's internal API (`path` is kept for the callers' logs). */async function headersFor(path) {-- if (tokens && TOKEN_PATHS.has(path) && Date.now() >= pausedUntil) {-- try {-- const h = await tokens.authHeaders();-- stats.token++;-- return h;-- } catch (err) {-- stats.tokenFailures++;-- pausedUntil = Date.now() + PAUSE_MS;-- if (lastFailure !== err.message) console.warn(`[Principal] no service token (${err.message}); using the internal key for ${PAUSE_MS / 60000} min`);-- lastFailure = err.message;-- }++ try {++ const h = await serviceHeaders('openvibe.network');++ stats.token++;++ return h;++ } catch (err) {++ stats.tokenFailures++;++ if (lastFailure !== err.message) console.warn(`[Principal] no service token for ${path} (${err.message})`);++ lastFailure = err.message;++ throw err;}-- stats.key++;-- return legacyHeaders();}--/** A token call came back 401: drop the cached token and use the key until the pause ends. */++/** A token call came back 401: drop the cached token so the retry mints a fresh one. */function tokenRejected(problemCode) {-- if (tokens) tokens.invalidate();-- pausedUntil = Date.now() + PAUSE_MS;-- console.warn(`[Principal] Network refused the service token (${problemCode || '401'}); using the internal key for ${PAUSE_MS / 60000} min`);++ invalidate('openvibe.network');++ console.warn(`[Principal] Network refused the service token (${problemCode || '401'}); minting a fresh one`);}--module.exports = { headersFor, tokenRejected, serviceHeaders, invalidate, TOKEN_PATHS, stats, _reset() { pausedUntil = 0; for (const c of _clients.values()) c.invalidate(); } };++module.exports = { headersFor, tokenRejected, serviceHeaders, invalidate, configured, stats, _reset() { for (const c of _clients.values()) c.invalidate(); } };@@@ -18,7 +18,7 @@*/const config = require('../config');const db = require('../db/database');--const { pushBulkNotification, toNetworkIds, toNetworkId, OV_NETWORK_INTERNAL_URL, INTERNAL_API_KEY } = require('../utils/notify');++const { pushBulkNotification, toNetworkIds, toNetworkId, OV_NETWORK_INTERNAL_URL } = require('../utils/notify');const { notifyDiscordGoLive } = require('../integrations/discord-webhook');const DEDUPE_MS = 60 * 60 * 1000;@@@ -74,9 +74,9 @@ function notifyFollowersGoLive(streamer, stream, { force = false } = {}) {follower_network_ids: followerNetworkIds,};-- if (!INTERNAL_API_KEY && !process.env.OV_OAUTH_CLIENT_SECRET) { _fallback(streamer, stream, followerNetworkIds); return; }-- // Service token (network.notifications.push) when Network grants it, the internal key otherwise.++ // Live's service token (network.notifications.push).const principal = require('../net/network-principal');++ if (!principal.configured()) { _fallback(streamer, stream, followerNetworkIds); return; }principal.headersFor('/internal/events/stream-live').then((auth) => fetch(`${OV_NETWORK_INTERNAL_URL}/internal/events/stream-live`, {method: 'POST',headers: { 'Content-Type': 'application/json', ...auth },@@@ -113,7 +113,6 @@ function ownControlConfigId(req, raw) {const { notifyDiscordGoLive } = require('../integrations/discord-webhook');--const INTERNAL_API_KEY = config.internalApiKey || process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';/*** Push "X went live" notification via openvibe.network unified event endpoint.@@@ -14,7 +14,6 @@const config = require('../config');const OV_NETWORK_INTERNAL_URL = (config.openvibeToolsInternalUrl || process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, '');--const INTERNAL_API_KEY = config.internalApiKey || process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';let _db = null;function db() { if (!_db) _db = require('../db/database'); return _db; }@@@ -36,15 +35,16 @@ function toNetworkIds(liveUserIds) {}async function _post(path, body, retried = false) {-- if (!INTERNAL_API_KEY) return null;-- const auth = await require('../net/network-principal').headersFor(path); // service token where Network accepts one++ const principal = require('../net/network-principal');++ if (!principal.configured()) return null;++ const auth = await principal.headersFor(path); // Live's service token (every /internal route Live calls takes one)const res = await fetch(`${OV_NETWORK_INTERNAL_URL}${path}`, {method: 'POST',headers: { 'Content-Type': 'application/json', ...auth },body: JSON.stringify(body),});if (res.status === 401 && auth.Authorization && !retried) {-- require('../net/network-principal').tokenRejected();++ principal.tokenRejected();return _post(path, body, true);}return res;@@@ -72,7 +72,7 @@ function pushNotification(payload) {// appears under their Linked Services. Fire-and-forget + deduped per process.const _linkedReported = new Map(); // live user id → what was last reported (so a new avatar or name is sent again)function reportLinkedAccount(user) {-- if (!user?.id || !INTERNAL_API_KEY) return;++ if (!user?.id || !require('../net/network-principal').configured()) return;// The Network adopts this picture and name when the account has none of its own, which is what makes the// same face appear on every OpenVibe site. Relative upload paths are made absolute against this site.let avatar = user.avatar_url || null;@@@ -97,7 +97,7 @@ function reportLinkedAccount(user) {* sign-in report above (which only fills an empty picture), this one replaces whatever the Network had.*/function reportAvatarChange(user) {-- if (!user?.id || !INTERNAL_API_KEY) return;++ if (!user?.id || !require('../net/network-principal').configured()) return;const networkId = toNetworkId(user.id);if (!networkId) return;_post('/internal/user-avatar', { user_id: networkId, avatar_url: user.avatar_url || null, origin: 'live' })@@@ -145,4 +145,4 @@ function markNotificationsRead(userId, type, urlPattern) {.catch(err => console.warn('[Notify] Mark-read error:', err.message));}--module.exports = { reportAvatarChange, pushNotification, pushBulkNotification, actorInfo, markNotificationsRead, reportLinkedAccount, toNetworkId, toNetworkIds, OV_NETWORK_INTERNAL_URL, INTERNAL_API_KEY };++module.exports = { reportAvatarChange, pushNotification, pushBulkNotification, actorInfo, markNotificationsRead, reportLinkedAccount, toNetworkId, toNetworkIds, OV_NETWORK_INTERNAL_URL };@@@ -1,8 +1,10 @@const assert = require('assert');++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';const config = require('../server/config');++const { tokenBody } = require('./helpers/network-token-stub');const originalFetch = global.fetch;--global.fetch = async () => ({++global.fetch = async (url, init = {}) => String(url).endsWith('/oauth/token') ? new Response(JSON.stringify(tokenBody()), { status: 200, headers: { 'content-type': 'application/json' } }) : ({ok: true,json: async () => ({registry: {@@@ -16,7 +18,6 @@ global.fetch = async () => ({});(async () => {-- config.internalApiKey = 'test-key';config.openvibeToolsInternalUrl = 'http://127.0.0.1:3100';await config.refreshRegistry();assert.strictEqual(config.baseUrl, 'https://openvibe.live');@@@ -11,11 +11,13 @@ const path = require('path');const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-golive-mode-'));process.env.DB_PATH = path.join(tmp, 'live.db');--process.env.INTERNAL_API_KEY = 'test-internal-key';++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';process.env.OV_NETWORK_INTERNAL_URL = 'http://127.0.0.1:9';++const { tokenBody } = require('./helpers/network-token-stub');const calls = [];global.fetch = async (url, init = {}) => {++ if (String(url).endsWith('/oauth/token')) return new Response(JSON.stringify(tokenBody()), { status: 200, headers: { 'content-type': 'application/json' } });calls.push({ url: String(url), method: init.method || 'GET' });return new Response(JSON.stringify({ ok: true, notifications: { sent: 0, total: 0 } }), { status: 200, headers: { 'content-type': 'application/json' } });};nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++/**++ * For tests with a stub OpenVibe.Network: answer POST /oauth/token the way Network does for Live's client-credentials++ * request, and tell whether a call carried Live's service token (and never X-Internal-Key, retired in plan T2).++ *++ * if (req.url === '/oauth/token') return tokenReply(res);++ * assert.ok(sentToken(req));++ */++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++let issued = 0;++++const CAPS = ['identity.subject.resolve', 'network.notifications.push', 'network.coins.credit', 'network.registry.read'];++/** Network's /oauth/token answer body for Live (a real RS256 service token). */++function tokenBody(cap = CAPS) {++ const now = Math.floor(Date.now() / 1000);++ issued += 1;++ const access_token = serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap, iat: now, exp: now + 300, jti: `tok_stub_${String(issued).padStart(6, '0')}` }, keys.privateKey);++ return { access_token, token_type: 'Bearer', expires_in: 300 };++}++function tokenReply(res, cap = CAPS) {++ res.setHeader('Content-Type', 'application/json');++ res.end(JSON.stringify(tokenBody(cap)));++}++++const sentToken = (req) => /^Bearer \S+/.test(String(req.headers.authorization || '')) && !req.headers['x-internal-key'];++++module.exports = { tokenReply, tokenBody, sentToken, keys, issued: () => issued };@@@ -12,7 +12,8 @@ const http = require('http');const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-identity-'));process.env.DB_PATH = path.join(tmp, 'live.db');--process.env.INTERNAL_API_KEY = 'k-test';++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++const { tokenReply, sentToken } = require('./helpers/network-token-stub');const received = [];const resolveCalls = [];@@@ -23,8 +24,9 @@ const network = http.createServer((req, res) => {let body = '';req.on('data', (c) => { body += c; });req.on('end', () => {++ if (req.url === '/oauth/token') return tokenReply(res);if (mode.old) { res.statusCode = 404; return res.end('{}'); }-- assert.strictEqual(req.headers['x-internal-key'], 'k-test');++ assert.ok(sentToken(req), 'Live sends its service token, never the internal key');if (req.url === '/internal/identity/resolve-batch') {const q = JSON.parse(body);resolveCalls.push(q);@@@ -1,8 +1,8 @@'use strict';--// Live as a service principal (roadmap Wave 1): coins and notification pushes go to Network with a--// client-credentials token; every other internal call keeps the X-Internal-Key; a token that can't be--// had or is refused falls back to the key without losing the call. Against a stub Network.++// Live as a service principal (ADR-003): every internal call to Network goes with a client-credentials++// token; the X-Internal-Key is never sent (plan T2). A refused token is retried once with a fresh one; no++// token at all fails the call like an unreachable Network. Against a stub Network.const assert = require('assert');const fs = require('fs');@@@ -14,7 +14,6 @@ const { serviceAuth } = require('openvibe-contracts');const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principal-'));process.env.DB_PATH = path.join(tmp, 'live.db');--process.env.INTERNAL_API_KEY = 'legacy-key';process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';@@@ -64,24 +63,28 @@ const network = http.createServer((req, res) => {assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);assert.strictEqual(tokenCalls, 1, 'one token for both calls');-- // 2. link-account takes a service token too since Network guards it (identity.subject.resolve, 2026-09-24).++ // 2. Every other internal call goes with the token too: link-account, the avatar report and mark-read (the last++ // two were key-only until Network guarded them, plan T2).seen.length = 0;-- await (async () => { notify.reportLinkedAccount({ id: uid, username: 'p' }); await new Promise((x) => setTimeout(x, 150)); })();-- assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`), ['/internal/link-account:token']);-- assert.ok(!principal.TOKEN_PATHS.has('/internal/notifications/mark-read'), 'routes Network does not guard by capability keep the key');++ notify.reportLinkedAccount({ id: uid, username: 'p' });++ notify.reportAvatarChange({ id: uid, avatar_url: 'https://openvibe.media/f/a.png' });++ notify.markNotificationsRead(uid, 'follow');++ await new Promise((x) => setTimeout(x, 200));++ assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`).sort(), ['/internal/link-account:token', '/internal/notifications/mark-read:token', '/internal/user-avatar:token']);-- // 3. A refused token is retried once with the key, and the key is used until the pause ends.++ // 3. A refused token is retried once with a fresh token (never the key); refused again, the call fails.seen.length = 0; mode.coins = 'refuse';-- assert.deepStrictEqual(await wallet.credit(uid, 5, 'test', 'idem-3'), { balance: 10 }, 'the call still succeeds');-- assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'key']);-- await wallet.credit(uid, 5, 'test', 'idem-4');-- assert.strictEqual(seen[2].auth, 'key', 'paused: no token attempt');++ const before = tokenCalls;++ await assert.rejects(wallet.credit(uid, 5, 'test', 'idem-3'), (e) => e.status === 401, 'refused twice: the call fails');++ assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);++ assert.strictEqual(tokenCalls, before + 1, 'the retry minted a fresh token');-- // 4. Network that can't issue a token (not upgraded, no grant): quietly use the key.++ // 4. No token at all (Network cannot issue one): the call fails like an unreachable Network; nothing is sent.principal._reset(); mode.coins = 'ok'; mode.token = 'fail'; seen.length = 0;-- await wallet.debit(uid, 1, 'test', 'idem-5');-- assert.deepStrictEqual(seen.map((s) => s.auth), ['key']);++ await assert.rejects(wallet.debit(uid, 1, 'test', 'idem-5'), (e) => e.status === 0);++ assert.deepStrictEqual(seen, []);assert.ok(principal.stats.tokenFailures >= 1);++ assert.ok(!seen.some((s) => s.auth === 'key'), 'the key is never sent');network.close();fs.rmSync(tmp, { recursive: true, force: true });@@@ -12,10 +12,12 @@ async function withServer(handler) {}(async () => {-- process.env.INTERNAL_API_KEY = 'test-refresh-key';++ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++ const { tokenReply, sentToken } = require('./helpers/network-token-stub');const server = http.createServer((req, res) => {-- if (req.method !== 'GET' || req.url !== '/internal/url-registry/resolved') {++ if (req.url === '/oauth/token') return tokenReply(res);++ if (req.method !== 'GET' || req.url !== '/internal/url-registry/resolved' || !sentToken(req)) {res.statusCode = 404;return res.end('not found');}
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
* resolver all key on it). Only an answer naming the same Network user id is stored.
* resolver all key on it). Only an answer naming the same Network user id is stored.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
-const { OV_NETWORK_INTERNAL_URL, INTERNAL_API_KEY } = require('../utils/notify');
+const { OV_NETWORK_INTERNAL_URL } = require('../utils/notify');
+const notConfigured = () => (require('../net/network-principal').configured() ? null : { skipped: 'no OV_OAUTH_CLIENT_SECRET' });
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const BATCH = 500;
const BATCH = 500;
@@ -55,7 +56,7 @@ async function networkPost(path, body) {
@@ -55,7 +56,7 @@ async function networkPost(path, body) {
}
}
async function syncLegacyMap() {
async function syncLegacyMap() {
- if (!INTERNAL_API_KEY) return { skipped: 'no INTERNAL_API_KEY' };
+ const off = notConfigured(); if (off) return off;
const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();
const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();
const total = { sent: rows.length, inserted: 0, unchanged: 0, conflicts: 0, rejected: 0 };
const total = { sent: rows.length, inserted: 0, unchanged: 0, conflicts: 0, rejected: 0 };
for (let i = 0; i < rows.length; i += BATCH) {
for (let i = 0; i < rows.length; i += BATCH) {
@@ -75,7 +76,7 @@ async function syncLegacyMap() {
@@ -75,7 +76,7 @@ async function syncLegacyMap() {
}
}
async function backfillSubjects() {
async function backfillSubjects() {
- if (!INTERNAL_API_KEY) return { skipped: 'no INTERNAL_API_KEY' };
+ const off = notConfigured(); if (off) return off;
const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NULL AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();
const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NULL AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();
const total = { asked: rows.length, stored: 0, unknown: 0 };
const total = { asked: rows.length, stored: 0, unknown: 0 };
const store = db.getDb().prepare("UPDATE linked_accounts SET subject_id = ? WHERE service = 'network' AND user_id = ? AND service_user_id = ? AND subject_id IS NULL");
const store = db.getDb().prepare("UPDATE linked_accounts SET subject_id = ? WHERE service = 'network' AND user_id = ? AND service_user_id = ? AND subject_id IS NULL");
@@ -82,7 +82,6 @@ class ChatServer {
@@ -82,7 +82,6 @@ class ChatServer {
// ── Unified anon resolution via openvibe.network internal API ──
// ── Unified anon resolution via openvibe.network internal API ──
this._openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';
this._openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';
- this._internalKey = process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';
/** @type {Map<string, Promise<number>>} IP → pending resolve promise (dedup concurrent) */
/** @type {Map<string, Promise<number>>} IP → pending resolve promise (dedup concurrent) */
this._pendingResolves = new Map();
this._pendingResolves = new Map();
}
}
@@ -285,7 +285,7 @@ const initialRegistry = resolveRegistryValues(process.env, {}, {}, URL_DEFINITIO
@@ -285,7 +285,7 @@ const initialRegistry = resolveRegistryValues(process.env, {}, {}, URL_DEFINITIO
const config = buildConfig(initialRegistry);
const config = buildConfig(initialRegistry);
async function refreshRegistry() {
async function refreshRegistry() {
- if (!config.openvibeToolsInternalUrl || (!config.internalApiKey && !process.env.OV_OAUTH_CLIENT_SECRET)) {
+ if (!config.openvibeToolsInternalUrl || !process.env.OV_OAUTH_CLIENT_SECRET) {
console.warn('[Config] Skipping registry refresh: missing internal URL, or neither a service identity nor an internal API key');
console.warn('[Config] Skipping registry refresh: missing internal URL, or neither a service identity nor an internal API key');
return config;
return config;
}
}
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* (users.openvibe_coins_balance) is frozen for the migration script; every earn/
* (users.openvibe_coins_balance) is frozen for the migration script; every earn/
* spend goes through this client now.
* spend goes through this client now.
*
*
- * POST /internal/coins/credit|debit|transfer (X-Internal-Key, server-to-server)
+ * POST /internal/coins/credit|debit|transfer (Live's service token, server-to-server)
* GET /api/coins/me, /api/coins/me/history (Bearer user JWT, read-side)
* GET /api/coins/me, /api/coins/me/history (Bearer user JWT, read-side)
*
*
* user ids: the wallet is keyed by the NETWORK (SSO) user id, never Live's local
* user ids: the wallet is keyed by the NETWORK (SSO) user id, never Live's local
@@ -17,7 +17,6 @@ const db = require('../db/database');
@@ -17,7 +17,6 @@ const db = require('../db/database');
const principal = require('../net/network-principal');
const principal = require('../net/network-principal');
const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
-const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';
class WalletError extends Error {
class WalletError extends Error {
constructor(message, status, body) {
constructor(message, status, body) {
@@ -45,7 +44,10 @@ function networkUserId(localUserId) {
@@ -45,7 +44,10 @@ function networkUserId(localUserId) {
async function _post(apiPath, body, retried = false) {
async function _post(apiPath, body, retried = false) {
let res;
let res;
- const auth = await principal.headersFor(apiPath); // scoped service token, or X-Internal-Key
+ let auth;
+ try { auth = await principal.headersFor(apiPath); } catch (err) { // Live's scoped service token
+ throw new WalletError(`Network wallet unavailable: no service token (${err.message})`, 0);
+ }
try {
try {
res = await fetch(`${NETWORK_INTERNAL_URL}${apiPath}`, {
res = await fetch(`${NETWORK_INTERNAL_URL}${apiPath}`, {
method: 'POST',
method: 'POST',
@@ -56,7 +58,7 @@ async function _post(apiPath, body, retried = false) {
@@ -56,7 +58,7 @@ async function _post(apiPath, body, retried = false) {
throw new WalletError(`Network wallet unreachable: ${err.message}`, 0);
throw new WalletError(`Network wallet unreachable: ${err.message}`, 0);
}
}
const json = await res.json().catch(() => null);
const json = await res.json().catch(() => null);
- // A rejected token (expired key rotation, revoked grant) is retried once with the internal key.
+ // A rejected token (a key rotation, a revoked grant) is retried once with a freshly minted token.
// Idempotency keys make the retry safe: a credit that did land is not applied twice.
// Idempotency keys make the retry safe: a credit that did land is not applied twice.
if (res.status === 401 && auth.Authorization && !retried) {
if (res.status === 401 && auth.Authorization && !retried) {
principal.tokenRejected(json && json.code);
principal.tokenRejected(json && json.code);
@@ -1,28 +1,19 @@
@@ -1,28 +1,19 @@
'use strict';
'use strict';
/**
/**
- * Live as a service principal toward OpenVibe.Network (roadmap Wave 1, ADR-003).
+ * Live as a service principal toward OpenVibe.Network (ADR-003).
*
*
- * headersFor(path) returns the auth headers for one internal call: a short-lived, capability-scoped
- * token from Network's /oauth/token (client_credentials, using Live's existing OAuth client id and
- * secret) for the routes Network guards by capability, and the shared X-Internal-Key for every other
- * route. If a token can't be had (Network not upgraded yet, no grant, secret missing) the key is used
- * and token attempts pause for a few minutes, so a Network outage never breaks coins or notifications.
- * After a 401 on a token call, call tokenRejected() and retry once; the retry uses the key.
+ * headersFor(path) returns the auth headers for one internal call: a short-lived, capability-scoped token from
+ * Network's /oauth/token (client_credentials with Live's OAuth client id and secret; the token carries every capability
+ * Network grants Live for audience openvibe.network). Every /internal route Live calls takes a token; the shared
+ * X-Internal-Key is no longer sent (plan T2). Without a token (no OV_OAUTH_CLIENT_SECRET, Network down) the call
+ * throws, and callers treat it like Network being unreachable. After a 401, call tokenRejected() and retry once: the
+ * retry mints a fresh token.
*/
*/
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
-const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';
const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'live';
const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'live';
const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';
const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';
-const PAUSE_MS = 5 * 60 * 1000;
-
-// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).
-const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk', '/internal/events/stream-live',
- // Network accepts a service token on these since 2026-09-24 (identity.subject.resolve / network.coins.credit).
- '/internal/link-account', '/internal/identity/legacy-map', '/internal/identity/resolve-batch', '/internal/coins/stats', '/internal/resolve-anon',
- // identity.subject.resolve, which Network already guards it with (WS-B task 3, C-54).
- '/internal/url-registry/resolved']);
const _clients = new Map();
const _clients = new Map();
/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */
/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */
@@ -33,43 +24,39 @@ function clientFor(audience) {
@@ -33,43 +24,39 @@ function clientFor(audience) {
}
}
return _clients.get(audience);
return _clients.get(audience);
}
}
-const tokens = clientFor('openvibe.network');
-/** Bearer headers for another service. Throws when no token can be had (no fallback: new APIs are token-only). */
+/** True when Live can call Network's internal API at all (it has its OAuth client secret). */
+function configured() { return Boolean(CLIENT_SECRET); }
+
+/** Bearer headers for another service. Throws when no token can be had. */
async function serviceHeaders(audience) {
async function serviceHeaders(audience) {
const c = clientFor(audience);
const c = clientFor(audience);
if (!c) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
if (!c) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
return c.authHeaders();
return c.authHeaders();
}
}
function invalidate(audience) { const c = _clients.get(audience); if (c) c.invalidate(); }
function invalidate(audience) { const c = _clients.get(audience); if (c) c.invalidate(); }
-let pausedUntil = 0;
-let lastFailure = null;
-const stats = { token: 0, key: 0, tokenFailures: 0 };
-const legacyHeaders = () => (INTERNAL_API_KEY ? { 'X-Internal-Key': INTERNAL_API_KEY } : {});
+const stats = { token: 0, tokenFailures: 0 };
+let lastFailure = null;
+/** Bearer headers for one call to Network's internal API (`path` is kept for the callers' logs). */
async function headersFor(path) {
async function headersFor(path) {
- if (tokens && TOKEN_PATHS.has(path) && Date.now() >= pausedUntil) {
- try {
- const h = await tokens.authHeaders();
- stats.token++;
- return h;
- } catch (err) {
- stats.tokenFailures++;
- pausedUntil = Date.now() + PAUSE_MS;
- if (lastFailure !== err.message) console.warn(`[Principal] no service token (${err.message}); using the internal key for ${PAUSE_MS / 60000} min`);
- lastFailure = err.message;
- }
+ try {
+ const h = await serviceHeaders('openvibe.network');
+ stats.token++;
+ return h;
+ } catch (err) {
+ stats.tokenFailures++;
+ if (lastFailure !== err.message) console.warn(`[Principal] no service token for ${path} (${err.message})`);
+ lastFailure = err.message;
+ throw err;
}
}
- stats.key++;
- return legacyHeaders();
}
}
-/** A token call came back 401: drop the cached token and use the key until the pause ends. */
+/** A token call came back 401: drop the cached token so the retry mints a fresh one. */
function tokenRejected(problemCode) {
function tokenRejected(problemCode) {
- if (tokens) tokens.invalidate();
- pausedUntil = Date.now() + PAUSE_MS;
- console.warn(`[Principal] Network refused the service token (${problemCode || '401'}); using the internal key for ${PAUSE_MS / 60000} min`);
+ invalidate('openvibe.network');
+ console.warn(`[Principal] Network refused the service token (${problemCode || '401'}); minting a fresh one`);
}
}
-module.exports = { headersFor, tokenRejected, serviceHeaders, invalidate, TOKEN_PATHS, stats, _reset() { pausedUntil = 0; for (const c of _clients.values()) c.invalidate(); } };
+module.exports = { headersFor, tokenRejected, serviceHeaders, invalidate, configured, stats, _reset() { for (const c of _clients.values()) c.invalidate(); } };
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
*/
*/
const config = require('../config');
const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
-const { pushBulkNotification, toNetworkIds, toNetworkId, OV_NETWORK_INTERNAL_URL, INTERNAL_API_KEY } = require('../utils/notify');
+const { pushBulkNotification, toNetworkIds, toNetworkId, OV_NETWORK_INTERNAL_URL } = require('../utils/notify');
const { notifyDiscordGoLive } = require('../integrations/discord-webhook');
const { notifyDiscordGoLive } = require('../integrations/discord-webhook');
const DEDUPE_MS = 60 * 60 * 1000;
const DEDUPE_MS = 60 * 60 * 1000;
@@ -74,9 +74,9 @@ function notifyFollowersGoLive(streamer, stream, { force = false } = {}) {
@@ -74,9 +74,9 @@ function notifyFollowersGoLive(streamer, stream, { force = false } = {}) {
follower_network_ids: followerNetworkIds,
follower_network_ids: followerNetworkIds,
};
};
- if (!INTERNAL_API_KEY && !process.env.OV_OAUTH_CLIENT_SECRET) { _fallback(streamer, stream, followerNetworkIds); return; }
- // Service token (network.notifications.push) when Network grants it, the internal key otherwise.
+ // Live's service token (network.notifications.push).
const principal = require('../net/network-principal');
const principal = require('../net/network-principal');
+ if (!principal.configured()) { _fallback(streamer, stream, followerNetworkIds); return; }
principal.headersFor('/internal/events/stream-live').then((auth) => fetch(`${OV_NETWORK_INTERNAL_URL}/internal/events/stream-live`, {
principal.headersFor('/internal/events/stream-live').then((auth) => fetch(`${OV_NETWORK_INTERNAL_URL}/internal/events/stream-live`, {
method: 'POST',
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth },
headers: { 'Content-Type': 'application/json', ...auth },
@@ -113,7 +113,6 @@ function ownControlConfigId(req, raw) {
@@ -113,7 +113,6 @@ function ownControlConfigId(req, raw) {
const { notifyDiscordGoLive } = require('../integrations/discord-webhook');
const { notifyDiscordGoLive } = require('../integrations/discord-webhook');
-const INTERNAL_API_KEY = config.internalApiKey || process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';
/**
/**
* Push "X went live" notification via openvibe.network unified event endpoint.
* Push "X went live" notification via openvibe.network unified event endpoint.
@@ -14,7 +14,6 @@
@@ -14,7 +14,6 @@
const config = require('../config');
const config = require('../config');
const OV_NETWORK_INTERNAL_URL = (config.openvibeToolsInternalUrl || process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, '');
const OV_NETWORK_INTERNAL_URL = (config.openvibeToolsInternalUrl || process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, '');
-const INTERNAL_API_KEY = config.internalApiKey || process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '';
let _db = null;
let _db = null;
function db() { if (!_db) _db = require('../db/database'); return _db; }
function db() { if (!_db) _db = require('../db/database'); return _db; }
@@ -36,15 +35,16 @@ function toNetworkIds(liveUserIds) {
@@ -36,15 +35,16 @@ function toNetworkIds(liveUserIds) {
}
}
async function _post(path, body, retried = false) {
async function _post(path, body, retried = false) {
- if (!INTERNAL_API_KEY) return null;
- const auth = await require('../net/network-principal').headersFor(path); // service token where Network accepts one
+ const principal = require('../net/network-principal');
+ if (!principal.configured()) return null;
+ const auth = await principal.headersFor(path); // Live's service token (every /internal route Live calls takes one)
const res = await fetch(`${OV_NETWORK_INTERNAL_URL}${path}`, {
const res = await fetch(`${OV_NETWORK_INTERNAL_URL}${path}`, {
method: 'POST',
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth },
headers: { 'Content-Type': 'application/json', ...auth },
body: JSON.stringify(body),
body: JSON.stringify(body),
});
});
if (res.status === 401 && auth.Authorization && !retried) {
if (res.status === 401 && auth.Authorization && !retried) {
- require('../net/network-principal').tokenRejected();
+ principal.tokenRejected();
return _post(path, body, true);
return _post(path, body, true);
}
}
return res;
return res;
@@ -72,7 +72,7 @@ function pushNotification(payload) {
@@ -72,7 +72,7 @@ function pushNotification(payload) {
// appears under their Linked Services. Fire-and-forget + deduped per process.
// appears under their Linked Services. Fire-and-forget + deduped per process.
const _linkedReported = new Map(); // live user id → what was last reported (so a new avatar or name is sent again)
const _linkedReported = new Map(); // live user id → what was last reported (so a new avatar or name is sent again)
function reportLinkedAccount(user) {
function reportLinkedAccount(user) {
- if (!user?.id || !INTERNAL_API_KEY) return;
+ if (!user?.id || !require('../net/network-principal').configured()) return;
// The Network adopts this picture and name when the account has none of its own, which is what makes the
// The Network adopts this picture and name when the account has none of its own, which is what makes the
// same face appear on every OpenVibe site. Relative upload paths are made absolute against this site.
// same face appear on every OpenVibe site. Relative upload paths are made absolute against this site.
let avatar = user.avatar_url || null;
let avatar = user.avatar_url || null;
@@ -97,7 +97,7 @@ function reportLinkedAccount(user) {
@@ -97,7 +97,7 @@ function reportLinkedAccount(user) {
* sign-in report above (which only fills an empty picture), this one replaces whatever the Network had.
* sign-in report above (which only fills an empty picture), this one replaces whatever the Network had.
*/
*/
function reportAvatarChange(user) {
function reportAvatarChange(user) {
- if (!user?.id || !INTERNAL_API_KEY) return;
+ if (!user?.id || !require('../net/network-principal').configured()) return;
const networkId = toNetworkId(user.id);
const networkId = toNetworkId(user.id);
if (!networkId) return;
if (!networkId) return;
_post('/internal/user-avatar', { user_id: networkId, avatar_url: user.avatar_url || null, origin: 'live' })
_post('/internal/user-avatar', { user_id: networkId, avatar_url: user.avatar_url || null, origin: 'live' })
@@ -145,4 +145,4 @@ function markNotificationsRead(userId, type, urlPattern) {
@@ -145,4 +145,4 @@ function markNotificationsRead(userId, type, urlPattern) {
.catch(err => console.warn('[Notify] Mark-read error:', err.message));
.catch(err => console.warn('[Notify] Mark-read error:', err.message));
}
}
-module.exports = { reportAvatarChange, pushNotification, pushBulkNotification, actorInfo, markNotificationsRead, reportLinkedAccount, toNetworkId, toNetworkIds, OV_NETWORK_INTERNAL_URL, INTERNAL_API_KEY };
+module.exports = { reportAvatarChange, pushNotification, pushBulkNotification, actorInfo, markNotificationsRead, reportLinkedAccount, toNetworkId, toNetworkIds, OV_NETWORK_INTERNAL_URL };
@@ -1,8 +1,10 @@
@@ -1,8 +1,10 @@
const assert = require('assert');
const assert = require('assert');
+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
const config = require('../server/config');
const config = require('../server/config');
+const { tokenBody } = require('./helpers/network-token-stub');
const originalFetch = global.fetch;
const originalFetch = global.fetch;
-global.fetch = async () => ({
+global.fetch = async (url, init = {}) => String(url).endsWith('/oauth/token') ? new Response(JSON.stringify(tokenBody()), { status: 200, headers: { 'content-type': 'application/json' } }) : ({
ok: true,
ok: true,
json: async () => ({
json: async () => ({
registry: {
registry: {
@@ -16,7 +18,6 @@ global.fetch = async () => ({
@@ -16,7 +18,6 @@ global.fetch = async () => ({
});
});
(async () => {
(async () => {
- config.internalApiKey = 'test-key';
config.openvibeToolsInternalUrl = 'http://127.0.0.1:3100';
config.openvibeToolsInternalUrl = 'http://127.0.0.1:3100';
await config.refreshRegistry();
await config.refreshRegistry();
assert.strictEqual(config.baseUrl, 'https://openvibe.live');
assert.strictEqual(config.baseUrl, 'https://openvibe.live');
@@ -11,11 +11,13 @@ const path = require('path');
@@ -11,11 +11,13 @@ const path = require('path');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-golive-mode-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-golive-mode-'));
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.DB_PATH = path.join(tmp, 'live.db');
-process.env.INTERNAL_API_KEY = 'test-internal-key';
+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_NETWORK_INTERNAL_URL = 'http://127.0.0.1:9';
process.env.OV_NETWORK_INTERNAL_URL = 'http://127.0.0.1:9';
+const { tokenBody } = require('./helpers/network-token-stub');
const calls = [];
const calls = [];
global.fetch = async (url, init = {}) => {
global.fetch = async (url, init = {}) => {
+ if (String(url).endsWith('/oauth/token')) return new Response(JSON.stringify(tokenBody()), { status: 200, headers: { 'content-type': 'application/json' } });
calls.push({ url: String(url), method: init.method || 'GET' });
calls.push({ url: String(url), method: init.method || 'GET' });
return new Response(JSON.stringify({ ok: true, notifications: { sent: 0, total: 0 } }), { status: 200, headers: { 'content-type': 'application/json' } });
return new Response(JSON.stringify({ ok: true, notifications: { sent: 0, total: 0 } }), { status: 200, headers: { 'content-type': 'application/json' } });
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+/**
+ * For tests with a stub OpenVibe.Network: answer POST /oauth/token the way Network does for Live's client-credentials
+ * request, and tell whether a call carried Live's service token (and never X-Internal-Key, retired in plan T2).
+ *
+ * if (req.url === '/oauth/token') return tokenReply(res);
+ * assert.ok(sentToken(req));
+ */
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+let issued = 0;
+
+const CAPS = ['identity.subject.resolve', 'network.notifications.push', 'network.coins.credit', 'network.registry.read'];
+/** Network's /oauth/token answer body for Live (a real RS256 service token). */
+function tokenBody(cap = CAPS) {
+ const now = Math.floor(Date.now() / 1000);
+ issued += 1;
+ const access_token = serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap, iat: now, exp: now + 300, jti: `tok_stub_${String(issued).padStart(6, '0')}` }, keys.privateKey);
+ return { access_token, token_type: 'Bearer', expires_in: 300 };
+}
+function tokenReply(res, cap = CAPS) {
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(tokenBody(cap)));
+}
+
+const sentToken = (req) => /^Bearer \S+/.test(String(req.headers.authorization || '')) && !req.headers['x-internal-key'];
+
+module.exports = { tokenReply, tokenBody, sentToken, keys, issued: () => issued };
@@ -12,7 +12,8 @@ const http = require('http');
@@ -12,7 +12,8 @@ const http = require('http');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-identity-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-identity-'));
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.DB_PATH = path.join(tmp, 'live.db');
-process.env.INTERNAL_API_KEY = 'k-test';
+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+const { tokenReply, sentToken } = require('./helpers/network-token-stub');
const received = [];
const received = [];
const resolveCalls = [];
const resolveCalls = [];
@@ -23,8 +24,9 @@ const network = http.createServer((req, res) => {
@@ -23,8 +24,9 @@ const network = http.createServer((req, res) => {
let body = '';
let body = '';
req.on('data', (c) => { body += c; });
req.on('data', (c) => { body += c; });
req.on('end', () => {
req.on('end', () => {
+ if (req.url === '/oauth/token') return tokenReply(res);
if (mode.old) { res.statusCode = 404; return res.end('{}'); }
if (mode.old) { res.statusCode = 404; return res.end('{}'); }
- assert.strictEqual(req.headers['x-internal-key'], 'k-test');
+ assert.ok(sentToken(req), 'Live sends its service token, never the internal key');
if (req.url === '/internal/identity/resolve-batch') {
if (req.url === '/internal/identity/resolve-batch') {
const q = JSON.parse(body);
const q = JSON.parse(body);
resolveCalls.push(q);
resolveCalls.push(q);
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
-// Live as a service principal (roadmap Wave 1): coins and notification pushes go to Network with a
-// client-credentials token; every other internal call keeps the X-Internal-Key; a token that can't be
-// had or is refused falls back to the key without losing the call. Against a stub Network.
+// Live as a service principal (ADR-003): every internal call to Network goes with a client-credentials
+// token; the X-Internal-Key is never sent (plan T2). A refused token is retried once with a fresh one; no
+// token at all fails the call like an unreachable Network. Against a stub Network.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -14,7 +14,6 @@ const { serviceAuth } = require('openvibe-contracts');
@@ -14,7 +14,6 @@ const { serviceAuth } = require('openvibe-contracts');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principal-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principal-'));
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.DB_PATH = path.join(tmp, 'live.db');
-process.env.INTERNAL_API_KEY = 'legacy-key';
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';
@@ -64,24 +63,28 @@ const network = http.createServer((req, res) => {
@@ -64,24 +63,28 @@ const network = http.createServer((req, res) => {
assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);
assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);
assert.strictEqual(tokenCalls, 1, 'one token for both calls');
assert.strictEqual(tokenCalls, 1, 'one token for both calls');
- // 2. link-account takes a service token too since Network guards it (identity.subject.resolve, 2026-09-24).
+ // 2. Every other internal call goes with the token too: link-account, the avatar report and mark-read (the last
+ // two were key-only until Network guarded them, plan T2).
seen.length = 0;
seen.length = 0;
- await (async () => { notify.reportLinkedAccount({ id: uid, username: 'p' }); await new Promise((x) => setTimeout(x, 150)); })();
- assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`), ['/internal/link-account:token']);
- assert.ok(!principal.TOKEN_PATHS.has('/internal/notifications/mark-read'), 'routes Network does not guard by capability keep the key');
+ notify.reportLinkedAccount({ id: uid, username: 'p' });
+ notify.reportAvatarChange({ id: uid, avatar_url: 'https://openvibe.media/f/a.png' });
+ notify.markNotificationsRead(uid, 'follow');
+ await new Promise((x) => setTimeout(x, 200));
+ assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`).sort(), ['/internal/link-account:token', '/internal/notifications/mark-read:token', '/internal/user-avatar:token']);
- // 3. A refused token is retried once with the key, and the key is used until the pause ends.
+ // 3. A refused token is retried once with a fresh token (never the key); refused again, the call fails.
seen.length = 0; mode.coins = 'refuse';
seen.length = 0; mode.coins = 'refuse';
- assert.deepStrictEqual(await wallet.credit(uid, 5, 'test', 'idem-3'), { balance: 10 }, 'the call still succeeds');
- assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'key']);
- await wallet.credit(uid, 5, 'test', 'idem-4');
- assert.strictEqual(seen[2].auth, 'key', 'paused: no token attempt');
+ const before = tokenCalls;
+ await assert.rejects(wallet.credit(uid, 5, 'test', 'idem-3'), (e) => e.status === 401, 'refused twice: the call fails');
+ assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);
+ assert.strictEqual(tokenCalls, before + 1, 'the retry minted a fresh token');
- // 4. Network that can't issue a token (not upgraded, no grant): quietly use the key.
+ // 4. No token at all (Network cannot issue one): the call fails like an unreachable Network; nothing is sent.
principal._reset(); mode.coins = 'ok'; mode.token = 'fail'; seen.length = 0;
principal._reset(); mode.coins = 'ok'; mode.token = 'fail'; seen.length = 0;
- await wallet.debit(uid, 1, 'test', 'idem-5');
- assert.deepStrictEqual(seen.map((s) => s.auth), ['key']);
+ await assert.rejects(wallet.debit(uid, 1, 'test', 'idem-5'), (e) => e.status === 0);
+ assert.deepStrictEqual(seen, []);
assert.ok(principal.stats.tokenFailures >= 1);
assert.ok(principal.stats.tokenFailures >= 1);
+ assert.ok(!seen.some((s) => s.auth === 'key'), 'the key is never sent');
network.close();
network.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -12,10 +12,12 @@ async function withServer(handler) {
@@ -12,10 +12,12 @@ async function withServer(handler) {
}
}
(async () => {
(async () => {
- process.env.INTERNAL_API_KEY = 'test-refresh-key';
+ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+ const { tokenReply, sentToken } = require('./helpers/network-token-stub');
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
- if (req.method !== 'GET' || req.url !== '/internal/url-registry/resolved') {
+ if (req.url === '/oauth/token') return tokenReply(res);
+ if (req.method !== 'GET' || req.url !== '/internal/url-registry/resolved' || !sentToken(req)) {
res.statusCode = 404;
res.statusCode = 404;
return res.end('not found');
return res.end('not found');
}
}