openvibers/openvibe.network Commits
Issue node tokens and add node self routes and topology details (#19)
Changes
@@@ -228,6 +228,14 @@ router.post('/token', async (req, res) => {// 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') {++ // A paired machine (node principal, docs/t2-cells-and-node-principal.md section 4.3): its own credential store.++ if (/^nod_/.test(client_id)) {++ const out = await require('../registry/node-principals').issueNodeToken(db, {++ clientId: client_id, clientSecret: client_secret, audience: req.body.audience,++ privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,++ });++ return res.status(out.status).json(out.body);++ }const out = await 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,@@@ -498,6 +498,8 @@ const notifyAccountData = async (userId, n) => await notificationService.create(app.use('/internal', pairingRouters.internal);// The person's own machines (slice N5): list and revoke, session only, same limit as /api/v1/me/blocks.app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), require('./registry/node-principals').userRouter(requireAuth));++ // The machine itself (slice N4c): its own capabilities and credential, resolved from its node token alone.++ app.use('/api/v1/node/self', require('./identity/principals').guard('network.node.self.manage'), require('./registry/node-principals').selfRouter());}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@@@ -88,14 +88,25 @@ async function getCell(db, id) {return r ? cellView(r) : null;}--/** Everything registered in one cell: its nodes (owner, trust, health), service instances and offers. Internal only. */--async function topology(db, id) {++/** A live paired machine with no platform_nodes row is 'up' while it signed in within this window. */++const SEEN_UP_MS = 10 * 60 * 1000;++++/**++ * Everything registered in one cell: its nodes (owner, trust, health, last seen, the capabilities they presented),++ * service instances and offers. Internal only.++ */++async function topology(db, id, now = Date.now()) {const cell = await getCell(db, id);if (!cell) return null;const health = new Map((await db.prepare('SELECT id, status FROM platform_nodes').all()).map((r) => [r.id, r.status]));++ const caps = new Map((await db.prepare(`SELECT c.node_id, c.doc FROM platform_node_capabilities c++ JOIN platform_node_principals p ON p.node_id = c.node_id WHERE p.home_cell = ?`).all(cell.id)).map((r) => [r.node_id, JSON.parse(r.doc)]));++ const seenUp = (p) => (p.status === 'active' && p.last_seen_at && now - Date.parse(p.last_seen_at) <= SEEN_UP_MS ? 'up' : 'unknown');const nodes = (await db.prepare('SELECT * FROM platform_node_principals WHERE home_cell = ? ORDER BY node_id').all(cell.id)).map((p) => ({-- principal_id: p.id, node_id: p.node_id, owner: { kind: p.owner_kind, project_id: p.project_id || null },-- trust: p.trust, status: p.status, health: health.get(p.node_id) || 'unknown',++ principal_id: p.id, node_id: p.node_id, name: p.name || null, owner: { kind: p.owner_kind, project_id: p.project_id || null },++ trust: p.trust, status: p.status, health: health.get(p.node_id) || seenUp(p), last_seen_at: p.last_seen_at || null,++ paired_for: p.paired_by_service ? { service: p.paired_by_service, ref: p.pairing_ref } : null,++ capabilities: caps.get(p.node_id) || null,}));const instances = (await db.prepare('SELECT * FROM platform_service_instances WHERE cell = ? ORDER BY service, id').all(cell.id)).map((r) => ({id: r.id, service: r.service, version: r.version, cell: r.cell, region: cell.region, node: r.node_id, endpoints: JSON.parse(r.endpoints), state: r.state,@@@ -15,14 +15,24 @@* POST /internal/node-principals/:id/revoke network.node.manage → the principal, revoked (idempotent)* GET /api/v1/me/nodes session → the person's own machines, newest first* POST /api/v1/me/nodes/:id/revoke session → their principal, revoked (idempotent)++ *++ * The paired machine itself (section 4.3, slice N4c): its credential buys a node token (sub node:nod_…) at++ * POST /oauth/token, and with it the machine manages only its own row. The routes resolve the node from the verified++ * token, never from the body or the path.++ * POST /oauth/token client_id=nod_… the credential → 200 node token (oauth-routes.js)++ * PUT /api/v1/node/self/capabilities network.node.self.manage → 200 {node_id, reported_at}++ * POST /api/v1/node/self/credential network.node.self.manage → 200 {principal, node_id, credential, …}*/const crypto = require('crypto');const express = require('express');--const { ids, http } = require('openvibe-contracts');++const { ids, http, serviceAuth, validate, assertValid } = require('openvibe-contracts');const { BOOTSTRAP_CELL, RegistryError } = require('./cells');++const { TOKEN_TTL_S, SELF_AUDIENCE } = require('../identity/principals');const CODE_TTL_MS = 10 * 60 * 1000;const MAX_TRIES = 5;++const PREV_GRACE_MS = 60 * 1000; // a rotated-out credential still works this long (Bot's grace window)++const SEEN_EVERY_MS = 60 * 1000; // last_seen_at is written at most this oftenconst SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const PAIRING_ID = /^pair_[0-9A-HJKMNP-TV-Z]{26}$/;const PRINCIPAL_ID = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -233,6 +243,115 @@ function userRouter(requireAuth, now = () => Date.now()) {return router;}++/** Every refusal of a node's credential, whatever the reason: one status, one body. */++const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });++++/**++ * grant_type=client_credentials for a node principal (client_id nod_…): its current credential, or the previous one++ * while prev_valid_until is in the future, buys a 300 s token for openvibe.network (network.node.self.manage) or for++ * the service that paired it (no capability: that service authorises the node by its own binding). The secret is only++ * ever hashed and compared in constant time; it is never logged or returned. → { status, body } (OAuth shapes).++ */++async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey, issuer, now = Date.now() }) {++ if (typeof clientId !== 'string' || !PRINCIPAL_ID.test(clientId) || typeof clientSecret !== 'string' || !clientSecret) return INVALID_CLIENT;++ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(clientId);++ const hash = sha256(clientSecret);++ // Both comparisons always run, so the answer's timing does not tell which secret (if any) matched.++ const current = sameHash(p && p.credential_hash, hash);++ const previous = sameHash(p && p.credential_prev_hash, hash);++ const prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);++ if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;++ const aud = typeof audience === 'string' ? audience.trim() : '';++ if (aud !== SELF_AUDIENCE && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {++ return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}` } };++ }++ const iat = Math.floor(now / 1000);++ const claims = {++ iss: issuer, sub: `node:${p.id}`, actor_type: 'node', aud: [aud],++ cap: aud === SELF_AUDIENCE ? ['network.node.self.manage'] : [],++ ...(p.owner_kind === 'project' && p.project_id ? { project_id: p.project_id } : {}),++ iat, exp: iat + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,++ };++ assertValid('identity.service-token-claims@1', claims);++ const access = serviceAuth.signServiceToken(claims, privateKey);++ // The health of a paired machine: written at most once a minute, so a busy machine is not a write per token.++ await db.prepare('UPDATE platform_node_principals SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)')++ .run(iso(now), p.id, iso(now - SEEN_EVERY_MS));++ return { status: 200, body: { access_token: access, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };++}++++const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;++++/**++ * The node principal a verified token speaks for, live. A token of any other actor (svc:, app:) is 403++ * capability.owner_denied whatever it holds; a node revoked since its token was issued (≤ 300 s ago) is 401.++ */++async function selfOf(db, req) {++ const m = NODE_SUB.exec(String(req.principal && req.principal.sub || ''));++ if (!m) throw new RegistryError(403, 'capability.owner_denied', 'only a node principal manages itself');++ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(m[1]);++ if (!p || p.status !== 'active') throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');++ return p;++}++++/**++ * Store the capabilities the machine presents (platform.node-capabilities@1, verbatim). Its node_id must be the++ * machine's own. Throws RegistryError. → { node_id, reported_at }++ */++async function putCapabilities(db, p, body, now = Date.now()) {++ const v = validate('platform.node-capabilities@1', body);++ if (!v.valid) throw new RegistryError(400, 'registry.invalid_capabilities', v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 500));++ if (body.node_id !== p.node_id) throw new RegistryError(409, 'registry.node_mismatch', `this token is node ${p.node_id}, not ${String(body.node_id).slice(0, 80)}`);++ const at = iso(now);++ await db.prepare(`INSERT INTO platform_node_capabilities (node_id, doc, reported_at) VALUES (?, ?, ?)++ ON CONFLICT (node_id) DO UPDATE SET doc = excluded.doc, reported_at = excluded.reported_at`).run(p.node_id, JSON.stringify(body), at);++ return { node_id: p.node_id, reported_at: at };++}++++/**++ * The machine rotates its own credential: a new one (returned this once), the old one valid PREV_GRACE_MS more so a++ * machine that crashes mid-rotation can still sign in. Throws RegistryError. → { principal, node_id, credential, prev_valid_until }++ */++async function rotateCredential(db, id, now = Date.now()) {++ const credential = crypto.randomBytes(32).toString('base64url');++ const at = iso(now);++ const until = iso(now + PREV_GRACE_MS);++ const row = await db.tx(async (t) => {++ const p = await t.prepare('SELECT * FROM platform_node_principals WHERE id = ? FOR UPDATE').get(id);++ if (!p || p.status !== 'active') return null;++ await t.prepare(`UPDATE platform_node_principals SET credential_prev_hash = credential_hash,++ prev_valid_until = CASE WHEN credential_hash IS NULL THEN NULL ELSE ? END, credential_hash = ?, updated_at = ?++ WHERE id = ? AND status = 'active'`).run(until, sha256(credential), at, id);++ return p;++ });++ if (!row) throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');++ return { principal: row.id, node_id: row.node_id, credential, prev_valid_until: row.credential_hash ? until : null };++}++++// Mounted at /api/v1/node/self behind principals.guard('network.node.self.manage') (server/index.js). The guard checks++// the token's signature, audience and capability; ownership is selfOf's, before any write.++function selfRouter(now = () => Date.now()) {++ const router = express.Router();++ router.use(http.middleware());++ const send = async (res, fn) => {++ res.set('Cache-Control', 'no-store');++ try { return await fn(); } catch (e) {++ if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });++ console.error('[Node self]', e.message);++ return http.sendProblem(res, 500, 'registry.failed', { detail: 'the node could not be changed' });++ }++ };++ router.put('/capabilities', express.json({ limit: '64kb' }), async (req, res) => await send(res, async () => {++ const db = req.app.locals.db;++ res.json(await putCapabilities(db, await selfOf(db, req), req.body, now()));++ }));++ router.post('/credential', async (req, res) => await send(res, async () => {++ const db = req.app.locals.db;++ res.json(await rotateCredential(db, (await selfOf(db, req)).id, now()));++ }));++ return router;++}++function routers({ guard, now = () => Date.now() }) {const problem = (res, e) => http.sendProblem(res, e.status, e.code, { detail: e.message });const internal = express.Router();@@@ -279,4 +398,4 @@ function routers({ guard, now = () => Date.now() }) {return { internal, pairing };}--module.exports = { CODE_TTL_MS, MAX_TRIES, createPairing, redeem, revoke, principalView, routers, userRouter };++module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, createPairing, redeem, revoke, principalView, routers, userRouter, issueNodeToken, selfRouter };@@@ -100,12 +100,13 @@ try {assert.deepStrictEqual((await globalThis.__ovNetworkMigrate({ dir: MIGRATIONS })).applied, []);await globalThis.__ovNetworkDdl(require('fs').readFileSync(require('path').join(MIGRATIONS, '0014_node_pairing.sql'), 'utf8'));-- // ── A node principal is not a service principal: it can hold no client-credentials token.++ // ── A node principal is not a service principal: an oauth_clients secret under a nod_ id buys nothing. Every nod_++ // client goes to the node branch (slice N4c), which knows only node credentials: 401 invalid_client.const nodClient = nod();await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES (?, 'node-secret', 'node', '[]')").run(nodClient);const t = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: nodClient, client_secret: 'node-secret', audience: 'openvibe.network' }) });-- assert.strictEqual(t.status, 400);-- assert.strictEqual((await t.json()).error, 'unauthorized_client');++ assert.strictEqual(t.status, 401);++ assert.strictEqual((await t.json()).error, 'invalid_client');// ── Host's node report: first-named machines become platform principals; a project's or a revoked machine refuses the report.const host = { authorization: `Bearer ${await token('host', 'host-secret')}` };nnew file mode 100644@@@ -0,0 +1,269 @@++'use strict';++// Node tokens and self routes (plan T2, docs/t2-cells-and-node-principal.md sections 4.3 and 7, slice N4c): a paired++// machine's credential buys a node token; with it the machine changes its own capabilities and credential and nobody++// else's; rotation keeps the old credential 60 s; revoke kills both; topology shows when the machine was last seen.++// node test/node-tokens.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { ids, validate, serviceAuth } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const principals = require('../server/identity/principals');++const nodePrincipals = require('../server/registry/node-principals');++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++// A service that holds network.node.self.manage anyway: it still manages no machine (403 capability.owner_denied).++await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('host', 'network.node.self.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();++++const alex = `usr_${ids.ulid()}`;++await db.prepare("INSERT INTO users (id, username, display_name, password_hash, subject_id) VALUES (9201, 'tokalex', 'Alex', 'x', ?)").run(alex);++++const ISSUER = 'https://openvibe.network';++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const app = express();++app.use(express.urlencoded({ extended: true }));++app.locals.db = db;++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'));++const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage') });++app.use('/api/v1/node-pairing', pr.pairing);++app.use('/internal', pr.internal);++app.use('/api/v1/node/self', principals.guard('network.node.self.manage'), nodePrincipals.selfRouter());++const cellRouters = require('../server/registry/cells').routers({ readGuard: principals.guard('network.registry.read') });++app.use('/internal/registry', cellRouters.internal);++const server = http.createServer(app);++++// Everything written to stdout or stderr while the routes run, to prove no secret reaches a log line.++const logs = [];++const out = process.stdout.write.bind(process.stdout);++const err = process.stderr.write.bind(process.stderr);++process.stdout.write = (c, ...a) => { logs.push(String(c)); return out(c, ...a); };++process.stderr.write = (c, ...a) => { logs.push(String(c)); return err(c, ...a); };++++await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++const base = `http://127.0.0.1:${server.address().port}`;++// Every answer except the two that hand out a credential (redeem, rotation), to prove none of them carries one.++const answers = [];++const tokenCall = (id, secret, audience) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, ...(audience === undefined ? {} : { audience }) }) })++ .then(async (x) => { const text = await x.text(); answers.push(text); return { status: x.status, text, body: JSON.parse(text) }; });++const call = (method, p, body, headers = {}, { secret = false } = {}) => fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })++ .then(async (x) => { const text = await x.text(); if (!secret) answers.push(text); let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: x.status, headers: x.headers, text, body: json }; });++const bearer = (t) => ({ authorization: `Bearer ${t}` });++const row = (id) => db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(id);++const capsOf = (nodeId) => db.prepare('SELECT * FROM platform_node_capabilities WHERE node_id = ?').get(nodeId);++const doc = (nodeId, cores = 4) => ({++ node_id: nodeId, cpu: { cores }, arch: 'arm64', memory_mb: 4096, storage: { capacity_gb: 64, available_gb: 32, kind: 'ssd' },++ network: { ingress_mbps: 100, egress_mbps: 20 }, regions: ['us-west'], tags: [], costs: { per_hour_usd: 0 }, capabilities: ['node:http'],++ agent_version: '0.1.0', updated_at: '2026-10-02T00:00:00Z',++});++let passed = 0;++const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };++try {++ const bot = bearer((await tokenCall('bot', 'bot-secret', 'openvibe.network')).body.access_token);++ const live = bearer((await tokenCall('live', 'live-secret', 'openvibe.network')).body.access_token);++ const credentials = [];++ const pair = async (name) => {++ const code = await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: `robot-${ids.ulid()}` }, bot);++ const r = await call('POST', '/api/v1/node-pairing', { pairing: code.body.pairing_id, code: code.body.code, name }, {}, { secret: true });++ assert.strictEqual(r.status, 201, r.text);++ credentials.push(r.body.credential);++ return r.body;++ };++ const nodeToken = async (n, audience = 'openvibe.network') => {++ const r = await tokenCall(n.principal, n.credential, audience);++ assert.strictEqual(r.status, 200, r.text);++ return r.body.access_token;++ };++ const A = await pair('Rover A');++ const B = await pair('Rover B');++++ await check('a credential buys a node token whose claims are identity.service-token-claims@1', async () => {++ assert.strictEqual((await row(A.principal)).last_seen_at, null);++ const r = await tokenCall(A.principal, A.credential, 'openvibe.network');++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.token_type, r.body.expires_in, r.body.scope], ['Bearer', 300, 'network.node.self.manage']);++ const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });++ assert.ok(v.ok, v.reason);++ assert.ok(validate('identity.service-token-claims@1', v.claims).valid);++ const c = v.claims;++ assert.deepStrictEqual([c.iss, c.sub, c.actor_type, c.aud, c.cap], [ISSUER, `node:${A.principal}`, 'node', ['openvibe.network'], ['network.node.self.manage']]);++ assert.match(c.sub, /^node:nod_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(c.exp - c.iat, principals.TOKEN_TTL_S);++ assert.match(c.jti, /^tok_[0-9a-f]{24}$/);++ assert.ok(!('project_id' in c) && !('ns' in c) && !('env' in c), 'a person\'s machine carries no project');++ const seen = (await row(A.principal)).last_seen_at;++ assert.ok(seen && Math.abs(Date.parse(seen) - Date.now()) < 5000, 'issuance sets last_seen_at');++ await nodeToken(A);++ assert.strictEqual((await row(A.principal)).last_seen_at, seen, 'not rewritten within 60 s');++ });++++ await check('the audience is Network or the pairing service, nothing else', async () => {++ const r = await tokenCall(A.principal, A.credential, 'openvibe.bot');++ assert.strictEqual(r.status, 200, r.text);++ const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.bot' });++ assert.ok(v.ok, v.reason);++ assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');++ for (const aud of ['openvibe.chat', 'openvibe.network.evil', '', undefined]) {++ const bad = await tokenCall(A.principal, A.credential, aud);++ assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));++ }++ // A token for Bot is no use on Network's self routes.++ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(r.body.access_token))).status, 401);++ });++++ await check('unknown, revoked or wrong secret: 401 with one identical answer', async () => {++ const C = await pair('Revoked');++ assert.strictEqual((await call('POST', `/internal/node-principals/${C.principal}/revoke`, null, bot)).status, 200);++ const refusals = [++ await tokenCall(A.principal, 'not-the-credential', 'openvibe.network'),++ await tokenCall(A.principal, B.credential, 'openvibe.network'),++ await tokenCall(A.principal, '', 'openvibe.network'),++ await tokenCall(`nod_${ids.ulid()}`, A.credential, 'openvibe.network'),++ await tokenCall('nod_nope', A.credential, 'openvibe.network'),++ await tokenCall(C.principal, C.credential, 'openvibe.network'),++ await tokenCall(C.principal, C.credential, 'openvibe.chat'),++ ];++ for (const r of refusals) assert.deepStrictEqual([r.status, r.text], [401, refusals[0].text]);++ assert.strictEqual(refusals[0].body.error, 'invalid_client');++ });++++ await check('rotation: the new credential works, the old one 60 s more; revoke kills both', async () => {++ const R = await pair('Rotator');++ const before = await row(R.principal);++ const rot = await call('POST', '/api/v1/node/self/credential', null, bearer(await nodeToken(R)), { secret: true });++ assert.strictEqual(rot.status, 200, rot.text);++ assert.strictEqual(rot.headers.get('cache-control'), 'no-store');++ assert.deepStrictEqual([rot.body.principal, rot.body.node_id], [R.principal, R.node_id]);++ credentials.push(rot.body.credential);++ assert.notStrictEqual(rot.body.credential, R.credential);++ const after = await row(R.principal);++ assert.deepStrictEqual([after.credential_prev_hash, after.prev_valid_until], [before.credential_hash, rot.body.prev_valid_until]);++ assert.strictEqual(after.credential_hash, crypto.createHash('sha256').update(rot.body.credential).digest('hex'));++ const at = Date.parse(after.prev_valid_until) - nodePrincipals.PREV_GRACE_MS;++ assert.ok(Math.abs(at - Date.now()) < 5000, 'the old credential lives 60 s from now');++ await nodeToken({ principal: R.principal, credential: rot.body.credential });++ await nodeToken(R);++ // The clock, injected: the old credential at 59 s still works, at 61 s it is refused like any wrong secret.++ const issue = (credential, now) => nodePrincipals.issueNodeToken(db, { clientId: R.principal, clientSecret: credential, audience: 'openvibe.network', privateKey: keys.privateKey, issuer: ISSUER, now });++ assert.strictEqual((await issue(R.credential, at + 59_000)).status, 200);++ const late = await issue(R.credential, at + 61_000);++ assert.deepStrictEqual([late.status, late.body.error], [401, 'invalid_client']);++ assert.strictEqual((await issue(rot.body.credential, at + 61_000)).status, 200, 'the new one is unaffected');++ // Revoke within the grace window: neither secret works any more.++ const D = await pair('Rotate then revoke');++ const rotD = await call('POST', '/api/v1/node/self/credential', null, bearer(await nodeToken(D)), { secret: true });++ credentials.push(rotD.body.credential);++ await nodeToken(D);++ const me = await call('POST', `/internal/node-principals/${D.principal}/revoke`, null, bot);++ assert.strictEqual(me.status, 200);++ const dead = await row(D.principal);++ assert.deepStrictEqual([dead.credential_prev_hash, dead.prev_valid_until], [null, null]);++ for (const cred of [D.credential, rotD.body.credential]) {++ const r = await tokenCall(D.principal, cred, 'openvibe.network');++ assert.deepStrictEqual([r.status, r.body.error], [401, 'invalid_client']);++ }++ });++++ await check('capabilities: the machine\'s own doc is stored; another node_id is 409; a bad doc 400', async () => {++ const a = bearer(await nodeToken(A));++ const ok = await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), a);++ assert.strictEqual(ok.status, 200, ok.text);++ assert.strictEqual(ok.body.node_id, A.node_id);++ assert.deepStrictEqual(JSON.parse((await capsOf(A.node_id)).doc), doc(A.node_id));++ const again = await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id, 8), a);++ assert.strictEqual(again.status, 200, 'an upsert');++ assert.strictEqual(JSON.parse((await capsOf(A.node_id)).doc).cpu.cores, 8);++ const bad = await call('PUT', '/api/v1/node/self/capabilities', { node_id: A.node_id }, a);++ assert.deepStrictEqual([bad.status, bad.body.code], [400, 'registry.invalid_capabilities']);++ });++++ await check('node A\'s valid token cannot change node B\'s capabilities or credential', async () => {++ const b = bearer(await nodeToken(B));++ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(B.node_id), b)).status, 200);++ const bRow = await row(B.principal);++ const bCaps = await capsOf(B.node_id);++ const a = bearer(await nodeToken(A));++ const mismatch = await call('PUT', '/api/v1/node/self/capabilities', doc(B.node_id, 64), a);++ assert.deepStrictEqual([mismatch.status, mismatch.body.code], [409, 'registry.node_mismatch']);++ for (const p of [`/api/v1/node/self/credential?principal=${B.principal}`, `/api/v1/node/self/${B.principal}/credential`]) {++ const r = await call('POST', p, { principal: B.principal, node_id: B.node_id }, a, { secret: true });++ if (r.status === 200) {++ assert.strictEqual(r.body.principal, A.principal, 'a rotation is always the caller\'s own');++ credentials.push(r.body.credential);++ A.credential = r.body.credential;++ } else assert.strictEqual(r.status, 404);++ }++ assert.deepStrictEqual(await row(B.principal), bRow, 'B\'s principal is unchanged');++ assert.deepStrictEqual(await capsOf(B.node_id), bCaps, 'B\'s capabilities are unchanged');++ await nodeToken(B);++ });++++ await check('a revoked node\'s unexpired token is 401 registry.node_revoked on both self routes', async () => {++ const E = await pair('Revoked later');++ const e = bearer(await nodeToken(E));++ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(E.node_id), e)).status, 200);++ const before = await capsOf(E.node_id);++ assert.strictEqual((await call('POST', `/internal/node-principals/${E.principal}/revoke`, null, bot)).status, 200);++ const revokedRow = await row(E.principal);++ for (const [m, p, body] of [['PUT', '/api/v1/node/self/capabilities', doc(E.node_id, 2)], ['POST', '/api/v1/node/self/credential', null]]) {++ const r = await call(m, p, body, e);++ assert.deepStrictEqual([r.status, r.body.code], [401, 'registry.node_revoked'], `${m} ${p}`);++ }++ assert.deepStrictEqual(await row(E.principal), revokedRow);++ assert.deepStrictEqual(await capsOf(E.node_id), before);++ });++++ await check('a svc: token holding network.node.self.manage is 403 capability.owner_denied', async () => {++ const host = await tokenCall('host', 'host-secret', 'openvibe.network');++ assert.ok(host.body.scope.split(' ').includes('network.node.self.manage'), host.text);++ for (const [m, p, body] of [['PUT', '/api/v1/node/self/capabilities', doc(A.node_id)], ['POST', '/api/v1/node/self/credential', null]]) {++ const r = await call(m, p, body, bearer(host.body.access_token));++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.owner_denied'], `${m} ${p}`);++ assert.strictEqual((await call(m, p, body)).status, 403, `${m} ${p} without a token`);++ }++ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), live)).status, 403, 'without the capability');++ });++++ await check('topology shows last_seen_at, health up, the name, who paired it and its capabilities', async () => {++ const F = await pair('Never signed in');++ const t = await call('GET', '/internal/registry/cells/wnam-1', null, live);++ assert.strictEqual(t.status, 200, t.text);++ const byNode = Object.fromEntries(t.body.nodes.map((n) => [n.node_id, n]));++ const a = byNode[A.node_id];++ assert.deepStrictEqual([a.health, a.last_seen_at, a.name, a.paired_for.service, a.status], ['up', (await row(A.principal)).last_seen_at, 'Rover A', 'bot', 'active']);++ assert.deepStrictEqual(a.capabilities, doc(A.node_id, 8));++ assert.deepStrictEqual([byNode[F.node_id].health, byNode[F.node_id].last_seen_at, byNode[F.node_id].capabilities], ['unknown', null, null]);++ // Seen eleven minutes ago: no longer up.++ await db.prepare('UPDATE platform_node_principals SET last_seen_at = ? WHERE id = ?').run(new Date(Date.now() - 11 * 60 * 1000).toISOString(), B.principal);++ const later = await call('GET', '/internal/registry/cells/wnam-1', null, live);++ assert.strictEqual(later.body.nodes.find((n) => n.node_id === B.node_id).health, 'unknown');++ assert.ok(!/hash/i.test(later.text), 'the topology exposes no hash');++ });++++ await check('no log line and no answer but issuance and rotation contains a credential', async () => {++ const logged = logs.join('\n');++ const said = answers.join('\n');++ assert.ok(credentials.length >= 9);++ for (const c of credentials) {++ assert.ok(!logged.includes(c), 'a credential was logged');++ assert.ok(!said.includes(c), 'a credential was in an answer');++ }++ });++++ log(`node-tokens: ${passed} checks passed`);++} finally {++ process.stdout.write = out;++ process.stderr.write = err;++ server.close();++}++})().catch((e) => { console.error(e); process.exit(1); });
@@ -228,6 +228,14 @@ router.post('/token', async (req, res) => {
@@ -228,6 +228,14 @@ router.post('/token', async (req, res) => {
// Service principals (Wave 1): a first-party service trades its client credentials for a
// 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.
// short-lived, capability-scoped token. Checked before the user-grant path below.
if (grant_type === 'client_credentials') {
if (grant_type === 'client_credentials') {
+ // A paired machine (node principal, docs/t2-cells-and-node-principal.md section 4.3): its own credential store.
+ if (/^nod_/.test(client_id)) {
+ const out = await require('../registry/node-principals').issueNodeToken(db, {
+ clientId: client_id, clientSecret: client_secret, audience: req.body.audience,
+ privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
+ });
+ return res.status(out.status).json(out.body);
+ }
const out = await principals.issueToken(db, {
const out = await principals.issueToken(db, {
clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
@@ -498,6 +498,8 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -498,6 +498,8 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/internal', pairingRouters.internal);
app.use('/internal', pairingRouters.internal);
// The person's own machines (slice N5): list and revoke, session only, same limit as /api/v1/me/blocks.
// The person's own machines (slice N5): list and revoke, session only, same limit as /api/v1/me/blocks.
app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), require('./registry/node-principals').userRouter(requireAuth));
app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), require('./registry/node-principals').userRouter(requireAuth));
+ // The machine itself (slice N4c): its own capabilities and credential, resolved from its node token alone.
+ app.use('/api/v1/node/self', require('./identity/principals').guard('network.node.self.manage'), require('./registry/node-principals').selfRouter());
}
}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), 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
@@ -88,14 +88,25 @@ async function getCell(db, id) {
@@ -88,14 +88,25 @@ async function getCell(db, id) {
return r ? cellView(r) : null;
return r ? cellView(r) : null;
}
}
-/** Everything registered in one cell: its nodes (owner, trust, health), service instances and offers. Internal only. */
-async function topology(db, id) {
+/** A live paired machine with no platform_nodes row is 'up' while it signed in within this window. */
+const SEEN_UP_MS = 10 * 60 * 1000;
+
+/**
+ * Everything registered in one cell: its nodes (owner, trust, health, last seen, the capabilities they presented),
+ * service instances and offers. Internal only.
+ */
+async function topology(db, id, now = Date.now()) {
const cell = await getCell(db, id);
const cell = await getCell(db, id);
if (!cell) return null;
if (!cell) return null;
const health = new Map((await db.prepare('SELECT id, status FROM platform_nodes').all()).map((r) => [r.id, r.status]));
const health = new Map((await db.prepare('SELECT id, status FROM platform_nodes').all()).map((r) => [r.id, r.status]));
+ const caps = new Map((await db.prepare(`SELECT c.node_id, c.doc FROM platform_node_capabilities c
+ JOIN platform_node_principals p ON p.node_id = c.node_id WHERE p.home_cell = ?`).all(cell.id)).map((r) => [r.node_id, JSON.parse(r.doc)]));
+ const seenUp = (p) => (p.status === 'active' && p.last_seen_at && now - Date.parse(p.last_seen_at) <= SEEN_UP_MS ? 'up' : 'unknown');
const nodes = (await db.prepare('SELECT * FROM platform_node_principals WHERE home_cell = ? ORDER BY node_id').all(cell.id)).map((p) => ({
const nodes = (await db.prepare('SELECT * FROM platform_node_principals WHERE home_cell = ? ORDER BY node_id').all(cell.id)).map((p) => ({
- principal_id: p.id, node_id: p.node_id, owner: { kind: p.owner_kind, project_id: p.project_id || null },
- trust: p.trust, status: p.status, health: health.get(p.node_id) || 'unknown',
+ principal_id: p.id, node_id: p.node_id, name: p.name || null, owner: { kind: p.owner_kind, project_id: p.project_id || null },
+ trust: p.trust, status: p.status, health: health.get(p.node_id) || seenUp(p), last_seen_at: p.last_seen_at || null,
+ paired_for: p.paired_by_service ? { service: p.paired_by_service, ref: p.pairing_ref } : null,
+ capabilities: caps.get(p.node_id) || null,
}));
}));
const instances = (await db.prepare('SELECT * FROM platform_service_instances WHERE cell = ? ORDER BY service, id').all(cell.id)).map((r) => ({
const instances = (await db.prepare('SELECT * FROM platform_service_instances WHERE cell = ? ORDER BY service, id').all(cell.id)).map((r) => ({
id: r.id, service: r.service, version: r.version, cell: r.cell, region: cell.region, node: r.node_id, endpoints: JSON.parse(r.endpoints), state: r.state,
id: r.id, service: r.service, version: r.version, cell: r.cell, region: cell.region, node: r.node_id, endpoints: JSON.parse(r.endpoints), state: r.state,
@@ -15,14 +15,24 @@
@@ -15,14 +15,24 @@
* POST /internal/node-principals/:id/revoke network.node.manage → the principal, revoked (idempotent)
* POST /internal/node-principals/:id/revoke network.node.manage → the principal, revoked (idempotent)
* GET /api/v1/me/nodes session → the person's own machines, newest first
* GET /api/v1/me/nodes session → the person's own machines, newest first
* POST /api/v1/me/nodes/:id/revoke session → their principal, revoked (idempotent)
* POST /api/v1/me/nodes/:id/revoke session → their principal, revoked (idempotent)
+ *
+ * The paired machine itself (section 4.3, slice N4c): its credential buys a node token (sub node:nod_…) at
+ * POST /oauth/token, and with it the machine manages only its own row. The routes resolve the node from the verified
+ * token, never from the body or the path.
+ * POST /oauth/token client_id=nod_… the credential → 200 node token (oauth-routes.js)
+ * PUT /api/v1/node/self/capabilities network.node.self.manage → 200 {node_id, reported_at}
+ * POST /api/v1/node/self/credential network.node.self.manage → 200 {principal, node_id, credential, …}
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { ids, http } = require('openvibe-contracts');
+const { ids, http, serviceAuth, validate, assertValid } = require('openvibe-contracts');
const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
+const { TOKEN_TTL_S, SELF_AUDIENCE } = require('../identity/principals');
const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_TTL_MS = 10 * 60 * 1000;
const MAX_TRIES = 5;
const MAX_TRIES = 5;
+const PREV_GRACE_MS = 60 * 1000; // a rotated-out credential still works this long (Bot's grace window)
+const SEEN_EVERY_MS = 60 * 1000; // last_seen_at is written at most this often
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const PAIRING_ID = /^pair_[0-9A-HJKMNP-TV-Z]{26}$/;
const PAIRING_ID = /^pair_[0-9A-HJKMNP-TV-Z]{26}$/;
const PRINCIPAL_ID = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
const PRINCIPAL_ID = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -233,6 +243,115 @@ function userRouter(requireAuth, now = () => Date.now()) {
@@ -233,6 +243,115 @@ function userRouter(requireAuth, now = () => Date.now()) {
return router;
return router;
}
}
+/** Every refusal of a node's credential, whatever the reason: one status, one body. */
+const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
+
+/**
+ * grant_type=client_credentials for a node principal (client_id nod_…): its current credential, or the previous one
+ * while prev_valid_until is in the future, buys a 300 s token for openvibe.network (network.node.self.manage) or for
+ * the service that paired it (no capability: that service authorises the node by its own binding). The secret is only
+ * ever hashed and compared in constant time; it is never logged or returned. → { status, body } (OAuth shapes).
+ */
+async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey, issuer, now = Date.now() }) {
+ if (typeof clientId !== 'string' || !PRINCIPAL_ID.test(clientId) || typeof clientSecret !== 'string' || !clientSecret) return INVALID_CLIENT;
+ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(clientId);
+ const hash = sha256(clientSecret);
+ // Both comparisons always run, so the answer's timing does not tell which secret (if any) matched.
+ const current = sameHash(p && p.credential_hash, hash);
+ const previous = sameHash(p && p.credential_prev_hash, hash);
+ const prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);
+ if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;
+ const aud = typeof audience === 'string' ? audience.trim() : '';
+ if (aud !== SELF_AUDIENCE && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {
+ return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}` } };
+ }
+ const iat = Math.floor(now / 1000);
+ const claims = {
+ iss: issuer, sub: `node:${p.id}`, actor_type: 'node', aud: [aud],
+ cap: aud === SELF_AUDIENCE ? ['network.node.self.manage'] : [],
+ ...(p.owner_kind === 'project' && p.project_id ? { project_id: p.project_id } : {}),
+ iat, exp: iat + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
+ };
+ assertValid('identity.service-token-claims@1', claims);
+ const access = serviceAuth.signServiceToken(claims, privateKey);
+ // The health of a paired machine: written at most once a minute, so a busy machine is not a write per token.
+ await db.prepare('UPDATE platform_node_principals SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)')
+ .run(iso(now), p.id, iso(now - SEEN_EVERY_MS));
+ return { status: 200, body: { access_token: access, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };
+}
+
+const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
+
+/**
+ * The node principal a verified token speaks for, live. A token of any other actor (svc:, app:) is 403
+ * capability.owner_denied whatever it holds; a node revoked since its token was issued (≤ 300 s ago) is 401.
+ */
+async function selfOf(db, req) {
+ const m = NODE_SUB.exec(String(req.principal && req.principal.sub || ''));
+ if (!m) throw new RegistryError(403, 'capability.owner_denied', 'only a node principal manages itself');
+ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(m[1]);
+ if (!p || p.status !== 'active') throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');
+ return p;
+}
+
+/**
+ * Store the capabilities the machine presents (platform.node-capabilities@1, verbatim). Its node_id must be the
+ * machine's own. Throws RegistryError. → { node_id, reported_at }
+ */
+async function putCapabilities(db, p, body, now = Date.now()) {
+ const v = validate('platform.node-capabilities@1', body);
+ if (!v.valid) throw new RegistryError(400, 'registry.invalid_capabilities', v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 500));
+ if (body.node_id !== p.node_id) throw new RegistryError(409, 'registry.node_mismatch', `this token is node ${p.node_id}, not ${String(body.node_id).slice(0, 80)}`);
+ const at = iso(now);
+ await db.prepare(`INSERT INTO platform_node_capabilities (node_id, doc, reported_at) VALUES (?, ?, ?)
+ ON CONFLICT (node_id) DO UPDATE SET doc = excluded.doc, reported_at = excluded.reported_at`).run(p.node_id, JSON.stringify(body), at);
+ return { node_id: p.node_id, reported_at: at };
+}
+
+/**
+ * The machine rotates its own credential: a new one (returned this once), the old one valid PREV_GRACE_MS more so a
+ * machine that crashes mid-rotation can still sign in. Throws RegistryError. → { principal, node_id, credential, prev_valid_until }
+ */
+async function rotateCredential(db, id, now = Date.now()) {
+ const credential = crypto.randomBytes(32).toString('base64url');
+ const at = iso(now);
+ const until = iso(now + PREV_GRACE_MS);
+ const row = await db.tx(async (t) => {
+ const p = await t.prepare('SELECT * FROM platform_node_principals WHERE id = ? FOR UPDATE').get(id);
+ if (!p || p.status !== 'active') return null;
+ await t.prepare(`UPDATE platform_node_principals SET credential_prev_hash = credential_hash,
+ prev_valid_until = CASE WHEN credential_hash IS NULL THEN NULL ELSE ? END, credential_hash = ?, updated_at = ?
+ WHERE id = ? AND status = 'active'`).run(until, sha256(credential), at, id);
+ return p;
+ });
+ if (!row) throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');
+ return { principal: row.id, node_id: row.node_id, credential, prev_valid_until: row.credential_hash ? until : null };
+}
+
+// Mounted at /api/v1/node/self behind principals.guard('network.node.self.manage') (server/index.js). The guard checks
+// the token's signature, audience and capability; ownership is selfOf's, before any write.
+function selfRouter(now = () => Date.now()) {
+ const router = express.Router();
+ router.use(http.middleware());
+ const send = async (res, fn) => {
+ res.set('Cache-Control', 'no-store');
+ try { return await fn(); } catch (e) {
+ if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
+ console.error('[Node self]', e.message);
+ return http.sendProblem(res, 500, 'registry.failed', { detail: 'the node could not be changed' });
+ }
+ };
+ router.put('/capabilities', express.json({ limit: '64kb' }), async (req, res) => await send(res, async () => {
+ const db = req.app.locals.db;
+ res.json(await putCapabilities(db, await selfOf(db, req), req.body, now()));
+ }));
+ router.post('/credential', async (req, res) => await send(res, async () => {
+ const db = req.app.locals.db;
+ res.json(await rotateCredential(db, (await selfOf(db, req)).id, now()));
+ }));
+ return router;
+}
+
function routers({ guard, now = () => Date.now() }) {
function routers({ guard, now = () => Date.now() }) {
const problem = (res, e) => http.sendProblem(res, e.status, e.code, { detail: e.message });
const problem = (res, e) => http.sendProblem(res, e.status, e.code, { detail: e.message });
const internal = express.Router();
const internal = express.Router();
@@ -279,4 +398,4 @@ function routers({ guard, now = () => Date.now() }) {
@@ -279,4 +398,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
return { internal, pairing };
}
}
-module.exports = { CODE_TTL_MS, MAX_TRIES, createPairing, redeem, revoke, principalView, routers, userRouter };
+module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, createPairing, redeem, revoke, principalView, routers, userRouter, issueNodeToken, selfRouter };
@@ -100,12 +100,13 @@ try {
@@ -100,12 +100,13 @@ try {
assert.deepStrictEqual((await globalThis.__ovNetworkMigrate({ dir: MIGRATIONS })).applied, []);
assert.deepStrictEqual((await globalThis.__ovNetworkMigrate({ dir: MIGRATIONS })).applied, []);
await globalThis.__ovNetworkDdl(require('fs').readFileSync(require('path').join(MIGRATIONS, '0014_node_pairing.sql'), 'utf8'));
await globalThis.__ovNetworkDdl(require('fs').readFileSync(require('path').join(MIGRATIONS, '0014_node_pairing.sql'), 'utf8'));
- // ── A node principal is not a service principal: it can hold no client-credentials token.
+ // ── A node principal is not a service principal: an oauth_clients secret under a nod_ id buys nothing. Every nod_
+ // client goes to the node branch (slice N4c), which knows only node credentials: 401 invalid_client.
const nodClient = nod();
const nodClient = nod();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES (?, 'node-secret', 'node', '[]')").run(nodClient);
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES (?, 'node-secret', 'node', '[]')").run(nodClient);
const t = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: nodClient, client_secret: 'node-secret', audience: 'openvibe.network' }) });
const t = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: nodClient, client_secret: 'node-secret', audience: 'openvibe.network' }) });
- assert.strictEqual(t.status, 400);
- assert.strictEqual((await t.json()).error, 'unauthorized_client');
+ assert.strictEqual(t.status, 401);
+ assert.strictEqual((await t.json()).error, 'invalid_client');
// ── Host's node report: first-named machines become platform principals; a project's or a revoked machine refuses the report.
// ── Host's node report: first-named machines become platform principals; a project's or a revoked machine refuses the report.
const host = { authorization: `Bearer ${await token('host', 'host-secret')}` };
const host = { authorization: `Bearer ${await token('host', 'host-secret')}` };
new file mode 100644
new file mode 100644
@@ -0,0 +1,269 @@
@@ -0,0 +1,269 @@
+'use strict';
+// Node tokens and self routes (plan T2, docs/t2-cells-and-node-principal.md sections 4.3 and 7, slice N4c): a paired
+// machine's credential buys a node token; with it the machine changes its own capabilities and credential and nobody
+// else's; rotation keeps the old credential 60 s; revoke kills both; topology shows when the machine was last seen.
+// node test/node-tokens.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { ids, validate, serviceAuth } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const principals = require('../server/identity/principals');
+const nodePrincipals = require('../server/registry/node-principals');
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+// A service that holds network.node.self.manage anyway: it still manages no machine (403 capability.owner_denied).
+await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('host', 'network.node.self.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
+
+const alex = `usr_${ids.ulid()}`;
+await db.prepare("INSERT INTO users (id, username, display_name, password_hash, subject_id) VALUES (9201, 'tokalex', 'Alex', 'x', ?)").run(alex);
+
+const ISSUER = 'https://openvibe.network';
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const app = express();
+app.use(express.urlencoded({ extended: true }));
+app.locals.db = db;
+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'));
+const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage') });
+app.use('/api/v1/node-pairing', pr.pairing);
+app.use('/internal', pr.internal);
+app.use('/api/v1/node/self', principals.guard('network.node.self.manage'), nodePrincipals.selfRouter());
+const cellRouters = require('../server/registry/cells').routers({ readGuard: principals.guard('network.registry.read') });
+app.use('/internal/registry', cellRouters.internal);
+const server = http.createServer(app);
+
+// Everything written to stdout or stderr while the routes run, to prove no secret reaches a log line.
+const logs = [];
+const out = process.stdout.write.bind(process.stdout);
+const err = process.stderr.write.bind(process.stderr);
+process.stdout.write = (c, ...a) => { logs.push(String(c)); return out(c, ...a); };
+process.stderr.write = (c, ...a) => { logs.push(String(c)); return err(c, ...a); };
+
+await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+const base = `http://127.0.0.1:${server.address().port}`;
+// Every answer except the two that hand out a credential (redeem, rotation), to prove none of them carries one.
+const answers = [];
+const tokenCall = (id, secret, audience) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, ...(audience === undefined ? {} : { audience }) }) })
+ .then(async (x) => { const text = await x.text(); answers.push(text); return { status: x.status, text, body: JSON.parse(text) }; });
+const call = (method, p, body, headers = {}, { secret = false } = {}) => fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (x) => { const text = await x.text(); if (!secret) answers.push(text); let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: x.status, headers: x.headers, text, body: json }; });
+const bearer = (t) => ({ authorization: `Bearer ${t}` });
+const row = (id) => db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(id);
+const capsOf = (nodeId) => db.prepare('SELECT * FROM platform_node_capabilities WHERE node_id = ?').get(nodeId);
+const doc = (nodeId, cores = 4) => ({
+ node_id: nodeId, cpu: { cores }, arch: 'arm64', memory_mb: 4096, storage: { capacity_gb: 64, available_gb: 32, kind: 'ssd' },
+ network: { ingress_mbps: 100, egress_mbps: 20 }, regions: ['us-west'], tags: [], costs: { per_hour_usd: 0 }, capabilities: ['node:http'],
+ agent_version: '0.1.0', updated_at: '2026-10-02T00:00:00Z',
+});
+let passed = 0;
+const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
+try {
+ const bot = bearer((await tokenCall('bot', 'bot-secret', 'openvibe.network')).body.access_token);
+ const live = bearer((await tokenCall('live', 'live-secret', 'openvibe.network')).body.access_token);
+ const credentials = [];
+ const pair = async (name) => {
+ const code = await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: `robot-${ids.ulid()}` }, bot);
+ const r = await call('POST', '/api/v1/node-pairing', { pairing: code.body.pairing_id, code: code.body.code, name }, {}, { secret: true });
+ assert.strictEqual(r.status, 201, r.text);
+ credentials.push(r.body.credential);
+ return r.body;
+ };
+ const nodeToken = async (n, audience = 'openvibe.network') => {
+ const r = await tokenCall(n.principal, n.credential, audience);
+ assert.strictEqual(r.status, 200, r.text);
+ return r.body.access_token;
+ };
+ const A = await pair('Rover A');
+ const B = await pair('Rover B');
+
+ await check('a credential buys a node token whose claims are identity.service-token-claims@1', async () => {
+ assert.strictEqual((await row(A.principal)).last_seen_at, null);
+ const r = await tokenCall(A.principal, A.credential, 'openvibe.network');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.token_type, r.body.expires_in, r.body.scope], ['Bearer', 300, 'network.node.self.manage']);
+ const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
+ assert.ok(v.ok, v.reason);
+ assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
+ const c = v.claims;
+ assert.deepStrictEqual([c.iss, c.sub, c.actor_type, c.aud, c.cap], [ISSUER, `node:${A.principal}`, 'node', ['openvibe.network'], ['network.node.self.manage']]);
+ assert.match(c.sub, /^node:nod_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(c.exp - c.iat, principals.TOKEN_TTL_S);
+ assert.match(c.jti, /^tok_[0-9a-f]{24}$/);
+ assert.ok(!('project_id' in c) && !('ns' in c) && !('env' in c), 'a person\'s machine carries no project');
+ const seen = (await row(A.principal)).last_seen_at;
+ assert.ok(seen && Math.abs(Date.parse(seen) - Date.now()) < 5000, 'issuance sets last_seen_at');
+ await nodeToken(A);
+ assert.strictEqual((await row(A.principal)).last_seen_at, seen, 'not rewritten within 60 s');
+ });
+
+ await check('the audience is Network or the pairing service, nothing else', async () => {
+ const r = await tokenCall(A.principal, A.credential, 'openvibe.bot');
+ assert.strictEqual(r.status, 200, r.text);
+ const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.bot' });
+ assert.ok(v.ok, v.reason);
+ assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');
+ for (const aud of ['openvibe.chat', 'openvibe.network.evil', '', undefined]) {
+ const bad = await tokenCall(A.principal, A.credential, aud);
+ assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
+ }
+ // A token for Bot is no use on Network's self routes.
+ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(r.body.access_token))).status, 401);
+ });
+
+ await check('unknown, revoked or wrong secret: 401 with one identical answer', async () => {
+ const C = await pair('Revoked');
+ assert.strictEqual((await call('POST', `/internal/node-principals/${C.principal}/revoke`, null, bot)).status, 200);
+ const refusals = [
+ await tokenCall(A.principal, 'not-the-credential', 'openvibe.network'),
+ await tokenCall(A.principal, B.credential, 'openvibe.network'),
+ await tokenCall(A.principal, '', 'openvibe.network'),
+ await tokenCall(`nod_${ids.ulid()}`, A.credential, 'openvibe.network'),
+ await tokenCall('nod_nope', A.credential, 'openvibe.network'),
+ await tokenCall(C.principal, C.credential, 'openvibe.network'),
+ await tokenCall(C.principal, C.credential, 'openvibe.chat'),
+ ];
+ for (const r of refusals) assert.deepStrictEqual([r.status, r.text], [401, refusals[0].text]);
+ assert.strictEqual(refusals[0].body.error, 'invalid_client');
+ });
+
+ await check('rotation: the new credential works, the old one 60 s more; revoke kills both', async () => {
+ const R = await pair('Rotator');
+ const before = await row(R.principal);
+ const rot = await call('POST', '/api/v1/node/self/credential', null, bearer(await nodeToken(R)), { secret: true });
+ assert.strictEqual(rot.status, 200, rot.text);
+ assert.strictEqual(rot.headers.get('cache-control'), 'no-store');
+ assert.deepStrictEqual([rot.body.principal, rot.body.node_id], [R.principal, R.node_id]);
+ credentials.push(rot.body.credential);
+ assert.notStrictEqual(rot.body.credential, R.credential);
+ const after = await row(R.principal);
+ assert.deepStrictEqual([after.credential_prev_hash, after.prev_valid_until], [before.credential_hash, rot.body.prev_valid_until]);
+ assert.strictEqual(after.credential_hash, crypto.createHash('sha256').update(rot.body.credential).digest('hex'));
+ const at = Date.parse(after.prev_valid_until) - nodePrincipals.PREV_GRACE_MS;
+ assert.ok(Math.abs(at - Date.now()) < 5000, 'the old credential lives 60 s from now');
+ await nodeToken({ principal: R.principal, credential: rot.body.credential });
+ await nodeToken(R);
+ // The clock, injected: the old credential at 59 s still works, at 61 s it is refused like any wrong secret.
+ const issue = (credential, now) => nodePrincipals.issueNodeToken(db, { clientId: R.principal, clientSecret: credential, audience: 'openvibe.network', privateKey: keys.privateKey, issuer: ISSUER, now });
+ assert.strictEqual((await issue(R.credential, at + 59_000)).status, 200);
+ const late = await issue(R.credential, at + 61_000);
+ assert.deepStrictEqual([late.status, late.body.error], [401, 'invalid_client']);
+ assert.strictEqual((await issue(rot.body.credential, at + 61_000)).status, 200, 'the new one is unaffected');
+ // Revoke within the grace window: neither secret works any more.
+ const D = await pair('Rotate then revoke');
+ const rotD = await call('POST', '/api/v1/node/self/credential', null, bearer(await nodeToken(D)), { secret: true });
+ credentials.push(rotD.body.credential);
+ await nodeToken(D);
+ const me = await call('POST', `/internal/node-principals/${D.principal}/revoke`, null, bot);
+ assert.strictEqual(me.status, 200);
+ const dead = await row(D.principal);
+ assert.deepStrictEqual([dead.credential_prev_hash, dead.prev_valid_until], [null, null]);
+ for (const cred of [D.credential, rotD.body.credential]) {
+ const r = await tokenCall(D.principal, cred, 'openvibe.network');
+ assert.deepStrictEqual([r.status, r.body.error], [401, 'invalid_client']);
+ }
+ });
+
+ await check('capabilities: the machine\'s own doc is stored; another node_id is 409; a bad doc 400', async () => {
+ const a = bearer(await nodeToken(A));
+ const ok = await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), a);
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.strictEqual(ok.body.node_id, A.node_id);
+ assert.deepStrictEqual(JSON.parse((await capsOf(A.node_id)).doc), doc(A.node_id));
+ const again = await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id, 8), a);
+ assert.strictEqual(again.status, 200, 'an upsert');
+ assert.strictEqual(JSON.parse((await capsOf(A.node_id)).doc).cpu.cores, 8);
+ const bad = await call('PUT', '/api/v1/node/self/capabilities', { node_id: A.node_id }, a);
+ assert.deepStrictEqual([bad.status, bad.body.code], [400, 'registry.invalid_capabilities']);
+ });
+
+ await check('node A\'s valid token cannot change node B\'s capabilities or credential', async () => {
+ const b = bearer(await nodeToken(B));
+ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(B.node_id), b)).status, 200);
+ const bRow = await row(B.principal);
+ const bCaps = await capsOf(B.node_id);
+ const a = bearer(await nodeToken(A));
+ const mismatch = await call('PUT', '/api/v1/node/self/capabilities', doc(B.node_id, 64), a);
+ assert.deepStrictEqual([mismatch.status, mismatch.body.code], [409, 'registry.node_mismatch']);
+ for (const p of [`/api/v1/node/self/credential?principal=${B.principal}`, `/api/v1/node/self/${B.principal}/credential`]) {
+ const r = await call('POST', p, { principal: B.principal, node_id: B.node_id }, a, { secret: true });
+ if (r.status === 200) {
+ assert.strictEqual(r.body.principal, A.principal, 'a rotation is always the caller\'s own');
+ credentials.push(r.body.credential);
+ A.credential = r.body.credential;
+ } else assert.strictEqual(r.status, 404);
+ }
+ assert.deepStrictEqual(await row(B.principal), bRow, 'B\'s principal is unchanged');
+ assert.deepStrictEqual(await capsOf(B.node_id), bCaps, 'B\'s capabilities are unchanged');
+ await nodeToken(B);
+ });
+
+ await check('a revoked node\'s unexpired token is 401 registry.node_revoked on both self routes', async () => {
+ const E = await pair('Revoked later');
+ const e = bearer(await nodeToken(E));
+ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(E.node_id), e)).status, 200);
+ const before = await capsOf(E.node_id);
+ assert.strictEqual((await call('POST', `/internal/node-principals/${E.principal}/revoke`, null, bot)).status, 200);
+ const revokedRow = await row(E.principal);
+ for (const [m, p, body] of [['PUT', '/api/v1/node/self/capabilities', doc(E.node_id, 2)], ['POST', '/api/v1/node/self/credential', null]]) {
+ const r = await call(m, p, body, e);
+ assert.deepStrictEqual([r.status, r.body.code], [401, 'registry.node_revoked'], `${m} ${p}`);
+ }
+ assert.deepStrictEqual(await row(E.principal), revokedRow);
+ assert.deepStrictEqual(await capsOf(E.node_id), before);
+ });
+
+ await check('a svc: token holding network.node.self.manage is 403 capability.owner_denied', async () => {
+ const host = await tokenCall('host', 'host-secret', 'openvibe.network');
+ assert.ok(host.body.scope.split(' ').includes('network.node.self.manage'), host.text);
+ for (const [m, p, body] of [['PUT', '/api/v1/node/self/capabilities', doc(A.node_id)], ['POST', '/api/v1/node/self/credential', null]]) {
+ const r = await call(m, p, body, bearer(host.body.access_token));
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.owner_denied'], `${m} ${p}`);
+ assert.strictEqual((await call(m, p, body)).status, 403, `${m} ${p} without a token`);
+ }
+ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), live)).status, 403, 'without the capability');
+ });
+
+ await check('topology shows last_seen_at, health up, the name, who paired it and its capabilities', async () => {
+ const F = await pair('Never signed in');
+ const t = await call('GET', '/internal/registry/cells/wnam-1', null, live);
+ assert.strictEqual(t.status, 200, t.text);
+ const byNode = Object.fromEntries(t.body.nodes.map((n) => [n.node_id, n]));
+ const a = byNode[A.node_id];
+ assert.deepStrictEqual([a.health, a.last_seen_at, a.name, a.paired_for.service, a.status], ['up', (await row(A.principal)).last_seen_at, 'Rover A', 'bot', 'active']);
+ assert.deepStrictEqual(a.capabilities, doc(A.node_id, 8));
+ assert.deepStrictEqual([byNode[F.node_id].health, byNode[F.node_id].last_seen_at, byNode[F.node_id].capabilities], ['unknown', null, null]);
+ // Seen eleven minutes ago: no longer up.
+ await db.prepare('UPDATE platform_node_principals SET last_seen_at = ? WHERE id = ?').run(new Date(Date.now() - 11 * 60 * 1000).toISOString(), B.principal);
+ const later = await call('GET', '/internal/registry/cells/wnam-1', null, live);
+ assert.strictEqual(later.body.nodes.find((n) => n.node_id === B.node_id).health, 'unknown');
+ assert.ok(!/hash/i.test(later.text), 'the topology exposes no hash');
+ });
+
+ await check('no log line and no answer but issuance and rotation contains a credential', async () => {
+ const logged = logs.join('\n');
+ const said = answers.join('\n');
+ assert.ok(credentials.length >= 9);
+ for (const c of credentials) {
+ assert.ok(!logged.includes(c), 'a credential was logged');
+ assert.ok(!said.includes(c), 'a credential was in an answer');
+ }
+ });
+
+ log(`node-tokens: ${passed} checks passed`);
+} finally {
+ process.stdout.write = out;
+ process.stderr.write = err;
+ server.close();
+}
+})().catch((e) => { console.error(e); process.exit(1); });