openvibers/openvibe.network Commits

Service principals: client_credentials tokens scoped by principal_grants; coins and notification pushes accept them alongside X-Internal-Key

OP OpenVibers committed 22 Sept 2026 · 9a1c9b1668 · 7 files, +305 −14

Roadmap Wave 1 (ADR-003), openvibe-contracts v0.2.0.
- POST /oauth/token grant_type=client_credentials: a first-party service trades the OAuth client
  id/secret it already has (timing-safe compare) for a 5-minute RS256 token. `cap` is exactly its
  unrevoked grants for the requested audience; `scope` narrows it; asking for anything ungranted
  fails with invalid_scope
- principal_grants (seeded: live -> network.coins.credit/debit/transfer, network.notifications.push)
- /internal/coins/credit|debit|transfer and /internal/notifications/push|push-bulk accept a service
  token instead of the key. A token acts only for its own app_id / service (capability.owner_denied);
  a bad token is never rescued by the legacy key; every other internal route still needs the key
- principal_usage counts each decision per caller, route and auth method, so the key can be retired
  once legacy use on a route reaches zero

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex cb9b9b13..49beee3f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
@@@ -1464,9 +1464,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.1.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
-- "integrity": "sha512-HfrmCKJe6WuleFUo2FGBQl+6ABEh07gxyH+QG+n42TK/nRBuSPo5jbt7FMAvrwB1JlQ4BbqfNbcoTU02qt8Jsw==",
++ "version": "0.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0",
++ "integrity": "sha512-ARq8C+5x6CDimij62hOjqYa3nu4liwxPpPaACp3khb50YHeOPgMFJiXKZlbhsVTTE4xHe/FcMhkcRZAob3iTzw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 5c78e761..a51ceaf1 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js",
++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 16ed22c6..80877ef3 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -9,6 +9,7 @@ const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const subjects = require('../identity/subjects');
++const principals = require('../identity/principals');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
@@@ -134,6 +135,17 @@ router.post('/token', (req, res) => {
const config = getConfig(req);
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
++ // Service principals (Wave 1): a first-party service trades its client credentials for a
++ // short-lived, capability-scoped token. Checked before the user-grant path below.
++ if (grant_type === 'client_credentials') {
++ const out = principals.issueToken(db, {
++ clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
++ privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
++ });
++ res.set('Cache-Control', 'no-store');
++ return res.status(out.status).json(out.body);
++ }
++
// Validate client credentials
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
if (!client || client.client_secret !== client_secret) {
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 8da07914..8110407f 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -783,6 +783,8 @@ function initDb(dbPath) {
// Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.
require('../identity/subjects').ensureSchema(db);
++ // Service principals: grants per OAuth client + usage counters (server/identity/principals.js).
++ require('../identity/principals').ensureSchema(db);
console.log('[DB] Central database initialized');
return db;
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
nnew file mode 100644
iindex 00000000..f64f62da
---- /dev/null
++++ b/server/identity/principals.js
@@@ -0,0 +1,145 @@
++'use strict';
++/**
++ * Service principals (roadmap Wave 1, ADR-003; contract identity.service-token-claims@1).
++ *
++ * A first-party service authenticates to /oauth/token with grant_type=client_credentials and the
++ * OAuth client id/secret it already has, and receives a 5-minute RS256 token whose `cap` claim is
++ * 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.
++ */
++const crypto = require('crypto');
++const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
++
++const TOKEN_TTL_S = 300;
++const SELF_AUDIENCE = 'openvibe.network';
++
++// Initial grants: what each service does against Network today (docs/roadmap-baseline/04-cross-service.md).
++const DEFAULT_GRANTS = [
++ ['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],
++ ['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
++ ['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],
++ ['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
++];
++
++function ensureSchema(db) {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS principal_grants (
++ client_id TEXT NOT NULL,
++ capability TEXT NOT NULL,
++ audience TEXT NOT NULL,
++ namespaces TEXT NOT NULL DEFAULT '[]',
++ granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ granted_by TEXT,
++ revoked_at DATETIME,
++ PRIMARY KEY (client_id, capability, audience)
++ );
++ CREATE TABLE IF NOT EXISTS principal_usage (
++ principal TEXT NOT NULL,
++ route TEXT NOT NULL,
++ capability TEXT NOT NULL,
++ auth TEXT NOT NULL,
++ allowed INTEGER NOT NULL,
++ code TEXT NOT NULL DEFAULT '',
++ count INTEGER NOT NULL DEFAULT 0,
++ first_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ last_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (principal, route, auth, allowed, code)
++ );
++ `);
++ const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");
++ for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {
++ if (db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) seed.run(client, cap, aud, JSON.stringify(ns));
++ }
++}
++
++function grantsFor(db, clientId, audience) {
++ return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL ORDER BY capability')
++ .all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));
++}
++
++function sameSecret(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
++/**
++ * grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).
++ * `scope` (space-separated capability ids) narrows the token; omitted = every grant for the audience.
++ */
++function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {
++ const client = db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
++ if (!client || !sameSecret(client.client_secret, clientSecret)) return { status: 401, body: { error: 'invalid_client', error_description: 'Invalid client credentials' } };
++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(client.client_id)) return { status: 400, body: { error: 'unauthorized_client', error_description: 'client id is not a service principal' } };
++ const aud = String(audience || '').trim();
++ if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };
++ const grants = grantsFor(db, client.client_id, aud);
++ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
++ const chosen = wanted ? grants.filter(g => wanted.includes(g.capability)) : grants;
++ if (wanted && wanted.some(w => !chosen.find(g => g.capability === w))) {
++ return { status: 400, body: { error: 'invalid_scope', error_description: `not granted: ${wanted.filter(w => !chosen.find(g => g.capability === w)).join(' ')}` } };
++ }
++ if (!chosen.length) return { status: 400, body: { error: 'invalid_scope', error_description: `no grants for audience ${aud}` } };
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: `svc:${client.client_id}`, actor_type: 'service', aud: [aud],
++ cap: chosen.map(g => g.capability),
++ ns: [...new Set(chosen.flatMap(g => g.namespaces))],
++ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ };
++ assertValid('identity.service-token-claims@1', claims);
++ return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };
++}
++
++/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */
++function recordDecision(db) {
++ const up = db.prepare(`INSERT INTO principal_usage (principal, route, capability, auth, allowed, code, count) VALUES (?, ?, ?, ?, ?, ?, 1)
++ ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);
++ return ({ req, capability, principal, allowed, code }) => {
++ const who = principal ? (principal.legacy ? 'legacy-key' : principal.sub) : 'unknown';
++ const auth = principal && principal.legacy ? 'internal-key' : 'service-token';
++ 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.
++ */
++function guard(capability, { ownApp } = {}) {
++ if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);
++ let check = null;
++ let record = null;
++ return function principalGuard(req, res, next) {
++ if (!check) {
++ record = recordDecision(req.app.locals.db);
++ check = serviceAuth.requireCapability(capability, {
++ getPublicKey: (r) => r.app.locals.publicKey,
++ issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,
++ audience: SELF_AUDIENCE,
++ legacy: (r) => r.internalKeyOk === true,
++ // Denials are final here; an allow is recorded below, after the ownership check.
++ onDecision: (d) => { if (!d.allowed) record(d); },
++ });
++ }
++ check(req, res, () => {
++ const principal = req.principal;
++ if (ownApp && principal && !principal.legacy) {
++ const app = ownApp(req);
++ const self = String(principal.sub).replace(/^svc:/, '');
++ if (app !== undefined && app !== self) {
++ record({ req, capability, principal, allowed: false, code: 'capability.owner_denied' });
++ return http.sendProblem(res, 403, 'capability.owner_denied', { detail: `${principal.sub} may only act for app_id '${self}'` });
++ }
++ }
++ record({ req, capability, principal, allowed: true, code: null });
++ next();
++ });
++ };
++}
++
++module.exports = { ensureSchema, issueToken, guard, grantsFor, recordDecision, DEFAULT_GRANTS, TOKEN_TTL_S, SELF_AUDIENCE };
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 3fb0ec74..ef6624b9 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -20,11 +20,19 @@ function getConfig(req) { return req.app.locals.config; }
function requireInternalKey(req, res, next) {
const key = req.headers['x-internal-key'];
const config = getConfig(req);
-- if (!key || key !== config.internalKey) {
-- return res.status(403).json({ error: 'Invalid or missing internal key' });
++ if (key && key === config.internalKey) {
++ req.internalKeyOk = true;
++ return next();
}
-- next();
++ // 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}`)) return next();
++ return res.status(403).json({ error: 'Invalid or missing internal key' });
}
++const principals = require('../identity/principals');
++const TOKEN_ROUTES = new Set(['POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk']);
++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);
router.use(requireInternalKey);
router.use('/identity', require('../identity/internal-routes'));
@@@ -249,7 +257,7 @@ router.get('/coins/stats', (req, res) => {
// ── POST /internal/coins/credit ──────────────────────────────
// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }
// → { balance }
--router.post('/coins/credit', (req, res) => {
++router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {
try {
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const result = wallet.credit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
@@@ -261,7 +269,7 @@ router.post('/coins/credit', (req, res) => {
// ── POST /internal/coins/debit ───────────────────────────────
// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }
--router.post('/coins/debit', (req, res) => {
++router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: forApp }), (req, res) => {
try {
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const result = wallet.debit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
@@@ -274,7 +282,7 @@ router.post('/coins/debit', (req, res) => {
// ── POST /internal/coins/transfer ────────────────────────────
// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }
// → { from_balance, to_balance } (atomic)
--router.post('/coins/transfer', (req, res) => {
++router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownApp: forApp }), (req, res) => {
try {
const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const result = wallet.transfer(getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });
@@@ -418,7 +426,7 @@ router.post('/events/stream-live', async (req, res) => {
// ── Push Single Notification ─────────────────────────────────
// POST /internal/notifications/push
// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
--router.post('/notifications/push', (req, res) => {
++router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
@@@ -438,7 +446,7 @@ router.post('/notifications/push', (req, res) => {
// ── Push Bulk Notifications ──────────────────────────────────
// POST /internal/notifications/push-bulk
// Body: { user_ids: [], type, title, message, ... }
--router.post('/notifications/push-bulk', (req, res) => {
++router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
ddiff --git a/test/principals.test.js b/test/principals.test.js
nnew file mode 100644
iindex 00000000..cdf7601b
---- /dev/null
++++ b/test/principals.test.js
@@@ -0,0 +1,124 @@
++'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.
++// node test/principals.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { serviceAuth, validate } = require('openvibe-contracts');
++const { initDb } = require('../server/db/database');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));
++const log = console.log; console.log = () => {};
++const db = initDb(path.join(dir, 'network.db'));
++console.log = log;
++
++db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
++db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
++db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++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.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/internal', require('../server/internal/routes'));
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', ...form }) })
++ .then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }));
++ const post = (p, body, headers) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
++ .then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', body: await r.json() }));
++ const credit = (extra = {}) => ({ user_id: 7, app_id: 'live', amount: 5, reason: 'test', idempotency_key: `k-${crypto.randomBytes(4).toString('hex')}`, ...extra });
++
++ // ── Issuing ──
++ let t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.strictEqual(t.cache, 'no-store');
++ const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
++ assert.ok(claims.ok, claims.reason);
++ assert.strictEqual(claims.claims.sub, 'svc:live');
++ assert.deepStrictEqual(claims.claims.cap, ['network.coins.credit', 'network.coins.debit', 'network.coins.transfer', 'network.notifications.push']);
++ assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
++ assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
++ const full = t.body.access_token;
++
++ t = await token({ client_id: 'live', client_secret: 'wrong', audience: 'openvibe.network' });
++ assert.strictEqual(t.status, 401); assert.strictEqual(t.body.error, 'invalid_client');
++ t = await token({ client_id: 'live', client_secret: 'live-secret' });
++ assert.strictEqual(t.status, 400, 'audience required');
++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit' });
++ assert.strictEqual(t.status, 200); assert.strictEqual(t.body.scope, 'network.coins.credit', 'scope narrows the token');
++ const creditOnly = t.body.access_token;
++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit identity.subject.resolve' });
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'asking for an ungranted capability fails');
++ t = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.network' });
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'a client with no grants gets no token');
++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.media' });
++ assert.strictEqual(t.status, 400, 'no grants for that audience');
++
++ // ── Guarded routes ──
++ let r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${full}` });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.strictEqual(r.body.balance, 5);
++ r = await post('/internal/coins/credit', credit({ app_id: 'games' }), { authorization: `Bearer ${full}` });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token acts only for its own app');
++ assert.ok(r.type.startsWith('application/problem+json'));
++ r = await post('/internal/coins/debit', credit({ amount: 1 }), { authorization: `Bearer ${creditOnly}` });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');
++ r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ 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');
++ 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');
++ const expired = serviceAuth.signServiceToken({ ...claims.claims, iat: 1000, exp: 1300 }, keys.privateKey);
++ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${expired}` });
++ assert.strictEqual(r.body.code, 'token.expired');
++ const wrongAud = serviceAuth.signServiceToken({ ...claims.claims, aud: ['openvibe.media'] }, keys.privateKey);
++ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${wrongAud}` });
++ assert.strictEqual(r.body.code, 'token.wrong_audience');
++ 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/coins/stats`, { headers: { authorization: `Bearer ${full}` } }).then(x => ({ status: x.status }));
++ assert.strictEqual(r.status, 403, 'unguarded internal routes still need the key');
++ r = await post('/internal/identity/legacy-map', { entries: [] }, { authorization: `Bearer ${full}` });
++ assert.strictEqual(r.status, 403, 'token routes are an explicit list');
++
++ // ── Legacy key keeps working ──
++ r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });
++ assert.strictEqual(r.status, 200);
++ r = await post('/internal/coins/credit', credit(), {});
++ assert.strictEqual(r.status, 403);
++
++ // ── 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();
++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });
++ assert.ok(!t.body.scope.split(' ').includes('network.coins.debit'));
++
++ // ── 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.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'));
++
++ server.close(); db.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ console.log('principals: all checks passed');
++})().catch(err => { console.error(err); process.exit(1); });