openvibers/openvibe.network Commits
Carry principal cells in node docs and reject mismatched reports (#30)
Changes
@@@ -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 Networkslices (main is not deployed and the PostgreSQL cutover waits on the owner); at Bot's deploy the owner provisions Bot's@@@ -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));}@@@ -56,6 +56,23 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatioassert.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'], locatioassert.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');
@@ -374,7 +374,7 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
@@ -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) |
| 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) |
| 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) |
| 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
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
slices (main is not deployed and the PostgreSQL cutover waits on the owner); at Bot's deploy the owner provisions Bot's
@@ -21,10 +21,18 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
@@ -21,10 +21,18 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
const cells = require('./cells');
const cells = require('./cells');
const upsert = db.prepare(`INSERT INTO platform_nodes (id, source, doc, status, reported_at) VALUES (?, ?, ?, ?, ?)
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`);
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 marked = 0;
let adopted = 0;
let adopted = 0;
await db.tx(async () => {
await db.tx(async () => {
await cells.checkPlatformNodes(db, nodes.map((n) => n.id));
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);
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);
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));
const ids = new Set(nodes.map((n) => n.id));
@@ -42,7 +50,12 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
@@ -42,7 +50,12 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
async function list(db, { role = null, region = null } = {}) {
async function list(db, { role = null, region = null } = {}) {
ensureSchema(db);
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));
.filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));
}
}
@@ -56,6 +56,23 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
@@ -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.strictEqual(x.status, 200, JSON.stringify(x.body));
assert.ok(validate('network.node-list-result@1', x.body).valid);
assert.ok(validate('network.node-list-result@1', x.body).valid);
assert.strictEqual(x.body.nodes.length, 2);
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();
x = await get();
assert.strictEqual(x.status, 200);
assert.strictEqual(x.status, 200);
@@ -86,6 +103,14 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
@@ -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('?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']);
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`);
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', '*']);
assert.deepStrictEqual([b.status, b.headers.get('cache-control'), b.headers.get('timing-allow-origin')], [204, 'no-store', '*']);
console.log('nodes: all tests passed');
console.log('nodes: all tests passed');