openvibers/openvibe.network Commits

Pair a person's machine as a node principal with scoped read and revoke (#16)

OP OpenVibers committed 2 Oct 2026 · cbaacc48bd · 5 files, +469 −0

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Network/pull/16

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex 644c32e6..cde61991 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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) {
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 0cce8e64..66ec18f6 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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
ddiff --git a/server/index.js b/server/index.js
iindex ceea0d3c..cbed9888 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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).
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
nnew file mode 100644
iindex 00000000..be53bb52
---- /dev/null
++++ b/server/registry/node-principals.js
@@@ -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 };
ddiff --git a/test/node-pairing.test.js b/test/node-pairing.test.js
nnew file mode 100644
iindex 00000000..f89a9d96
---- /dev/null
++++ b/test/node-pairing.test.js
@@@ -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); });