openvibers/openvibe.network Commits

T2 lane B step 1: implement registry operator writers and reads from… (#27)

OP OpenVibers committed 3 Oct 2026 · 9cdd82aa50 · 9 files, +392 −7

local checks ✓ 4/4 · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

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

Changes

ddiff --git a/server/index.js b/server/index.js
iindex d94d1bef..8d5d66b1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -463,9 +463,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/internal', dataRouters.internal);
app.use('/api/admin/account-deletions', dataRouters.admin);
}
--// The node registry (WS-X1, ADR-034 §12): public list for the geo API; Host reports its inventory's machines.
++// The node registry (WS-X1, ADR-034 §12): public list for the geo API; Host reports its inventory's machines; services
++// holding network.registry.read read the same list uncached at GET /internal/nodes.
{
-- const nodeRouters = require('./registry/nodes').routers({ guard: require('./identity/principals').guard('network.node.report') });
++ const { guard } = require('./identity/principals');
++ const nodeRouters = require('./registry/nodes').routers({ guard: guard('network.node.report'), readGuard: guard('network.registry.read') });
app.use('/api/v1/nodes', nodeRouters.pub);
app.use('/internal/nodes', nodeRouters.internal);
}
@@@ -674,6 +676,8 @@ app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAut
// Deploy (TLS / Nginx / Infrastructure) admin API
app.use('/api/admin/deploy', createDeployRoutes(db, requireAuth));
++// Registry operators (plan T2): cell and instance route weights, draining, node principal status; staff session only.
++app.use('/api/admin/registry', require('./registry/registry-admin').createRegistryAdmin(db, requireAuth, requireAdmin));
// SSH access provisioning info — powers the admin "SSH" tab.
// Returns non-secret connection context (host, project roots, services). The
ddiff --git a/server/registry/cells.js b/server/registry/cells.js
iindex 4c2663ec..69211d5e 100644
---- a/server/registry/cells.js
++++ b/server/registry/cells.js
@@@ -88,6 +88,25 @@ async function getCell(db, id) {
return r ? cellView(r) : null;
}
++/** A cell's lifecycle (0008 CHECK) and the routing weight of a cell or an instance: an integer 0-1000 (0008 CHECK). */
++const CELL_STATUS = ['planned', 'active', 'draining', 'retired'];
++const isWeight = (v) => Number.isInteger(v) && v >= 0 && v <= 1000;
++
++/**
++ * Operator write (plan T2): a cell's routing weight and lifecycle status, from a staff session
++ * (server/registry/registry-admin.js); no report carries either. Nothing routes on them until a scheduler exists.
++ * Throws RegistryError. → the cell
++ */
++async function setCell(db, id, { route_weight: weight, status } = {}, now = new Date().toISOString()) {
++ if (weight !== undefined && !isWeight(weight)) throw new RegistryError(422, 'registry.invalid_weight', 'route_weight must be an integer from 0 to 1000');
++ if (status !== undefined && !CELL_STATUS.includes(status)) throw new RegistryError(422, 'registry.invalid_status', `status must be one of ${CELL_STATUS.join(', ')}`);
++ const r = await db.prepare('SELECT * FROM platform_cells WHERE id = ?').get(String(id));
++ if (!r) throw new RegistryError(404, 'registry.unknown_cell', `no cell ${id}`);
++ await db.prepare('UPDATE platform_cells SET route_weight = ?, status = ?, updated_at = ? WHERE id = ?')
++ .run(weight === undefined ? Number(r.route_weight) : weight, status === undefined ? r.status : status, now, r.id);
++ return getCell(db, r.id);
++}
++
/** A live paired machine with no platform_nodes row is 'up' while it signed in within this window. */
const SEEN_UP_MS = 10 * 60 * 1000;
@@@ -137,7 +156,13 @@ function routers({ readGuard }) {
if (!t) return http.sendProblem(res, 404, 'registry.unknown_cell', { detail: `no cell ${req.params.id}` });
res.json({ ...t, generated_at: new Date().toISOString() });
});
++ // Every node principal (platform, project and person-owned), newest first; ?cell=&owner_kind=&status= narrow it.
++ internal.get('/node-principals', readGuard, async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ const nodePrincipals = await require('./node-principals').listPrincipals(req.app.locals.db, require('./node-principals').principalFilter(req.query));
++ res.json({ node_principals: nodePrincipals, generated_at: new Date().toISOString() });
++ });
return { pub, internal };
}
--module.exports = { BOOTSTRAP_CELL, RegistryError, ensureSchema, checkPlatformNodes, adoptPlatformNodes, checkPlacement, listCells, getCell, topology, routers };
++module.exports = { BOOTSTRAP_CELL, CELL_STATUS, RegistryError, isWeight, ensureSchema, checkPlatformNodes, adoptPlatformNodes, checkPlacement, cellView, listCells, getCell, setCell, topology, routers };
ddiff --git a/server/registry/instances.js b/server/registry/instances.js
iindex d4838761..2a51570c 100644
---- a/server/registry/instances.js
++++ b/server/registry/instances.js
@@@ -90,6 +90,31 @@ async function report(db, { source, instances } = {}, now = new Date().toISOStri
return { instances: instances.length, stopped };
}
++/** An instance's lifecycle (0008 CHECK, platform.service-instance@1). */
++const INSTANCE_STATE = ['starting', 'ready', 'degraded', 'draining', 'stopped'];
++
++const instanceView = (r) => ({
++ id: r.id, service: r.service, version: r.version, cell: r.cell, node: r.node_id, endpoints: JSON.parse(r.endpoints), state: r.state,
++ route_weight: Number(r.route_weight), source: r.source, started_at: r.started_at, reported_at: r.reported_at,
++});
++
++/**
++ * Operator write (plan T2): an instance's routing weight and state, from a staff session
++ * (server/registry/registry-admin.js). This is the only writer of route_weight; Host's next report of the instance
++ * sets its state again (report() above), so an operator's state lasts until then. Throws RegistryError. → the instance
++ */
++async function setInstance(db, id, { route_weight: weight, state } = {}) {
++ const { RegistryError, isWeight } = require('./cells');
++ if (weight !== undefined && !isWeight(weight)) throw new RegistryError(422, 'registry.invalid_weight', 'route_weight must be an integer from 0 to 1000');
++ if (state !== undefined && !INSTANCE_STATE.includes(state)) throw new RegistryError(422, 'registry.invalid_state', `state must be one of ${INSTANCE_STATE.join(', ')}`);
++ const get = db.prepare('SELECT * FROM platform_service_instances WHERE id = ?');
++ const r = await get.get(String(id));
++ if (!r) throw new RegistryError(404, 'registry.unknown_instance', `no service instance ${id}`);
++ await db.prepare('UPDATE platform_service_instances SET route_weight = ?, state = ? WHERE id = ?')
++ .run(weight === undefined ? Number(r.route_weight) : weight, state === undefined ? r.state : state, r.id);
++ return instanceView(await get.get(r.id));
++}
++
function routers({ guard }) {
const { http } = require('openvibe-contracts');
const internal = express.Router();
@@@ -107,4 +132,4 @@ function routers({ guard }) {
return { internal };
}
--module.exports = { CONTRACT, ensureSchema, check, checkPlacement, report, routers };
++module.exports = { CONTRACT, INSTANCE_STATE, ensureSchema, check, checkPlacement, report, instanceView, setInstance, routers };
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
iindex c0d9b8d5..2fc7c485 100644
---- a/server/registry/node-principals.js
++++ b/server/registry/node-principals.js
@@@ -202,6 +202,39 @@ async function revokeScoped(db, id, scope, revokedBy, now = Date.now()) {
/** Revoke a principal `service` paired; revoked_by is svc:<service>. → the row, or null. */
const revoke = (db, id, service, now = Date.now()) => revokeScoped(db, id, { where: 'paired_by_service = ?', params: [service] }, `svc:${service}`, now);
++/** The query filters of the operator and service principal lists; anything but a string is ignored. */
++const principalFilter = (q = {}) => Object.fromEntries(['cell', 'owner_kind', 'status'].map((k) => [k, typeof q[k] === 'string' ? q[k] : null]));
++
++/** Every node principal for operators and services holding network.registry.read, newest first, at most 500. */
++async function listPrincipals(db, { cell = null, owner_kind: ownerKind = null, status = null } = {}) {
++ const where = []; const params = [];
++ if (cell) { where.push('home_cell = ?'); params.push(cell); }
++ if (ownerKind) { where.push('owner_kind = ?'); params.push(ownerKind); }
++ if (status) { where.push('status = ?'); params.push(status); }
++ const rows = await db.prepare(`SELECT * FROM platform_node_principals ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
++ ORDER BY created_at DESC, id DESC LIMIT 500`).all(...params);
++ return rows.map((p) => ({ ...principalView(p), trust: p.trust }));
++}
++
++/** What an operator may set a principal to: revoked has its own routes (revokeScoped), and is never undone. */
++const OPERATOR_STATUS = ['active', 'draining'];
++
++/**
++ * Operator write (plan T2): drain a node principal, or make it active again, from a staff session
++ * (server/registry/registry-admin.js). Throws RegistryError. → the principal (with trust)
++ */
++async function setPrincipalStatus(db, id, status, now = Date.now()) {
++ if (!OPERATOR_STATUS.includes(status)) throw new RegistryError(422, 'registry.invalid_status', 'status must be active or draining (revoking has its own route)');
++ const get = db.prepare('SELECT * FROM platform_node_principals WHERE id = ?');
++ const p = PRINCIPAL_ID.test(String(id)) ? await get.get(String(id)) : null;
++ if (!p) throw new RegistryError(404, 'registry.unknown_node', `no node principal ${id}`);
++ const at = iso(now);
++ const changed = (await db.prepare("UPDATE platform_node_principals SET status = ?, updated_at = ? WHERE id = ? AND status <> 'revoked'").run(status, at, p.id)).changes;
++ if (!changed) throw new RegistryError(409, 'registry.node_revoked', `node principal ${id} was revoked`);
++ const row = await get.get(p.id);
++ return { ...principalView(row), trust: row.trust };
++}
++
/** Scope for a person's own machines: the principals they own, and no others. */
const ownScope = (me) => ({ where: "owner_kind = 'user' AND owner_subject = ?", params: [me] });
@@@ -398,4 +431,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
}
--module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, createPairing, redeem, revoke, principalView, routers, userRouter, issueNodeToken, selfRouter };
++module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
ddiff --git a/server/registry/nodes.js b/server/registry/nodes.js
iindex f19053a8..d41f0ca0 100644
---- a/server/registry/nodes.js
++++ b/server/registry/nodes.js
@@@ -46,7 +46,7 @@ async function list(db, { role = null, region = null } = {}) {
.filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));
}
--function routers({ guard }) {
++function routers({ guard, readGuard = null }) {
const pub = express.Router();
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
.set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
@@@ -59,6 +59,12 @@ function routers({ guard }) {
// serves it; a report names another URL for nodes whose traffic does not come through here.
pub.get('/:id/beacon', (req, res) => open(res, 0).status(204).end());
const internal = express.Router();
++ // The same list for services holding network.registry.read (GET /internal/nodes), never cached.
++ if (readGuard) internal.get('/', readGuard, async (req, res) => {
++ const role = typeof req.query.role === 'string' ? req.query.role : null;
++ const region = typeof req.query.region === 'string' ? req.query.region : null;
++ res.set('Cache-Control', 'no-store').json({ nodes: await list(req.app.locals.db, { role, region }), generated_at: new Date().toISOString() });
++ });
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
const { validate } = require('openvibe-contracts');
const v = validate('network.node-report-request@1', req.body);
ddiff --git a/server/registry/registry-admin.js b/server/registry/registry-admin.js
nnew file mode 100644
iindex 00000000..201c7a02
---- /dev/null
++++ b/server/registry/registry-admin.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * Operator writers and reads for the registry (plan T2, docs/t2-cells-and-node-principal.md section 8): the routing
++ * weight and the draining lifecycle of a cell, a service instance and a node principal. These are routing decisions,
++ * so no report carries them; a staff session sets them, next to /api/admin/deploy. No service capability is involved.
++ * Nothing routes on a weight or on draining until a scheduler and a route consumer exist. Every change lands in
++ * audit_log.
++ * GET /api/admin/registry/node-principals?cell=&owner_kind=&status= → { node_principals: [...] }
++ * PUT /api/admin/registry/cells/:id { route_weight?, status? } → the cell
++ * PUT /api/admin/registry/instances/:id { route_weight?, state? } → the instance
++ * PUT /api/admin/registry/node-principals/:id { status } → the principal (active or draining)
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { RegistryError, setCell } = require('./cells');
++const { setInstance } = require('./instances');
++const { principalFilter, listPrincipals, setPrincipalStatus } = require('./node-principals');
++
++/** The body of a PUT: a JSON object with at least one of `allowed` and nothing else. Throws RegistryError. */
++function fields(body, allowed) {
++ const keys = body && typeof body === 'object' && !Array.isArray(body) ? Object.keys(body) : null;
++ if (!keys || !keys.length || keys.some((k) => !allowed.includes(k))) {
++ throw new RegistryError(400, 'registry.invalid_body', `the body must be an object with ${allowed.join(' and/or ')}`);
++ }
++ return body;
++}
++
++function createRegistryAdmin(db, requireAuth, requireAdmin) {
++ const router = express.Router();
++ router.use(requireAuth, requireAdmin, express.json({ limit: '4kb' }));
++ const send = (fn) => async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try {
++ res.json(await fn(req));
++ } catch (e) {
++ if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ console.error('[Registry admin]', e.message);
++ return http.sendProblem(res, 500, 'registry.failed', { detail: 'the registry could not be read or changed' });
++ }
++ };
++ // The write and its audit row together, so a change is never unaudited.
++ const write = (req, action, fn) => db.tx(async () => {
++ const out = await fn();
++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(req.user.id, action, JSON.stringify({ id: req.params.id, ...req.body }));
++ return out;
++ });
++
++ router.get('/node-principals', send(async (req) => ({
++ node_principals: await listPrincipals(db, principalFilter(req.query)), generated_at: new Date().toISOString(),
++ })));
++ router.put('/cells/:id', send((req) => {
++ const body = fields(req.body, ['route_weight', 'status']);
++ return write(req, 'registry_cell_update', () => setCell(db, req.params.id, body));
++ }));
++ router.put('/instances/:id', send((req) => {
++ const body = fields(req.body, ['route_weight', 'state']);
++ return write(req, 'registry_instance_update', () => setInstance(db, req.params.id, body));
++ }));
++ router.put('/node-principals/:id', send((req) => {
++ const { status } = fields(req.body, ['status']);
++ return write(req, 'registry_node_principal_update', () => setPrincipalStatus(db, req.params.id, status));
++ }));
++ return router;
++}
++
++module.exports = { createRegistryAdmin };
ddiff --git a/test/cells-registry.test.js b/test/cells-registry.test.js
iindex 3ea3988e..05e6064e 100644
---- a/test/cells-registry.test.js
++++ b/test/cells-registry.test.js
@@@ -179,6 +179,26 @@ try {
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);
++
++ // ── Every node principal for network.registry.read (T2 step 1), newest first, filtered by cell, owner and status.
++ assert.strictEqual((await call('GET', '/internal/registry/node-principals')).status, 403, 'nobody');
++ assert.strictEqual((await call('GET', '/internal/registry/node-principals', null, host)).status, 403, 'Host lacks network.registry.read');
++ x = await call('GET', '/internal/registry/node-principals', null, live);
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++ assert.strictEqual(x.headers.get('cache-control'), 'no-store');
++ const stored = await db.prepare('SELECT id FROM platform_node_principals ORDER BY created_at DESC, id DESC').all();
++ assert.deepStrictEqual(x.body.node_principals.map((p) => p.principal), stored.map((p) => p.id), 'all of them, newest first');
++ assert.ok(!/hash|credential/.test(JSON.stringify(x.body)), 'never a hash or a credential');
++ const byId = Object.fromEntries(x.body.node_principals.map((p) => [p.node_id, p]));
++ assert.deepStrictEqual([byId['pi-1'].owner, byId['pi-1'].trust, byId['pi-1'].home_cell], [{ kind: 'project', project_id: project }, 'community', 'wnam-1']);
++ assert.deepStrictEqual([byId['ovh-1'].owner, byId['ovh-1'].trust], [{ kind: 'platform' }, 'first-party']);
++ assert.strictEqual(byId['pair-user'].owner.kind, 'user');
++ const nodesOf = async (q) => (await call('GET', `/internal/registry/node-principals${q}`, null, live)).body.node_principals.map((p) => p.node_id).sort();
++ assert.deepStrictEqual(await nodesOf('?cell=weu-1'), ['pair-cred-a', 'pair-platform', 'pair-user']);
++ assert.deepStrictEqual(await nodesOf('?owner_kind=user'), ['pair-user']);
++ assert.deepStrictEqual(await nodesOf('?status=revoked'), ['old-1']);
++ assert.deepStrictEqual(await nodesOf('?cell=weu-1&owner_kind=platform'), ['pair-cred-a', 'pair-platform']);
++ assert.deepStrictEqual(await nodesOf('?status=bogus'), [], 'an unknown value matches nothing');
console.log('cells-registry: all tests passed');
} finally {
server.close();
ddiff --git a/test/nodes.test.js b/test/nodes.test.js
iindex d978a26c..26e3262e 100644
---- a/test/nodes.test.js
++++ b/test/nodes.test.js
@@@ -31,7 +31,7 @@ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpi
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
--const r = nodes.routers({ guard: principals.guard('network.node.report', { legacy: false }) });
++const r = nodes.routers({ guard: principals.guard('network.node.report', { legacy: false }), readGuard: principals.guard('network.registry.read') });
app.use('/api/v1/nodes', r.pub);
app.use('/internal/nodes', r.internal);
const server = http.createServer(app);
@@@ -73,6 +73,19 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
assert.deepStrictEqual(all.map((n) => [n.id, n.health.status]), [['fra-1', 'up'], ['oregon-1', 'up'], ['oregon-2', 'down']]);
assert.ok(validate('network.node-list-result@1', { nodes: all, generated_at: new Date().toISOString() }).valid, 'a down node still matches the contract');
++ // GET /internal/nodes: the same list, uncached, for network.registry.read only (Live holds it, Host does not).
++ const internal = (q, headers = {}) => fetch(`${base}/internal/nodes${q}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: await y.json().catch(() => null) }));
++ assert.strictEqual((await internal('')).status, 403, 'nobody');
++ assert.strictEqual((await internal('', auth)).status, 403, 'Host lacks network.registry.read');
++ const reader = { authorization: `Bearer ${await token('live', 'live-secret')}` };
++ x = await internal('', reader);
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++ assert.strictEqual(x.headers.get('cache-control'), 'no-store');
++ assert.ok(validate('network.node-list-result@1', x.body).valid);
++ assert.deepStrictEqual(x.body.nodes, all, 'the same nodes as the public list');
++ assert.deepStrictEqual((await internal('?region=eu-central', reader)).body.nodes.map((n) => n.id), ['fra-1']);
++ assert.deepStrictEqual((await internal('?role=edge-probe', reader)).body.nodes.map((n) => n.id), ['oregon-2']);
++
const b = await fetch(`${base}/api/v1/nodes/oregon-1/beacon`);
assert.deepStrictEqual([b.status, b.headers.get('cache-control'), b.headers.get('timing-allow-origin')], [204, 'no-store', '*']);
console.log('nodes: all tests passed');
ddiff --git a/test/registry-admin.test.js b/test/registry-admin.test.js
nnew file mode 100644
iindex 00000000..79f3c3dc
---- /dev/null
++++ b/test/registry-admin.test.js
@@@ -0,0 +1,192 @@
++'use strict';
++// Registry operators (plan T2 step 1, server/registry/registry-admin.js): a staff session sets a cell's route weight
++// and status, an instance's route weight and state, and drains a node principal or makes it active again. No session
++// is 401, a non-admin session and a service token are refused; weights are integers 0-1000 and states are the 0008
++// CHECK's; unknown ids are 404 and a revoked principal stays revoked. Every change persists and lands in audit_log,
++// and Host's next instance report keeps the operator's weight.
++// node test/registry-admin.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 { signToken } = require('../server/auth/routes');
++
++(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(`INSERT INTO users (id, username, password_hash, role) VALUES
++ (9301, 'regadmin', 'x', 'admin'), (9302, 'reguser', 'x', 'user')`).run();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
++// The same guard as server/index.js requireAdmin.
++const requireAdmin = (req, res, next) => (!req.user || req.user.role !== 'admin' ? res.status(403).json({ error: 'Admin access required' }) : next());
++
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/internal/registry/instances', require('../server/registry/instances').routers({ guard: principals.guard('network.node.report') }).internal);
++app.use('/api/v1/cells', require('../server/registry/cells').routers({ readGuard: principals.guard('network.registry.read') }).pub);
++app.use('/api/admin/registry', require('../server/registry/registry-admin').createRegistryAdmin(db, requireAuth, requireAdmin));
++const server = http.createServer(app);
++
++const nod = () => `nod_${ids.ulid()}`;
++const platformId = nod(); const pairedId = nod(); const revokedId = nod();
++await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, trust, status, created_by, revoked_at, credential_hash) VALUES
++ (?, 'ovh-1', 'wnam-1', 'platform', NULL, 'first-party', 'active', 'test', NULL, NULL),
++ (?, 'pi-1', 'wnam-1', 'user', ?, 'community', 'active', 'test', NULL, ?),
++ (?, 'old-1', 'wnam-1', 'platform', NULL, 'first-party', 'revoked', 'test', '2026-10-01T00:00:00Z', NULL)`)
++ .run(platformId, pairedId, `usr_${ids.ulid()}`, '1'.repeat(64), revokedId);
++await db.prepare(`INSERT INTO platform_service_instances (id, service, version, cell, node_id, endpoints, state, source, started_at, reported_at)
++ VALUES ('media-1', 'media', '1.4.0', 'wnam-1', 'ovh-1', '["https://media.internal.example"]', 'ready', 'oregon', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`).run();
++
++await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++const base = `http://127.0.0.1:${server.address().port}`;
++const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json', ...headers }, body: body === undefined ? undefined : JSON.stringify(body) })
++ .then(async (x) => { const text = await x.text(); let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: x.status, headers: x.headers, text, body: json }; });
++const bearer = (t) => ({ authorization: `Bearer ${t}` });
++const session = async (id) => bearer(signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config));
++const serviceToken = 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 cellRow = () => db.prepare('SELECT status, route_weight FROM platform_cells WHERE id = ?').get('wnam-1');
++const instanceRow = () => db.prepare('SELECT state, route_weight FROM platform_service_instances WHERE id = ?').get('media-1');
++const principalRow = (id) => db.prepare('SELECT status, revoked_at FROM platform_node_principals WHERE id = ?').get(id);
++let passed = 0;
++const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
++try {
++ const admin = await session(9301); const user = await session(9302);
++ const host = bearer(await serviceToken('host', 'host-secret'));
++ const A = (method, p, body) => call(method, `/api/admin/registry${p}`, body, admin);
++
++ await check('staff session only: no session 401, a person 403, a service token refused', async () => {
++ for (const [method, p, body] of [['GET', '/node-principals'], ['PUT', '/cells/wnam-1', { route_weight: 5 }], ['PUT', '/instances/media-1', { route_weight: 5 }], ['PUT', `/node-principals/${platformId}`, { status: 'draining' }]]) {
++ assert.strictEqual((await call(method, `/api/admin/registry${p}`, body)).status, 401, `${method} ${p} with no session`);
++ assert.strictEqual((await call(method, `/api/admin/registry${p}`, body, user)).status, 403, `${method} ${p} as a person`);
++ assert.ok([401, 403].includes((await call(method, `/api/admin/registry${p}`, body, host)).status), `${method} ${p} with a service token`);
++ }
++ assert.deepStrictEqual(await cellRow(), { status: 'active', route_weight: 100 }, 'nothing written');
++ assert.deepStrictEqual(await instanceRow(), { state: 'ready', route_weight: 100 });
++ assert.strictEqual((await principalRow(platformId)).status, 'active');
++ });
++
++ await check('a cell: weight and status persist, show in the public list, and are audited', async () => {
++ let r = await A('PUT', '/cells/wnam-1', { route_weight: 250 });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body, { id: 'wnam-1', region: 'us-west', residency: 'US', status: 'active', route_weight: 250 });
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ r = await A('PUT', '/cells/wnam-1', { status: 'draining' });
++ assert.deepStrictEqual([r.status, r.body.status, r.body.route_weight], [200, 'draining', 250], 'the other field is kept');
++ assert.deepStrictEqual(await cellRow(), { status: 'draining', route_weight: 250 });
++ assert.deepStrictEqual((await call('GET', '/api/v1/cells/wnam-1')).body, { id: 'wnam-1', region: 'us-west', residency: 'US', status: 'draining', route_weight: 250 });
++ r = await A('PUT', '/cells/wnam-1', { status: 'active', route_weight: 0 });
++ assert.deepStrictEqual([r.status, r.body.status, r.body.route_weight], [200, 'active', 0]);
++ r = await A('PUT', '/cells/wnam-1', { route_weight: 1000 });
++ assert.deepStrictEqual([r.status, r.body.route_weight], [200, 1000]);
++ const audit = await db.prepare("SELECT user_id, details FROM audit_log WHERE action = 'registry_cell_update' ORDER BY id").all();
++ assert.deepStrictEqual(audit.map((a) => [Number(a.user_id), JSON.parse(a.details)]), [
++ [9301, { id: 'wnam-1', route_weight: 250 }], [9301, { id: 'wnam-1', status: 'draining' }],
++ [9301, { id: 'wnam-1', status: 'active', route_weight: 0 }], [9301, { id: 'wnam-1', route_weight: 1000 }],
++ ]);
++ });
++
++ await check('invalid values: weights outside 0-1000 or not integers, unknown statuses, bad bodies', async () => {
++ for (const w of [1001, -1, 1.5, '250', null, true]) {
++ const r = await A('PUT', '/cells/wnam-1', { route_weight: w });
++ assert.deepStrictEqual([r.status, r.body.code], [422, 'registry.invalid_weight'], `cell weight ${JSON.stringify(w)}`);
++ const i = await A('PUT', '/instances/media-1', { route_weight: w });
++ assert.deepStrictEqual([i.status, i.body.code], [422, 'registry.invalid_weight'], `instance weight ${JSON.stringify(w)}`);
++ }
++ for (const s of ['revoked', 'DRAINING', '', 7]) {
++ const r = await A('PUT', '/cells/wnam-1', { status: s });
++ assert.deepStrictEqual([r.status, r.body.code], [422, 'registry.invalid_status'], `cell status ${JSON.stringify(s)}`);
++ }
++ for (const s of ['retired', 'up']) {
++ const r = await A('PUT', '/instances/media-1', { state: s });
++ assert.deepStrictEqual([r.status, r.body.code], [422, 'registry.invalid_state'], `instance state ${s}`);
++ }
++ for (const s of ['revoked', 'retired']) {
++ const r = await A('PUT', `/node-principals/${platformId}`, { status: s });
++ assert.deepStrictEqual([r.status, r.body.code], [422, 'registry.invalid_status'], `principal status ${s}`);
++ }
++ // A valid field next to an invalid one writes neither.
++ let r = await A('PUT', '/cells/wnam-1', { route_weight: 10, status: 'gone' });
++ assert.strictEqual(r.status, 422);
++ for (const body of [{}, [], { weight: 5 }, { route_weight: 5, region: 'eu-west' }]) {
++ r = await A('PUT', '/cells/wnam-1', body);
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'registry.invalid_body'], JSON.stringify(body));
++ }
++ r = await A('PUT', '/instances/media-1', { status: 'draining' });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'registry.invalid_body'], 'an instance has a state, not a status');
++ r = await A('PUT', `/node-principals/${platformId}`, { route_weight: 5 });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'registry.invalid_body'], 'a principal has no weight');
++ assert.deepStrictEqual(await cellRow(), { status: 'active', route_weight: 1000 }, 'nothing written');
++ assert.deepStrictEqual(await instanceRow(), { state: 'ready', route_weight: 100 });
++ assert.strictEqual((await principalRow(platformId)).status, 'active');
++ });
++
++ await check('unknown ids are 404 and write nothing', async () => {
++ const before = (await db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action LIKE 'registry_%'").get()).n;
++ let r = await A('PUT', '/cells/nope-1', { route_weight: 5 });
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_cell']);
++ r = await A('PUT', '/instances/nope-1', { route_weight: 5 });
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_instance']);
++ for (const id of [nod(), 'ovh-1', 'nope']) {
++ r = await A('PUT', `/node-principals/${id}`, { status: 'draining' });
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_node'], id);
++ }
++ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action LIKE 'registry_%'").get()).n, before, 'no audit row for a refused write');
++ });
++
++ await check('an instance: weight and draining persist; Host\'s next report keeps the weight', async () => {
++ let r = await A('PUT', '/instances/media-1', { route_weight: 40, state: 'draining' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.id, r.body.service, r.body.node, r.body.state, r.body.route_weight], ['media-1', 'media', 'ovh-1', 'draining', 40]);
++ assert.deepStrictEqual(await instanceRow(), { state: 'draining', route_weight: 40 });
++ const doc = { id: 'media-1', service: 'media', version: '1.4.1', cell: 'wnam-1', node: 'ovh-1', region: 'us-west', endpoints: ['https://media.internal.example'], state: 'ready', started_at: '2026-10-01T00:00:00Z' };
++ const rep = await call('POST', '/internal/registry/instances/report', { source: 'oregon', instances: [doc] }, host);
++ assert.strictEqual(rep.status, 200, rep.text);
++ assert.deepStrictEqual(await instanceRow(), { state: 'ready', route_weight: 40 }, 'a report sets the state, never the weight');
++ });
++
++ await check('a node principal: draining and back to active; revoked stays revoked', async () => {
++ let r = await A('PUT', `/node-principals/${platformId}`, { status: 'draining' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.principal, r.body.node_id, r.body.status, r.body.trust, r.body.owner], [platformId, 'ovh-1', 'draining', 'first-party', { kind: 'platform' }]);
++ assert.strictEqual((await principalRow(platformId)).status, 'draining');
++ r = await A('PUT', `/node-principals/${pairedId}`, { status: 'draining' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!/hash|credential/.test(r.text), 'never a hash or a credential');
++ r = await A('PUT', `/node-principals/${platformId}`, { status: 'active' });
++ assert.deepStrictEqual([r.status, r.body.status], [200, 'active']);
++ r = await A('PUT', `/node-principals/${revokedId}`, { status: 'active' });
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'registry.node_revoked']);
++ assert.deepStrictEqual(await principalRow(revokedId), { status: 'revoked', revoked_at: '2026-10-01T00:00:00Z' });
++ });
++
++ await check('the operator principal list: every owner, with trust, filtered', async () => {
++ let r = await A('GET', '/node-principals');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const byNode = Object.fromEntries(r.body.node_principals.map((p) => [p.node_id, p]));
++ assert.deepStrictEqual(Object.keys(byNode).sort(), ['old-1', 'ovh-1', 'pi-1']);
++ assert.deepStrictEqual([byNode['pi-1'].status, byNode['pi-1'].trust, byNode['pi-1'].owner.kind], ['draining', 'community', 'user']);
++ assert.ok(!/hash|credential/.test(r.text));
++ r = await A('GET', '/node-principals?status=draining');
++ assert.deepStrictEqual(r.body.node_principals.map((p) => p.node_id), ['pi-1']);
++ r = await A('GET', '/node-principals?owner_kind=platform&cell=wnam-1');
++ assert.deepStrictEqual(r.body.node_principals.map((p) => p.node_id).sort(), ['old-1', 'ovh-1']);
++ r = await A('GET', '/node-principals?cell=nope-1');
++ assert.deepStrictEqual(r.body.node_principals, []);
++ });
++ log(`registry-admin: ${passed} checks passed`);
++} finally {
++ server.close();
++}
++})().catch((err) => { console.error(err); process.exit(1); });