openvibers/openvibe.network Commits

Ecosystem registry: /.well-known/openvibe and /api/v1/registry/{services,domains,capabilities,namespaces,contracts,topics} from openvibe-contracts manifests, with polled, timestamped health; schemas served at their $id URLs (/contracts/...)

OP OpenVibers committed 23 Sept 2026 · 682f80fa22 · 4 files, +190 −1


Changes

ddiff --git a/package.json b/package.json
iindex 8a2d9c3a..787d543d 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 packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.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 packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.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/modules.test.js",
++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.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 packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.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/modules.test.js && node test/registry-ecosystem.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/index.js b/server/index.js
iindex 91e1b6bb..abe22a73 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -353,6 +353,11 @@ const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publi
// Serves BOTH the standard JWKS `keys` array (RFC 7517 — Media and other
// spec-compliant consumers) and the legacy `public_key` PEM shape that the
// inherited service clients read.
++// Ecosystem registry: services, capabilities, namespaces and contracts from openvibe-contracts, with polled health.
++const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issuer: config.jwt.issuer });
++app.use(ecosystem.router());
++ecosystem.start();
++
app.get('/api/.well-known/jwks', (_req, res) => {
const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };
if (publicKey.includes('BEGIN')) {
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
nnew file mode 100644
iindex 00000000..c69e4414
---- /dev/null
++++ b/server/registry/ecosystem.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * Ecosystem registry (roadmap Wave 2, implementation plan §5.1).
++ *
++ * What exists, what each service can do, and whether it is up — built from the versioned manifests in
++ * openvibe-contracts (services, capabilities, user-module namespaces, contract catalog) instead of route
++ * maps hard-coded into each product. Health is polled here and every value says when it was checked;
++ * nothing is presented as live without a timestamp.
++ *
++ * GET /.well-known/openvibe platform descriptor
++ * GET /api/v1/registry/services[?status=] manifests + health
++ * GET /api/v1/registry/services/:id
++ * GET /api/v1/registry/domains/:domain which service answers for a host
++ * GET /api/v1/registry/capabilities[?owner=] capability manifests
++ * GET /api/v1/registry/capabilities/:id
++ * GET /api/v1/registry/namespaces user-module namespaces
++ * GET /api/v1/registry/contracts contract catalog
++ * GET /api/v1/registry/topics event topics (Events, Wave 3)
++ * GET /contracts/<domain>/<name>.v<N>.json the schema at its $id URL
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const contractsPkg = require('openvibe-contracts/package.json');
++
++// Where each running service answers health checks from this host (manifests carry the public origin).
++const INTERNAL = {
++ network: 'http://127.0.0.1:4000', live: 'http://127.0.0.1:3000', media: 'http://127.0.0.1:4100',
++ tools: 'http://127.0.0.1:4001', games: 'http://127.0.0.1:8000', community: 'http://127.0.0.1:4200',
++ events: 'http://127.0.0.1:4300',
++};
++const POLL_MS = 60 * 1000;
++
++function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = globalThis.fetch, pollMs = POLL_MS } = {}) {
++ const internal = { ...INTERNAL, ...internalOverrides };
++ const health = new Map(); // id -> { status: up|down|unknown, http_status, latency_ms, checked_at }
++ let timer = null;
++
++ async function checkOne(m) {
++ const base = internal[m.id];
++ const path = m.ready || m.health;
++ if (m.status === 'placeholder' || !base || !path) {
++ health.set(m.id, { status: m.status === 'placeholder' ? 'not-running' : 'unknown', checked_at: new Date().toISOString() });
++ return;
++ }
++ const t0 = Date.now();
++ try {
++ const res = await fetchImpl(`${base}${path}`, { signal: AbortSignal.timeout(3000) });
++ health.set(m.id, { status: res.ok ? 'up' : 'down', http_status: res.status, latency_ms: Date.now() - t0, checked_at: new Date().toISOString() });
++ } catch (err) {
++ health.set(m.id, { status: 'down', error: err.name === 'TimeoutError' ? 'timeout' : 'unreachable', checked_at: new Date().toISOString() });
++ }
++ }
++ const pollAll = () => Promise.all(contracts.services.manifests.map(checkOne));
++ function start() {
++ if (timer) return;
++ const loop = async () => { try { await pollAll(); } catch { /* one bad poll never stops the loop */ } timer = setTimeout(loop, pollMs); if (timer.unref) timer.unref(); };
++ loop();
++ }
++ function stop() { if (timer) clearTimeout(timer); timer = null; }
++
++ const withHealth = (m) => ({ ...m, runtime: health.get(m.id) || { status: 'unknown', checked_at: null } });
++
++ function descriptor() {
++ return {
++ name: 'OpenVibe',
++ issuer,
++ jwks_uri: `${issuer}/api/.well-known/jwks`,
++ token_endpoint: `${issuer}/oauth/token`,
++ openid_configuration: `${issuer}/oauth/.well-known/openid-configuration`,
++ registry: `${issuer}/api/v1/registry`,
++ contracts: { package: 'openvibe-contracts', version: contractsPkg.version, repository: 'https://github.com/OpenVibers/OpenVibe.Contracts', catalog: `${issuer}/api/v1/registry/contracts` },
++ services: contracts.services.manifests.map(m => ({ id: m.id, status: m.status, origin: m.publicOrigin || null })),
++ };
++ }
++
++ function router() {
++ const r = express.Router();
++ const cache = (res, s = 60) => res.set('Cache-Control', `public, max-age=${s}`).set('Access-Control-Allow-Origin', '*');
++ const notFound = (res, code, detail) => contracts.http.sendProblem(res, 404, code, { detail });
++
++ r.get('/.well-known/openvibe', (_req, res) => cache(res, 300).json(descriptor()));
++ r.get('/api/v1/registry', (_req, res) => cache(res).json({ services: '/api/v1/registry/services', capabilities: '/api/v1/registry/capabilities', namespaces: '/api/v1/registry/namespaces', contracts: '/api/v1/registry/contracts', topics: '/api/v1/registry/topics', domains: '/api/v1/registry/domains/:domain' }));
++ r.get('/api/v1/registry/services', (req, res) => {
++ let list = contracts.services.manifests.map(withHealth);
++ if (req.query.status) list = list.filter(m => m.status === String(req.query.status));
++ cache(res, 30).json({ services: list, contracts_version: contractsPkg.version });
++ });
++ r.get('/api/v1/registry/services/:id', (req, res) => {
++ const m = contracts.services.get(req.params.id);
++ if (!m) return notFound(res, 'registry.unknown_service', `no service ${req.params.id}`);
++ cache(res, 30).json({ ...withHealth(m), capability_details: m.capabilities.map(id => contracts.capabilities.get(id)).filter(Boolean) });
++ });
++ r.get('/api/v1/registry/domains/:domain', (req, res) => {
++ const d = String(req.params.domain).toLowerCase();
++ const m = contracts.services.manifests.find(x => (x.domains || []).includes(d));
++ if (!m) return notFound(res, 'registry.unknown_domain', `no service claims ${d}`);
++ cache(res).json({ domain: d, service: withHealth(m) });
++ });
++ r.get('/api/v1/registry/capabilities', (req, res) => {
++ let list = contracts.capabilities.manifests;
++ if (req.query.owner) list = list.filter(c => c.owner === String(req.query.owner));
++ cache(res).json({ capabilities: list });
++ });
++ r.get('/api/v1/registry/capabilities/:id', (req, res) => {
++ const c = contracts.capabilities.get(req.params.id);
++ if (!c) return notFound(res, 'registry.unknown_capability', `no capability ${req.params.id}`);
++ cache(res).json(c);
++ });
++ r.get('/api/v1/registry/namespaces', (_req, res) => cache(res).json({ namespaces: contracts.modules.namespaces }));
++ r.get('/api/v1/registry/contracts', (_req, res) => cache(res).json({ version: contractsPkg.version, contracts: contracts.catalog.map(c => ({ ...c, $id: contracts.schema(c.id).$id })) }));
++ r.get('/api/v1/registry/topics', (_req, res) => cache(res).json({ topics: [], note: 'Durable event topics are published by OpenVibe.Events (roadmap Wave 3).' }));
++ r.get(/^\/contracts\/([a-z0-9-]+)\/([a-z0-9-]+\.v\d+)\.json$/, (req, res) => {
++ const file = `${req.params[0]}/${req.params[1]}.json`;
++ const entry = contracts.catalog.find(c => c.schema === file);
++ if (!entry) return notFound(res, 'registry.unknown_contract', `no contract ${file}`);
++ cache(res, 3600).type('application/schema+json').send(JSON.stringify(contracts.schema(entry.id), null, 2));
++ });
++ return r;
++ }
++
++ return { router, start, stop, pollAll, health, descriptor };
++}
++
++module.exports = { createEcosystemRegistry, INTERNAL };
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
nnew file mode 100644
iindex 00000000..2de5b7c0
---- /dev/null
++++ b/test/registry-ecosystem.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++// Ecosystem registry (server/registry/ecosystem.js): descriptor, services with timestamped health,
++// capability/namespace/contract listings, and schemas served at their $id URLs.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { createEcosystemRegistry } = require('../server/registry/ecosystem');
++
++(async () => {
++ const up = http.createServer((req, res) => { res.statusCode = req.url === '/api/health' ? 200 : 404; res.end('{}'); });
++ await new Promise(r => up.listen(0, '127.0.0.1', r));
++ const upUrl = `http://127.0.0.1:${up.address().port}`;
++ const eco = createEcosystemRegistry({ issuer: 'https://openvibe.network', internalOverrides: { network: upUrl, live: 'http://127.0.0.1:1', media: upUrl } });
++ await eco.pollAll();
++ const app = express();
++ app.use(eco.router());
++ const srv = http.createServer(app);
++ await new Promise(r => srv.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ const get = (p) => fetch(base + p).then(async r => ({ status: r.status, type: r.headers.get('content-type'), cors: r.headers.get('access-control-allow-origin'), body: await r.json() }));
++
++ let r = await get('/.well-known/openvibe');
++ assert.strictEqual(r.body.jwks_uri, 'https://openvibe.network/api/.well-known/jwks');
++ assert.ok(r.body.services.some(s => s.id === 'community'));
++ assert.strictEqual(r.cors, '*');
++
++ r = await get('/api/v1/registry/services');
++ const byId = Object.fromEntries(r.body.services.map(s => [s.id, s]));
++ assert.strictEqual(byId.network.runtime.status, 'up');
++ assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
++ assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
++ assert.strictEqual(byId.wiki.runtime.status, 'not-running', 'placeholders are never shown as running');
++ assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime'))).valid));
++ r = await get('/api/v1/registry/services?status=placeholder');
++ assert.ok(r.body.services.length >= 15 && r.body.services.every(s => s.status === 'placeholder'));
++
++ r = await get('/api/v1/registry/services/network');
++ assert.ok(r.body.capability_details.some(c => c.id === 'network.coins.credit'));
++ r = await get('/api/v1/registry/services/nope');
++ assert.strictEqual(r.status, 404); assert.strictEqual(r.body.code, 'registry.unknown_service');
++ r = await get('/api/v1/registry/domains/openvibe.community');
++ assert.strictEqual(r.body.service.id, 'community');
++ r = await get('/api/v1/registry/capabilities?owner=community');
++ assert.ok(r.body.capabilities.length >= 3 && r.body.capabilities.every(c => c.owner === 'community'));
++ r = await get('/api/v1/registry/namespaces');
++ assert.ok(r.body.namespaces.some(n => n.namespace === 'chat.tts_defaults'));
++
++ r = await get('/api/v1/registry/contracts');
++ const sub = r.body.contracts.find(c => c.id === 'identity.subject-ref');
++ const path = new URL(sub.$id).pathname;
++ r = await get(path);
++ assert.strictEqual(r.status, 200); assert.ok(r.type.startsWith('application/schema+json'));
++ assert.strictEqual(r.body.$id, sub.$id, 'the schema is served at its own $id');
++ r = await get('/contracts/identity/nope.v1.json');
++ assert.strictEqual(r.status, 404);
++
++ eco.stop(); srv.close(); up.close();
++ console.log('ecosystem registry: all checks passed');
++})().catch(err => { console.error(err); process.exit(1); });