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.

OP OpenVibers committed 29 Sept 2026 · bea231e516 · 14 files, +111 −83


Changes

ddiff --git a/server/auth/identity-sync.js b/server/auth/identity-sync.js
iindex af041b05..2e53c609 100644
---- a/server/auth/identity-sync.js
++++ b/server/auth/identity-sync.js
@@@ -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");
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 2919e9b1..844dd8ee 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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();
}
ddiff --git a/server/config.js b/server/config.js
iindex 03d3bea3..026e3323 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -285,7 +285,7 @@ const initialRegistry = resolveRegistryValues(process.env, {}, {}, URL_DEFINITIO
const 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;
}
ddiff --git a/server/monetization/wallet-client.js b/server/monetization/wallet-client.js
iindex d8dafec4..c3fc9672 100644
---- a/server/monetization/wallet-client.js
++++ b/server/monetization/wallet-client.js
@@@ -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);
ddiff --git a/server/net/network-principal.js b/server/net/network-principal.js
iindex 45e49993..610d9edd 100644
---- a/server/net/network-principal.js
++++ b/server/net/network-principal.js
@@@ -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(); } };
ddiff --git a/server/streaming/golive-notify.js b/server/streaming/golive-notify.js
iindex 9567a37f..907a0100 100644
---- a/server/streaming/golive-notify.js
++++ b/server/streaming/golive-notify.js
@@@ -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 },
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex eccd8739..14b25cfd 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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.
ddiff --git a/server/utils/notify.js b/server/utils/notify.js
iindex b3579d68..7b95ef19 100644
---- a/server/utils/notify.js
++++ b/server/utils/notify.js
@@@ -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 };
ddiff --git a/test/config-refresh.test.js b/test/config-refresh.test.js
iindex 3fd24540..e1c4b646 100644
---- a/test/config-refresh.test.js
++++ b/test/config-refresh.test.js
@@@ -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');
ddiff --git a/test/golive-notify-mode.test.js b/test/golive-notify-mode.test.js
iindex dee2d5a8..78079101 100644
---- a/test/golive-notify-mode.test.js
++++ b/test/golive-notify-mode.test.js
@@@ -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' } });
};
ddiff --git a/test/helpers/network-token-stub.js b/test/helpers/network-token-stub.js
nnew file mode 100644
iindex 00000000..a1110fb6
---- /dev/null
++++ b/test/helpers/network-token-stub.js
@@@ -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 };
ddiff --git a/test/identity-sync.test.js b/test/identity-sync.test.js
iindex b86645ff..29e375de 100644
---- a/test/identity-sync.test.js
++++ b/test/identity-sync.test.js
@@@ -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);
ddiff --git a/test/network-principal.test.js b/test/network-principal.test.js
iindex 19ecc1c6..7ab4aa5e 100644
---- a/test/network-principal.test.js
++++ b/test/network-principal.test.js
@@@ -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 });
ddiff --git a/test/refresh-registry.test.js b/test/refresh-registry.test.js
iindex 930a8969..08c17f46 100644
---- a/test/refresh-registry.test.js
++++ b/test/refresh-registry.test.js
@@@ -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');
}