openvibers/openvibe.network Commits
Pair a person's machine as a node principal with scoped read and revoke (#16)
Changes
@@@ -104,6 +104,7 @@ async function seedDb(db, { log = console } = {}) {{ client_id: 'codes', name: 'OpenVibe.Codes', redirect_uris: ['https://openvibe.codes/auth/callback'] },{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] },{ client_id: 'ai', name: 'OpenVibe.AI', redirect_uris: ['https://ai.openvibe.network/auth/callback'] },++ { client_id: 'bot', name: 'OpenVibe.Bot', redirect_uris: ['https://openvibe.bot/auth/callback'] },];let seededAny = false;for (const c of contractClients) {@@@ -150,6 +150,8 @@ const DEFAULT_GRANTS = [// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).['host', 'network.node.report', SELF_AUDIENCE, []],['host', 'network.resource.report', SELF_AUDIENCE, []],++ // Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.++ ['bot', 'network.node.manage', SELF_AUDIENCE, []],// WS-N task 12: ovhost incident / maintenance post to the status page.['host', 'network.status.incident', SELF_AUDIENCE, []],// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples@@@ -489,6 +489,14 @@ const notifyAccountData = async (userId, n) => await notificationService.create(app.use('/api/v1/cells', cellRouters.pub);app.use('/internal/registry', cellRouters.internal);}++// Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md section 4.2): a service holding++// network.node.manage mints one-time codes and reads or revokes the principals it paired; the machine redeems its code++// with no other credential, so the redeem route has its own tight rate limit.++{++ const pairingRouters = require('./registry/node-principals').routers({ guard: require('./identity/principals').guard('network.node.manage') });++ app.use('/api/v1/node-pairing', rateLimit({ windowMs: 60_000, max: 10 }), pairingRouters.pairing);++ app.use('/internal', pairingRouters.internal);++}app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).nnew file mode 100644@@@ -0,0 +1,223 @@++'use strict';++/**++ * Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md section 4.2, slice N4b;++ * migrations/0014_node_pairing.sql). A service holding network.node.manage (Bot) mints a one-time pairing code for one++ * of its users; the machine redeems it with no other credential and becomes a node principal (nod_<ULID>) owned by that++ * person, with a long-lived credential shown once. The service may then read and revoke only the principals it paired.++ *++ * Secrets: the code and the credential are shown once and stored only as their sha256; neither is ever logged nor++ * returned by a read. A paired principal is never a platform machine: no platform_nodes row is created, and Host's node++ * report naming its node_id is refused (cells.checkPlatformNodes, 409 registry.node_not_platform).++ *++ * POST /internal/node-pairings network.node.manage → 201 {pairing_id, code, expires_at}++ * POST /api/v1/node-pairing none (the code) → 201 {principal, node_id, home_cell, credential, …}++ * GET /internal/node-principals/:id network.node.manage → the principal, if the caller paired it++ * POST /internal/node-principals/:id/revoke network.node.manage → the principal, revoked (idempotent)++ */++const crypto = require('crypto');++const express = require('express');++const { ids, http } = require('openvibe-contracts');++const { BOOTSTRAP_CELL, RegistryError } = require('./cells');++++const CODE_TTL_MS = 10 * 60 * 1000;++const MAX_TRIES = 5;++const 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}$/;++const REGION = /^[a-z][a-z0-9-]{1,39}$/;++const CONTROL = /[\u0000-\u001f\u007f]/;++++// Crockford base32 (Bot's alphabet): no I, L, O or U, so a code read aloud cannot be mistyped into another.++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));++const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);++const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;++function newCode() {++ const b = crypto.randomBytes(8);++ let s = '';++ for (let i = 0; i < 8; i++) s += CROCKFORD[b[i] % 32]; // 256 % 32 = 0: no bias++ return s;++}++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++/** Constant-time equality of two stored hashes. */++function sameHash(a, b) {++ const x = Buffer.from(String(a || ''));++ const y = Buffer.from(String(b || ''));++ if (x.length !== y.length) { crypto.timingSafeEqual(x, x); return false; }++ return crypto.timingSafeEqual(x, y);++}++const text = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !CONTROL.test(v) ? v.trim() : null;++const iso = (ms) => new Date(ms).toISOString();++++/** The first active cell of a region, else wnam-1 (the same rule as cells.js cellForRegion, for a paired machine). */++async function cellForRegion(db, region) {++ const row = region ? await db.prepare("SELECT id FROM platform_cells WHERE region = ? AND status = 'active' ORDER BY id LIMIT 1").get(region) : null;++ return row ? row.id : BOOTSTRAP_CELL;++}++++/** The service a verified service token speaks for: svc:<id> → <id>. */++const serviceOf = (req) => String(req.principal && req.principal.sub || '').replace(/^svc:/, '');++++/** What a service or the owner may see of a principal: never a hash. */++const principalView = (p) => ({++ principal: p.id, node_id: p.node_id, name: p.name || null,++ owner: p.owner_kind === 'user' ? { kind: 'user', subject: p.owner_subject } : p.owner_kind === 'project' ? { kind: 'project', project_id: p.project_id } : { kind: p.owner_kind },++ home_cell: p.home_cell, status: p.status,++ paired_for: p.paired_by_service ? { service: p.paired_by_service, ref: p.pairing_ref } : null,++ last_seen_at: p.last_seen_at || null, created_at: p.created_at, revoked_at: p.revoked_at || null,++});++++/**++ * Mint a pairing code for `service`'s user. Replaces that service's unused codes for the same ref.++ * Throws RegistryError. → { pairing_id, code, expires_at } (the code in the clear, this once).++ */++async function createPairing(db, service, body, now = Date.now()) {++ const owner = body && body.owner;++ if (owner && owner.kind === 'project') throw new RegistryError(501, 'registry.not_yet', 'project-owned machines are not paired yet');++ if (!owner || owner.kind !== 'user' || typeof owner.subject !== 'string' || !SUBJECT.test(owner.subject)) {++ throw new RegistryError(400, 'registry.invalid_pairing_request', "owner must be {kind: 'user', subject: 'usr_…'}");++ }++ const ref = text(body.ref, 80);++ if (!ref) throw new RegistryError(400, 'registry.invalid_pairing_request', 'ref must be 1-80 characters');++ if (body.home_cell !== undefined && body.home_cell !== null && typeof body.home_cell !== 'string') throw new RegistryError(400, 'registry.invalid_pairing_request', 'home_cell must be a cell id');++ const user = await db.prepare(`SELECT id FROM users WHERE subject_id = ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL++ AND merged_into IS NULL AND COALESCE(is_anon, 0) = 0`).get(owner.subject);++ if (!user) throw new RegistryError(404, 'registry.unknown_owner', `no person ${owner.subject}`);++ const homeCell = body.home_cell || null;++ if (homeCell) {++ const cell = await db.prepare('SELECT status FROM platform_cells WHERE id = ?').get(homeCell);++ if (!cell) throw new RegistryError(400, 'registry.unknown_cell', `no cell ${homeCell}`);++ if (cell.status !== 'active') throw new RegistryError(409, 'registry.cell_not_active', `cell ${homeCell} is ${cell.status}`);++ }++ const id = `pair_${ids.ulid(now)}`;++ const expiresAt = iso(now + CODE_TTL_MS);++ let code = null;++ await db.tx(async (t) => {++ // One live code per (service, ref), as Bot's one live code per robot.++ await t.prepare('DELETE FROM platform_node_pairings WHERE service = ? AND ref = ? AND used_at IS NULL').run(service, ref);++ const insert = t.prepare(`INSERT INTO platform_node_pairings (id, code_hash, owner_kind, owner_subject, service, ref, home_cell, created_by, created_at, expires_at)++ VALUES (?, ?, 'user', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (code_hash) DO NOTHING`);++ // 40 random bits: a clash with a stored hash is improbable, and is drawn again rather than failing.++ for (let i = 0; i < 3 && !code; i++) {++ const c = newCode();++ if ((await insert.run(id, sha256(c), owner.subject, service, ref, homeCell, `svc:${service}`, iso(now), expiresAt)).changes) code = c;++ }++ if (!code) throw new Error('could not draw a unique pairing code');++ });++ return { pairing_id: id, code: formatCode(code), expires_at: expiresAt };++}++++/**++ * Redeem a code (Bot's redeem, OpenVibe.Bot server/domain/index.js:158-205). With `pairing` a wrong code counts a try++ * against that row (5 lock it); without it the code is found by its hash. Refusals are returned from the transaction and++ * thrown after it commits, so a counted try survives. Throws RegistryError. → { principal row, credential }++ */++async function redeem(db, body, now = Date.now()) {++ const b = body && typeof body === 'object' ? body : {};++ const normal = normaliseCode(typeof b.code === 'string' ? b.code : '');++ if (!isCodeShape(normal)) throw new RegistryError(422, 'registry.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');++ if (b.pairing !== undefined && b.pairing !== null && (typeof b.pairing !== 'string' || !PAIRING_ID.test(b.pairing))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'pairing must be a pair_ id');++ const name = b.name === undefined || b.name === null ? null : text(b.name, 80);++ if (b.name != null && !name) throw new RegistryError(400, 'registry.invalid_pairing_request', 'name must be 1-80 characters');++ if (b.region != null && (typeof b.region !== 'string' || !REGION.test(b.region))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'region must be a region id');++ const hash = sha256(normal);++ const at = iso(now);++ const result = await db.tx(async (t) => {++ let row;++ if (b.pairing) {++ row = await t.prepare('SELECT * FROM platform_node_pairings WHERE id = ? FOR UPDATE').get(b.pairing);++ if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };++ if (!sameHash(row.code_hash, hash)) {++ if (row.used_at) return { error: [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };++ if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };++ const tries = Number(row.tries) + 1;++ await t.prepare('UPDATE platform_node_pairings SET tries = ? WHERE id = ?').run(tries, row.id);++ return { error: tries >= MAX_TRIES ? [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] : [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };++ }++ } else {++ row = await t.prepare('SELECT * FROM platform_node_pairings WHERE code_hash = ? FOR UPDATE').get(hash);++ if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };++ }++ if (row.used_at) return { error: [403, 'registry.pairing_code_used', 'that pairing code has already been used'] };++ if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };++ if (Date.parse(row.expires_at) <= now) return { error: [403, 'registry.pairing_code_expired', 'that pairing code has expired'] };++ if (row.owner_kind !== 'user') return { error: [501, 'registry.not_yet', 'project-owned machines are not paired yet'] };++ await t.prepare('UPDATE platform_node_pairings SET used_at = ? WHERE id = ?').run(at, row.id);++ const ulid = ids.ulid(now);++ const principalId = `nod_${ulid}`;++ const credential = crypto.randomBytes(32).toString('base64url');++ const homeCell = row.home_cell || await cellForRegion(t, b.region);++ // A paired machine is a principal only: no platform_nodes row, so it is never a platform machine.++ await t.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, name, trust, status,++ credential_hash, paired_by_service, pairing_ref, created_at, created_by, updated_at)++ VALUES (?, ?, ?, 'user', ?, ?, 'community', 'active', ?, ?, ?, ?, ?, ?)`)++ .run(principalId, `n-${ulid.toLowerCase()}`, homeCell, row.owner_subject, name, sha256(credential), row.service, row.ref, at, `pairing:${row.id}`, at);++ await t.prepare('UPDATE platform_node_pairings SET principal_id = ? WHERE id = ?').run(principalId, row.id);++ return { principal: await t.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(principalId), credential };++ });++ if (result.error) throw new RegistryError(...result.error);++ return result;++}++++/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */++async function pairedBy(db, id, service) {++ if (!PRINCIPAL_ID.test(String(id))) return null;++ return await db.prepare('SELECT * FROM platform_node_principals WHERE id = ? AND paired_by_service = ?').get(String(id), service) || null;++}++++/** Revoke a principal `service` paired; a second revoke changes nothing. → the row, or null. */++async function revoke(db, id, service, now = Date.now()) {++ if (!await pairedBy(db, id, service)) return null;++ const at = iso(now);++ await db.prepare(`UPDATE platform_node_principals SET status = 'revoked', revoked_at = ?, revoked_by = ?, credential_prev_hash = NULL,++ prev_valid_until = NULL, updated_at = ? WHERE id = ? AND paired_by_service = ? AND status <> 'revoked'`).run(at, `svc:${service}`, at, String(id), service);++ return pairedBy(db, id, service);++}++++function routers({ guard, now = () => Date.now() }) {++ const problem = (res, e) => http.sendProblem(res, e.status, e.code, { detail: e.message });++ const internal = express.Router();++ internal.post('/node-pairings', guard, express.json({ limit: '8kb' }), async (req, res) => {++ res.set('Cache-Control', 'no-store');++ try {++ res.status(201).json(await createPairing(req.app.locals.db, serviceOf(req), req.body || {}, now()));++ } catch (e) {++ if (e instanceof RegistryError) return problem(res, e);++ throw e;++ }++ });++ internal.get('/node-principals/:id', guard, async (req, res) => {++ res.set('Cache-Control', 'no-store');++ const p = await pairedBy(req.app.locals.db, req.params.id, serviceOf(req));++ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });++ res.json(principalView(p));++ });++ internal.post('/node-principals/:id/revoke', guard, async (req, res) => {++ res.set('Cache-Control', 'no-store');++ const p = await revoke(req.app.locals.db, req.params.id, serviceOf(req), now());++ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });++ res.json(principalView(p));++ });++++ // Mounted at /api/v1/node-pairing behind its own rate limit (server/index.js).++ const pairing = express.Router();++ pairing.post('/', express.json({ limit: '8kb' }), async (req, res) => {++ res.set('Cache-Control', 'no-store');++ let out;++ try {++ out = await redeem(req.app.locals.db, req.body, now());++ } catch (e) {++ if (e instanceof RegistryError) return problem(res, e);++ throw e;++ }++ const p = out.principal;++ res.status(201).json({++ principal: p.id, node_id: p.node_id, home_cell: p.home_cell, credential: out.credential,++ token_endpoint: `${req.app.locals.config.jwt.issuer}/oauth/token`,++ paired_for: { service: p.paired_by_service, ref: p.pairing_ref },++ });++ });++ return { internal, pairing };++}++++module.exports = { CODE_TTL_MS, MAX_TRIES, createPairing, redeem, revoke, principalView, routers };nnew file mode 100644@@@ -0,0 +1,235 @@++'use strict';++// Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md sections 4.2 and 7, slice N4b): Bot's five++// pairing checks (OpenVibe.Bot test/pairing.test.js: one use, 10 min expiry, 5 tries, never logged nor read, revoke),++// the read and revoke scoped to the service that paired, and a paired machine that never becomes a platform machine.++// node test/node-pairing.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { ids } = 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 second service holding network.node.manage, to prove each service sees only what it paired.++await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.node.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();++++const subject = () => `usr_${ids.ulid()}`;++const alex = subject(); const banned = subject(); const gone = subject();++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, is_banned, deleted_at) VALUES++ (9101, 'pairalex', 'Alex', 'x', ?, 0, NULL), (9102, 'pairbanned', 'Banned', 'x', ?, 1, NULL), (9103, 'pairgone', 'Gone', 'x', ?, 0, '2026-09-01T00:00:00Z')`).run(alex, banned, gone);++await db.prepare("INSERT INTO platform_regions (id, country) VALUES ('eu-west', 'IE') ON CONFLICT DO NOTHING").run();++await db.prepare("INSERT INTO platform_cells (id, region, residency, status) VALUES ('weur-1', 'eu-west', 'EU', 'active'), ('wnam-2', 'us-west', 'US', 'planned') ON CONFLICT DO NOTHING").run();++++const clock = { offset: 0 };++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: 'https://openvibe.network', accessTokenExpiry: '1h' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/oauth', require('../server/auth/oauth-routes'));++const nodeRouters = require('../server/registry/nodes').routers({ guard: principals.guard('network.node.report') });++app.use('/api/v1/nodes', nodeRouters.pub);++app.use('/internal/nodes', nodeRouters.internal);++const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage'), now: () => Date.now() + clock.offset });++app.use('/api/v1/node-pairing', pr.pairing);++app.use('/internal', pr.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}`;++const token = async (id, secret) => (await (await 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: 'openvibe.network' }) })).json()).access_token;++const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })++ .then(async (x) => { const text = await x.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}` });++let passed = 0;++const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };++try {++ const bot = bearer(await token('bot', 'bot-secret'));++ const live = bearer(await token('live', 'live-secret'));++ const host = bearer(await token('host', 'host-secret'));++ const mint = (ref = `robot-${ids.ulid()}`, extra = {}) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref, ...extra }, bot);++ const redeem = (body) => call('POST', '/api/v1/node-pairing', body);++++ await check('minting a code needs network.node.manage and a live person', async () => {++ assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' })).status, 403, 'no token');++ assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' }, host)).status, 403, 'host lacks network.node.manage');++ for (const s of [subject(), banned, gone]) {++ const r = await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: s }, ref: 'r' }, bot);++ assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_owner']);++ }++ const p = await call('POST', '/internal/node-pairings', { owner: { kind: 'project', project_id: 'prj_x' }, ref: 'r' }, bot);++ assert.deepStrictEqual([p.status, p.body.code], [501, 'registry.not_yet']);++ const planned = await mint(undefined, { home_cell: 'wnam-2' });++ assert.deepStrictEqual([planned.status, planned.body.code], [409, 'registry.cell_not_active']);++ assert.strictEqual((await mint(undefined, { home_cell: 'nope-9' })).status, 400);++ assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex } }, bot)).status, 400, 'ref is required');++ });++++ await check('a code is shown once, stored only as its sha256, and replaces that ref\'s unused code', async () => {++ const ref = 'robot-replace';++ const first = await mint(ref);++ assert.strictEqual(first.status, 201, first.text);++ assert.match(first.body.code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);++ assert.match(first.body.pairing_id, /^pair_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(first.headers.get('cache-control'), 'no-store');++ const row = await db.prepare('SELECT * FROM platform_node_pairings WHERE id = ?').get(first.body.pairing_id);++ assert.strictEqual(row.code_hash, crypto.createHash('sha256').update(first.body.code.replace('-', '')).digest('hex'));++ assert.ok(!JSON.stringify(row).includes(first.body.code.replace('-', '')), 'the code is not stored in the clear');++ assert.deepStrictEqual([row.service, row.ref, row.owner_subject, row.created_by], ['bot', ref, alex, 'svc:bot']);++ const second = await mint(ref);++ assert.strictEqual(await db.prepare('SELECT 1 FROM platform_node_pairings WHERE id = ?').get(first.body.pairing_id), undefined, 'the older unused code is gone');++ const old = await redeem({ code: first.body.code });++ assert.deepStrictEqual([old.status, old.body.code], [403, 'registry.pairing_code_invalid']);++ assert.strictEqual((await redeem({ code: second.body.code })).status, 201);++ });++++ await check('a pairing code works exactly once', async () => {++ const { body: pairing } = await mint();++ const first = await redeem({ pairing: pairing.pairing_id, code: pairing.code, name: 'Rover' });++ assert.strictEqual(first.status, 201, first.text);++ assert.match(first.body.principal, /^nod_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(first.body.node_id, `n-${first.body.principal.slice(4).toLowerCase()}`);++ assert.strictEqual(first.body.node_id.length, 28);++ assert.ok(first.body.credential.length >= 40);++ assert.strictEqual(first.body.token_endpoint, 'https://openvibe.network/oauth/token');++ assert.deepStrictEqual(first.body.paired_for, { service: 'bot', ref: (await db.prepare('SELECT ref FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id)).ref });++ assert.strictEqual(first.headers.get('cache-control'), 'no-store');++ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(first.body.principal);++ assert.deepStrictEqual([p.owner_kind, p.owner_subject, p.trust, p.status, p.name, p.created_by, p.paired_by_service],++ ['user', alex, 'community', 'active', 'Rover', `pairing:${pairing.pairing_id}`, 'bot']);++ assert.strictEqual(p.credential_hash, crypto.createHash('sha256').update(first.body.credential).digest('hex'), 'only the credential\'s sha256 is stored');++ const code = await db.prepare('SELECT used_at, principal_id FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id);++ assert.ok(code.used_at);++ assert.strictEqual(code.principal_id, first.body.principal);++ for (const again of [await redeem({ pairing: pairing.pairing_id, code: pairing.code }), await redeem({ code: pairing.code })]) {++ assert.deepStrictEqual([again.status, again.body.code], [403, 'registry.pairing_code_used']);++ }++ });++++ await check('a pairing code expires after 10 minutes', async () => {++ const { body: pairing } = await mint();++ assert.ok(Math.abs(Date.parse(pairing.expires_at) - Date.now() - nodePrincipals.CODE_TTL_MS) < 5000 && nodePrincipals.CODE_TTL_MS === 10 * 60 * 1000, 'the code lives 10 minutes');++ clock.offset += 10 * 60 * 1000 + 1000;++ const late = await redeem({ pairing: pairing.pairing_id, code: pairing.code });++ clock.offset -= 10 * 60 * 1000 + 1000;++ assert.deepStrictEqual([late.status, late.body.code], [403, 'registry.pairing_code_expired']);++ assert.strictEqual((await db.prepare('SELECT used_at FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id)).used_at, null);++ });++++ await check('five wrong tries end the code', async () => {++ const { body: pairing } = await mint();++ const wrong = pairing.code === 'AAAA-AAAA' ? 'BBBB-BBBB' : 'AAAA-AAAA';++ for (let i = 1; i <= 5; i++) {++ const bad = await redeem({ pairing: pairing.pairing_id, code: wrong });++ assert.strictEqual(bad.status, 403, `try ${i}`);++ assert.strictEqual(bad.body.code, i < 5 ? 'registry.pairing_code_invalid' : 'registry.pairing_code_locked', `try ${i}`);++ }++ assert.strictEqual(Number((await db.prepare('SELECT tries FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id)).tries), 5);++ for (const correct of [await redeem({ pairing: pairing.pairing_id, code: pairing.code }), await redeem({ code: pairing.code })]) {++ assert.deepStrictEqual([correct.status, correct.body.code], [403, 'registry.pairing_code_locked']);++ }++ const shape = await redeem({ code: 'nope' });++ assert.deepStrictEqual([shape.status, shape.body.code], [422, 'registry.invalid_pairing_code']);++ const unknown = await redeem({ code: wrong });++ assert.deepStrictEqual([unknown.status, unknown.body.code], [403, 'registry.pairing_code_invalid']);++ });++++ await check('the code and credential are never logged and never in a read answer', async () => {++ const { body: pairing } = await mint('robot-secret');++ const paired = await redeem({ pairing: pairing.pairing_id, code: pairing.code });++ const { credential, principal } = paired.body;++ const reads = [await call('GET', `/internal/node-principals/${principal}`, null, bot), await call('GET', '/api/v1/nodes'), await call('GET', '/api/v1/nodes', null, bot)];++ for (const r of reads) {++ assert.ok(!r.text.includes(credential), 'a read leaked the credential');++ assert.ok(!r.text.includes(pairing.code.replace('-', '')) && !r.text.includes(pairing.code), 'a read leaked the code');++ assert.ok(!/hash/i.test(r.text), 'a read exposed a hash');++ }++ const logged = logs.join('\n');++ assert.ok(!logged.includes(credential), 'the credential was logged');++ assert.ok(!logged.includes(pairing.code) && !logged.includes(pairing.code.replace('-', '')), 'the pairing code was logged');++ });++++ await check('a service reads and revokes only the principals it paired', async () => {++ const { body: pairing } = await mint('robot-scope');++ const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code, region: 'eu-west' });++ const read = await call('GET', `/internal/node-principals/${paired.principal}`, null, bot);++ assert.strictEqual(read.status, 200, read.text);++ assert.deepStrictEqual(Object.keys(read.body).sort(), ['created_at', 'home_cell', 'last_seen_at', 'name', 'node_id', 'owner', 'paired_for', 'principal', 'revoked_at', 'status']);++ assert.deepStrictEqual([read.body.owner, read.body.paired_for, read.body.status], [{ kind: 'user', subject: alex }, { service: 'bot', ref: 'robot-scope' }, 'active']);++ for (const [m, p] of [['GET', `/internal/node-principals/${paired.principal}`], ['POST', `/internal/node-principals/${paired.principal}/revoke`]]) {++ const other = await call(m, p, null, live);++ assert.deepStrictEqual([other.status, other.body.code], [404, 'registry.unknown_node'], `${m} by another service`);++ assert.strictEqual((await call(m, p, null, host)).status, 403, `${m} without network.node.manage`);++ }++ assert.strictEqual((await call('GET', `/internal/node-principals/nod_${ids.ulid()}`, null, bot)).status, 404);++ assert.strictEqual((await db.prepare('SELECT status FROM platform_node_principals WHERE id = ?').get(paired.principal)).status, 'active', 'the other service changed nothing');++ });++++ await check('revoke is scoped, clears the previous hash and is idempotent', async () => {++ const { body: pairing } = await mint('robot-revoke');++ const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });++ await db.prepare("UPDATE platform_node_principals SET credential_prev_hash = ?, prev_valid_until = '2026-10-02T00:01:00Z' WHERE id = ?").run('b'.repeat(64), paired.principal);++ const r = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.status, 'revoked');++ assert.ok(r.body.revoked_at);++ const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(paired.principal);++ assert.deepStrictEqual([row.status, row.revoked_by, row.credential_prev_hash, row.prev_valid_until], ['revoked', 'svc:bot', null, null]);++ clock.offset += 5000;++ const again = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);++ clock.offset -= 5000;++ assert.deepStrictEqual([again.status, again.body.revoked_at], [200, r.body.revoked_at], 'a second revoke changes nothing');++ assert.strictEqual((await call('GET', `/internal/node-principals/${paired.principal}`, null, bot)).body.status, 'revoked');++ });++++ await check('pairing creates no platform machine; Host cannot report a paired node', async () => {++ const { body: pairing } = await mint('robot-platform');++ const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });++ assert.strictEqual(await db.prepare('SELECT 1 FROM platform_nodes WHERE id = ?').get(paired.node_id), undefined, 'no platform_nodes row');++ const list = await call('GET', '/api/v1/nodes');++ assert.strictEqual(list.status, 200);++ assert.ok(!list.text.includes(paired.node_id), 'the paired machine is absent from the public node list');++ const report = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [{ id: paired.node_id, roles: ['app'], location: { region: 'us-west', country: 'US' }, health: { status: 'up', checked_at: '2026-10-01T12:00:00Z' }, updated_at: '2026-10-01T12:00:00Z' }] }, host);++ assert.deepStrictEqual([report.status, report.body.code], [409, 'registry.node_not_platform']);++ assert.strictEqual(await db.prepare('SELECT 1 FROM platform_nodes WHERE id = ?').get(paired.node_id), undefined, 'the refused report wrote nothing');++ });++++ await check('the home cell is the code\'s, else the region\'s active cell, else wnam-1', async () => {++ const home = async (mintExtra, redeemExtra) => {++ const { body: pairing } = await mint(undefined, mintExtra);++ const r = await redeem({ pairing: pairing.pairing_id, code: pairing.code, ...redeemExtra });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual((await db.prepare('SELECT home_cell FROM platform_node_principals WHERE id = ?').get(r.body.principal)).home_cell, r.body.home_cell);++ return r.body.home_cell;++ };++ assert.strictEqual(await home({}, { region: 'eu-west' }), 'weur-1');++ assert.strictEqual(await home({ home_cell: 'wnam-1' }, { region: 'eu-west' }), 'wnam-1', 'the code\'s cell wins');++ assert.strictEqual(await home({}, { region: 'ap-south' }), 'wnam-1', 'a region without a cell');++ assert.strictEqual(await home({}, {}), 'wnam-1');++ });++ log(`node-pairing: ${passed} checks passed`);++} finally {++ process.stdout.write = out;++ process.stderr.write = err;++ server.close();++ await db.close?.();++}++})().catch((e) => { console.error(e); process.exit(1); });
@@ -104,6 +104,7 @@ async function seedDb(db, { log = console } = {}) {
@@ -104,6 +104,7 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'codes', name: 'OpenVibe.Codes', redirect_uris: ['https://openvibe.codes/auth/callback'] },
{ client_id: 'codes', name: 'OpenVibe.Codes', redirect_uris: ['https://openvibe.codes/auth/callback'] },
{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] },
{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] },
{ client_id: 'ai', name: 'OpenVibe.AI', redirect_uris: ['https://ai.openvibe.network/auth/callback'] },
{ client_id: 'ai', name: 'OpenVibe.AI', redirect_uris: ['https://ai.openvibe.network/auth/callback'] },
+ { client_id: 'bot', name: 'OpenVibe.Bot', redirect_uris: ['https://openvibe.bot/auth/callback'] },
];
];
let seededAny = false;
let seededAny = false;
for (const c of contractClients) {
for (const c of contractClients) {
@@ -150,6 +150,8 @@ const DEFAULT_GRANTS = [
@@ -150,6 +150,8 @@ const DEFAULT_GRANTS = [
// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
['host', 'network.node.report', SELF_AUDIENCE, []],
['host', 'network.node.report', SELF_AUDIENCE, []],
['host', 'network.resource.report', SELF_AUDIENCE, []],
['host', 'network.resource.report', SELF_AUDIENCE, []],
+ // Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
+ ['bot', 'network.node.manage', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
@@ -489,6 +489,14 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -489,6 +489,14 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/api/v1/cells', cellRouters.pub);
app.use('/api/v1/cells', cellRouters.pub);
app.use('/internal/registry', cellRouters.internal);
app.use('/internal/registry', cellRouters.internal);
}
}
+// Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md section 4.2): a service holding
+// network.node.manage mints one-time codes and reads or revokes the principals it paired; the machine redeems its code
+// with no other credential, so the redeem route has its own tight rate limit.
+{
+ const pairingRouters = require('./registry/node-principals').routers({ guard: require('./identity/principals').guard('network.node.manage') });
+ app.use('/api/v1/node-pairing', rateLimit({ windowMs: 60_000, max: 10 }), pairingRouters.pairing);
+ app.use('/internal', pairingRouters.internal);
+}
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
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,223 @@
@@ -0,0 +1,223 @@
+'use strict';
+/**
+ * Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md section 4.2, slice N4b;
+ * migrations/0014_node_pairing.sql). A service holding network.node.manage (Bot) mints a one-time pairing code for one
+ * of its users; the machine redeems it with no other credential and becomes a node principal (nod_<ULID>) owned by that
+ * person, with a long-lived credential shown once. The service may then read and revoke only the principals it paired.
+ *
+ * Secrets: the code and the credential are shown once and stored only as their sha256; neither is ever logged nor
+ * returned by a read. A paired principal is never a platform machine: no platform_nodes row is created, and Host's node
+ * report naming its node_id is refused (cells.checkPlatformNodes, 409 registry.node_not_platform).
+ *
+ * POST /internal/node-pairings network.node.manage → 201 {pairing_id, code, expires_at}
+ * POST /api/v1/node-pairing none (the code) → 201 {principal, node_id, home_cell, credential, …}
+ * GET /internal/node-principals/:id network.node.manage → the principal, if the caller paired it
+ * POST /internal/node-principals/:id/revoke network.node.manage → the principal, revoked (idempotent)
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { ids, http } = require('openvibe-contracts');
+const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
+
+const CODE_TTL_MS = 10 * 60 * 1000;
+const MAX_TRIES = 5;
+const 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}$/;
+const REGION = /^[a-z][a-z0-9-]{1,39}$/;
+const CONTROL = /[\u0000-\u001f\u007f]/;
+
+// Crockford base32 (Bot's alphabet): no I, L, O or U, so a code read aloud cannot be mistyped into another.
+const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));
+const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
+const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
+function newCode() {
+ const b = crypto.randomBytes(8);
+ let s = '';
+ for (let i = 0; i < 8; i++) s += CROCKFORD[b[i] % 32]; // 256 % 32 = 0: no bias
+ return s;
+}
+const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+/** Constant-time equality of two stored hashes. */
+function sameHash(a, b) {
+ const x = Buffer.from(String(a || ''));
+ const y = Buffer.from(String(b || ''));
+ if (x.length !== y.length) { crypto.timingSafeEqual(x, x); return false; }
+ return crypto.timingSafeEqual(x, y);
+}
+const text = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !CONTROL.test(v) ? v.trim() : null;
+const iso = (ms) => new Date(ms).toISOString();
+
+/** The first active cell of a region, else wnam-1 (the same rule as cells.js cellForRegion, for a paired machine). */
+async function cellForRegion(db, region) {
+ const row = region ? await db.prepare("SELECT id FROM platform_cells WHERE region = ? AND status = 'active' ORDER BY id LIMIT 1").get(region) : null;
+ return row ? row.id : BOOTSTRAP_CELL;
+}
+
+/** The service a verified service token speaks for: svc:<id> → <id>. */
+const serviceOf = (req) => String(req.principal && req.principal.sub || '').replace(/^svc:/, '');
+
+/** What a service or the owner may see of a principal: never a hash. */
+const principalView = (p) => ({
+ principal: p.id, node_id: p.node_id, name: p.name || null,
+ owner: p.owner_kind === 'user' ? { kind: 'user', subject: p.owner_subject } : p.owner_kind === 'project' ? { kind: 'project', project_id: p.project_id } : { kind: p.owner_kind },
+ home_cell: p.home_cell, status: p.status,
+ paired_for: p.paired_by_service ? { service: p.paired_by_service, ref: p.pairing_ref } : null,
+ last_seen_at: p.last_seen_at || null, created_at: p.created_at, revoked_at: p.revoked_at || null,
+});
+
+/**
+ * Mint a pairing code for `service`'s user. Replaces that service's unused codes for the same ref.
+ * Throws RegistryError. → { pairing_id, code, expires_at } (the code in the clear, this once).
+ */
+async function createPairing(db, service, body, now = Date.now()) {
+ const owner = body && body.owner;
+ if (owner && owner.kind === 'project') throw new RegistryError(501, 'registry.not_yet', 'project-owned machines are not paired yet');
+ if (!owner || owner.kind !== 'user' || typeof owner.subject !== 'string' || !SUBJECT.test(owner.subject)) {
+ throw new RegistryError(400, 'registry.invalid_pairing_request', "owner must be {kind: 'user', subject: 'usr_…'}");
+ }
+ const ref = text(body.ref, 80);
+ if (!ref) throw new RegistryError(400, 'registry.invalid_pairing_request', 'ref must be 1-80 characters');
+ if (body.home_cell !== undefined && body.home_cell !== null && typeof body.home_cell !== 'string') throw new RegistryError(400, 'registry.invalid_pairing_request', 'home_cell must be a cell id');
+ const user = await db.prepare(`SELECT id FROM users WHERE subject_id = ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL
+ AND merged_into IS NULL AND COALESCE(is_anon, 0) = 0`).get(owner.subject);
+ if (!user) throw new RegistryError(404, 'registry.unknown_owner', `no person ${owner.subject}`);
+ const homeCell = body.home_cell || null;
+ if (homeCell) {
+ const cell = await db.prepare('SELECT status FROM platform_cells WHERE id = ?').get(homeCell);
+ if (!cell) throw new RegistryError(400, 'registry.unknown_cell', `no cell ${homeCell}`);
+ if (cell.status !== 'active') throw new RegistryError(409, 'registry.cell_not_active', `cell ${homeCell} is ${cell.status}`);
+ }
+ const id = `pair_${ids.ulid(now)}`;
+ const expiresAt = iso(now + CODE_TTL_MS);
+ let code = null;
+ await db.tx(async (t) => {
+ // One live code per (service, ref), as Bot's one live code per robot.
+ await t.prepare('DELETE FROM platform_node_pairings WHERE service = ? AND ref = ? AND used_at IS NULL').run(service, ref);
+ const insert = t.prepare(`INSERT INTO platform_node_pairings (id, code_hash, owner_kind, owner_subject, service, ref, home_cell, created_by, created_at, expires_at)
+ VALUES (?, ?, 'user', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (code_hash) DO NOTHING`);
+ // 40 random bits: a clash with a stored hash is improbable, and is drawn again rather than failing.
+ for (let i = 0; i < 3 && !code; i++) {
+ const c = newCode();
+ if ((await insert.run(id, sha256(c), owner.subject, service, ref, homeCell, `svc:${service}`, iso(now), expiresAt)).changes) code = c;
+ }
+ if (!code) throw new Error('could not draw a unique pairing code');
+ });
+ return { pairing_id: id, code: formatCode(code), expires_at: expiresAt };
+}
+
+/**
+ * Redeem a code (Bot's redeem, OpenVibe.Bot server/domain/index.js:158-205). With `pairing` a wrong code counts a try
+ * against that row (5 lock it); without it the code is found by its hash. Refusals are returned from the transaction and
+ * thrown after it commits, so a counted try survives. Throws RegistryError. → { principal row, credential }
+ */
+async function redeem(db, body, now = Date.now()) {
+ const b = body && typeof body === 'object' ? body : {};
+ const normal = normaliseCode(typeof b.code === 'string' ? b.code : '');
+ if (!isCodeShape(normal)) throw new RegistryError(422, 'registry.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');
+ if (b.pairing !== undefined && b.pairing !== null && (typeof b.pairing !== 'string' || !PAIRING_ID.test(b.pairing))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'pairing must be a pair_ id');
+ const name = b.name === undefined || b.name === null ? null : text(b.name, 80);
+ if (b.name != null && !name) throw new RegistryError(400, 'registry.invalid_pairing_request', 'name must be 1-80 characters');
+ if (b.region != null && (typeof b.region !== 'string' || !REGION.test(b.region))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'region must be a region id');
+ const hash = sha256(normal);
+ const at = iso(now);
+ const result = await db.tx(async (t) => {
+ let row;
+ if (b.pairing) {
+ row = await t.prepare('SELECT * FROM platform_node_pairings WHERE id = ? FOR UPDATE').get(b.pairing);
+ if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };
+ if (!sameHash(row.code_hash, hash)) {
+ if (row.used_at) return { error: [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };
+ if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };
+ const tries = Number(row.tries) + 1;
+ await t.prepare('UPDATE platform_node_pairings SET tries = ? WHERE id = ?').run(tries, row.id);
+ return { error: tries >= MAX_TRIES ? [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] : [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };
+ }
+ } else {
+ row = await t.prepare('SELECT * FROM platform_node_pairings WHERE code_hash = ? FOR UPDATE').get(hash);
+ if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };
+ }
+ if (row.used_at) return { error: [403, 'registry.pairing_code_used', 'that pairing code has already been used'] };
+ if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };
+ if (Date.parse(row.expires_at) <= now) return { error: [403, 'registry.pairing_code_expired', 'that pairing code has expired'] };
+ if (row.owner_kind !== 'user') return { error: [501, 'registry.not_yet', 'project-owned machines are not paired yet'] };
+ await t.prepare('UPDATE platform_node_pairings SET used_at = ? WHERE id = ?').run(at, row.id);
+ const ulid = ids.ulid(now);
+ const principalId = `nod_${ulid}`;
+ const credential = crypto.randomBytes(32).toString('base64url');
+ const homeCell = row.home_cell || await cellForRegion(t, b.region);
+ // A paired machine is a principal only: no platform_nodes row, so it is never a platform machine.
+ await t.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, name, trust, status,
+ credential_hash, paired_by_service, pairing_ref, created_at, created_by, updated_at)
+ VALUES (?, ?, ?, 'user', ?, ?, 'community', 'active', ?, ?, ?, ?, ?, ?)`)
+ .run(principalId, `n-${ulid.toLowerCase()}`, homeCell, row.owner_subject, name, sha256(credential), row.service, row.ref, at, `pairing:${row.id}`, at);
+ await t.prepare('UPDATE platform_node_pairings SET principal_id = ? WHERE id = ?').run(principalId, row.id);
+ return { principal: await t.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(principalId), credential };
+ });
+ if (result.error) throw new RegistryError(...result.error);
+ return result;
+}
+
+/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */
+async function pairedBy(db, id, service) {
+ if (!PRINCIPAL_ID.test(String(id))) return null;
+ return await db.prepare('SELECT * FROM platform_node_principals WHERE id = ? AND paired_by_service = ?').get(String(id), service) || null;
+}
+
+/** Revoke a principal `service` paired; a second revoke changes nothing. → the row, or null. */
+async function revoke(db, id, service, now = Date.now()) {
+ if (!await pairedBy(db, id, service)) return null;
+ const at = iso(now);
+ await db.prepare(`UPDATE platform_node_principals SET status = 'revoked', revoked_at = ?, revoked_by = ?, credential_prev_hash = NULL,
+ prev_valid_until = NULL, updated_at = ? WHERE id = ? AND paired_by_service = ? AND status <> 'revoked'`).run(at, `svc:${service}`, at, String(id), service);
+ return pairedBy(db, id, service);
+}
+
+function routers({ guard, now = () => Date.now() }) {
+ const problem = (res, e) => http.sendProblem(res, e.status, e.code, { detail: e.message });
+ const internal = express.Router();
+ internal.post('/node-pairings', guard, express.json({ limit: '8kb' }), async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ try {
+ res.status(201).json(await createPairing(req.app.locals.db, serviceOf(req), req.body || {}, now()));
+ } catch (e) {
+ if (e instanceof RegistryError) return problem(res, e);
+ throw e;
+ }
+ });
+ internal.get('/node-principals/:id', guard, async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const p = await pairedBy(req.app.locals.db, req.params.id, serviceOf(req));
+ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
+ res.json(principalView(p));
+ });
+ internal.post('/node-principals/:id/revoke', guard, async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const p = await revoke(req.app.locals.db, req.params.id, serviceOf(req), now());
+ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
+ res.json(principalView(p));
+ });
+
+ // Mounted at /api/v1/node-pairing behind its own rate limit (server/index.js).
+ const pairing = express.Router();
+ pairing.post('/', express.json({ limit: '8kb' }), async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ let out;
+ try {
+ out = await redeem(req.app.locals.db, req.body, now());
+ } catch (e) {
+ if (e instanceof RegistryError) return problem(res, e);
+ throw e;
+ }
+ const p = out.principal;
+ res.status(201).json({
+ principal: p.id, node_id: p.node_id, home_cell: p.home_cell, credential: out.credential,
+ token_endpoint: `${req.app.locals.config.jwt.issuer}/oauth/token`,
+ paired_for: { service: p.paired_by_service, ref: p.pairing_ref },
+ });
+ });
+ return { internal, pairing };
+}
+
+module.exports = { CODE_TTL_MS, MAX_TRIES, createPairing, redeem, revoke, principalView, routers };
new file mode 100644
new file mode 100644
@@ -0,0 +1,235 @@
@@ -0,0 +1,235 @@
+'use strict';
+// Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md sections 4.2 and 7, slice N4b): Bot's five
+// pairing checks (OpenVibe.Bot test/pairing.test.js: one use, 10 min expiry, 5 tries, never logged nor read, revoke),
+// the read and revoke scoped to the service that paired, and a paired machine that never becomes a platform machine.
+// node test/node-pairing.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { ids } = 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 second service holding network.node.manage, to prove each service sees only what it paired.
+await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.node.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
+
+const subject = () => `usr_${ids.ulid()}`;
+const alex = subject(); const banned = subject(); const gone = subject();
+await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, is_banned, deleted_at) VALUES
+ (9101, 'pairalex', 'Alex', 'x', ?, 0, NULL), (9102, 'pairbanned', 'Banned', 'x', ?, 1, NULL), (9103, 'pairgone', 'Gone', 'x', ?, 0, '2026-09-01T00:00:00Z')`).run(alex, banned, gone);
+await db.prepare("INSERT INTO platform_regions (id, country) VALUES ('eu-west', 'IE') ON CONFLICT DO NOTHING").run();
+await db.prepare("INSERT INTO platform_cells (id, region, residency, status) VALUES ('weur-1', 'eu-west', 'EU', 'active'), ('wnam-2', 'us-west', 'US', 'planned') ON CONFLICT DO NOTHING").run();
+
+const clock = { offset: 0 };
+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: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/oauth', require('../server/auth/oauth-routes'));
+const nodeRouters = require('../server/registry/nodes').routers({ guard: principals.guard('network.node.report') });
+app.use('/api/v1/nodes', nodeRouters.pub);
+app.use('/internal/nodes', nodeRouters.internal);
+const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage'), now: () => Date.now() + clock.offset });
+app.use('/api/v1/node-pairing', pr.pairing);
+app.use('/internal', pr.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}`;
+const token = async (id, secret) => (await (await 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: 'openvibe.network' }) })).json()).access_token;
+const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (x) => { const text = await x.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}` });
+let passed = 0;
+const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
+try {
+ const bot = bearer(await token('bot', 'bot-secret'));
+ const live = bearer(await token('live', 'live-secret'));
+ const host = bearer(await token('host', 'host-secret'));
+ const mint = (ref = `robot-${ids.ulid()}`, extra = {}) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref, ...extra }, bot);
+ const redeem = (body) => call('POST', '/api/v1/node-pairing', body);
+
+ await check('minting a code needs network.node.manage and a live person', async () => {
+ assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' })).status, 403, 'no token');
+ assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' }, host)).status, 403, 'host lacks network.node.manage');
+ for (const s of [subject(), banned, gone]) {
+ const r = await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: s }, ref: 'r' }, bot);
+ assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_owner']);
+ }
+ const p = await call('POST', '/internal/node-pairings', { owner: { kind: 'project', project_id: 'prj_x' }, ref: 'r' }, bot);
+ assert.deepStrictEqual([p.status, p.body.code], [501, 'registry.not_yet']);
+ const planned = await mint(undefined, { home_cell: 'wnam-2' });
+ assert.deepStrictEqual([planned.status, planned.body.code], [409, 'registry.cell_not_active']);
+ assert.strictEqual((await mint(undefined, { home_cell: 'nope-9' })).status, 400);
+ assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex } }, bot)).status, 400, 'ref is required');
+ });
+
+ await check('a code is shown once, stored only as its sha256, and replaces that ref\'s unused code', async () => {
+ const ref = 'robot-replace';
+ const first = await mint(ref);
+ assert.strictEqual(first.status, 201, first.text);
+ assert.match(first.body.code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);
+ assert.match(first.body.pairing_id, /^pair_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(first.headers.get('cache-control'), 'no-store');
+ const row = await db.prepare('SELECT * FROM platform_node_pairings WHERE id = ?').get(first.body.pairing_id);
+ assert.strictEqual(row.code_hash, crypto.createHash('sha256').update(first.body.code.replace('-', '')).digest('hex'));
+ assert.ok(!JSON.stringify(row).includes(first.body.code.replace('-', '')), 'the code is not stored in the clear');
+ assert.deepStrictEqual([row.service, row.ref, row.owner_subject, row.created_by], ['bot', ref, alex, 'svc:bot']);
+ const second = await mint(ref);
+ assert.strictEqual(await db.prepare('SELECT 1 FROM platform_node_pairings WHERE id = ?').get(first.body.pairing_id), undefined, 'the older unused code is gone');
+ const old = await redeem({ code: first.body.code });
+ assert.deepStrictEqual([old.status, old.body.code], [403, 'registry.pairing_code_invalid']);
+ assert.strictEqual((await redeem({ code: second.body.code })).status, 201);
+ });
+
+ await check('a pairing code works exactly once', async () => {
+ const { body: pairing } = await mint();
+ const first = await redeem({ pairing: pairing.pairing_id, code: pairing.code, name: 'Rover' });
+ assert.strictEqual(first.status, 201, first.text);
+ assert.match(first.body.principal, /^nod_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(first.body.node_id, `n-${first.body.principal.slice(4).toLowerCase()}`);
+ assert.strictEqual(first.body.node_id.length, 28);
+ assert.ok(first.body.credential.length >= 40);
+ assert.strictEqual(first.body.token_endpoint, 'https://openvibe.network/oauth/token');
+ assert.deepStrictEqual(first.body.paired_for, { service: 'bot', ref: (await db.prepare('SELECT ref FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id)).ref });
+ assert.strictEqual(first.headers.get('cache-control'), 'no-store');
+ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(first.body.principal);
+ assert.deepStrictEqual([p.owner_kind, p.owner_subject, p.trust, p.status, p.name, p.created_by, p.paired_by_service],
+ ['user', alex, 'community', 'active', 'Rover', `pairing:${pairing.pairing_id}`, 'bot']);
+ assert.strictEqual(p.credential_hash, crypto.createHash('sha256').update(first.body.credential).digest('hex'), 'only the credential\'s sha256 is stored');
+ const code = await db.prepare('SELECT used_at, principal_id FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id);
+ assert.ok(code.used_at);
+ assert.strictEqual(code.principal_id, first.body.principal);
+ for (const again of [await redeem({ pairing: pairing.pairing_id, code: pairing.code }), await redeem({ code: pairing.code })]) {
+ assert.deepStrictEqual([again.status, again.body.code], [403, 'registry.pairing_code_used']);
+ }
+ });
+
+ await check('a pairing code expires after 10 minutes', async () => {
+ const { body: pairing } = await mint();
+ assert.ok(Math.abs(Date.parse(pairing.expires_at) - Date.now() - nodePrincipals.CODE_TTL_MS) < 5000 && nodePrincipals.CODE_TTL_MS === 10 * 60 * 1000, 'the code lives 10 minutes');
+ clock.offset += 10 * 60 * 1000 + 1000;
+ const late = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
+ clock.offset -= 10 * 60 * 1000 + 1000;
+ assert.deepStrictEqual([late.status, late.body.code], [403, 'registry.pairing_code_expired']);
+ assert.strictEqual((await db.prepare('SELECT used_at FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id)).used_at, null);
+ });
+
+ await check('five wrong tries end the code', async () => {
+ const { body: pairing } = await mint();
+ const wrong = pairing.code === 'AAAA-AAAA' ? 'BBBB-BBBB' : 'AAAA-AAAA';
+ for (let i = 1; i <= 5; i++) {
+ const bad = await redeem({ pairing: pairing.pairing_id, code: wrong });
+ assert.strictEqual(bad.status, 403, `try ${i}`);
+ assert.strictEqual(bad.body.code, i < 5 ? 'registry.pairing_code_invalid' : 'registry.pairing_code_locked', `try ${i}`);
+ }
+ assert.strictEqual(Number((await db.prepare('SELECT tries FROM platform_node_pairings WHERE id = ?').get(pairing.pairing_id)).tries), 5);
+ for (const correct of [await redeem({ pairing: pairing.pairing_id, code: pairing.code }), await redeem({ code: pairing.code })]) {
+ assert.deepStrictEqual([correct.status, correct.body.code], [403, 'registry.pairing_code_locked']);
+ }
+ const shape = await redeem({ code: 'nope' });
+ assert.deepStrictEqual([shape.status, shape.body.code], [422, 'registry.invalid_pairing_code']);
+ const unknown = await redeem({ code: wrong });
+ assert.deepStrictEqual([unknown.status, unknown.body.code], [403, 'registry.pairing_code_invalid']);
+ });
+
+ await check('the code and credential are never logged and never in a read answer', async () => {
+ const { body: pairing } = await mint('robot-secret');
+ const paired = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
+ const { credential, principal } = paired.body;
+ const reads = [await call('GET', `/internal/node-principals/${principal}`, null, bot), await call('GET', '/api/v1/nodes'), await call('GET', '/api/v1/nodes', null, bot)];
+ for (const r of reads) {
+ assert.ok(!r.text.includes(credential), 'a read leaked the credential');
+ assert.ok(!r.text.includes(pairing.code.replace('-', '')) && !r.text.includes(pairing.code), 'a read leaked the code');
+ assert.ok(!/hash/i.test(r.text), 'a read exposed a hash');
+ }
+ const logged = logs.join('\n');
+ assert.ok(!logged.includes(credential), 'the credential was logged');
+ assert.ok(!logged.includes(pairing.code) && !logged.includes(pairing.code.replace('-', '')), 'the pairing code was logged');
+ });
+
+ await check('a service reads and revokes only the principals it paired', async () => {
+ const { body: pairing } = await mint('robot-scope');
+ const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code, region: 'eu-west' });
+ const read = await call('GET', `/internal/node-principals/${paired.principal}`, null, bot);
+ assert.strictEqual(read.status, 200, read.text);
+ assert.deepStrictEqual(Object.keys(read.body).sort(), ['created_at', 'home_cell', 'last_seen_at', 'name', 'node_id', 'owner', 'paired_for', 'principal', 'revoked_at', 'status']);
+ assert.deepStrictEqual([read.body.owner, read.body.paired_for, read.body.status], [{ kind: 'user', subject: alex }, { service: 'bot', ref: 'robot-scope' }, 'active']);
+ for (const [m, p] of [['GET', `/internal/node-principals/${paired.principal}`], ['POST', `/internal/node-principals/${paired.principal}/revoke`]]) {
+ const other = await call(m, p, null, live);
+ assert.deepStrictEqual([other.status, other.body.code], [404, 'registry.unknown_node'], `${m} by another service`);
+ assert.strictEqual((await call(m, p, null, host)).status, 403, `${m} without network.node.manage`);
+ }
+ assert.strictEqual((await call('GET', `/internal/node-principals/nod_${ids.ulid()}`, null, bot)).status, 404);
+ assert.strictEqual((await db.prepare('SELECT status FROM platform_node_principals WHERE id = ?').get(paired.principal)).status, 'active', 'the other service changed nothing');
+ });
+
+ await check('revoke is scoped, clears the previous hash and is idempotent', async () => {
+ const { body: pairing } = await mint('robot-revoke');
+ const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
+ await db.prepare("UPDATE platform_node_principals SET credential_prev_hash = ?, prev_valid_until = '2026-10-02T00:01:00Z' WHERE id = ?").run('b'.repeat(64), paired.principal);
+ const r = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.status, 'revoked');
+ assert.ok(r.body.revoked_at);
+ const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(paired.principal);
+ assert.deepStrictEqual([row.status, row.revoked_by, row.credential_prev_hash, row.prev_valid_until], ['revoked', 'svc:bot', null, null]);
+ clock.offset += 5000;
+ const again = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);
+ clock.offset -= 5000;
+ assert.deepStrictEqual([again.status, again.body.revoked_at], [200, r.body.revoked_at], 'a second revoke changes nothing');
+ assert.strictEqual((await call('GET', `/internal/node-principals/${paired.principal}`, null, bot)).body.status, 'revoked');
+ });
+
+ await check('pairing creates no platform machine; Host cannot report a paired node', async () => {
+ const { body: pairing } = await mint('robot-platform');
+ const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
+ assert.strictEqual(await db.prepare('SELECT 1 FROM platform_nodes WHERE id = ?').get(paired.node_id), undefined, 'no platform_nodes row');
+ const list = await call('GET', '/api/v1/nodes');
+ assert.strictEqual(list.status, 200);
+ assert.ok(!list.text.includes(paired.node_id), 'the paired machine is absent from the public node list');
+ const report = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [{ id: paired.node_id, roles: ['app'], location: { region: 'us-west', country: 'US' }, health: { status: 'up', checked_at: '2026-10-01T12:00:00Z' }, updated_at: '2026-10-01T12:00:00Z' }] }, host);
+ assert.deepStrictEqual([report.status, report.body.code], [409, 'registry.node_not_platform']);
+ assert.strictEqual(await db.prepare('SELECT 1 FROM platform_nodes WHERE id = ?').get(paired.node_id), undefined, 'the refused report wrote nothing');
+ });
+
+ await check('the home cell is the code\'s, else the region\'s active cell, else wnam-1', async () => {
+ const home = async (mintExtra, redeemExtra) => {
+ const { body: pairing } = await mint(undefined, mintExtra);
+ const r = await redeem({ pairing: pairing.pairing_id, code: pairing.code, ...redeemExtra });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual((await db.prepare('SELECT home_cell FROM platform_node_principals WHERE id = ?').get(r.body.principal)).home_cell, r.body.home_cell);
+ return r.body.home_cell;
+ };
+ assert.strictEqual(await home({}, { region: 'eu-west' }), 'weur-1');
+ assert.strictEqual(await home({ home_cell: 'wnam-1' }, { region: 'eu-west' }), 'wnam-1', 'the code\'s cell wins');
+ assert.strictEqual(await home({}, { region: 'ap-south' }), 'wnam-1', 'a region without a cell');
+ assert.strictEqual(await home({}, {}), 'wnam-1');
+ });
+ log(`node-pairing: ${passed} checks passed`);
+} finally {
+ process.stdout.write = out;
+ process.stderr.write = err;
+ server.close();
+ await db.close?.();
+}
+})().catch((e) => { console.error(e); process.exit(1); });