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.
Changes
@@@ -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",@@@ -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",@@@ -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;@@@ -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@@@ -449,6 +449,12 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: uapp.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).nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.24.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.24.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -754,6 +754,8 @@ function initDb(dbPath) {
@@ -754,6 +754,8 @@ function initDb(dbPath) {
require('../status/incidents').ensureSchema(db);
require('../status/incidents').ensureSchema(db);
// Creator analytics from live.stream.ended (server/analytics/creators.js).
// Creator analytics from live.stream.ended (server/analytics/creators.js).
require('../analytics/creators').ensureSchema(db);
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');
console.log('[DB] Central database initialized');
return db;
return db;
@@ -136,6 +136,8 @@ const DEFAULT_GRANTS = [
@@ -136,6 +136,8 @@ const DEFAULT_GRANTS = [
['live', 'network.analytics.creator.read', SELF_AUDIENCE, []],
['live', 'network.analytics.creator.read', SELF_AUDIENCE, []],
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
['host', 'network.operator.alert', SELF_AUDIENCE, []],
['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.
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
@@ -449,6 +449,12 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
@@ -449,6 +449,12 @@ const notifyAccountData = (userId, n) => notificationService.create({ user_id: u
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.
+{
+ 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));
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
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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); });