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).
Changes
@@@ -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 strategyconst 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}`;}@@@ -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 = {@@@ -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');@@@ -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());@@@ -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: {@@@ -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 || [];@@@ -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');@@@ -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);@@@ -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) {@@@ -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: uconst 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);@@@ -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) });@@@ -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(?)');@@@ -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;}@@@ -75,22 +75,27 @@ assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is neverassert.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' } });@@@ -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', er = 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' } });@@@ -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 } } });@@@ -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');@@@ -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.jsconst 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'));@@@ -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' },@@@ -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' });@@@ -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 });
@@ -13,9 +13,7 @@ const router = express.Router();
@@ -13,9 +13,7 @@ const router = express.Router();
module.exports = function createAnalyticsRoutes(analytics, requireAuth, config, { selfToken = () => null } = {}) {
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
// All remote services with their internal URLs and fetch strategy
const REMOTE_SERVICES = [
const REMOTE_SERVICES = [
@@ -57,9 +55,8 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -57,9 +55,8 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
if (svc.auth === 'beacon') return subPath ? null : beaconFallback(svc, days);
if (svc.auth === 'beacon') return subPath ? null : beaconFallback(svc, days);
if (svc.auth === 'internal') {
if (svc.auth === 'internal') {
const t = selfToken(`openvibe.${svc.name}`, [`${svc.name}.analytics.read`]);
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 {
} else {
headers['Authorization'] = `Bearer ${token}`;
headers['Authorization'] = `Bearer ${token}`;
}
}
@@ -118,13 +118,11 @@ async function refreshService(req, serviceName) {
@@ -118,13 +118,11 @@ async function refreshService(req, serviceName) {
const controller = new AbortController();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
const timer = setTimeout(() => controller.abort(), 3000);
try {
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, {
const r = await fetch(info.target, {
method: 'POST',
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,
signal: controller.signal,
});
});
const baseResult = {
const baseResult = {
@@ -36,7 +36,7 @@ function createStaffApi({ db, requireAuth, guard }) {
@@ -36,7 +36,7 @@ function createStaffApi({ db, requireAuth, guard }) {
res.set('Cache-Control', 'private, no-store');
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 || [] });
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 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;
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');
res.set('Cache-Control', 'private, no-store');
@@ -114,7 +114,7 @@ function viewerOf(req) {
@@ -114,7 +114,7 @@ function viewerOf(req) {
} catch { return null; }
} catch { return null; }
}
}
-/** Routes; fullGuard = principals.guard('network.analytics.creator.read', { legacy: false }). */
+/** Routes; fullGuard = principals.guard('network.analytics.creator.read'). */
function router({ fullGuard }) {
function router({ fullGuard }) {
const r = express.Router();
const r = express.Router();
r.use(http.middleware());
r.use(http.middleware());
@@ -25,8 +25,6 @@ module.exports = {
@@ -25,8 +25,6 @@ module.exports = {
issuer: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
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 projects (server/developer, ADR-014)
developer: {
developer: {
@@ -85,13 +85,9 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
@@ -85,13 +85,9 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
async function refreshRank() {
async function refreshRank() {
const scores = Object.assign({}, rank.scores);
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 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);
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.
// Everything else: the navbar's page-view beacon, summed per site over 7 days.
let tools = rank.tools || [];
let tools = rank.tools || [];
@@ -186,7 +186,7 @@ function userRouter(requireAuth) {
@@ -186,7 +186,7 @@ function userRouter(requireAuth) {
return router;
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) {
function internalHandler(db) {
return (req, res) => {
return (req, res) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
@@ -382,7 +382,7 @@ function serviceRoutes(router, principals) {
@@ -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 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:/, '');
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;
const db = req.app.locals.db;
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
const sid = resolveSubject(db, req.params.subject);
const sid = resolveSubject(db, req.params.subject);
@@ -394,7 +394,7 @@ function serviceRoutes(router, principals) {
@@ -394,7 +394,7 @@ function serviceRoutes(router, principals) {
withEtag(res, rec).json(rec);
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 db = req.app.locals.db;
const sid = resolveSubject(db, req.params.subject);
const sid = resolveSubject(db, req.params.subject);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
@@ -405,7 +405,7 @@ function serviceRoutes(router, principals) {
@@ -405,7 +405,7 @@ function serviceRoutes(router, principals) {
withEtag(res, out.record).status(out.status).json(out.record);
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 db = req.app.locals.db;
const sid = resolveSubject(db, req.params.subject);
const sid = resolveSubject(db, req.params.subject);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
@@ -7,8 +7,8 @@
@@ -7,8 +7,8 @@
* exactly what principal_grants allows it for the requested audience. Receivers check the one
* exactly what principal_grants allows it for the requested audience. Receivers check the one
* capability each route performs (openvibe-contracts serviceAuth.requireCapability).
* 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 crypto = require('crypto');
const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
@@ -341,18 +341,18 @@ function recordDecision(db) {
@@ -341,18 +341,18 @@ function recordDecision(db) {
ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);
ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);
return ({ req, capability, principal, allowed, code }) => {
return ({ req, capability, principal, allowed, code }) => {
const bearer = String(req.headers.authorization || '').startsWith('Bearer ');
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 */ }
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}`);
if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);
let check = null;
let check = null;
let record = null;
let record = null;
@@ -363,8 +363,6 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
@@ -363,8 +363,6 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
getPublicKey: (r) => r.app.locals.publicKey,
getPublicKey: (r) => r.app.locals.publicKey,
issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,
issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,
audience: SELF_AUDIENCE,
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,
namespace,
// Denials are final here; an allow is recorded below, after the ownership check.
// Denials are final here; an allow is recorded below, after the ownership check.
onDecision: (d) => { if (!d.allowed) { record(d); require('../observability').principalDenied(d); } },
onDecision: (d) => { if (!d.allowed) { record(d); require('../observability').principalDenied(d); } },
@@ -374,7 +372,7 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
@@ -374,7 +372,7 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
const principal = req.principal;
const principal = req.principal;
// Developer sandbox tokens (env: sandbox) are refused unless Network opted in as an audience
// Developer sandbox tokens (env: sandbox) are refused unless Network opted in as an audience
// (DEV_SANDBOX_AUDIENCES); the signature was verified by check() above.
// (DEV_SANDBOX_AUDIENCES); the signature was verified by check() above.
- if (principal && !principal.legacy) {
+ if (principal) {
const devPolicy = require('../developer/policy');
const devPolicy = require('../developer/policy');
const claims = devPolicy.unverifiedClaims(String(req.headers.authorization || '').slice(7).trim());
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) });
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 } = {}) {
@@ -384,7 +382,7 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
return http.sendProblem(res, 401, env.code, { detail: env.reason, ctx: req.ov });
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 app = ownApp(req);
const self = String(principal.sub).replace(/^svc:/, '');
const self = String(principal.sub).replace(/^svc:/, '');
if (app !== undefined && app !== self) {
if (app !== undefined && app !== self) {
@@ -412,7 +412,7 @@ app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }),
@@ -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.
// 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.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
// 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).
// (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));
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('
@@ -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.
// its grants (mods.grant.manage, service token only); staff (staff.games.manage) from /api/admin/mods.
{
{
const modRouters = require('./identity/mod-principals').routers({
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);
require('./identity/mod-principals').ensureSchema(db);
app.use('/internal/mods', modRouters.internal);
app.use('/internal/mods', modRouters.internal);
@@ -446,7 +446,7 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
@@ -446,7 +446,7 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
const guard = require('./identity/principals').guard;
const guard = require('./identity/principals').guard;
const dataRouters = accountData.routers({
const dataRouters = accountData.routers({
requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,
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('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), dataRouters.me);
app.use('/internal', dataRouters.internal);
app.use('/internal', dataRouters.internal);
@@ -454,19 +454,19 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
@@ -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.
// 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('/api/v1/nodes', nodeRouters.pub);
app.use('/internal/nodes', nodeRouters.internal);
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
// 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).
// (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/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
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).
// 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.
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));
require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));
}
}
@@ -501,9 +501,9 @@ libraryTags.start();
@@ -501,9 +501,9 @@ libraryTags.start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// 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
// 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).
// 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.
// 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 }));
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
// 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);
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
@@ -4,8 +4,7 @@
@@ -4,8 +4,7 @@
// openvibe.network — Internal Server-to-Server API (loopback only)
// openvibe.network — Internal Server-to-Server API (loopback only)
// First-party services resolve identities, move OpenCoins, push notifications and read
// 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
// 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');
const express = require('express');
@@ -16,61 +15,26 @@ const wallet = require('../coins/wallet');
@@ -16,61 +15,26 @@ const wallet = require('../coins/wallet');
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
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
// 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.
// 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' });
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 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 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);
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) {
function ownSourceSystem(req, res, next) {
const p = req.principal;
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 self = String(p.sub).replace(/^svc:/, '');
const entries = req.body && Array.isArray(req.body.entries) ? req.body.entries : [];
const entries = req.body && Array.isArray(req.body.entries) ? req.body.entries : [];
if (entries.some(e => !e || String(e.source_system) !== self)) {
if (entries.some(e => !e || String(e.source_system) !== self)) {
@@ -79,7 +43,7 @@ function ownSourceSystem(req, res, next) {
@@ -79,7 +43,7 @@ function ownSourceSystem(req, res, next) {
next();
next();
}
}
-router.use(requireInternalKey);
+router.use(requireServiceToken);
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
// 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.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', 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)
@@ -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 });
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 {
try {
const db = getDb(req);
const db = getDb(req);
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
@@ -179,7 +142,7 @@ function handleWalletError(res, err) {
@@ -179,7 +142,7 @@ function handleWalletError(res, err) {
// so the answer is held for a minute.
// so the answer is held for a minute.
let _coinStats = { at: 0, data: null };
let _coinStats = { at: 0, data: null };
// Guarded by the ledger capability its caller (Live's home hero) holds. TODO(contracts): network.coins.read.
// 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 {
try {
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
const db = getDb(req);
const db = getDb(req);
@@ -403,7 +366,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
@@ -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.
// 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
// 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.
// 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 contracts = require('openvibe-contracts');
const v = contracts.validate('network.operator-alerts-request@1', req.body);
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) });
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) });
@@ -55,12 +55,10 @@ const initialSvg = (name, size) => {
@@ -55,12 +55,10 @@ const initialSvg = (name, size) => {
};
};
function createAvatarService({ db, config, requireAuth, selfToken = () => null, log = console }) {
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) {
function authHeaders(audience, cap) {
const t = selfToken(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 getUrl = db.prepare('SELECT avatar_url FROM users WHERE id = ?');
const byName = db.prepare('SELECT username, avatar_url FROM users WHERE lower(username) = lower(?)');
const byName = db.prepare('SELECT username, avatar_url FROM users WHERE lower(username) = lower(?)');
@@ -59,9 +59,6 @@ function createSetupRoutes(db, config) {
@@ -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)) {
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.');
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;
return warnings;
}
}
@@ -75,22 +75,27 @@ assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is never
@@ -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.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);
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 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.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(express.json());
app.use('/internal', require('../server/internal/routes'));
app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
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');
const { signToken } = require('../server/auth/routes');
(async () => {
(async () => {
await new Promise(res => server.listen(0, '127.0.0.1', res));
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 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() }));
.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');
let h = await call('/resolve?system=live&id=77');
@@ -117,13 +122,16 @@ const { signToken } = require('../server/auth/routes');
@@ -117,13 +122,16 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual(h.status, 413);
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 }] }) });
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);
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(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');
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: [] }) });
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
assert.strictEqual(h.status, 400);
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) {
if (signToken) {
const token = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });
const token = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });
@@ -40,7 +40,7 @@ db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id)
@@ -40,7 +40,7 @@ db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id)
const modulesLib = require('../server/identity/modules');
const modulesLib = require('../server/identity/modules');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
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 { signToken } = require('../server/auth/routes');
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
const app = express();
const app = express();
@@ -144,7 +144,10 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -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 });
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: chat });
assert.strictEqual(r.status, 404);
assert.strictEqual(r.status, 404);
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' } });
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 ──
// ── 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' } });
modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });
@@ -27,7 +27,7 @@ db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (
@@ -27,7 +27,7 @@ db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
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 { signToken } = require('../server/auth/routes');
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
const app = express();
const app = express();
@@ -112,8 +112,12 @@ const server = http.createServer(app);
@@ -112,8 +112,12 @@ const server = http.createServer(app);
r = await call('GET', '/internal/modules/chat.tts_defaults/usr_01JAB2C3D4E5F6G7H8J9K0MNPB', { headers: { authorization: `Bearer ${chat}` } });
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');
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 } } });
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 } } });
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');
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 } } });
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}`, 'if-match': '0' }, body: { data: { followers: 43 } } });
@@ -30,7 +30,7 @@ const app = express();
@@ -30,7 +30,7 @@ const app = express();
app.use(express.json());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
app.locals.db = db;
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.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.locals.publicKey = keys.publicKey;
const revised = [];
const revised = [];
@@ -55,9 +55,12 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
@@ -55,9 +55,12 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.ok(host, 'Host gets a token for openvibe.network');
assert.ok(host, 'Host gets a token for openvibe.network');
const auth = { authorization: `Bearer ${host}` };
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');
const live = await token('live', 'live-secret');
assert.strictEqual((await post({ source: 'prometheus', alerts: [] }, { authorization: `Bearer ${live}` })).status, 403, 'Live lacks network.operator.alert');
assert.strictEqual((await post({ source: 'prometheus', alerts: [] }, { authorization: `Bearer ${live}` })).status, 403, 'Live lacks network.operator.alert');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// Service principals (server/identity/principals.js): client_credentials tokens, capability guards on
// 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
// node test/principals.test.js
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -30,7 +30,7 @@ const app = express();
@@ -30,7 +30,7 @@ const app = express();
app.use(express.json());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
app.locals.db = db;
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.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
app.use('/oauth', require('../server/auth/oauth-routes'));
@@ -116,7 +116,7 @@ const server = http.createServer(app);
@@ -116,7 +116,7 @@ const server = http.createServer(app);
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');
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' });
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);
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}` });
r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${forged}` });
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.bad_signature');
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.bad_signature');
@@ -129,9 +129,9 @@ const server = http.createServer(app);
@@ -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}` });
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');
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 }));
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.
// 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();
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
@@ -165,16 +165,16 @@ const server = http.createServer(app);
@@ -165,16 +165,16 @@ const server = http.createServer(app);
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${creditOnly}` });
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403);
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 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 KEY = { 'x-internal-key': 'legacy-key' };
const LIVE = { authorization: `Bearer ${full}` };
const LIVE = { authorization: `Bearer ${full}` };
r = await get('/internal/url-registry/resolved', LIVE);
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(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);
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(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);
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(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);
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);
@@ -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');
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);
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');
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);
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']) {
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`);
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' });
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(), {});
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));
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 ──
// ── 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();
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);
@@ -219,7 +236,8 @@ const server = http.createServer(app);
// ── Usage audit ──
// ── Usage audit ──
const usage = db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();
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 === '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.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 === 'capability.owner_denied' && u.allowed === 0), 'ownership denials are audited as denials');
assert.ok(usage.some(u => u.code === 'token.bad_signature'));
assert.ok(usage.some(u => u.code === 'token.bad_signature'));
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
// Every GET route Express knows after boot (test/security-crawl.js walks the router, so a route added
// 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
// 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,
// 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.
// 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
// 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
// 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');
@@ -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'];
'/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 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 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
// 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.
// admins and the owner list verification keys (they make them to hand out); nobody else sees one.
const needlesFor = (who) => {
const needlesFor = (who) => {
@@ -102,8 +103,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -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/secrets'), 200);
assert.strictEqual(st('owner', '/api/admin/settings'), 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('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.
// JWKS and OIDC discovery: the public key, never a private part.
const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
@@ -130,7 +131,7 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -130,7 +131,7 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
}
}
// Wrong credentials of every kind on the paths that take them.
// Wrong credentials of every kind on the paths that take them.
const wrong = {
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 })}` },
'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' })}` },
'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' },
'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },
@@ -27,7 +27,7 @@ db.prepare("INSERT INTO users (id, username, password_hash) VALUES (8, 'changed'
@@ -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 keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
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();
const app = express();
app.use(express.json());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
@@ -61,15 +61,21 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
@@ -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}` });
r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot be refreshed into a full session');
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):
// 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,
// nginx only lets loopback reach `location /internal/` (case-sensitive); Express mounts are not,
// so another spelling must not reach the internal router from outside.
// so another spelling must not reach the internal router from outside.
const get = (p, headers) => fetch(base + p, { headers }).then((x) => x.status);
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) ──────────────────
// ── Revoked tokens (password changed after iat) ──────────────────
const old = sign(8, { expiresIn: '1h' });
const old = sign(8, { expiresIn: '1h' });
@@ -144,6 +144,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -144,6 +144,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
return r.body.access_token;
return r.body.access_token;
}
}
const liveToken = await serviceToken('live');
const liveToken = await serviceToken('live');
+ const gamesToken = await serviceToken('games');
// ── Developer projects ───────────────────────────────────────────
// ── Developer projects ───────────────────────────────────────────
const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };
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 = {} } = {}) {
@@ -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' });
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' }));
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;
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' });
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' } };
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 });
await call('alice', 'POST', '/api/push/subscribe', { subscription: pushSub });