openvibers/openvibe.network Commits

Add cells, node principals and service instances with wnam-1 bootstrap (#5)

OP OpenVibers committed 2 Oct 2026 · 1ea05ffebb · 9 files, +447 −6

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

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

Changes

ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex 271c00a4..5459a2bc 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -1,7 +1,7 @@
# T2 — the resource registry (design)
Status: **slice 1 built** (`migrations/0007_resource_registry.sql`, `server/registry/offers.js` write path,
--`test/resource-registry.test.js`); slices 2-5 are design.
++`test/resource-registry.test.js`); **cells and node principals built** (section 9, `migrations/0008`); slices 2-5 are design.
Pinned versions at time of writing: `openvibe-contracts` **v0.83.0** (`package.json:30`), `openvibe-sdk` v0.25.2,
`openvibe-shared` v2.3.1. Every claim below was checked against that pin.
@@@ -102,7 +102,7 @@ Therefore:
no constant, no test and no runtime code. Naming them in code — even inside a map — would reference contracts that
do not exist, and `validate()` would throw inside the route and turn a 400 into a 500.
3. The `CHECK` is widened **only** by slice 5, after T1 publishes the tags and Network bumps the pin
-- (`migrations/0008_resource_offer_kinds.sql`: `ALTER TABLE … DROP CONSTRAINT … ADD CONSTRAINT`).
++ (`migrations/0009_resource_offer_kinds.sql`: `ALTER TABLE … DROP CONSTRAINT … ADD CONSTRAINT`).
## 3. Module — `server/registry/offers.js`
@@@ -223,7 +223,7 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
4. **Placement consumption proof.** §5 gains a worked example; test 9 added. No production-code change — this slice
exists to fail loudly if the response shape drifts from what `plan()` accepts.
5. **Contracts bump + kind widening.** *Blocked on T1.* `package.json:30` pin bump, a
-- `node -e "validate('platform.storage-offer@1', …)"` smoke check, `migrations/0008_resource_offer_kinds.sql` widening
++ `node -e "validate('platform.storage-offer@1', …)"` smoke check, `migrations/0009_resource_offer_kinds.sql` widening
the `kind` CHECK to the published kinds, the one-line capability switch, a `KIND → CONTRACT` map used for per-kind
validation (`validate(KIND_CONTRACT[kind], offer)` when the map has an entry, else the base offer contract), and a
test per new kind. Nothing waits on this slice; it is only startable once T1 publishes the tags, and
@@@ -238,3 +238,46 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
`platform.service-instance@1`, neither of which is at v0.83.0, so they cannot be modelled here now.
- **`network.resource.report`** does not exist at v0.83.0; `network.node.report` is reused for slice 1. The semantic
mismatch (nodes reporting offers) is deliberate and temporary, and it is the only reason slice 1 does not need T1.
++
++## 9. Cells and node principals (lane B, first slice)
++
++Built: `migrations/0008_cells_and_node_principals.sql`, `server/registry/cells.js`, `test/cells-registry.test.js`.
++Plan T2: "cells now, hardware later" and "node identity is a Network principal", on today's single host.
++
++**Schema.**
++
++| Table | What it is | Constraints that carry the rule |
++|---|---|---|
++| `platform_regions` | `us-west`, … (`network.node@1`'s region pattern) + country | id pattern |
++| `platform_cells` | `wnam-1`, … with region, residency, `status` (`planned\|active\|draining\|retired`), `route_weight` | region FK; the migration seeds `us-west` and `wnam-1` (`active`) |
++| `platform_node_principals` | one per machine: `nod_<ULID>`, `node_id` (unique), `home_cell`, owner, `trust`, `status` (`active\|draining\|revoked`) | `owner_kind = 'platform'` ⇔ no project and `first-party`; `owner_kind = 'project'` ⇔ a `dev_projects` row and never `first-party`; `revoked` ⇔ `revoked_at`; home cell FK |
++| `platform_service_instances` | `platform.service-instance@1`'s columns (`state` is its enum) + `route_weight`, `source` | `(node_id, cell)` FK to the principal's `(node_id, home_cell)`: an instance runs on a registered node, in its home cell |
++| `dev_projects.home_cell` | `project.home_cell` | FK to cells; existing rows take `wnam-1` |
++| `platform_resource_offers.cell` | (0007) | FK to cells, `NOT VALID`: new writes are checked, rows written before 0008 are not |
++
++Ownership reuses `dev_projects` (the project is the ownership boundary) and the trust classes of
++`platform.resource-offer@1`; no second identity store, no new capability string.
++
++**The node-principal boundary.** A report never assigns identity, owner, trust class or home cell:
++
++- Host's node report (`POST /internal/nodes/report`, `network.node.report`) creates a platform-owned, first-party
++ principal for a machine it is the first to name, in the first active cell of the machine's region, else `wnam-1`.
++ It is refused whole (`409 registry.node_not_platform` / `registry.node_revoked`, nothing written, nothing marked down)
++ when it names a machine a project owns or one that was revoked.
++- At boot, every machine already in `platform_nodes` without a principal gets its platform principal (the backfill;
++ `created_by = 'bootstrap'`).
++- An offer (`POST /internal/resources/report`) must name a known, non-retired cell (`400 registry.unknown_cell`,
++ `409 registry.cell_retired`); an offer for a registered node must carry that node's home cell and trust class
++ (`409 registry.node_cell_mismatch`, `registry.trust_mismatch`, `registry.node_revoked`). Checked before anything is
++ written, like the contract validation.
++- A node principal holds no token yet: `identity.service-token-claims@1` has no node actor, and a `nod_` id is refused
++ as a client-credentials client (`unauthorized_client`).
++
++**Read API.** `GET /api/v1/cells` and `/api/v1/cells/:id` (public, `max-age=60`, `ACAO: *`; 404
++`registry.unknown_cell`): id, region, residency, status, route weight. `GET /internal/registry/cells/:id`
++(`network.registry.read`, `no-store`): the cell's nodes (principal id, owner, trust, status, health from the node
++registry), service instances and offers (capabilities, capacity). Project machines are never public.
++
++**Not in this slice.** A writer for service instances and for project-owned principals (pairing credentials) — both
++wait for the contracts pin bump to v0.84.0, which publishes `platform.service-instance@1` and
++`platform.node-capabilities@1`; the node actor in service-token claims; a second physical cell, WireGuard, geo routing.
ddiff --git a/migrations/0008_cells_and_node_principals.sql b/migrations/0008_cells_and_node_principals.sql
nnew file mode 100644
iindex 00000000..6dc395ff
---- /dev/null
++++ b/migrations/0008_cells_and_node_principals.sql
@@@ -0,0 +1,70 @@
++-- phase: expand
++-- plan T2. Cells now, hardware later (docs/t2-resource-registry.md section 9): regions, cells, node principals and
++-- service instances on today's single host, with the one cell wnam-1 in us-west seeded here. A node principal is the
++-- Network identity of a machine (nod_<ULID>): who owns it (the platform, or a developer project), its trust class and
++-- its home cell are assigned by Network and never taken from a report. Service instances run on a registered node in
++-- that node's home cell. Projects gain a home cell (backfilled to wnam-1). The offers of 0007 must name a known cell;
++-- the constraint is NOT VALID so rows written before it are not re-checked.
++
++CREATE TABLE IF NOT EXISTS platform_regions (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^[a-z]{2}-[a-z]+(-[0-9])?$'),
++ country text COLLATE "C" NOT NULL CHECK (country ~ '^[A-Z]{2}$'),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++
++CREATE TABLE IF NOT EXISTS platform_cells (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^[a-z]{2,8}-[0-9]{1,3}$'),
++ region text COLLATE "C" NOT NULL REFERENCES platform_regions(id),
++ residency text COLLATE "C" NOT NULL CHECK (residency ~ '^[A-Z]{2}$'),
++ status text COLLATE "C" NOT NULL CHECK (status IN ('planned', 'active', 'draining', 'retired')),
++ route_weight integer NOT NULL DEFAULT 100 CHECK (route_weight BETWEEN 0 AND 1000),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++
++INSERT INTO platform_regions (id, country) VALUES ('us-west', 'US') ON CONFLICT DO NOTHING;
++INSERT INTO platform_cells (id, region, residency, status) VALUES ('wnam-1', 'us-west', 'US', 'active') ON CONFLICT DO NOTHING;
++
++CREATE TABLE IF NOT EXISTS platform_node_principals (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^nod_[0-9A-HJKMNP-TV-Z]{26}$'),
++ node_id text COLLATE "C" NOT NULL UNIQUE CHECK (node_id ~ '^[a-z][a-z0-9-]{1,39}$'),
++ home_cell text COLLATE "C" NOT NULL REFERENCES platform_cells(id),
++ owner_kind text COLLATE "C" NOT NULL CHECK (owner_kind IN ('platform', 'project')),
++ project_id text COLLATE "C" REFERENCES dev_projects(id),
++ trust text COLLATE "C" NOT NULL CHECK (trust IN ('first-party', 'partner', 'community', 'external')),
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'draining', 'revoked')),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ created_by text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C",
++ -- Only the platform's own machines are first-party; every other machine belongs to exactly one project.
++ CONSTRAINT platform_node_principals_owner CHECK (
++ (owner_kind = 'platform' AND project_id IS NULL AND trust = 'first-party')
++ OR (owner_kind = 'project' AND project_id IS NOT NULL AND trust <> 'first-party')),
++ CONSTRAINT platform_node_principals_revoked CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)),
++ CONSTRAINT platform_node_principals_node_cell UNIQUE (node_id, home_cell)
++);
++CREATE INDEX IF NOT EXISTS platform_node_principals_cell_idx ON platform_node_principals (home_cell, status);
++CREATE INDEX IF NOT EXISTS platform_node_principals_project_idx ON platform_node_principals (project_id) WHERE project_id IS NOT NULL;
++
++CREATE TABLE IF NOT EXISTS platform_service_instances (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^[a-z0-9][a-z0-9._-]{0,79}$'),
++ service text COLLATE "C" NOT NULL CHECK (service ~ '^[a-z][a-z0-9-]{1,39}$'),
++ version text COLLATE "C" NOT NULL,
++ cell text COLLATE "C" NOT NULL,
++ node_id text COLLATE "C" NOT NULL,
++ endpoints text COLLATE "C" NOT NULL DEFAULT '[]',
++ state text COLLATE "C" NOT NULL CHECK (state IN ('starting', 'ready', 'degraded', 'draining', 'stopped')),
++ route_weight integer NOT NULL DEFAULT 100 CHECK (route_weight BETWEEN 0 AND 1000),
++ source text COLLATE "C" NOT NULL,
++ started_at text COLLATE "C" NOT NULL,
++ reported_at text COLLATE "C" NOT NULL,
++ -- An instance runs on a registered node, in that node's home cell.
++ CONSTRAINT platform_service_instances_node FOREIGN KEY (node_id, cell) REFERENCES platform_node_principals (node_id, home_cell)
++);
++CREATE INDEX IF NOT EXISTS platform_service_instances_cell_idx ON platform_service_instances (cell, service, state);
++
++ALTER TABLE dev_projects ADD COLUMN IF NOT EXISTS home_cell text COLLATE "C" NOT NULL DEFAULT 'wnam-1' REFERENCES platform_cells(id);
++
++ALTER TABLE platform_resource_offers ADD CONSTRAINT platform_resource_offers_cell FOREIGN KEY (cell) REFERENCES platform_cells(id) NOT VALID;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex d2a412f3..644c32e6 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -245,6 +245,8 @@ async function seedDb(db, { log = console } = {}) {
await require('../registry/nodes').ensureSchema(db);
// What the network can place on: node and provider offers (server/registry/offers.js; plan T2).
await require('../registry/offers').ensureSchema(db);
++ // Cells and node principals (plan T2): every machine already in the node registry gets its platform principal.
++ await require('../registry/cells').ensureSchema(db);
// Refresh tokens: hash-only storage (server/auth/refresh-tokens.js).
await require('../auth/refresh-tokens').ensureSchema(db);
ddiff --git a/server/index.js b/server/index.js
iindex 47b51ae0..349809ba 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -478,6 +478,13 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
const offerRouters = require('./registry/offers').routers({ guard: require('./identity/principals').guard('network.node.report') });
app.use('/internal/resources', offerRouters.internal);
}
++// Cells and node principals (plan T2, docs/t2-resource-registry.md section 9): the public cell list; a cell's nodes,
++// instances and offers for services holding network.registry.read.
++{
++ const cellRouters = require('./registry/cells').routers({ readGuard: require('./identity/principals').guard('network.registry.read') });
++ app.use('/api/v1/cells', cellRouters.pub);
++ app.use('/internal/registry', cellRouters.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/cells.js b/server/registry/cells.js
nnew file mode 100644
iindex 00000000..c65d9d5f
---- /dev/null
++++ b/server/registry/cells.js
@@@ -0,0 +1,132 @@
++'use strict';
++/**
++ * Cells and node principals (plan T2 "cells now, hardware later", docs/t2-resource-registry.md section 9;
++ * migrations/0008_cells_and_node_principals.sql). Network is the authority for where things run: regions, cells, the
++ * node principal of every machine (nod_<ULID>: owner, trust class, home cell) and the service instances on them.
++ *
++ * The node-principal boundary: a report never assigns identity. Host's node report (network.node.report) creates
++ * platform-owned, first-party principals for machines it is the first to name, and is refused for a machine that a
++ * project owns or that was revoked. A resource offer must name a known cell, and an offer for a registered node must
++ * carry that node's home cell and trust class. Today there is one cell, wnam-1 (us-west), seeded by the migration.
++ */
++const express = require('express');
++const { ids, http } = require('openvibe-contracts');
++
++const BOOTSTRAP_CELL = 'wnam-1';
++const NODE_ID = /^[a-z][a-z0-9-]{1,39}$/; // network.node@1's id, and the 0008 CHECK
++
++class RegistryError extends Error {
++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
++}
++
++/** Boot work: every machine already in the node registry gets its platform principal (the 0008 backfill). */
++async function ensureSchema(db) {
++ const rows = await db.prepare(`SELECT n.id, n.doc FROM platform_nodes n
++ WHERE NOT EXISTS (SELECT 1 FROM platform_node_principals p WHERE p.node_id = n.id) ORDER BY n.id`).all();
++ // A row the node contract could not have produced is left for an operator rather than failing the boot.
++ const nodes = rows.filter((r) => NODE_ID.test(r.id)).map((r) => { try { return { ...JSON.parse(r.doc), id: r.id }; } catch { return { id: r.id }; } });
++ if (nodes.length) await db.tx(async () => adoptPlatformNodes(db, nodes, 'bootstrap'));
++}
++
++/** The cell a newly named platform machine starts in: the first active cell of its region, else wnam-1. */
++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;
++}
++
++/** Refuse a platform report that names a machine Network does not hold as a live platform principal. Throws RegistryError. */
++async function checkPlatformNodes(db, nodeIds) {
++ const get = db.prepare('SELECT id, owner_kind, status FROM platform_node_principals WHERE node_id = ?');
++ for (const nodeId of nodeIds) {
++ const p = await get.get(nodeId);
++ if (!p) continue;
++ if (p.owner_kind !== 'platform') throw new RegistryError(409, 'registry.node_not_platform', `node ${nodeId} is owned by a project, not the platform`);
++ if (p.status === 'revoked') throw new RegistryError(409, 'registry.node_revoked', `node ${nodeId} was revoked`);
++ }
++}
++
++/** Create the platform principal of every machine not yet registered (inside the caller's transaction). → count */
++async function adoptPlatformNodes(db, nodes, by, now = new Date().toISOString()) {
++ const insert = db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, trust, status, created_at, created_by, updated_at)
++ VALUES (?, ?, ?, 'platform', 'first-party', 'active', ?, ?, ?) ON CONFLICT (node_id) DO NOTHING`);
++ let added = 0;
++ for (const n of nodes) {
++ const cell = await cellForRegion(db, n.location && n.location.region);
++ added += (await insert.run(`nod_${ids.ulid(Date.parse(now) || Date.now())}`, n.id, cell, now, by, now)).changes || 0;
++ }
++ return added;
++}
++
++/**
++ * Check where a batch of offers says it runs, before anything is written. `items` are [{cell, node_id, trust}] in
++ * report order. → null, or { status, code, detail } for the first problem.
++ */
++async function checkPlacement(db, items) {
++ const cells = new Map((await db.prepare('SELECT id, status FROM platform_cells').all()).map((r) => [r.id, r.status]));
++ const principal = db.prepare('SELECT home_cell, trust, status FROM platform_node_principals WHERE node_id = ?');
++ for (let i = 0; i < items.length; i++) {
++ const { cell, node_id: nodeId, trust } = items[i];
++ if (!cells.has(cell)) return { status: 400, code: 'registry.unknown_cell', detail: `offer ${i}: no cell ${cell}` };
++ if (cells.get(cell) === 'retired') return { status: 409, code: 'registry.cell_retired', detail: `offer ${i}: cell ${cell} is retired` };
++ const p = nodeId ? await principal.get(nodeId) : null;
++ if (!p) continue;
++ if (p.status === 'revoked') return { status: 409, code: 'registry.node_revoked', detail: `offer ${i}: node ${nodeId} was revoked` };
++ if (p.home_cell !== cell) return { status: 409, code: 'registry.node_cell_mismatch', detail: `offer ${i}: node ${nodeId} lives in ${p.home_cell}, not ${cell}` };
++ if (p.trust !== trust) return { status: 409, code: 'registry.trust_mismatch', detail: `offer ${i}: node ${nodeId} has trust class ${p.trust}, not ${trust}` };
++ }
++ return null;
++}
++
++const cellView = (r) => ({ id: r.id, region: r.region, residency: r.residency, status: r.status, route_weight: Number(r.route_weight) });
++
++async function listCells(db) {
++ return (await db.prepare('SELECT * FROM platform_cells ORDER BY id').all()).map(cellView);
++}
++
++async function getCell(db, id) {
++ const r = await db.prepare('SELECT * FROM platform_cells WHERE id = ?').get(String(id));
++ return r ? cellView(r) : null;
++}
++
++/** Everything registered in one cell: its nodes (owner, trust, health), service instances and offers. Internal only. */
++async function topology(db, id) {
++ const cell = await getCell(db, id);
++ if (!cell) return null;
++ const health = new Map((await db.prepare('SELECT id, status FROM platform_nodes').all()).map((r) => [r.id, r.status]));
++ const nodes = (await db.prepare('SELECT * FROM platform_node_principals WHERE home_cell = ? ORDER BY node_id').all(cell.id)).map((p) => ({
++ principal_id: p.id, node_id: p.node_id, owner: { kind: p.owner_kind, project_id: p.project_id || null },
++ trust: p.trust, status: p.status, health: health.get(p.node_id) || 'unknown',
++ }));
++ const instances = (await db.prepare('SELECT * FROM platform_service_instances WHERE cell = ? ORDER BY service, id').all(cell.id)).map((r) => ({
++ id: r.id, service: r.service, version: r.version, node: r.node_id, endpoints: JSON.parse(r.endpoints), state: r.state,
++ route_weight: Number(r.route_weight), started_at: r.started_at, reported_at: r.reported_at,
++ }));
++ const offers = (await db.prepare('SELECT doc FROM platform_resource_offers WHERE cell = ? ORDER BY id').all(cell.id)).map((r) => {
++ const o = JSON.parse(r.doc);
++ return { offer_id: o.offer_id, kind: o.kind, node_id: o.node_id || null, trust: o.trust, status: o.health.status, capabilities: o.capabilities || [], capacity: o.capacity || {} };
++ });
++ return { cell, nodes, instances, offers };
++}
++
++function routers({ readGuard }) {
++ const pub = express.Router();
++ const open = (res) => res.set('Cache-Control', 'public, max-age=60').set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
++ pub.get('/', async (req, res) => {
++ open(res).json({ cells: await listCells(req.app.locals.db), generated_at: new Date().toISOString() });
++ });
++ pub.get('/:id', async (req, res) => {
++ const cell = await getCell(req.app.locals.db, req.params.id);
++ if (!cell) return http.sendProblem(res, 404, 'registry.unknown_cell', { detail: `no cell ${req.params.id}` });
++ open(res).json(cell);
++ });
++ const internal = express.Router();
++ internal.get('/cells/:id', readGuard, async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ const t = await topology(req.app.locals.db, req.params.id);
++ if (!t) return http.sendProblem(res, 404, 'registry.unknown_cell', { detail: `no cell ${req.params.id}` });
++ res.json({ ...t, generated_at: new Date().toISOString() });
++ });
++ return { pub, internal };
++}
++
++module.exports = { BOOTSTRAP_CELL, RegistryError, ensureSchema, checkPlatformNodes, adoptPlatformNodes, checkPlacement, listCells, getCell, topology, routers };
ddiff --git a/server/registry/nodes.js b/server/registry/nodes.js
iindex 42897df4..f19053a8 100644
---- a/server/registry/nodes.js
++++ b/server/registry/nodes.js
@@@ -11,13 +11,21 @@ const express = require('express');
const ensured = new WeakSet();
function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
--/** Apply a report: upsert every node; mark this source's other nodes down. → { nodes, marked_down } */
++/**
++ * Apply a report: upsert every node; mark this source's other nodes down; give a machine named for the first time its
++ * platform principal (server/registry/cells.js). A machine a project owns, or one that was revoked, refuses the whole
++ * report (RegistryError) before anything is written. → { nodes, marked_down, adopted }
++ */
async function report(db, { source, nodes }, now = new Date().toISOString()) {
ensureSchema(db);
++ 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`);
let marked = 0;
++ let adopted = 0;
await db.tx(async () => {
++ await cells.checkPlatformNodes(db, nodes.map((n) => n.id));
++ 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));
for (const row of await db.prepare('SELECT id, doc FROM platform_nodes WHERE source = ? AND status <> ?').all(source, 'down')) {
@@@ -29,7 +37,7 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
marked++;
}
});
-- return { nodes: nodes.length, marked_down: marked };
++ return { nodes: nodes.length, marked_down: marked, adopted };
}
async function list(db, { role = null, region = null } = {}) {
@@@ -55,7 +63,11 @@ function routers({ guard }) {
const { validate } = require('openvibe-contracts');
const v = validate('network.node-report-request@1', req.body);
if (!v.valid) return res.status(400).json({ error: 'The body does not match network.node-report-request@1', details: (v.errors || []).slice(0, 5) });
-- const out = await report(req.app.locals.db, req.body);
++ let out;
++ try { out = await report(req.app.locals.db, req.body); } catch (e) {
++ if (e instanceof require('./cells').RegistryError) return require('openvibe-contracts').http.sendProblem(res, e.status, e.code, { detail: e.message });
++ throw e;
++ }
res.set('X-Nodes-Marked-Down', String(out.marked_down))
.json({ nodes: await list(req.app.locals.db), generated_at: new Date().toISOString() });
});
ddiff --git a/server/registry/offers.js b/server/registry/offers.js
iindex 6b8476fa..69859318 100644
---- a/server/registry/offers.js
++++ b/server/registry/offers.js
@@@ -78,6 +78,12 @@ function routers({ guard }) {
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
const bad = check(req.body);
if (bad) return res.status(400).json(bad);
++ // Where the offers say they run must agree with the cells and node principals Network holds (cells.js).
++ const misplaced = await require('./cells').checkPlacement(req.app.locals.db, req.body.offers.map((o) => {
++ const c = columns(o);
++ return { cell: c.cell, node_id: o.node_id, trust: c.trust };
++ }));
++ if (misplaced) return require('openvibe-contracts').http.sendProblem(res, misplaced.status, misplaced.code, { detail: misplaced.detail });
const out = await report(req.app.locals.db, req.body);
res.set('Cache-Control', 'no-store').set('X-Offers-Marked-Down', String(out.marked_down))
.json({ source: req.body.source, ...out, generated_at: new Date().toISOString() });
ddiff --git a/test/cells-registry.test.js b/test/cells-registry.test.js
nnew file mode 100644
iindex 00000000..3af58586
---- /dev/null
++++ b/test/cells-registry.test.js
@@@ -0,0 +1,166 @@
++'use strict';
++// Cells and node principals (plan T2, docs/t2-resource-registry.md section 9; migrations/0008): the wnam-1 bootstrap,
++// the ownership and identity constraints of node principals and service instances, Host's node report creating only
++// platform principals, offers that must agree with their node's home cell and trust class, and the cell read API.
++// node test/cells-registry.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 cells = require('../server/registry/cells');
++
++(async () => {
++const log = console.log; console.log = () => {};
++const db = getDb();
++console.log = log;
++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();
++
++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'));
++app.use('/internal/nodes', require('../server/registry/nodes').routers({ guard: principals.guard('network.node.report') }).internal);
++app.use('/internal/resources', require('../server/registry/offers').routers({ guard: principals.guard('network.node.report') }).internal);
++const c = cells.routers({ readGuard: principals.guard('network.registry.read') });
++app.use('/api/v1/cells', c.pub);
++app.use('/internal/registry', c.internal);
++const server = http.createServer(app);
++
++const nod = () => `nod_${ids.ulid()}`;
++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);
++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' });
++const offer = (id, extra = {}) => ({ offer_id: id, kind: 'node', node_id: id, provider: 'ovh', region: 'us-west', cell: 'wnam-1', trust: 'first-party', capabilities: ['node:http'], capacity: { cpu: { utilization: 0.2, available_cores: 6 } }, health: { status: 'up', checked_at: '2026-10-01T12:00:00Z' }, pricing: { model: 'prepaid' }, updated_at: '2026-10-01T12:00:00Z', ...extra });
++
++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) => ({ status: x.status, headers: x.headers, body: await x.json().catch(() => null) }));
++try {
++ // ── The migration: wnam-1 in us-west is seeded; projects gain a home cell; offers must name a known cell.
++ assert.deepStrictEqual(await cells.listCells(db), [{ id: 'wnam-1', region: 'us-west', residency: 'US', status: 'active', route_weight: 100 }]);
++ await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by) VALUES (?, 'usr_owner', 'Home lab', '2026-10-01T00:00:00Z', 'usr_owner')").run(project);
++ assert.strictEqual((await db.prepare('SELECT home_cell FROM dev_projects WHERE id = ?').get(project)).home_cell, 'wnam-1', 'a project starts in wnam-1');
++ await assert.rejects(db.prepare("UPDATE dev_projects SET home_cell = 'nope-1' WHERE id = ?").run(project), 'a project home cell must exist');
++ await assert.rejects(db.prepare("INSERT INTO platform_resource_offers (id, source, kind, region, cell, trust, status, doc, reported_at) VALUES ('x', 'x', 'node', 'us-west', 'nope-1', 'partner', 'up', '{}', 'now')").run(), 'an offer cell must exist');
++ await assert.rejects(db.prepare("INSERT INTO platform_cells (id, region, residency, status) VALUES ('weu-1', 'eu-west', 'IE', 'planned')").run(), 'a cell region must exist');
++ await db.prepare("INSERT INTO platform_regions (id, country) VALUES ('eu-west', 'IE')").run();
++ await db.prepare("INSERT INTO platform_cells (id, region, residency, status) VALUES ('weu-1', 'eu-west', 'IE', 'planned')").run();
++ await assert.rejects(db.prepare("INSERT INTO platform_cells (id, region, residency, status) VALUES ('wnam-2', 'us-west', 'US', 'live')").run(), 'cell status is an enum');
++
++ // ── Ownership and identity constraints of node principals.
++ const refused = [
++ [{ node_id: 'p1', owner_kind: 'platform', project_id: project, trust: 'first-party' }, 'a platform node has no project'],
++ [{ node_id: 'p2', owner_kind: 'platform', trust: 'partner' }, 'a platform node is first-party'],
++ [{ node_id: 'p3', owner_kind: 'project', trust: 'community' }, 'a project node names its project'],
++ [{ node_id: 'p4', owner_kind: 'project', project_id: project, trust: 'first-party' }, 'a project node is never first-party'],
++ [{ node_id: 'p5', owner_kind: 'project', project_id: `prj_${ids.ulid()}`, trust: 'community' }, 'the project must exist'],
++ [{ node_id: 'p6', owner_kind: 'platform', trust: 'first-party', home_cell: 'nope-1' }, 'the home cell must exist'],
++ [{ node_id: 'p7', owner_kind: 'platform', trust: 'first-party', status: 'revoked' }, 'revoked needs revoked_at'],
++ [{ node_id: 'p8', owner_kind: 'platform', trust: 'first-party', revoked_at: '2026-10-01T00:00:00Z' }, 'revoked_at needs revoked'],
++ [{ node_id: 'p9', owner_kind: 'project', project_id: project, trust: 'trusted' }, 'trust is the trust-class enum'],
++ [{ id: `usr_${ids.ulid()}`, node_id: 'p10', owner_kind: 'platform', trust: 'first-party' }, 'a node principal id is nod_<ULID>, never another subject'],
++ [{ node_id: 'Bad_Node', owner_kind: 'platform', trust: 'first-party' }, 'node ids follow network.node@1'],
++ ];
++ 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 insertPrincipal({ node_id: 'old-1', owner_kind: 'platform', trust: 'first-party', status: 'revoked', revoked_at: '2026-10-01T00:00:00Z' });
++
++ // ── 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);
++ const t = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: nodClient, client_secret: 'node-secret', audience: 'openvibe.network' }) });
++ assert.strictEqual(t.status, 400);
++ assert.strictEqual((await t.json()).error, 'unauthorized_client');
++
++ // ── Host's node report: first-named machines become platform principals; a project's or a revoked machine refuses the report.
++ const host = { authorization: `Bearer ${await token('host', 'host-secret')}` };
++ let x = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [node('ovh-1'), node('edge-1', 'eu-west')] }, host);
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++ const ovh = await principal('ovh-1');
++ assert.match(ovh.id, /^nod_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.deepStrictEqual([ovh.owner_kind, ovh.project_id, ovh.trust, ovh.home_cell, ovh.status, ovh.created_by], ['platform', null, 'first-party', 'wnam-1', 'active', 'report:primary']);
++ assert.strictEqual((await principal('edge-1')).home_cell, 'wnam-1', 'no active cell in eu-west yet: the bootstrap cell');
++ x = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [node('ovh-1'), node('edge-1', 'eu-west')] }, host);
++ assert.strictEqual(x.status, 200);
++ assert.strictEqual((await principal('ovh-1')).id, ovh.id, 'a re-report keeps the principal');
++ for (const [id, code] of [['pi-1', 'registry.node_not_platform'], ['old-1', 'registry.node_revoked']]) {
++ x = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [node('ovh-1'), node(id)] }, host);
++ assert.deepStrictEqual([x.status, x.body.code], [409, code], `${id}: ${JSON.stringify(x.body)}`);
++ }
++ assert.deepStrictEqual((await db.prepare("SELECT id, status FROM platform_nodes ORDER BY id").all()).map((r) => [r.id, r.status]), [['edge-1', 'up'], ['ovh-1', 'up']],
++ 'a refused report wrote nothing and marked nothing down');
++
++ // The 0008 backfill: a machine already in the node registry gets its platform principal at boot.
++ await db.prepare("INSERT INTO platform_nodes (id, source, doc, status, reported_at) VALUES ('legacy-1', 'primary', ?, 'up', 'now')").run(JSON.stringify(node('legacy-1')));
++ await cells.ensureSchema(db);
++ assert.deepStrictEqual([(await principal('legacy-1')).owner_kind, (await principal('legacy-1')).created_by], ['platform', 'bootstrap']);
++
++ // ── Service instances run on a registered node, in that node's home cell.
++ await assert.rejects(insertInstance({ id: 'media-x', node_id: 'ghost-1' }), 'an unregistered node');
++ await assert.rejects(insertInstance({ id: 'media-x', node_id: 'ovh-1', cell: 'weu-1' }), 'not the node\'s home cell');
++ await assert.rejects(insertInstance({ id: 'media-x', node_id: 'ovh-1', state: 'running' }), 'state is the platform.service-instance@1 enum');
++ await insertInstance({ id: 'media-1', node_id: 'ovh-1' });
++ await insertInstance({ id: 'run-1', service: 'run', node_id: 'pi-1', state: 'starting' });
++
++ // ── Offers must agree with the cell and node principal Network holds.
++ const offers = (list) => call('POST', '/internal/resources/report', { source: 'primary', offers: list }, host);
++ for (const [o, status, code] of [
++ [offer('ovh-1', { cell: 'nope-1' }), 400, 'registry.unknown_cell'],
++ [offer('ovh-1', { cell: 'weu-1' }), 409, 'registry.node_cell_mismatch'],
++ [offer('ovh-1', { trust: 'community' }), 409, 'registry.trust_mismatch'],
++ [offer('pi-1'), 409, 'registry.trust_mismatch'],
++ [offer('old-1'), 409, 'registry.node_revoked'],
++ ]) {
++ x = await offers([offer('edge-1'), o]);
++ assert.deepStrictEqual([x.status, x.body.code], [status, code], JSON.stringify(x.body));
++ assert.match(x.body.detail, /^offer 1: /);
++ }
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM platform_resource_offers').get()).n, 0, 'a misplaced offer writes nothing');
++ x = await offers([offer('ovh-1'), offer('pi-1', { trust: 'community' }), offer('ovh-1-gpu', { node_id: undefined, kind: 'provider', provider: 'acme', trust: 'external' })]);
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++
++ // ── The read API: public cells; a cell's topology for network.registry.read only.
++ x = await call('GET', '/api/v1/cells');
++ assert.deepStrictEqual(x.body.cells.map((cell) => [cell.id, cell.status]), [['weu-1', 'planned'], ['wnam-1', 'active']]);
++ assert.strictEqual(x.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(x.headers.get('access-control-allow-origin'), '*');
++ x = await call('GET', '/api/v1/cells/wnam-1');
++ assert.deepStrictEqual(x.body, { id: 'wnam-1', region: 'us-west', residency: 'US', status: 'active', route_weight: 100 });
++ x = await call('GET', '/api/v1/cells/nope-1');
++ assert.deepStrictEqual([x.status, x.headers.get('content-type'), x.body.code], [404, 'application/problem+json', 'registry.unknown_cell']);
++
++ assert.strictEqual((await call('GET', '/internal/registry/cells/wnam-1')).status, 403, 'nobody');
++ assert.strictEqual((await call('GET', '/internal/registry/cells/wnam-1', null, host)).status, 403, 'Host lacks network.registry.read');
++ const live = { authorization: `Bearer ${await token('live', 'live-secret')}` };
++ x = await call('GET', '/internal/registry/cells/wnam-1', null, live);
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++ assert.strictEqual(x.headers.get('cache-control'), 'no-store');
++ const byNode = Object.fromEntries(x.body.nodes.map((n) => [n.node_id, n]));
++ assert.deepStrictEqual(Object.keys(byNode), ['edge-1', 'legacy-1', 'old-1', 'ovh-1', 'pi-1']);
++ assert.deepStrictEqual([byNode['ovh-1'].owner, byNode['ovh-1'].trust, byNode['ovh-1'].health], [{ kind: 'platform', project_id: null }, 'first-party', 'up']);
++ assert.deepStrictEqual([byNode['pi-1'].owner, byNode['pi-1'].trust, byNode['pi-1'].health], [{ kind: 'project', project_id: project }, 'community', 'unknown']);
++ assert.deepStrictEqual(x.body.instances.map((i) => [i.id, i.node, i.state]), [['media-1', 'ovh-1', 'ready'], ['run-1', 'pi-1', 'starting']]);
++ assert.deepStrictEqual(x.body.offers.map((o) => [o.offer_id, o.node_id, o.trust]), [['ovh-1', 'ovh-1', 'first-party'], ['ovh-1-gpu', null, 'external'], ['pi-1', 'pi-1', 'community']]);
++ assert.deepStrictEqual(x.body.offers[0].capacity, { cpu: { utilization: 0.2, available_cores: 6 } });
++ assert.strictEqual((await call('GET', '/internal/registry/cells/nope-1', null, live)).status, 404);
++ console.log('cells-registry: all tests passed');
++} finally {
++ server.close();
++}
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/resource-registry.test.js b/test/resource-registry.test.js
iindex 3d1037af..0388de73 100644
---- a/test/resource-registry.test.js
++++ b/test/resource-registry.test.js
@@@ -117,6 +117,9 @@ const assertLockstep = async () => {
await assertLockstep();
// 4. Mark-down: o-2 leaves oregon's report; frankfurt's offers are untouched; nothing is deleted.
++ // An offer names a cell Network knows (migrations/0008), so frankfurt's cell is registered first.
++ await db.prepare("INSERT INTO platform_regions (id, country) VALUES ('eu-central', 'DE')").run();
++ await db.prepare("INSERT INTO platform_cells (id, region, residency, status) VALUES ('weur-1', 'eu-central', 'DE', 'planned')").run();
x = await post({ source: 'frankfurt', offers: [offer('f-1', { region: 'eu-central', cell: 'weur-1' })] }, auth);
assert.strictEqual(x.headers.get('x-offers-marked-down'), '0', 'a new source marks nothing of another source down');
const now = Date.now();