openvibers/openvibe.network Commits
T2 lane B step 1: implement registry operator writers and reads from… (#27)
Changes
@@@ -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 APIapp.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@@@ -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 };@@@ -90,6 +90,31 @@ async function report(db, { source, instances } = {}, now = new Date().toISOStrireturn { 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 };@@@ -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 };@@@ -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);nnew file mode 100644@@@ -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 };@@@ -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();@@@ -31,7 +31,7 @@ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiapp.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'], locatioassert.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');nnew file mode 100644@@@ -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); });
@@ -463,9 +463,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -463,9 +463,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/internal', dataRouters.internal);
app.use('/internal', dataRouters.internal);
app.use('/api/admin/account-deletions', dataRouters.admin);
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('/api/v1/nodes', nodeRouters.pub);
app.use('/internal/nodes', nodeRouters.internal);
app.use('/internal/nodes', nodeRouters.internal);
}
}
@@ -674,6 +676,8 @@ app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAut
@@ -674,6 +676,8 @@ app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAut
// Deploy (TLS / Nginx / Infrastructure) admin API
// Deploy (TLS / Nginx / Infrastructure) admin API
app.use('/api/admin/deploy', createDeployRoutes(db, requireAuth));
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.
// SSH access provisioning info — powers the admin "SSH" tab.
// Returns non-secret connection context (host, project roots, services). The
// Returns non-secret connection context (host, project roots, services). The
@@ -88,6 +88,25 @@ async function getCell(db, id) {
@@ -88,6 +88,25 @@ async function getCell(db, id) {
return r ? cellView(r) : null;
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. */
/** 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;
const SEEN_UP_MS = 10 * 60 * 1000;
@@ -137,7 +156,13 @@ function routers({ readGuard }) {
@@ -137,7 +156,13 @@ function routers({ readGuard }) {
if (!t) return http.sendProblem(res, 404, 'registry.unknown_cell', { detail: `no cell ${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() });
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 };
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 };
@@ -90,6 +90,31 @@ async function report(db, { source, instances } = {}, now = new Date().toISOStri
@@ -90,6 +90,31 @@ async function report(db, { source, instances } = {}, now = new Date().toISOStri
return { instances: instances.length, stopped };
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 }) {
function routers({ guard }) {
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const internal = express.Router();
const internal = express.Router();
@@ -107,4 +132,4 @@ function routers({ guard }) {
@@ -107,4 +132,4 @@ function routers({ guard }) {
return { internal };
return { internal };
}
}
-module.exports = { CONTRACT, ensureSchema, check, checkPlacement, report, routers };
+module.exports = { CONTRACT, INSTANCE_STATE, ensureSchema, check, checkPlacement, report, instanceView, setInstance, routers };
@@ -202,6 +202,39 @@ async function revokeScoped(db, id, scope, revokedBy, now = Date.now()) {
@@ -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. */
/** 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);
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. */
/** 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] });
const ownScope = (me) => ({ where: "owner_kind = 'user' AND owner_subject = ?", params: [me] });
@@ -398,4 +431,4 @@ function routers({ guard, now = () => Date.now() }) {
@@ -398,4 +431,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
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 };
@@ -46,7 +46,7 @@ async function list(db, { role = null, region = null } = {}) {
@@ -46,7 +46,7 @@ async function list(db, { role = null, region = null } = {}) {
.filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));
.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 pub = express.Router();
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
.set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
.set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
@@ -59,6 +59,12 @@ function routers({ guard }) {
@@ -59,6 +59,12 @@ function routers({ guard }) {
// serves it; a report names another URL for nodes whose traffic does not come through here.
// 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());
pub.get('/:id/beacon', (req, res) => open(res, 0).status(204).end());
const internal = express.Router();
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) => {
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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 };
@@ -179,6 +179,26 @@ try {
@@ -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.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.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);
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');
console.log('cells-registry: all tests passed');
} finally {
} finally {
server.close();
server.close();
@@ -31,7 +31,7 @@ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpi
@@ -31,7 +31,7 @@ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpi
app.locals.privateKey = keys.privateKey;
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
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('/api/v1/nodes', r.pub);
app.use('/internal/nodes', r.internal);
app.use('/internal/nodes', r.internal);
const server = http.createServer(app);
const server = http.createServer(app);
@@ -73,6 +73,19 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
@@ -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.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');
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`);
const b = await fetch(`${base}/api/v1/nodes/oregon-1/beacon`);
assert.deepStrictEqual([b.status, b.headers.get('cache-control'), b.headers.get('timing-allow-origin')], [204, 'no-store', '*']);
assert.deepStrictEqual([b.status, b.headers.get('cache-control'), b.headers.get('timing-allow-origin')], [204, 'no-store', '*']);
console.log('nodes: all tests passed');
console.log('nodes: all tests passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -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); });