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/...)
Changes
@@@ -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"},@@@ -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')) {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -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 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -353,6 +353,11 @@ const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publi
@@ -353,6 +353,11 @@ const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publi
// Serves BOTH the standard JWKS `keys` array (RFC 7517 — Media and other
// Serves BOTH the standard JWKS `keys` array (RFC 7517 — Media and other
// spec-compliant consumers) and the legacy `public_key` PEM shape that the
// spec-compliant consumers) and the legacy `public_key` PEM shape that the
// inherited service clients read.
// 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) => {
app.get('/api/.well-known/jwks', (_req, res) => {
const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };
const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };
if (publicKey.includes('BEGIN')) {
if (publicKey.includes('BEGIN')) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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); });