openvibers/openvibe.network Commits

Carry principal cells in node docs and reject mismatched reports (#30)

OP OpenVibers committed 3 Oct 2026 · 011355a452 · 3 files, +40 −2

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

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

Changes

ddiff --git a/docs/t2-cells-and-node-principal.md b/docs/t2-cells-and-node-principal.md
iindex 59c4c2b3..5e39c50b 100644
---- a/docs/t2-cells-and-node-principal.md
++++ b/docs/t2-cells-and-node-principal.md
@@@ -374,7 +374,7 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
| N4c | Node tokens + self routes + topology | `server/registry/node-principals.js`, `server/auth/oauth-routes.js:230`, `server/registry/cells.js` (topology), `test/node-tokens.test.js` | N4b; **T1** (node actor, `network.node.self.manage`) | **Opus** (token issuance) | **merged** (main `4e7b56b`, PR #19) |
| N5 | Owner API for own machines | `server/registry/node-principals.js` (`userRouter`), `server/index.js` (`/api/v1/me/nodes`, `rateLimit` as `/api/v1/me/blocks` `:427`), `test/node-principals-me.test.js` | N4b | basic (page: For Opus) | **merged** (PR #18) |
| N6 | Registry slice 5 (offer kinds as `detail`) | `package.json` pin, `migrations/0009_resource_offer_kinds.sql`, `server/registry/offers.js`, `server/index.js:473`, `principals.js` grant, `test/resource-registry.test.js` | **T1 release** (§9.1 items 4-5) | basic | **merged** (pin is v0.85.0) |
--| N7 | Registry writers & operator reads | `server/registry/cells.js` (`cellView`, `setCell`, `listPrincipals`, internal node-principals read), `server/registry/instances.js` (`instanceView`, `setInstance`), `server/registry/nodes.js` (`GET /internal/nodes`), `server/index.js` (staff `/api/admin/registry`), tests | nothing (staff session; no new capability) | basic | **open — the only lane-B gap** |
++| N7 | Registry writers & operator reads | `server/registry/cells.js` (`cellView`, `setCell`, internal node-principals read), `server/registry/instances.js` (`setInstance`), `server/registry/nodes.js` (`GET /internal/nodes`), `server/index.js` (staff `/api/admin/registry`), tests | nothing (staff session; no new capability) | basic | **merged** (PR #27) |
Bot (T15) and Node (T14) follow, each step keeping Bot working (§9.2, §9.3). Owner steps: none for the Network
slices (main is not deployed and the PostgreSQL cutover waits on the owner); at Bot's deploy the owner provisions Bot's
ddiff --git a/server/registry/nodes.js b/server/registry/nodes.js
iindex d41f0ca0..1826f978 100644
---- a/server/registry/nodes.js
++++ b/server/registry/nodes.js
@@@ -21,10 +21,18 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
const cells = require('./cells');
const upsert = db.prepare(`INSERT INTO platform_nodes (id, source, doc, status, reported_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET source = excluded.source, doc = excluded.doc, status = excluded.status, reported_at = excluded.reported_at`);
++ const principal = db.prepare('SELECT home_cell FROM platform_node_principals WHERE node_id = ?');
let marked = 0;
let adopted = 0;
await db.tx(async () => {
await cells.checkPlatformNodes(db, nodes.map((n) => n.id));
++ for (const n of nodes) {
++ if (n.cell === undefined) continue;
++ const p = await principal.get(n.id);
++ if (p && p.home_cell !== null && p.home_cell !== n.cell) {
++ throw new cells.RegistryError(409, 'registry.node_cell_mismatch', `node ${n.id} lives in ${p.home_cell}, not ${n.cell}`);
++ }
++ }
adopted = await cells.adoptPlatformNodes(db, nodes, `report:${source}`, now);
for (const n of nodes) await upsert.run(n.id, source, JSON.stringify(n), n.health.status, now);
const ids = new Set(nodes.map((n) => n.id));
@@@ -42,7 +50,12 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
async function list(db, { role = null, region = null } = {}) {
ensureSchema(db);
-- return (await db.prepare('SELECT doc FROM platform_nodes ORDER BY id').all()).map((r) => JSON.parse(r.doc))
++ return (await db.prepare(`SELECT n.doc, p.home_cell AS cell FROM platform_nodes n
++ LEFT JOIN platform_node_principals p ON p.node_id = n.id ORDER BY n.id`).all()).map((r) => {
++ const { cell: reportedCell, ...doc } = JSON.parse(r.doc);
++ if (r.cell !== null && r.cell !== undefined) doc.cell = r.cell;
++ return doc;
++ })
.filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));
}
ddiff --git a/test/nodes.test.js b/test/nodes.test.js
iindex 26e3262e..3af8f191 100644
---- a/test/nodes.test.js
++++ b/test/nodes.test.js
@@@ -56,6 +56,23 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
assert.strictEqual(x.status, 200, JSON.stringify(x.body));
assert.ok(validate('network.node-list-result@1', x.body).valid);
assert.strictEqual(x.body.nodes.length, 2);
++ assert.deepStrictEqual(x.body.nodes.map((n) => n.cell), ['wnam-1', 'wnam-1'], 'list fills cell from each principal');
++
++ // A matching cell is accepted. A different cell rejects the whole report before any node is stored.
++ x = await post({ source: 'oregon', nodes: [node('oregon-1', { cell: 'wnam-1' }), node('oregon-2', { cell: 'wnam-1', roles: ['edge-probe'], location: { region: 'us-east', country: 'US' } })] }, auth);
++ assert.strictEqual(x.status, 200, 'matching cell report');
++ assert.deepStrictEqual(x.body.nodes.map((n) => n.cell), ['wnam-1', 'wnam-1']);
++ const beforeMismatch = await db.prepare('SELECT doc, reported_at FROM platform_nodes WHERE id = ?').get('oregon-1');
++ x = await post({ source: 'oregon', nodes: [node('oregon-1', { cell: 'weu-1' }), node('never-stored')] }, auth);
++ assert.deepStrictEqual([x.status, x.body.code], [409, 'registry.node_cell_mismatch'], 'mismatched cell report');
++ assert.deepStrictEqual(await db.prepare('SELECT doc, reported_at FROM platform_nodes WHERE id = ?').get('oregon-1'), beforeMismatch, 'existing node unchanged');
++ assert.strictEqual(await db.prepare('SELECT id FROM platform_nodes WHERE id = ?').get('never-stored'), undefined, 'new node not stored');
++ assert.strictEqual(await db.prepare('SELECT node_id FROM platform_node_principals WHERE node_id = ?').get('never-stored'), undefined, 'new principal not stored');
++
++ x = await post({ source: 'oregon', nodes: [node('oregon-1'), node('oregon-2', { roles: ['edge-probe'], location: { region: 'us-east', country: 'US' } })] }, auth);
++ assert.strictEqual(x.status, 200, 'report without cell remains accepted');
++ assert.strictEqual(JSON.parse((await db.prepare('SELECT doc FROM platform_nodes WHERE id = ?').get('oregon-1')).doc).cell, undefined, 'stored report has no cell');
++ assert.strictEqual(x.body.nodes.find((n) => n.id === 'oregon-1').cell, 'wnam-1', 'list still joins the principal cell');
x = await get();
assert.strictEqual(x.status, 200);
@@@ -86,6 +103,14 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
assert.deepStrictEqual((await internal('?region=eu-central', reader)).body.nodes.map((n) => n.id), ['fra-1']);
assert.deepStrictEqual((await internal('?role=edge-probe', reader)).body.nodes.map((n) => n.id), ['oregon-2']);
++ await db.prepare('DELETE FROM platform_node_principals WHERE node_id = ?').run('fra-1');
++ assert.strictEqual((await get()).body.nodes.find((n) => n.id === 'fra-1').cell, undefined, 'list omits cell without a principal');
++
++ x = await post({ source: 'fresh', nodes: [node('fresh-1', { cell: 'weu-1' })] }, auth);
++ assert.strictEqual(x.status, 200, 'a node with no principal accepts a reported cell');
++ assert.strictEqual(JSON.parse((await db.prepare('SELECT doc FROM platform_nodes WHERE id = ?').get('fresh-1')).doc).cell, 'weu-1', 'report stored unchanged');
++ assert.strictEqual(x.body.nodes.find((n) => n.id === 'fresh-1').cell, 'wnam-1', 'read uses the adopted principal cell');
++
const b = await fetch(`${base}/api/v1/nodes/oregon-1/beacon`);
assert.deepStrictEqual([b.status, b.headers.get('cache-control'), b.headers.get('timing-allow-origin')], [204, 'no-store', '*']);
console.log('nodes: all tests passed');