openvibers/openvibe.network Commits
Add 0014 node pairing schema and its accept/reject tests (#15)
Changes
nnew file mode 100644@@@ -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++);@@@ -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);@@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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
+);
@@ -8,7 +8,7 @@ const crypto = require('crypto');
@@ -8,7 +8,7 @@ const crypto = require('crypto');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { ids } = require('openvibe-contracts');
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 principals = require('../server/identity/principals');
const cells = require('../server/registry/cells');
const cells = require('../server/registry/cells');
@@ -39,6 +39,10 @@ const project = `prj_${ids.ulid()}`;
@@ -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 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)
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);
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)
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');
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' });
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 {
@@ -77,10 +81,25 @@ try {
];
];
for (const [row, why] of refused) await assert.rejects(insertPrincipal(row), why);
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');
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' });
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.
// ── A node principal is not a service principal: it can hold no client-credentials token.
const nodClient = nod();
const nodClient = nod();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES (?, 'node-secret', 'node', '[]')").run(nodClient);
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES (?, 'node-secret', 'node', '[]')").run(nodClient);
@@ -27,6 +27,13 @@ globalThis.__ovNetworkDdl = async (sql) => {
@@ -27,6 +27,13 @@ globalThis.__ovNetworkDdl = async (sql) => {
const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });
const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });
try { return await owner.exec(sql); } finally { await owner.close(); }
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
// 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().
// 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();
await require(path.join(root, 'server', 'db', 'database.js')).initDb();