openvibers/openvibe.network Commits

X-Internal-Key is gone from Network (plan T2, the last step of the sweep): every /internal route takes only a service token with the capability it performs (the router gate answers 401 token.missing without a Bearer and keeps the exact-spelling rule); principals.guard has no legacy option and principal_usage records service tokens only; the URL registry read takes network.registry.read and the coin totals network.coins.read. Network's calls to Live, Media and Tools carry only its self-signed token (all three accept it: Live 66f590b, Media 8364eee, Tools 55e4b84). config.internalKey and the setup warning are deleted. Tests: the retired key is refused on every route that took it, deleted routes are gone rather than locked, a Games token keeps the ownership coverage the key gave. npm test green (verified).

OP OpenVibers committed 29 Sept 2026 · 3bff136078 · 21 files, +148 −159


Changes

ddiff --git a/server/admin/analytics-routes.js b/server/admin/analytics-routes.js
iindex a6c92b30..35556dab 100644
---- a/server/admin/analytics-routes.js
++++ b/server/admin/analytics-routes.js
@@@ -13,9 +13,7 @@ const router = express.Router();
module.exports = function createAnalyticsRoutes(analytics, requireAuth, config, { selfToken = () => null } = {}) {
-- // Tools is asked with a Network service token (tools.analytics.read); the deployment's INTERNAL_API_KEY rides
-- // along only while Tools moves to tokens (plan T2), then goes.
-- const INTERNAL_KEY = config.internalKey;
++ // Tools is asked with a Network service token (tools.analytics.read).
// All remote services with their internal URLs and fetch strategy
const REMOTE_SERVICES = [
@@@ -57,9 +55,8 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
if (svc.auth === 'beacon') return subPath ? null : beaconFallback(svc, days);
if (svc.auth === 'internal') {
const t = selfToken(`openvibe.${svc.name}`, [`${svc.name}.analytics.read`]);
-- if (t) headers['Authorization'] = `Bearer ${t}`;
-- if (INTERNAL_KEY && INTERNAL_KEY !== 'change-me-in-production') headers['X-Internal-Key'] = INTERNAL_KEY;
-- if (!headers['Authorization'] && !headers['X-Internal-Key']) return null;
++ if (!t) return null;
++ headers['Authorization'] = `Bearer ${t}`;
} else {
headers['Authorization'] = `Bearer ${token}`;
}
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex d382979a..1fb18c00 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -118,13 +118,11 @@ async function refreshService(req, serviceName) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
-- // Live's refresh takes live.url_registry.refresh; the key rides along only while services move to tokens (T2).
-- const cap = `${serviceName}.url_registry.refresh`;
-- const t = serviceName === 'live' && req.app.locals.selfToken ? req.app.locals.selfToken('openvibe.live', [cap]) : null;
-- const key = req.app.locals.config?.internalKey;
++ // A service's refresh takes <service>.url_registry.refresh (Live's is the one that exists).
++ const t = req.app.locals.selfToken ? req.app.locals.selfToken(`openvibe.${serviceName}`, [`${serviceName}.url_registry.refresh`]) : null;
const r = await fetch(info.target, {
method: 'POST',
-- headers: { ...(key && key !== 'change-me-in-production' ? { 'X-Internal-Key': key } : {}), ...(t ? { Authorization: `Bearer ${t}` } : {}) },
++ headers: t ? { Authorization: `Bearer ${t}` } : {},
signal: controller.signal,
});
const baseResult = {
ddiff --git a/server/admin/staff-api.js b/server/admin/staff-api.js
iindex c0e57d03..53ca9f17 100644
---- a/server/admin/staff-api.js
++++ b/server/admin/staff-api.js
@@@ -36,7 +36,7 @@ function createStaffApi({ db, requireAuth, guard }) {
res.set('Cache-Control', 'private, no-store');
res.json({ role: claims.is_owner ? 'owner' : req.user.role || 'user', is_owner: !!claims.is_owner, staff_map: staff.map.version, capabilities: claims.staff_caps || [] });
});
-- const serviceGuard = guard('network.staff.read', { legacy: false });
++ const serviceGuard = guard('network.staff.read');
const list = (req, res) => {
const service = typeof req.query.service === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(req.query.service) ? req.query.service : null;
res.set('Cache-Control', 'private, no-store');
ddiff --git a/server/analytics/creators.js b/server/analytics/creators.js
iindex fc6202c0..efa78f93 100644
---- a/server/analytics/creators.js
++++ b/server/analytics/creators.js
@@@ -114,7 +114,7 @@ function viewerOf(req) {
} catch { return null; }
}
--/** Routes; fullGuard = principals.guard('network.analytics.creator.read', { legacy: false }). */
++/** Routes; fullGuard = principals.guard('network.analytics.creator.read'). */
function router({ fullGuard }) {
const r = express.Router();
r.use(http.middleware());
ddiff --git a/server/config.js b/server/config.js
iindex 8d4599ee..74428f3f 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -25,8 +25,6 @@ module.exports = {
issuer: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
},
-- // Internal API key for server-to-server calls (X-Internal-Key)
-- internalKey: process.env.INTERNAL_API_KEY || 'change-me-in-production',
// Developer projects (server/developer, ADR-014)
developer: {
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 362c1af6..67915f47 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -85,13 +85,9 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
async function refreshRank() {
const scores = Object.assign({}, rank.scores);
-- // Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only). The key
-- // rides along only while Live moves to tokens (plan T2).
++ // Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only).
const liveToken = selfToken('openvibe.live', ['live.analytics.read']);
-- const live = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, {
-- ...(config.internalKey && config.internalKey !== 'change-me-in-production' ? { 'X-Internal-Key': config.internalKey } : {}),
-- ...(liveToken ? { Authorization: `Bearer ${liveToken}` } : {}),
-- });
++ const live = liveToken ? await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, { Authorization: `Bearer ${liveToken}` }) : null;
if (live && live.summary) scores.live = Math.round(Number(live.summary.total_pageviews) || 0);
// Everything else: the navbar's page-view beacon, summed per site over 7 days.
let tools = rank.tools || [];
ddiff --git a/server/identity/blocks.js b/server/identity/blocks.js
iindex 8b420a29..20be8147 100644
---- a/server/identity/blocks.js
++++ b/server/identity/blocks.js
@@@ -186,7 +186,7 @@ function userRouter(requireAuth) {
return router;
}
--/** GET /internal/blocks?subject=usr_… (mounted behind principals.guard('network.blocks.read', { legacy: false })). */
++/** GET /internal/blocks?subject=usr_… (mounted behind principals.guard('network.blocks.read')). */
function internalHandler(db) {
return (req, res) => {
res.set('Cache-Control', 'no-store');
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
iindex 6701b84f..f3b2f642 100644
---- a/server/identity/modules.js
++++ b/server/identity/modules.js
@@@ -382,7 +382,7 @@ function serviceRoutes(router, principals) {
const resolveSubject = (db, s) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) && db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(s) ? s : null);
const serviceOf = (req) => String(req.principal.sub).replace(/^svc:/, '');
-- router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf, legacy: false }), (req, res) => {
++ router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf }), (req, res) => {
const db = req.app.locals.db;
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
const sid = resolveSubject(db, req.params.subject);
@@@ -394,7 +394,7 @@ function serviceRoutes(router, principals) {
withEtag(res, rec).json(rec);
});
-- router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf, legacy: false }), (req, res) => {
++ router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), (req, res) => {
const db = req.app.locals.db;
const sid = resolveSubject(db, req.params.subject);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
@@@ -405,7 +405,7 @@ function serviceRoutes(router, principals) {
withEtag(res, out.record).status(out.status).json(out.record);
});
-- router.delete('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf, legacy: false }), (req, res) => {
++ router.delete('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), (req, res) => {
const db = req.app.locals.db;
const sid = resolveSubject(db, req.params.subject);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 10feebdc..e552ff7b 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -7,8 +7,8 @@
* exactly what principal_grants allows it for the requested audience. Receivers check the one
* capability each route performs (openvibe-contracts serviceAuth.requireCapability).
*
-- * The shared X-Internal-Key keeps working in parallel. principal_usage counts, per caller and route,
-- * how each request authenticated, so the key can be retired once legacy use reaches zero.
++ * X-Internal-Key was retired in plan T2 (2026-09-29): every internal route takes a service token and nothing
++ * else. principal_usage counts, per caller and route, each decision.
*/
const crypto = require('crypto');
const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
@@@ -341,18 +341,18 @@ function recordDecision(db) {
ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);
return ({ req, capability, principal, allowed, code }) => {
const bearer = String(req.headers.authorization || '').startsWith('Bearer ');
-- const auth = bearer ? 'service-token' : req.internalKeyOk ? 'internal-key' : 'none';
-- const who = principal ? (principal.legacy ? 'legacy-key' : principal.sub) : (auth === 'internal-key' ? 'legacy-key' : 'unknown');
++ const auth = bearer ? 'service-token' : 'none';
++ const who = principal ? principal.sub : 'unknown';
try { up.run(who, `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`, capability, auth, allowed ? 1 : 0, code || ''); } catch { /* best effort */ }
};
}
/**
-- * Guard for a Network internal route. `ownApp(req)` returns the app id the request acts for; a
-- * service token may only act for its own app (svc:live -> app_id 'live'). Legacy-key callers are
-- * unchanged.
++ * Guard for a Network internal route: a service token holding `capability`, nothing else. `ownApp(req)`
++ * returns the app id the request acts for; a service token may only act for its own app (svc:live ->
++ * app_id 'live').
*/
--function guard(capability, { ownApp, namespace, legacy = true } = {}) {
++function guard(capability, { ownApp, namespace } = {}) {
if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);
let check = null;
let record = null;
@@@ -363,8 +363,6 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
getPublicKey: (r) => r.app.locals.publicKey,
issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,
audience: SELF_AUDIENCE,
-- // New routes can refuse the shared key outright (legacy: false) so its use never grows.
-- legacy: legacy ? (r) => r.internalKeyOk === true : undefined,
namespace,
// Denials are final here; an allow is recorded below, after the ownership check.
onDecision: (d) => { if (!d.allowed) { record(d); require('../observability').principalDenied(d); } },
@@@ -374,7 +372,7 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
const principal = req.principal;
// Developer sandbox tokens (env: sandbox) are refused unless Network opted in as an audience
// (DEV_SANDBOX_AUDIENCES); the signature was verified by check() above.
-- if (principal && !principal.legacy) {
++ if (principal) {
const devPolicy = require('../developer/policy');
const claims = devPolicy.unverifiedClaims(String(req.headers.authorization || '').slice(7).trim());
const env = devPolicy.environmentDecision(claims, { acceptSandbox: devPolicy.settings(req.app.locals.config).sandboxAudiences.has(SELF_AUDIENCE) });
@@@ -384,7 +382,7 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
return http.sendProblem(res, 401, env.code, { detail: env.reason, ctx: req.ov });
}
}
-- if (ownApp && principal && !principal.legacy) {
++ if (ownApp && principal) {
const app = ownApp(req);
const self = String(principal.sub).replace(/^svc:/, '');
if (app !== undefined && app !== self) {
ddiff --git a/server/index.js b/server/index.js
iindex a25c526c..5d202024 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -412,7 +412,7 @@ app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }),
});
// The GitHub token (admin → Settings → GitHub, or GITHUB_TOKEN): owner-only admin, and Blog's changelog reads it.
app.use('/api/admin/integrations/github', requireAuth, require('./integrations/github').adminRouter(db));
--app.get('/internal/integrations/github-token', require('./identity/principals').guard('network.integration.github.read', { legacy: false }), require('./integrations/github').internalHandler(db));
++app.get('/internal/integrations/github-token', require('./identity/principals').guard('network.integration.github.read'), require('./integrations/github').internalHandler(db));
// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community
// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).
app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));
@@@ -429,7 +429,7 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
// its grants (mods.grant.manage, service token only); staff (staff.games.manage) from /api/admin/mods.
{
const modRouters = require('./identity/mod-principals').routers({
-- guard: require('./identity/principals').guard('mods.grant.manage', { legacy: false }), requireAuth, staffClaims: require('./auth/staff-claims').staffClaims,
++ guard: require('./identity/principals').guard('mods.grant.manage'), requireAuth, staffClaims: require('./auth/staff-claims').staffClaims,
});
require('./identity/mod-principals').ensureSchema(db);
app.use('/internal/mods', modRouters.internal);
@@@ -446,7 +446,7 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
const guard = require('./identity/principals').guard;
const dataRouters = accountData.routers({
requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,
-- contributeGuard: guard('network.account.export.contribute', { legacy: false }), confirmGuard: guard('network.account.deletion.confirm', { legacy: false }),
++ contributeGuard: guard('network.account.export.contribute'), confirmGuard: guard('network.account.deletion.confirm'),
});
app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), dataRouters.me);
app.use('/internal', dataRouters.internal);
@@@ -454,19 +454,19 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
}
// The node registry (WS-X1, ADR-034 §12): public list for the geo API; Host reports its inventory's machines.
{
-- const nodeRouters = require('./registry/nodes').routers({ guard: require('./identity/principals').guard('network.node.report', { legacy: false }) });
++ const nodeRouters = require('./registry/nodes').routers({ guard: require('./identity/principals').guard('network.node.report') });
app.use('/api/v1/nodes', nodeRouters.pub);
app.use('/internal/nodes', nodeRouters.internal);
}
--app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read', { legacy: false }), require('./identity/blocks').internalHandler(db));
++app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
{
-- const followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read', { legacy: false }) });
++ const followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read') });
app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).
-- app.use('/internal/follows', require('./identity/principals').guard('network.follows.write', { legacy: false }), followRouters.internal);
++ app.use('/internal/follows', require('./identity/principals').guard('network.follows.write'), followRouters.internal);
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));
}
@@@ -501,9 +501,9 @@ libraryTags.start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// Creator analytics (WS-E task 6): from live.stream.ended, counts only; the full figures for the creator or
// network.analytics.creator.read (Live's dashboards).
--app.use('/api/v1/creators', rateLimit({ windowMs: 60_000, max: 300 }), require('./analytics/creators').router({ fullGuard: require('./identity/principals').guard('network.analytics.creator.read', { legacy: false }) }));
++app.use('/api/v1/creators', rateLimit({ windowMs: 60_000, max: 300 }), require('./analytics/creators').router({ fullGuard: require('./identity/principals').guard('network.analytics.creator.read') }));
// Incidents and maintenance (WS-N task 12): public list; staff admins or network.status.incident (ovhost) write.
--app.use('/api/v1/status/incidents', rateLimit({ windowMs: 60_000, max: 120 }), require('./status/incidents').router({ requireAuth, incidentGuard: require('./identity/principals').guard('network.status.incident', { legacy: false }) }));
++app.use('/api/v1/status/incidents', rateLimit({ windowMs: 60_000, max: 120 }), require('./status/incidents').router({ requireAuth, incidentGuard: require('./identity/principals').guard('network.status.incident') }));
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 838a0fd3..47941af1 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -4,8 +4,7 @@
// openvibe.network — Internal Server-to-Server API (loopback only)
// First-party services resolve identities, move OpenCoins, push notifications and read
// shared data here. Every route checks the one capability it performs on a service token
--// (identity/principals.js guard); X-Internal-Key is being retired (plan T2) and is still
--// accepted only where a route's guard allows the legacy path.
++// (identity/principals.js guard), and nothing else is accepted.
// ═══════════════════════════════════════════════════════════════
const express = require('express');
@@@ -16,61 +15,26 @@ const wallet = require('../coins/wallet');
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
--// ── Internal Key Middleware ──────────────────────────────────
--let legacyStmt = null;
--function countLegacyKey(req, res) {
-- res.on('finish', () => {
-- try {
-- const db = getDb(req);
-- if (!legacyStmt) {
-- legacyStmt = db.prepare(`INSERT INTO principal_usage (principal, route, capability, auth, allowed, code, count) VALUES ('legacy-key', ?, '-', 'internal-key-route', ?, '', 1)
-- ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);
-- }
-- const route = `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`;
-- legacyStmt.run(route, res.statusCode < 400 ? 1 : 0);
-- } catch { /* telemetry is best effort */ }
-- });
--}
--function sameKey(a, b) {
-- const x = Buffer.from(String(a || ''));
-- const y = Buffer.from(String(b || ''));
-- return x.length === y.length && x.length > 0 && require('crypto').timingSafeEqual(x, y);
--}
--function requireInternalKey(req, res, next) {
++// ── The gate ────────────────────────────────────────────────
++// Every route below checks the one capability it performs on a service token (principals.guard); nothing
++// else gets in. X-Internal-Key was retired in plan T2 (2026-09-29).
++function requireServiceToken(req, res, next) {
// Express mounts are case-insensitive, nginx locations are not: /INTERNAL/... would skip the
// proxy's loopback-only `location /internal/` rule. Only the exact spelling is served.
if (req.baseUrl !== '/internal') return res.status(404).json({ error: 'Not found' });
-- const key = req.headers['x-internal-key'];
-- const config = getConfig(req);
-- if (key && sameKey(key, config.internalKey)) {
-- req.internalKeyOk = true;
-- // Retirement telemetry (roadmap Wave 22): count every legacy-key call per route, so the key can
-- // be removed route by route once nothing uses it. Capability-guarded routes also record their
-- // decision; this covers the routes that only know the key.
-- countLegacyKey(req, res);
-- return next();
++ if (!String(req.headers.authorization || '').startsWith('Bearer ')) {
++ return require('openvibe-contracts').http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required' });
}
-- // Routes guarded by a capability also accept a service-principal token instead of the key;
-- // principals.guard() on the route verifies it (and refuses anything it can't verify).
-- if (String(req.headers.authorization || '').startsWith('Bearer ') && (TOKEN_ROUTES.has(`${req.method} ${req.path}`) || TOKEN_ROUTE_PATTERNS.some(re => re.test(`${req.method} ${req.path}`)))) return next();
-- return res.status(403).json({ error: 'Invalid or missing internal key' });
++ return next();
}
const principals = require('../identity/principals');
--const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',
-- // Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.
-- 'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account',
-- // The last two Live called with the key only (C-50): Live's avatar picker and its mark-read-by-type.
-- 'POST /user-avatar', 'POST /notifications/mark-read',
-- // Token only (legacy: false): Host's relay of the alerts firing on the production host (WS-H task 11).
-- 'POST /operator/alerts']);
--const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);
const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);
--// A service token may only map ids of its own system (svc:live -> source_system 'live'); the key is unchanged.
++// A service token may only map ids of its own system (svc:live -> source_system 'live').
function ownSourceSystem(req, res, next) {
const p = req.principal;
-- if (!p || p.legacy) return next();
++ if (!p) return require('openvibe-contracts').http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required' });
const self = String(p.sub).replace(/^svc:/, '');
const entries = req.body && Array.isArray(req.body.entries) ? req.body.entries : [];
if (entries.some(e => !e || String(e.source_system) !== self)) {
@@@ -79,7 +43,7 @@ function ownSourceSystem(req, res, next) {
next();
}
--router.use(requireInternalKey);
++router.use(requireServiceToken);
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
router.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
@@@ -142,10 +106,9 @@ router.post('/user-avatar', principals.guard('network.avatar.write'), (req, res)
res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });
});
--// Service URLs only (no secrets); any first-party service that resolves identities may read them.
--// Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the owner's, not the services'.
--// TODO(contracts): a network.registry.read capability would say it more exactly.
--router.get('/url-registry/resolved', principals.guard('identity.subject.resolve'), (req, res) => {
++// Service URLs only (no secrets). Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the
++// owner's, not the services'.
++router.get('/url-registry/resolved', principals.guard('network.registry.read'), (req, res) => {
try {
const db = getDb(req);
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
@@@ -179,7 +142,7 @@ function handleWalletError(res, err) {
// so the answer is held for a minute.
let _coinStats = { at: 0, data: null };
// Guarded by the ledger capability its caller (Live's home hero) holds. TODO(contracts): network.coins.read.
--router.get('/coins/stats', principals.guard('network.coins.credit'), (req, res) => {
++router.get('/coins/stats', principals.guard('network.coins.read'), (req, res) => {
try {
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
const db = getDb(req);
@@@ -403,7 +366,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
// POST /internal/operator/alerts — network.operator-alerts-request@1 → network.operator-alerts-result@1.
// Host's relay (ovhost alerts relay) sends the complete set of alerts firing now; server/operator/alerts.js
// pages the owner when one opens, once a day while it stays open, and when it resolves. Token only.
--router.post('/operator/alerts', principals.guard('network.operator.alert', { legacy: false }), (req, res) => {
++router.post('/operator/alerts', principals.guard('network.operator.alert'), (req, res) => {
const contracts = require('openvibe-contracts');
const v = contracts.validate('network.operator-alerts-request@1', req.body);
if (!v.valid) return res.status(400).json({ error: 'The body does not match network.operator-alerts-request@1', details: (v.errors || []).slice(0, 5) });
ddiff --git a/server/profile/avatar.js b/server/profile/avatar.js
iindex 8a095e37..d68493d7 100644
---- a/server/profile/avatar.js
++++ b/server/profile/avatar.js
@@@ -55,12 +55,10 @@ const initialSvg = (name, size) => {
};
function createAvatarService({ db, config, requireAuth, selfToken = () => null, log = console }) {
-- // A service token for the call (live.avatar.write / media.avatar.ingest); X-Internal-Key rides along only while the
-- // receivers move to tokens (plan T2), then goes.
-- const legacyKey = () => (config.internalKey && config.internalKey !== 'change-me-in-production' ? { 'X-Internal-Key': config.internalKey } : {});
++ // Network's own service token for the call (live.avatar.write / media.avatar.ingest).
function authHeaders(audience, cap) {
const t = selfToken(audience, [cap]);
-- return { ...legacyKey(), ...(t ? { Authorization: `Bearer ${t}` } : {}) };
++ return t ? { Authorization: `Bearer ${t}` } : {};
}
const getUrl = db.prepare('SELECT avatar_url FROM users WHERE id = ?');
const byName = db.prepare('SELECT username, avatar_url FROM users WHERE lower(username) = lower(?)');
ddiff --git a/server/setup/routes.js b/server/setup/routes.js
iindex 1cb974e0..5cb7cda3 100644
---- a/server/setup/routes.js
++++ b/server/setup/routes.js
@@@ -59,9 +59,6 @@ function createSetupRoutes(db, config) {
if (resolvedRegistry.MEDIASOUP_ANNOUNCED_IP?.value && ['127.0.0.1', 'localhost'].includes(resolvedRegistry.MEDIASOUP_ANNOUNCED_IP.value)) {
warnings.push('MEDIASOUP_ANNOUNCED_IP is set to a local address in production. External WebRTC clients may fail to connect.');
}
-- if (!config.internalKey || config.internalKey === 'change-me-in-production') {
-- warnings.push('INTERNAL_API_KEY is not configured or using an insecure default. Internal service communication must be protected.');
-- }
}
return warnings;
}
ddiff --git a/test/identity-subjects.test.js b/test/identity-subjects.test.js
iindex 4496196f..2cb83390 100644
---- a/test/identity-subjects.test.js
++++ b/test/identity-subjects.test.js
@@@ -75,22 +75,27 @@ assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is never
assert.deepStrictEqual(r.rejected.map(c => c.index), [3, 4, 5, 6]);
assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88' }).subject.id, beth.subject_id);
--// ── HTTP: /internal/identity behind the internal key ──
++// ── HTTP: /internal/identity behind a service token (X-Internal-Key was retired in plan T2) ──
const app = express();
++const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
app.locals.db = db;
--app.locals.config = { internalKey: 'k-test' };
++app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
++app.locals.publicKey = publicKey;
app.use(express.json());
app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
--// Tokens carry subject_id next to the integer sub.
--const { privateKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++// Live's own token: sub svc:live, openvibe.network, identity.subject.resolve (what these routes perform).
++const { serviceAuth } = contracts;
++const t0 = Math.floor(Date.now() / 1000);
++const LIVE_TOKEN = serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: ['identity.subject.resolve'], iat: t0, exp: t0 + 300, jti: 'tok_identity_subjects' }, privateKey);
const { signToken } = require('../server/auth/routes');
(async () => {
await new Promise(res => server.listen(0, '127.0.0.1', res));
const base = `http://127.0.0.1:${server.address().port}/internal/identity`;
-- const call = (p, opts = {}) => fetch(base + p, { ...opts, headers: { 'x-internal-key': 'k-test', 'content-type': 'application/json', ...(opts.headers || {}) } })
++ const call = (p, opts = {}) => fetch(base + p, { ...opts, headers: { authorization: `Bearer ${LIVE_TOKEN}`, 'content-type': 'application/json', ...(opts.headers || {}) } })
.then(async x => ({ status: x.status, type: x.headers.get('content-type'), body: await x.json() }));
let h = await call('/resolve?system=live&id=77');
@@@ -117,13 +122,16 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual(h.status, 413);
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [{ network_user_id: 3, source_system: 'live', source_id: 99 }] }) });
assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);
-- h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { 'x-internal-key': 'k-test', 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });
++ h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { authorization: `Bearer ${LIVE_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });
assert.strictEqual(h.status, 200);
assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '555' }).network_user_id, 3, '/internal/link-account also writes the legacy map');
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
assert.strictEqual(h.status, 400);
-- h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status }));
-- assert.strictEqual(h.status, 403, 'no internal key, no identity data');
++ h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));
++ assert.strictEqual(h.status, 401, 'no service token, no identity data');
++ h = await fetch(base + '/resolve?system=live&id=77', { headers: { 'x-internal-key': 'k-test' } }).then(async x => ({ status: x.status, body: await x.json() }));
++ assert.strictEqual(h.status, 401, 'the retired internal key opens nothing');
++ assert.strictEqual(h.body.code, 'token.missing');
if (signToken) {
const token = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });
ddiff --git a/test/module-events.test.js b/test/module-events.test.js
iindex ccef03d3..888e1a18 100644
---- a/test/module-events.test.js
++++ b/test/module-events.test.js
@@@ -40,7 +40,7 @@ db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id)
const modulesLib = require('../server/identity/modules');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
--const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const { signToken } = require('../server/auth/routes');
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
const app = express();
@@@ -144,7 +144,10 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: chat });
assert.strictEqual(r.status, 404);
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' } });
-- assert.strictEqual(r.status, 403, 'never the shared key');
++ assert.strictEqual(r.status, 401, 'never the retired key'); assert.strictEqual(r.body.code, 'token.missing');
++ r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { 'x-internal-key': 'legacy-key', ...live } });
++ assert.strictEqual(r.status, 403, 'and a bearer is still judged on the token alone');
++ assert.strictEqual(events(mark).length, 1, 'a refused delete emits no event');
// ── Account removal: rows and revisions go, one event each; the trigger guards the account row ──
modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });
ddiff --git a/test/modules.test.js b/test/modules.test.js
iindex 747771eb..d4397500 100644
---- a/test/modules.test.js
++++ b/test/modules.test.js
@@@ -27,7 +27,7 @@ db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
--const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const { signToken } = require('../server/auth/routes');
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
const app = express();
@@@ -112,8 +112,12 @@ const server = http.createServer(app);
r = await call('GET', '/internal/modules/chat.tts_defaults/usr_01JAB2C3D4E5F6G7H8J9K0MNPB', { headers: { authorization: `Bearer ${chat}` } });
assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, {}, 'v1 tts fields are dropped by the migration');
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' }, body: { data: { followers: 1 } } });
-- assert.strictEqual(r.status, 403, 'module routes never accept the shared key');
-- assert.ok(db.prepare("SELECT 1 FROM principal_usage WHERE principal = 'legacy-key' AND auth = 'internal-key' AND allowed = 0 AND route LIKE 'PUT /internal/modules%'").get(), 'a refused key is audited as the key');
++ assert.strictEqual(r.status, 401, 'module routes take a service token, not the retired key'); assert.strictEqual(r.body.code, 'token.missing');
++ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key', authorization: 'Bearer not-a-service-token' }, body: { data: { followers: 1 } } });
++ assert.strictEqual(r.status, 401, 'and a bearer is still judged on the token alone'); assert.strictEqual(r.body.code, 'token.malformed');
++ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${tools}` }, body: { data: { followers: 1 } } });
++ assert.strictEqual(r.status, 403, 'a service without the network.modules.write grant is refused');
++ assert.ok(db.prepare("SELECT 1 FROM principal_usage WHERE auth = 'service-token' AND allowed = 0 AND route LIKE 'PUT /internal/modules%'").get(), 'a refused service token is audited');
r = await call('PUT', `/internal/modules/live.profile/usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 1 } } });
assert.strictEqual(r.status, 404, 'unknown subject');
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}`, 'if-match': '0' }, body: { data: { followers: 43 } } });
ddiff --git a/test/operator-alerts.test.js b/test/operator-alerts.test.js
iindex 28ad8f60..355f5b49 100644
---- a/test/operator-alerts.test.js
++++ b/test/operator-alerts.test.js
@@@ -30,7 +30,7 @@ const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.locals.db = db;
--app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
const revised = [];
@@@ -55,9 +55,12 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.ok(host, 'Host gets a token for openvibe.network');
const auth = { authorization: `Bearer ${host}` };
-- // Who may call it: Host's token; not the shared key, not another service, not nobody.
-- assert.strictEqual((await post({ source: 'prometheus', alerts: [] })).status, 403);
-- assert.ok([401, 403].includes((await post({ source: 'prometheus', alerts: [] }, { 'x-internal-key': 'legacy-key' })).status), 'the shared key is refused (legacy: false)');
++ // Who may call it: Host's token; not the retired key, not another service, not nobody.
++ const nobody = await post({ source: 'prometheus', alerts: [] });
++ assert.strictEqual(nobody.status, 401); assert.strictEqual(nobody.body.code, 'token.missing');
++ const withKey = await post({ source: 'prometheus', alerts: [] }, { 'x-internal-key': 'legacy-key' });
++ assert.strictEqual(withKey.status, 401, 'the shared key is refused (retired in plan T2)');
++ assert.strictEqual(withKey.body.code, 'token.missing');
const live = await token('live', 'live-secret');
assert.strictEqual((await post({ source: 'prometheus', alerts: [] }, { authorization: `Bearer ${live}` })).status, 403, 'Live lacks network.operator.alert');
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 8f0be088..e3c11464 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -1,6 +1,6 @@
'use strict';
// Service principals (server/identity/principals.js): client_credentials tokens, capability guards on
--// /internal routes, the legacy-key compatibility path and the usage audit. Roadmap Wave 1, ADR-003.
++// /internal routes, the refusal of the retired X-Internal-Key and the usage audit. Roadmap Wave 1, ADR-003.
// node test/principals.test.js
const assert = require('assert');
const crypto = require('crypto');
@@@ -30,7 +30,7 @@ const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.locals.db = db;
--app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
++app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
@@@ -116,7 +116,7 @@ const server = http.createServer(app);
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });
-- assert.strictEqual(r.status, 401, 'a bad token is not rescued by the legacy key');
++ assert.strictEqual(r.status, 401, 'a bad token is not rescued by a header the server no longer reads');
const forged = serviceAuth.signServiceToken({ ...claims.claims, jti: 'tok_forged000' }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey);
r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${forged}` });
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.bad_signature');
@@@ -129,9 +129,9 @@ const server = http.createServer(app);
r = await post('/internal/notifications/push', { user_id: 7, service: 'games', title: 'x' }, { authorization: `Bearer ${full}` });
assert.strictEqual(r.status, 403, 'notifications are sent only as the principal\'s own service');
r = await fetch(`${base}/internal/stats`, { headers: { authorization: `Bearer ${full}` } }).then(x => ({ status: x.status }));
-- assert.strictEqual(r.status, 403, 'key-only internal routes still need the key');
-- r = await post('/internal/audit', { action: 'x' }, { authorization: `Bearer ${full}` });
-- assert.strictEqual(r.status, 403, 'token routes are an explicit list');
++ assert.strictEqual(r.status, 404, 'the key-only internal routes are gone, not merely locked');
++ r = await fetch(`${base}/internal/audit`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${full}` }, body: JSON.stringify({ action: 'x' }) }).then(x => x.status);
++ assert.strictEqual(r, 404, 'the key-only internal routes are gone');
// Community resolves authors with its own token; Live's token lacks that grant.
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
@@@ -165,16 +165,16 @@ const server = http.createServer(app);
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403);
-- // ── The routes Live still called with the key (register C-50/C-52) take its token too ──
++ // ── The routes Live called with the key (register C-50/C-52) take its token, and only that ──
const get = (p, headers) => fetch(base + p, { headers }).then(async x => ({ status: x.status, body: await x.json() }));
const KEY = { 'x-internal-key': 'legacy-key' };
const LIVE = { authorization: `Bearer ${full}` };
r = await get('/internal/url-registry/resolved', LIVE);
assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.strictEqual(r.body.ok, true);
-- assert.strictEqual((await get('/internal/url-registry/resolved', { authorization: `Bearer ${creditOnly}` })).status, 403, 'needs identity.subject.resolve');
++ assert.strictEqual((await get('/internal/url-registry/resolved', { authorization: `Bearer ${creditOnly}` })).status, 403, 'needs network.registry.read');
r = await get('/internal/coins/stats', LIVE);
assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.ok(Number.isFinite(r.body.earned) && r.body.recent);
-- assert.strictEqual((await get('/internal/coins/stats', { authorization: `Bearer ${mediaResolve}` })).status, 403, 'needs network.coins.credit');
++ assert.strictEqual((await get('/internal/coins/stats', { authorization: `Bearer ${mediaResolve}` })).status, 403, 'needs network.coins.read');
r = await post('/internal/resolve-anon', { ip: '203.0.113.9' }, LIVE);
assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.ok(r.body.anon_number >= 1);
assert.strictEqual((await post('/internal/resolve-anon', { ip: '203.0.113.9' }, { authorization: `Bearer ${creditOnly}` })).status, 403);
@@@ -189,27 +189,44 @@ const server = http.createServer(app);
assert.strictEqual(db.prepare("SELECT service_user_id FROM linked_accounts WHERE user_id = 7 AND service = 'live'").get().service_user_id, '4242');
r = await post('/internal/link-account', { user_id: 7, service: 'games', service_user_id: '4242' }, LIVE);
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token links accounts only for its own service');
-- // The key still works on all five (callers switch later), unchanged: no ownership rule for the key.
-- assert.strictEqual((await get('/internal/url-registry/resolved', KEY)).status, 200);
-- assert.strictEqual((await get('/internal/coins/stats', KEY)).status, 200);
-- assert.strictEqual((await post('/internal/resolve-anon', { ip: '203.0.113.10' }, KEY)).status, 200);
-- assert.strictEqual((await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 8, source_system: 'games', source_id: '77' }] }, KEY)).status, 200);
-- assert.strictEqual((await post('/internal/link-account', { user_id: 8, service: 'games', service_user_id: '77' }, KEY)).status, 200);
-- for (const p of ['/internal/url-registry/resolved', '/internal/coins/stats']) assert.strictEqual((await get(p, {})).status, 403, `${p}: neither key nor token`);
++ // The key is refused on all five with token.missing: the gate asks for a Bearer and nothing else.
++ for (const p of ['/internal/url-registry/resolved', '/internal/coins/stats']) {
++ const k = await get(p, KEY);
++ assert.strictEqual(k.status, 401, `${p}: the retired key`); assert.strictEqual(k.body.code, 'token.missing');
++ }
++ for (const [p, body] of [['/internal/resolve-anon', { ip: '203.0.113.10' }], ['/internal/identity/legacy-map', { entries: [{ network_user_id: 8, source_system: 'games', source_id: '77' }] }], ['/internal/link-account', { user_id: 8, service: 'games', service_user_id: '77' }]]) {
++ const k = await post(p, body, KEY);
++ assert.strictEqual(k.status, 401, `${p}: the retired key`); assert.strictEqual(k.body.code, 'token.missing');
++ }
++ for (const p of ['/internal/url-registry/resolved', '/internal/coins/stats']) {
++ const none = await get(p, {});
++ assert.strictEqual(none.status, 401, `${p}: no key, no token`); assert.strictEqual(none.body.code, 'token.missing');
++ }
++ // What the key never had to answer for is now a matter of ownership: a Games token maps its own system's
++ // ids, and Live's token still may not (the token-based equivalent of the key's no-ownership-rule note).
++ db.prepare("UPDATE oauth_clients SET client_secret = 'games-secret' WHERE client_id = 'games'").run();
++ const GAMES = { authorization: `Bearer ${(await token({ client_id: 'games', client_secret: 'games-secret', audience: 'openvibe.network' })).body.access_token}` };
++ assert.strictEqual((await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 8, source_system: 'games', source_id: '77' }] }, GAMES)).status, 200, 'a Games token maps its own ids');
++ assert.strictEqual((await post('/internal/link-account', { user_id: 8, service: 'games', service_user_id: '77' }, GAMES)).status, 200, 'and links its own accounts');
const decided = db.prepare("SELECT route FROM principal_usage WHERE principal = 'svc:live' AND allowed = 1").all().map(x => x.route);
for (const route of ['GET /internal/url-registry/resolved', 'GET /internal/coins/stats', 'POST /internal/resolve-anon', 'POST /internal/identity/legacy-map', 'POST /internal/link-account']) {
assert.ok(decided.includes(route), `token use of ${route} is recorded`);
}
-- // ── Legacy key keeps working ──
++ // ── The key is gone: refused at the gate, on every route, with and without a Bearer ──
++ // A syntactically valid service token from a stranger, signed with a key Network does not trust: the
++ // equivalent token-based check for the old "wrong internal key" assertion.
++ const notAKey = serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: ['network.coins.credit'], iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: 'tok_stranger000' }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey);
r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });
-- assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.missing');
r = await post('/internal/coins/credit', credit(), {});
-- assert.strictEqual(r.status, 403);
-- // Every legacy-key call is counted per route (Wave 22 retirement telemetry).
++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.missing');
++ r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key', authorization: `Bearer ${notAKey}` });
++ assert.strictEqual(r.status, 401, 'a header cannot lift a bearer that is not a service token');
++ assert.strictEqual(r.body.code, 'token.bad_signature');
++ // The retired key leaves no trace in the usage audit: only service principals are recorded there now.
await new Promise((resolve) => setTimeout(resolve, 20));
-- const legacy = db.prepare("SELECT route, count FROM principal_usage WHERE principal = 'legacy-key' AND auth = 'internal-key-route' AND allowed = 1").all();
-- assert.ok(legacy.some(row => row.route === 'POST /internal/coins/credit' && row.count >= 1), JSON.stringify(legacy));
++ assert.deepStrictEqual(db.prepare("SELECT DISTINCT principal FROM principal_usage WHERE principal NOT LIKE 'svc:%' AND principal <> 'unknown'").all(), [], 'no principal outside the svc: namespace is audited (a refused stranger is "unknown")');
// ── Revocation: new tokens stop carrying a revoked grant ──
db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = 'live' AND capability = 'network.coins.debit'").run();
@@@ -219,7 +236,8 @@ const server = http.createServer(app);
// ── Usage audit ──
const usage = db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();
assert.ok(usage.some(u => u.principal === 'svc:live' && u.route === 'POST /internal/coins/credit' && u.allowed === 1 && u.count === 1));
-- assert.ok(usage.some(u => u.principal === 'legacy-key' && u.auth === 'internal-key' && u.allowed === 1));
++ assert.ok(usage.some(u => u.auth === 'service-token' && u.allowed === 1), 'every allow is a service token');
++ assert.ok(!usage.some(u => u.auth === 'internal-key' || u.principal === 'legacy-key'), 'the retired key is not an auth path any more');
assert.ok(usage.some(u => u.code === 'capability.denied' && u.allowed === 0));
assert.ok(usage.some(u => u.code === 'capability.owner_denied' && u.allowed === 0), 'ownership denials are audited as denials');
assert.ok(usage.some(u => u.code === 'token.bad_signature'));
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 3adf6135..6bdaf836 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -8,7 +8,8 @@
// Every GET route Express knows after boot (test/security-crawl.js walks the router, so a route added
// later is crawled without anyone listing it) is requested with seeded and nonsense ids, with and
// without a query string, plus probes for files that must never be served, as: anonymous, a user,
--// a staff admin, the owner, a service principal (live), a developer app and the legacy internal key.
++// a staff admin, the owner, a service principal (live), a developer app and a caller still sending the
++// retired X-Internal-Key (which the server must keep refusing and must never answer).
// Then every write route is sent malformed JSON (and anonymous an empty body), and wrong credentials.
// No body or header may carry any secret (as is, URL-encoded or base64); the responses that show a
// secret once (token, credential and session creation) must be Cache-Control: no-store. The log, the
@@@ -61,7 +62,7 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
'/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');
-- const people = { ...w.callers, legacy: { 'x-internal-key': w.sentinels.INTERNAL_API_KEY } };
++ const people = { ...w.callers, key: { 'x-internal-key': w.sentinels.INTERNAL_API_KEY } };
// What each caller must never see: every secret, plus every other caller's credential. Staff
// admins and the owner list verification keys (they make them to hand out); nobody else sees one.
const needlesFor = (who) => {
@@@ -102,8 +103,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
assert.strictEqual(st('owner', '/api/admin/secrets'), 200);
assert.strictEqual(st('owner', '/api/admin/settings'), 200);
assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');
-- assert.strictEqual(st('legacy', '/internal/url-registry/resolved'), 200);
-- assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 403);
++ assert.strictEqual(st('key', '/internal/url-registry/resolved'), 401, 'the retired X-Internal-Key opens nothing');
++ assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 401, 'and nobody without a service token gets in');
// JWKS and OIDC discovery: the public key, never a private part.
const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
@@@ -130,7 +131,7 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
}
// Wrong credentials of every kind on the paths that take them.
const wrong = {
-- 'wrong internal key': { 'x-internal-key': w.sentinels.INTERNAL_API_KEY.slice(0, -1) + 'x' },
++ 'retired internal key': { 'x-internal-key': w.sentinels.INTERNAL_API_KEY },
'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },
'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },
'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex bcfdd4ce..2c009af9 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -27,7 +27,7 @@ db.prepare("INSERT INTO users (id, username, password_hash) VALUES (8, 'changed'
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
--const config = { internalKey: 'k'.repeat(32), baseUrl: ISSUER, loginUrl: ISSUER, networkUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
++const config = { baseUrl: ISSUER, loginUrl: ISSUER, networkUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@@ -61,15 +61,21 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot be refreshed into a full session');
// POST /internal/verify-token (key-only, no caller left) was deleted with the X-Internal-Key retirement (plan T2):
-- // services verify tokens offline against the JWKS.
-- r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
-- assert.strictEqual(r.status, 404, 'internal verify-token is gone');
++ // services verify tokens offline against the JWKS. The gate answers before routing, so the removed path
++ // is indistinguishable from every other unauthenticated internal call: 401, no route behind it.
++ r = await post('/internal/verify-token', { token: sign(7) }, {});
++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.missing');
++ r = await fetch(`${base}/internal/verify-token`, { headers: { 'x-internal-key': 'k'.repeat(32) } }).then((x) => x.status);
++ assert.strictEqual(r, 401, 'and the retired key does not resurrect it');
// nginx only lets loopback reach `location /internal/` (case-sensitive); Express mounts are not,
// so another spelling must not reach the internal router from outside.
const get = (p, headers) => fetch(base + p, { headers }).then((x) => x.status);
-- assert.strictEqual(await get('/INTERNAL/coins/stats', { 'x-internal-key': config.internalKey }), 404, '/INTERNAL/... is not the internal API');
-- assert.strictEqual(await get('/Internal/coins/stats', { 'x-internal-key': config.internalKey }), 404);
-- assert.strictEqual(await get('/internal/coins/stats', { 'x-internal-key': 'k'.repeat(31) + 'x' }), 403, 'wrong internal key');
++ assert.strictEqual(await get('/INTERNAL/coins/stats', {}), 404, '/INTERNAL/... is not the internal API');
++ assert.strictEqual(await get('/Internal/coins/stats', {}), 404);
++ assert.strictEqual(await get('/internal/coins/stats', { 'x-internal-key': 'k'.repeat(32) }), 401, 'the retired internal key');
++ // The equivalent check for a wrong credential: a bearer that is not one of Network's own tokens.
++ assert.strictEqual(await get('/internal/coins/stats', { 'x-internal-key': 'k'.repeat(32), authorization: `Bearer ${sign(7)}` }), 401, 'a user session is not a service token');
++ assert.strictEqual(await get('/internal/coins/stats', {}), 401, 'no key, no token');
// ── Revoked tokens (password changed after iat) ──────────────────
const old = sign(8, { expiresIn: '1h' });
ddiff --git a/test/security-world.js b/test/security-world.js
iindex cb677a9d..3afacabf 100644
---- a/test/security-world.js
++++ b/test/security-world.js
@@@ -144,6 +144,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
return r.body.access_token;
}
const liveToken = await serviceToken('live');
++ const gamesToken = await serviceToken('games');
// ── Developer projects ───────────────────────────────────────────
const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };
@@@ -175,7 +176,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
await call({ authorization: `Bearer ${liveToken}` }, 'POST', '/internal/notifications/push', { user_id: users.alice.id, type: 'system', category: 'system', title: 'alice-private-notification', message: 'for alice only', service: 'live' });
const sess = once('session creation', await call('alice', 'POST', '/api/auth/sessions', { device_name: 'alice-laptop' }));
if (sess.body && sess.body.session_token) secrets['alice session token'] = sess.body.session_token;
-- await call({ 'x-internal-key': sentinels.INTERNAL_API_KEY }, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'games', service_user_id: 'alice-games-771' });
++ await call({ authorization: `Bearer ${gamesToken}` }, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'games', service_user_id: 'alice-games-771' });
await call('alice', 'POST', '/api/history', { service: 'live', url: 'https://openvibe.live/@alice-private-history', title: 'alice-private-history' });
const pushSub = { endpoint: 'https://push.services.mozilla.com/wpush/v2/alice-endpoint-capability-0001', keys: { p256dh: vapid.publicKey, auth: 'alice-push-auth-secret' } };
await call('alice', 'POST', '/api/push/subscribe', { subscription: pushSub });