openvibers/openvibe.network Commits

The node registry (roadmap WS-X1 step 3, ADR-034 §12; Contracts v0.76.0 network.node@1). POST /internal/nodes/report takes Host's token only (network.node.report, granted to host; no shared key) and network.node-report-request@1: it upserts every node of that source and marks the source's nodes missing from the report down, never deleting them. GET /api/v1/nodes is public (CORS *, max-age 60) and filterable by ?role= and ?region=, answering network.node-list-result@1; it is what openvibe-sdk/geo reads. GET /api/v1/nodes/:id/beacon is a no-store 204 with Timing-Allow-Origin for browser distance timing. The contract has no address field, so nothing private is stored. openvibe-contracts pinned at v0.76.0. test/nodes.test.js covers who may report, the contract, down-marking per source, the filters and the beacon.

OP OpenVibers committed 28 Sept 2026 · 719a00d243 · 7 files, +173 −6


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex ec566f51..7e05e927 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.75.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.24.0",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.75.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.75.0",
-- "integrity": "sha512-l+VZGYvVpX2IGsEFPAFwJa8upFRBpZ0ufZxTSua72UeGSj7z2ceHxNjuua0oQ1fnsshl4hCvXHyen1eHjic/eA==",
++ "version": "0.76.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex f8075ecc..debd0f96 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/nodes.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.75.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.24.0",
"uuid": "^11.1.0",
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 1b9b0d9a..9460b1be 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -754,6 +754,8 @@ function initDb(dbPath) {
require('../status/incidents').ensureSchema(db);
// Creator analytics from live.stream.ended (server/analytics/creators.js).
require('../analytics/creators').ensureSchema(db);
++ // The platform's machines (server/registry/nodes.js; ADR-034 §12).
++ require('../registry/nodes').ensureSchema(db);
console.log('[DB] Central database initialized');
return db;
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex c082132c..f03eafd1 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -136,6 +136,8 @@ const DEFAULT_GRANTS = [
['live', 'network.analytics.creator.read', SELF_AUDIENCE, []],
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
['host', 'network.operator.alert', SELF_AUDIENCE, []],
++ // WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
++ ['host', 'network.node.report', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
ddiff --git a/server/index.js b/server/index.js
iindex 06504bcf..b3ae3169 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -449,6 +449,12 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
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.
++{
++ const nodeRouters = require('./registry/nodes').routers({ guard: require('./identity/principals').guard('network.node.report', { legacy: false }) });
++ app.use('/api/v1/nodes', nodeRouters.pub);
++ app.use('/internal/nodes', nodeRouters.internal);
++}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read', { legacy: false }), require('./identity/blocks').internalHandler(db));
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
ddiff --git a/server/registry/nodes.js b/server/registry/nodes.js
nnew file mode 100644
iindex 00000000..39252a1b
---- /dev/null
++++ b/server/registry/nodes.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * The node registry (ADR-034 section 12, roadmap WS-X1; Contracts 0.76.0 network.node@1): every machine of the
++ * platform with its roles, region-level location, beacon and health. Host reports the complete set its inventory
++ * knows (POST /internal/nodes/report, network.node.report); a node absent from a later report of the same source is
++ * marked down, never silently deleted. GET /api/v1/nodes is public and cacheable: products and openvibe-sdk/geo
++ * find "the nearest node with role R" from it. Nothing private is stored: the contract has no address field.
++ */
++const express = require('express');
++
++const ensured = new WeakSet();
++function ensureSchema(db) {
++ if (ensured.has(db)) return;
++ ensured.add(db);
++ db.exec(`CREATE TABLE IF NOT EXISTS platform_nodes (
++ id TEXT PRIMARY KEY,
++ source TEXT NOT NULL,
++ doc TEXT NOT NULL,
++ status TEXT NOT NULL,
++ reported_at TEXT NOT NULL
++ )`);
++}
++
++/** Apply a report: upsert every node; mark this source's other nodes down. → { nodes, marked_down } */
++function report(db, { source, nodes }, now = new Date().toISOString()) {
++ ensureSchema(db);
++ const upsert = db.prepare(`INSERT INTO platform_nodes (id, source, doc, status, reported_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT(id) DO UPDATE SET source = excluded.source, doc = excluded.doc, status = excluded.status, reported_at = excluded.reported_at`);
++ let marked = 0;
++ db.transaction(() => {
++ for (const n of nodes) upsert.run(n.id, source, JSON.stringify(n), n.health.status, now);
++ const ids = new Set(nodes.map((n) => n.id));
++ for (const row of db.prepare('SELECT id, doc FROM platform_nodes WHERE source = ? AND status <> ?').all(source, 'down')) {
++ if (ids.has(row.id)) continue;
++ const doc = JSON.parse(row.doc);
++ doc.health = { status: 'down', checked_at: now };
++ doc.updated_at = now;
++ db.prepare('UPDATE platform_nodes SET doc = ?, status = ? WHERE id = ?').run(JSON.stringify(doc), 'down', row.id);
++ marked++;
++ }
++ })();
++ return { nodes: nodes.length, marked_down: marked };
++}
++
++function list(db, { role = null, region = null } = {}) {
++ ensureSchema(db);
++ return db.prepare('SELECT doc FROM platform_nodes ORDER BY id').all().map((r) => JSON.parse(r.doc))
++ .filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));
++}
++
++function routers({ guard }) {
++ 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', '*');
++ pub.get('/', (req, res) => {
++ const role = typeof req.query.role === 'string' ? req.query.role : null;
++ const region = typeof req.query.region === 'string' ? req.query.region : null;
++ open(res, 60).json({ nodes: list(req.app.locals.db, { role, region }), generated_at: new Date().toISOString() });
++ });
++ // A beacon a browser times to estimate its distance from a node (openvibe-sdk/geo). Answered by the node that
++ // 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();
++ internal.post('/report', guard, express.json({ limit: '256kb' }), (req, res) => {
++ const { validate } = require('openvibe-contracts');
++ const v = validate('network.node-report-request@1', req.body);
++ if (!v.valid) return res.status(400).json({ error: 'The body does not match network.node-report-request@1', details: (v.errors || []).slice(0, 5) });
++ const out = report(req.app.locals.db, req.body);
++ res.set('X-Nodes-Marked-Down', String(out.marked_down))
++ .json({ nodes: list(req.app.locals.db), generated_at: new Date().toISOString() });
++ });
++ return { pub, internal };
++}
++
++module.exports = { ensureSchema, report, list, routers };
ddiff --git a/test/nodes.test.js b/test/nodes.test.js
nnew file mode 100644
iindex 00000000..93400f9f
---- /dev/null
++++ b/test/nodes.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++// The node registry (roadmap WS-X1, ADR-034 §12; Contracts network.node@1): POST /internal/nodes/report takes
++// Host's token only and the request contract; a node missing from the same source's next report is marked down,
++// not deleted; GET /api/v1/nodes is public, cacheable, filterable and answers network.node-list-result@1.
++// node test/nodes.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { validate } = require('openvibe-contracts');
++const { initDb } = require('../server/db/database');
++const principals = require('../server/identity/principals');
++const nodes = require('../server/registry/nodes');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-nodes-'));
++const log = console.log; console.log = () => {};
++const db = initDb(path.join(dir, 'network.db'));
++console.log = log;
++db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
++db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const app = express();
++app.use(express.urlencoded({ extended: true }));
++app.locals.db = db;
++app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++const r = nodes.routers({ guard: principals.guard('network.node.report', { legacy: false }) });
++app.use('/api/v1/nodes', r.pub);
++app.use('/internal/nodes', r.internal);
++const server = http.createServer(app);
++
++const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], location: { region: 'us-west', country: 'US' }, provider: 'ovh', beacon: `https://openvibe.network/api/v1/nodes/${id}/beacon`, health: { status: 'up', checked_at: '2026-09-28T12:00:00Z' }, updated_at: '2026-09-28T12:00:00Z', ...extra });
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const token = async (id, secret) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }) })).json()).access_token;
++ const post = (body, headers) => fetch(`${base}/internal/nodes/report`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
++ .then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json().catch(() => null) }));
++ const get = (q = '') => fetch(`${base}/api/v1/nodes${q}`).then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json() }));
++ try {
++ const auth = { authorization: `Bearer ${await token('host', 'host-secret')}` };
++ assert.strictEqual((await post({ source: 'oregon', nodes: [] })).status, 403, 'nobody');
++ assert.ok([401, 403].includes((await post({ source: 'oregon', nodes: [] }, { 'x-internal-key': 'legacy-key' })).status), 'not the shared key');
++ assert.strictEqual((await post({ source: 'oregon', nodes: [] }, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.node.report');
++ assert.strictEqual((await post({ source: 'oregon', nodes: [node('a', { address: '10.0.0.1' })] }, auth)).status, 400, 'no address field: the contract refuses it');
++
++ let x = await post({ source: 'oregon', nodes: [node('oregon-1', { roles: ['web', 'app', 'data'] }), node('oregon-2', { roles: ['edge-probe'], location: { region: 'us-east', country: 'US' } })] }, auth);
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++ assert.ok(validate('network.node-list-result@1', x.body).valid);
++ assert.strictEqual(x.body.nodes.length, 2);
++
++ x = await get();
++ assert.strictEqual(x.status, 200);
++ assert.ok(validate('network.node-list-result@1', x.body).valid);
++ assert.match(x.headers.get('cache-control'), /public, max-age=60/);
++ assert.strictEqual(x.headers.get('access-control-allow-origin'), '*');
++ assert.deepStrictEqual((await get('?role=edge-probe')).body.nodes.map((n) => n.id), ['oregon-2']);
++ assert.deepStrictEqual((await get('?region=us-west')).body.nodes.map((n) => n.id), ['oregon-1']);
++
++ // oregon-2 leaves the inventory: marked down, still listed; another source's nodes are untouched.
++ await post({ source: 'frankfurt', nodes: [node('fra-1', { location: { region: 'eu-central', country: 'DE' } })] }, auth);
++ x = await post({ source: 'oregon', nodes: [node('oregon-1')] }, auth);
++ assert.strictEqual(x.headers.get('x-nodes-marked-down'), '1');
++ const all = (await get()).body.nodes;
++ 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');
++
++ 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');
++ } finally {
++ server.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exit(1); });