openvibers/openvibe.network Commits

Add 0014 node pairing schema and its accept/reject tests (#15)

OP OpenVibers committed 2 Oct 2026 · 757dc98b1b · 3 files, +96 −3

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

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

Changes

ddiff --git a/migrations/0014_node_pairing.sql b/migrations/0014_node_pairing.sql
nnew file mode 100644
iindex 00000000..3d720b62
---- /dev/null
++++ b/migrations/0014_node_pairing.sql
@@@ -0,0 +1,67 @@
++-- phase: expand
++-- plan T2. A paired machine's principal gains its owner, the credential that pairs it and its pairing record.
++
++ALTER TABLE platform_node_principals
++ ADD COLUMN IF NOT EXISTS owner_subject text COLLATE "C",
++ ADD COLUMN IF NOT EXISTS name text COLLATE "C" CHECK (length(name) BETWEEN 1 AND 80),
++ ADD COLUMN IF NOT EXISTS credential_hash text COLLATE "C" CHECK (credential_hash ~ '^[0-9a-f]{64}$'),
++ ADD COLUMN IF NOT EXISTS credential_prev_hash text COLLATE "C" CHECK (credential_prev_hash ~ '^[0-9a-f]{64}$'),
++ ADD COLUMN IF NOT EXISTS prev_valid_until text COLLATE "C",
++ ADD COLUMN IF NOT EXISTS paired_by_service text COLLATE "C" CHECK (paired_by_service ~ '^[a-z][a-z0-9-]{1,39}$'),
++ ADD COLUMN IF NOT EXISTS pairing_ref text COLLATE "C" CHECK (length(pairing_ref) BETWEEN 1 AND 80),
++ ADD COLUMN IF NOT EXISTS last_seen_at text COLLATE "C";
++
++ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_owner;
++ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_owner CHECK (
++ (owner_kind = 'platform' AND project_id IS NULL AND owner_subject IS NULL AND trust = 'first-party')
++ OR (owner_kind = 'project' AND project_id IS NOT NULL AND owner_subject IS NULL AND trust <> 'first-party')
++ OR (owner_kind = 'user' AND project_id IS NULL AND owner_subject IS NOT NULL
++ AND owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND trust = 'community'));
++ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_owner_kind_check;
++ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_owner_kind_check
++ CHECK (owner_kind IN ('platform', 'project', 'user'));
++-- A paired machine always holds a credential; a platform machine reported by Host holds none (yet).
++ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_credential;
++ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_credential
++ CHECK (owner_kind = 'platform' OR credential_hash IS NOT NULL) NOT VALID;
++ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_prev;
++ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_prev
++ CHECK ((credential_prev_hash IS NULL) = (prev_valid_until IS NULL));
++ALTER TABLE platform_node_principals DROP CONSTRAINT IF EXISTS platform_node_principals_paired;
++ALTER TABLE platform_node_principals ADD CONSTRAINT platform_node_principals_paired
++ CHECK ((paired_by_service IS NULL) = (pairing_ref IS NULL));
++CREATE UNIQUE INDEX IF NOT EXISTS platform_node_principals_credential_idx ON platform_node_principals (credential_hash)
++ WHERE status <> 'revoked' AND credential_hash IS NOT NULL;
++CREATE INDEX IF NOT EXISTS platform_node_principals_prev_idx ON platform_node_principals (credential_prev_hash)
++ WHERE status <> 'revoked' AND credential_prev_hash IS NOT NULL;
++CREATE INDEX IF NOT EXISTS platform_node_principals_user_idx ON platform_node_principals (owner_subject) WHERE owner_subject IS NOT NULL;
++CREATE INDEX IF NOT EXISTS platform_node_principals_paired_idx ON platform_node_principals (paired_by_service, pairing_ref) WHERE paired_by_service IS NOT NULL;
++
++CREATE TABLE IF NOT EXISTS platform_node_pairings (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^pair_[0-9A-HJKMNP-TV-Z]{26}$'),
++ code_hash text COLLATE "C" NOT NULL UNIQUE CHECK (code_hash ~ '^[0-9a-f]{64}$'),
++ owner_kind text COLLATE "C" NOT NULL CHECK (owner_kind IN ('project', 'user')),
++ project_id text COLLATE "C" REFERENCES dev_projects(id),
++ owner_subject text COLLATE "C",
++ service text COLLATE "C" NOT NULL CHECK (service ~ '^[a-z][a-z0-9-]{1,39}$'),
++ ref text COLLATE "C" NOT NULL CHECK (length(ref) BETWEEN 1 AND 80),
++ home_cell text COLLATE "C" REFERENCES platform_cells(id),
++ created_by text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ expires_at text COLLATE "C" NOT NULL,
++ tries integer NOT NULL DEFAULT 0 CHECK (tries BETWEEN 0 AND 5),
++ used_at text COLLATE "C",
++ principal_id text COLLATE "C" REFERENCES platform_node_principals(id),
++ CONSTRAINT platform_node_pairings_owner CHECK (
++ (owner_kind = 'project' AND project_id IS NOT NULL AND owner_subject IS NULL)
++ OR (owner_kind = 'user' AND project_id IS NULL AND owner_subject IS NOT NULL
++ AND owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')),
++ CONSTRAINT platform_node_pairings_used CHECK (principal_id IS NULL OR used_at IS NOT NULL)
++);
++CREATE INDEX IF NOT EXISTS platform_node_pairings_ref_idx ON platform_node_pairings (service, ref, created_at);
++
++CREATE TABLE IF NOT EXISTS platform_node_capabilities (
++ node_id text COLLATE "C" PRIMARY KEY REFERENCES platform_node_principals(node_id),
++ doc text COLLATE "C" NOT NULL,
++ reported_at text COLLATE "C" NOT NULL
++);
ddiff --git a/test/cells-registry.test.js b/test/cells-registry.test.js
iindex 3af58586..4652cd4a 100644
---- a/test/cells-registry.test.js
++++ b/test/cells-registry.test.js
@@@ -8,7 +8,7 @@ const crypto = require('crypto');
const http = require('http');
const express = require('express');
const { ids } = require('openvibe-contracts');
--const { getDb } = require('../server/db/database');
++const { getDb, MIGRATIONS } = require('../server/db/database');
const principals = require('../server/identity/principals');
const cells = require('../server/registry/cells');
@@@ -39,6 +39,10 @@ const project = `prj_${ids.ulid()}`;
const principal = (nodeId) => db.prepare('SELECT * FROM platform_node_principals WHERE node_id = ?').get(nodeId);
const insertPrincipal = (p) => db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, trust, status, created_by, revoked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'test', ?)`).run(p.id || nod(), p.node_id, p.home_cell || 'wnam-1', p.owner_kind, p.project_id ?? null, p.trust, p.status || 'active', p.revoked_at ?? null);
++// The pairing columns of 0014, on the same insert path (never a real credential: a fake 64-hex hash).
++const hash = (c) => c.repeat(64);
++const insertPaired = (p) => db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, owner_subject, trust, status, created_by, revoked_at, credential_hash, credential_prev_hash, prev_valid_until)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'test', ?, ?, ?, ?)`).run(p.id || nod(), p.node_id, p.home_cell || 'wnam-1', p.owner_kind, p.project_id ?? null, p.owner_subject ?? null, p.trust, p.status || 'active', p.revoked_at ?? null, p.credential_hash ?? null, p.credential_prev_hash ?? null, p.prev_valid_until ?? null);
const insertInstance = (i) => db.prepare(`INSERT INTO platform_service_instances (id, service, version, cell, node_id, endpoints, state, source, started_at, reported_at)
VALUES (?, ?, '1.0.0', ?, ?, '["https://media.internal.example"]', ?, 'test', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`).run(i.id, i.service || 'media', i.cell || 'wnam-1', i.node_id, i.state || 'ready');
const node = (id, region = 'us-west') => ({ id, roles: ['web', 'app'], location: { region, country: 'US' }, health: { status: 'up', checked_at: '2026-10-01T12:00:00Z' }, updated_at: '2026-10-01T12:00:00Z' });
@@@ -77,10 +81,25 @@ try {
];
for (const [row, why] of refused) await assert.rejects(insertPrincipal(row), why);
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM platform_node_principals').get()).n, 0, 'nothing was written by a refused insert');
-- await insertPrincipal({ node_id: 'pi-1', owner_kind: 'project', project_id: project, trust: 'community' });
-- await assert.rejects(insertPrincipal({ node_id: 'pi-1', owner_kind: 'project', project_id: project, trust: 'community' }), 'one principal per machine');
++ await insertPaired({ node_id: 'pi-1', owner_kind: 'project', project_id: project, trust: 'community', credential_hash: hash('1') });
++ await assert.rejects(insertPaired({ node_id: 'pi-1', owner_kind: 'project', project_id: project, trust: 'community', credential_hash: hash('2') }), 'one principal per machine');
await insertPrincipal({ node_id: 'old-1', owner_kind: 'platform', trust: 'first-party', status: 'revoked', revoked_at: '2026-10-01T00:00:00Z' });
++ // ── N4a: a paired machine's owner, credential and pairing record (migrations/0014, section 3.2).
++ await insertPaired({ node_id: 'pair-user', home_cell: 'weu-1', owner_kind: 'user', owner_subject: `usr_${ids.ulid()}`, trust: 'community', credential_hash: hash('a') });
++ await assert.rejects(insertPaired({ node_id: 'pair-user-1p', home_cell: 'weu-1', owner_kind: 'user', owner_subject: `usr_${ids.ulid()}`, trust: 'first-party', credential_hash: hash('b') }), 'a user machine is never first-party');
++ await assert.rejects(insertPaired({ node_id: 'pair-user-nocred', home_cell: 'weu-1', owner_kind: 'user', owner_subject: `usr_${ids.ulid()}`, trust: 'community' }), 'a user machine holds a credential');
++ await insertPaired({ node_id: 'pair-platform', home_cell: 'weu-1', owner_kind: 'platform', trust: 'first-party' });
++ await assert.rejects(insertPaired({ node_id: 'pair-prev', home_cell: 'weu-1', owner_kind: 'platform', trust: 'first-party', credential_prev_hash: hash('c') }), 'a previous credential always has a grace window');
++ await insertPaired({ node_id: 'pair-cred-a', home_cell: 'weu-1', owner_kind: 'platform', trust: 'first-party', credential_hash: hash('d') });
++ await assert.rejects(insertPaired({ node_id: 'pair-cred-b', home_cell: 'weu-1', owner_kind: 'platform', trust: 'first-party', credential_hash: hash('d') }), 'one live principal per credential');
++ await assert.rejects(db.prepare(`INSERT INTO platform_node_pairings (id, code_hash, owner_kind, project_id, owner_subject, service, ref, created_by, expires_at)
++ VALUES (?, ?, 'user', ?, ?, 'bot', 'rob-1', 'usr_owner', '2026-10-02T00:00:00Z')`).run(`pair_${ids.ulid()}`, hash('e'), project, `usr_${ids.ulid()}`), 'a pairing row has one owner');
++ // The migration applied a second time is a no-op: the runner records it by number, and every statement is IF NOT EXISTS / DROP ... IF EXISTS
++ // (both as the owner: on the containers the runtime role may not create in the schema).
++ assert.deepStrictEqual((await globalThis.__ovNetworkMigrate({ dir: MIGRATIONS })).applied, []);
++ await globalThis.__ovNetworkDdl(require('fs').readFileSync(require('path').join(MIGRATIONS, '0014_node_pairing.sql'), 'utf8'));
++
// ── A node principal is not a service principal: it can hold no client-credentials token.
const nodClient = nod();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES (?, 'node-secret', 'node', '[]')").run(nodClient);
ddiff --git a/test/helpers/pg-preload.mjs b/test/helpers/pg-preload.mjs
iindex 88bb12c1..cce733eb 100644
---- a/test/helpers/pg-preload.mjs
++++ b/test/helpers/pg-preload.mjs
@@@ -27,6 +27,13 @@ globalThis.__ovNetworkDdl = async (sql) => {
const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });
try { return await owner.exec(sql); } finally { await owner.close(); }
};
++// The migration runner, on the same owner connection (a test that re-applies the migrations to prove them idempotent).
++globalThis.__ovNetworkMigrate = async (opts) => {
++ if (!t.directUrl) return t.db.migrate(opts);
++ const { createDb } = require('openvibe-sdk/db');
++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });
++ try { return await owner.migrate(opts); } finally { await owner.close(); }
++};
// initDb() adopts the handle above, attaches the handle helpers and seeds the boot data (OAuth clients, site
// settings, built-in themes) once, exactly as the server does at boot. Every test file then reads it with getDb().
await require(path.join(root, 'server', 'db', 'database.js')).initDb();