openvibers/openvibe.network Commits
Add cells, node principals and service instances with wnam-1 bootstrap (#5)
Changes
@@@ -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 thatdo 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 sliceexists 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` wideningthe `kind` CHECK to the published kinds, the one-line capability switch, a `KIND → CONTRACT` map used for per-kindvalidation (`validate(KIND_CONTRACT[kind], offer)` when the map has an entry, else the base offer contract), and atest 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 semanticmismatch (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.nnew file mode 100644@@@ -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;@@@ -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);@@@ -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).nnew file mode 100644@@@ -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 };@@@ -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() });});@@@ -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() });nnew file mode 100644@@@ -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); });@@@ -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();
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
# T2 — the resource registry (design)
# T2 — the resource registry (design)
Status: **slice 1 built** (`migrations/0007_resource_registry.sql`, `server/registry/offers.js` write path,
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,
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.
`openvibe-shared` v2.3.1. Every claim below was checked against that pin.
@@ -102,7 +102,7 @@ Therefore:
@@ -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
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.
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
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`
## 3. Module — `server/registry/offers.js`
@@ -223,7 +223,7 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
@@ -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
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.
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
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
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
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
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`
@@ -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.
`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
- **`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.
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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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;
@@ -245,6 +245,8 @@ async function seedDb(db, { log = console } = {}) {
@@ -245,6 +245,8 @@ async function seedDb(db, { log = console } = {}) {
await require('../registry/nodes').ensureSchema(db);
await require('../registry/nodes').ensureSchema(db);
// What the network can place on: node and provider offers (server/registry/offers.js; plan T2).
// What the network can place on: node and provider offers (server/registry/offers.js; plan T2).
await require('../registry/offers').ensureSchema(db);
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).
// Refresh tokens: hash-only storage (server/auth/refresh-tokens.js).
await require('../auth/refresh-tokens').ensureSchema(db);
await require('../auth/refresh-tokens').ensureSchema(db);
@@ -478,6 +478,13 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -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') });
const offerRouters = require('./registry/offers').routers({ guard: require('./identity/principals').guard('network.node.report') });
app.use('/internal/resources', offerRouters.internal);
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));
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
// 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).
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -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 };
@@ -11,13 +11,21 @@ const express = require('express');
@@ -11,13 +11,21 @@ const express = require('express');
const ensured = new WeakSet();
const ensured = new WeakSet();
function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
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()) {
async function report(db, { source, nodes }, now = new Date().toISOString()) {
ensureSchema(db);
ensureSchema(db);
+ 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`);
let marked = 0;
let marked = 0;
+ let adopted = 0;
await db.tx(async () => {
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);
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));
for (const row of await db.prepare('SELECT id, doc FROM platform_nodes WHERE source = ? AND status <> ?').all(source, 'down')) {
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()) {
@@ -29,7 +37,7 @@ async function report(db, { source, nodes }, now = new Date().toISOString()) {
marked++;
marked++;
}
}
});
});
- return { nodes: nodes.length, marked_down: marked };
+ return { nodes: nodes.length, marked_down: marked, adopted };
}
}
async function list(db, { role = null, region = null } = {}) {
async function list(db, { role = null, region = null } = {}) {
@@ -55,7 +63,11 @@ function routers({ guard }) {
@@ -55,7 +63,11 @@ function routers({ guard }) {
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
const v = validate('network.node-report-request@1', req.body);
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) });
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))
res.set('X-Nodes-Marked-Down', String(out.marked_down))
.json({ nodes: await list(req.app.locals.db), generated_at: new Date().toISOString() });
.json({ nodes: await list(req.app.locals.db), generated_at: new Date().toISOString() });
});
});
@@ -78,6 +78,12 @@ function routers({ guard }) {
@@ -78,6 +78,12 @@ function routers({ guard }) {
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
const bad = check(req.body);
const bad = check(req.body);
if (bad) return res.status(400).json(bad);
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);
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))
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() });
.json({ source: req.body.source, ...out, generated_at: new Date().toISOString() });
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -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); });
@@ -117,6 +117,9 @@ const assertLockstep = async () => {
@@ -117,6 +117,9 @@ const assertLockstep = async () => {
await assertLockstep();
await assertLockstep();
// 4. Mark-down: o-2 leaves oregon's report; frankfurt's offers are untouched; nothing is deleted.
// 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);
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');
assert.strictEqual(x.headers.get('x-offers-marked-down'), '0', 'a new source marks nothing of another source down');
const now = Date.now();
const now = Date.now();