openvibers/openvibe.tools Commits
The catalog resolves other services through Network's registry (GET /api/v1/registry/services, cached, last good kept, local list as fallback and labelled as such); placeholder and retired services are not linked
Changes
@@@ -126,7 +126,7 @@ async function requireAuth(req, res, next) {// ── Basic API ────────────────────────────────────────────────app.get('/api/health', (_req, res) => {-- res.json({ status: 'ok', service: 'openvibe-tools-gateway', version: '2.0.0' });++ res.json({ status: 'ok', service: 'openvibe-tools-gateway', version: '2.0.0', registry: registry.services.status() });});app.get('/api/brand', (_req, res) => res.json(BRAND));@@@ -256,7 +256,7 @@ app.use((req, res, next) => {if (isStaticPath(req.path)) return next();// Pastes live on openvibe.community. Old links keep their paste: /<slug> and /p/<slug> → /p/<slug> there// (the page used to drop the slug and show the index). Signed-in visitors go through its silent sign-in.-- const COMMUNITY = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');++ const COMMUNITY = (process.env.OV_COMMUNITY_URL || registry.services.origin('community', 'https://openvibe.community')).replace(/\/$/, ''); // Community's origin from Network's registryconst m = /^\/(?:p\/)?([A-Za-z0-9][A-Za-z0-9_-]{1,80})\/?$/.exec(req.path);const target = m && !['new', 'my', 'pastes'].includes(m[1]) ? `/p/${m[1]}` : (req.path === '/new' ? '/new' : req.path === '/my' ? '/my' : '/pastes');if (/(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''))) {@@@ -94,16 +94,24 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px@media (prefers-reduced-motion:reduce){.tool{transition:none}}${icons.CSS}`;--const NETWORK = [-- ['live', 'OpenVibe.Live', 'Live streams, clips and chat', 'https://openvibe.live/'],-- ['community', 'OpenVibe.Community', 'Pastes, posts and people', 'https://openvibe.community/'],-- ['games', 'OpenVibe.Games', 'Browser games', 'https://openvibe.games/'],-- ['media', 'OpenVibe.Media', 'VODs, clips and files', 'https://openvibe.media/'],-- ['network', 'OpenVibe.Network', 'One account for every site', 'https://openvibe.network/'],++// "Elsewhere on OpenVibe": which services, their names and origins come from Network's registry++// (registry.services); only the one-line descriptions are ours. A service the registry lists as a++// placeholder or retired is not linked.++const NETWORK_COPY = [++ ['live', 'Live streams, clips and chat'],++ ['community', 'Pastes, posts and people'],++ ['games', 'Browser games'],++ ['media', 'VODs, clips and files'],++ ['network', 'One account for every site'],];++function networkLinks() {++ const up = new Map(registry.services.linkable().map(s => [s.id, s]));++ return NETWORK_COPY.filter(([id]) => up.has(id)).map(([id, d]) => [id, up.get(id).name, d, `${up.get(id).origin}/`]);++}function shell({ head, body, families }) {// Every icon on the page once, as a sprite; the markup above only references them.++ const NETWORK = networkLinks();const spriteSvg = icons.sprite([..._used, ...NETWORK.map(n => n[0])]); _used = new Set();return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${head}@@@ -13,6 +13,7 @@const { FAMILIES } = require('./families');const { SATELLITE_COPY } = require('./copy-satellites');const { CATALOG: SEO } = require('../seo/catalog');++const { createServiceDirectory } = require('./services');const ZONE = 'openvibe.tools';const APEX = ZONE;@@@ -79,6 +80,10 @@ const PLANNED = [];const familyById = new Map(FAMILIES.map(f => [f.id, f]));++// Other services' origins come from Network's registry; a change rebuilds the catalog (and the page caches).++const services = createServiceDirectory({ onChange: () => build() });++// Tools that live on another service: where they are, by registry service id.++const EXTERNAL = { pastes: { service: 'community', path: '/pastes' } };let overrides = []; // [{ tool_id, host, role }]let overridesAt = 0;let built = null; // { tools, families, byHost, updated }@@@ -110,7 +115,8 @@ function build() {const tools = all.filter(t => !MERGED[t.id] || !all.some(x => x.id === MERGED[t.id].into)).map(t => {const fam = familyById.get(t.family);const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);-- const external = t.id === 'pastes' ? 'https://openvibe.community/pastes' : null;++ const ext = EXTERNAL[t.id];++ const external = ext ? `${services.origin(ext.service)}${ext.path}` : null;const folded = all.filter(x => MERGED[x.id] && MERGED[x.id].into === t.id);const alsoIn = [...new Set(folded.map(x => MERGED[x.id].alsoIn).filter(f => f && f !== t.family))];// Keywords from the folded build come along, so search finds the tool under either vocabulary.@@@ -161,6 +167,7 @@ function catalog() {const r = get();return {updated: r.updated,++ services: services.snapshot(),families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),planned: r.planned.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, icon: t.icon, status: 'planned' })),tools: r.tools.map(t => ({ id: t.id, family: t.family, alsoIn: t.alsoIn, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),@@@ -188,6 +195,6 @@ async function refresh(fetchImpl = fetch) {} catch { return false; /* keep the last good copy */ }}--function start() { refresh(); const t = setInterval(refresh, 60_000); t.unref(); return t; }++function start() { refresh(); services.start(); const t = setInterval(refresh, 60_000); t.unref(); return t; }--module.exports = { get, catalog, resolveHost, setOverrides, refresh, start, ZONE, APEX, FAMILIES };++module.exports = { get, catalog, resolveHost, setOverrides, refresh, start, services, ZONE, APEX, FAMILIES };nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Where the other OpenVibe services are — from OpenVibe.Network's registry++// (GET /api/v1/registry/services, roadmap Wave 2), not from knowledge written into this gateway.++//++// The registry answers with every service manifest (id, name, status, publicOrigin, domains) plus a++// health reading. It is fetched on boot and every 10 minutes; the last good answer is kept, and until++// one arrives (or when Network is unreachable) the small local list below is used and the catalog++// says so (`services.source: 'fallback'`). Placeholder and retired services are never linked.++// ═══════════════════════════════════════════════════════════════++++const REFRESH_MS = 10 * 60 * 1000;++const LINKABLE = new Set(['alpha', 'beta', 'stable', 'degraded']);++const ORIGIN_RE = /^https:\/\/[a-z0-9.-]+$/;++++// Used only until the registry answers, or when it cannot be reached.++const FALLBACK = [++ { id: 'network', name: 'OpenVibe.Network', status: 'stable', origin: 'https://openvibe.network' },++ { id: 'live', name: 'OpenVibe.Live', status: 'stable', origin: 'https://openvibe.live' },++ { id: 'community', name: 'OpenVibe.Community', status: 'alpha', origin: 'https://openvibe.community' },++ { id: 'games', name: 'OpenVibe.Games', status: 'stable', origin: 'https://openvibe.games' },++ { id: 'media', name: 'OpenVibe.Media', status: 'beta', origin: 'https://openvibe.media' },++ { id: 'tools', name: 'OpenVibe.Tools', status: 'stable', origin: 'https://openvibe.tools' },++];++++/** The registry as the public knows it (named in the catalog; the fetch itself may go to the internal URL). */++function publicRegistryUrl(env = process.env) {++ return `${(env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/registry/services`;++}++++function registryUrl(env = process.env) {++ if (env.OV_REGISTRY_URL) return env.OV_REGISTRY_URL;++ const base = (env.OV_NETWORK_INTERNAL_URL || env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/+$/, '');++ return `${base}/api/v1/registry/services`;++}++++/**++ * @param {object} [o]++ * @param {string} [o.url]++ * @param {Function} [o.fetchImpl]++ * @param {Function} [o.onChange] called after the list changes (the catalog rebuilds)++ */++function createServiceDirectory(o = {}) {++ const url = o.url || registryUrl();++ const fetchImpl = o.fetchImpl || ((...a) => globalThis.fetch(...a));++ let state = { source: 'fallback', as_of: null, error: null, services: FALLBACK.map(s => ({ ...s })) };++ let timer = null;++++ function normalise(list) {++ const out = [];++ for (const m of Array.isArray(list) ? list : []) {++ if (!m || typeof m.id !== 'string' || !/^[a-z][a-z0-9-]{1,39}$/.test(m.id)) continue;++ const origin = String(m.publicOrigin || '').replace(/\/+$/, '');++ out.push({++ id: m.id,++ name: typeof m.name === 'string' ? m.name.slice(0, 80) : m.id,++ status: typeof m.status === 'string' ? m.status : 'unknown',++ origin: ORIGIN_RE.test(origin) ? origin : null,++ });++ }++ return out;++ }++++ /** One fetch; keeps the last good list on any failure. → true when the registry answered. */++ async function refresh() {++ try {++ const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });++ if (!res.ok) throw new Error(`HTTP ${res.status}`);++ const body = await res.json();++ const services = normalise(body && body.services);++ if (!services.length) throw new Error('empty service list');++ const changed = JSON.stringify(services.map(s => [s.id, s.name, s.status, s.origin])) !== JSON.stringify(state.services.map(s => [s.id, s.name, s.status, s.origin])) || state.source !== 'registry';++ // as_of moves only when the list does, so cached pages and catalog.json stay stable between changes.++ state = { source: 'registry', as_of: changed ? new Date().toISOString() : state.as_of, error: null, checked_at: Date.now(), services };++ if (changed && o.onChange) o.onChange();++ return true;++ } catch (err) {++ state = { ...state, error: err.message };++ return false;++ }++ }++++ const get = (id) => state.services.find(s => s.id === id) || null;++ /** The public origin of a service, or `fallback` when the registry has none for it. */++ function origin(id, fallback = null) {++ const s = get(id);++ if (s && s.origin) return s.origin;++ const f = FALLBACK.find(x => x.id === id);++ return (f && f.origin) || fallback;++ }++ /** Services that may be linked as working products (not placeholders, not retired). */++ const linkable = () => state.services.filter(s => s.origin && LINKABLE.has(s.status));++++ /** For catalog.json: where the list came from, and the linkable-or-not facts about each service. */++ function snapshot() {++ return {++ source: state.source,++ registry: o.publicUrl || publicRegistryUrl(),++ as_of: state.as_of,++ services: state.services.filter(s => s.origin).map(s => ({ id: s.id, name: s.name, status: s.status, origin: s.origin })),++ };++ }++ /** For health checks: the last fetch's outcome. */++ const status = () => ({ source: state.source, as_of: state.as_of, last_ok: state.checked_at ? new Date(state.checked_at).toISOString() : null, last_error: state.error });++++ function start() {++ if (timer) return;++ refresh();++ timer = setInterval(refresh, o.refreshMs || REFRESH_MS);++ if (timer.unref) timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++++ return { refresh, origin, get, linkable, snapshot, status, start, stop, url };++}++++module.exports = { createServiceDirectory, registryUrl, publicRegistryUrl, FALLBACK };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++// The catalog resolves other services through Network's registry (GET /api/v1/registry/services):++// origins and names come from the registry, placeholders/retired services are not linked, the last++// good answer survives a failed refresh, and until the registry answers the local list is used and++// the catalog says so.++const assert = require('assert');++const { createServiceDirectory } = require('../server/registry/services');++++const manifest = (id, status, origin, name) => ({ id, name: name || `OpenVibe.${id}`, status, publicOrigin: origin, domains: [], runtime: { status: 'up', checked_at: '2026-09-22T00:00:00Z' } });++const answer = (services) => async () => ({ ok: true, status: 200, json: async () => ({ services, contracts_version: '0.7.0' }) });++const broken = async () => { throw new Error('connect ECONNREFUSED'); };++++(async () => {++ // ── The directory on its own ────────────────────────────++ let changes = 0;++ let fetchImpl = broken;++ const dir = createServiceDirectory({ url: 'http://registry.test/api/v1/registry/services', publicUrl: 'https://openvibe.network/api/v1/registry/services', fetchImpl: (...a) => fetchImpl(...a), onChange: () => { changes++; } });++ assert.strictEqual(dir.snapshot().source, 'fallback', 'before the registry answers: the local list, and it says so');++ assert.strictEqual(dir.origin('community'), 'https://openvibe.community');++ assert.strictEqual(await dir.refresh(), false);++ assert.strictEqual(dir.snapshot().source, 'fallback');++ assert.match(dir.status().last_error, /ECONNREFUSED/);++++ fetchImpl = answer([++ manifest('network', 'stable', 'https://openvibe.network'),++ manifest('community', 'alpha', 'https://community.example.org', 'OpenVibe.Community'),++ manifest('live', 'retired', 'https://openvibe.live'),++ manifest('wiki', 'placeholder', 'https://openvibe.wiki'),++ manifest('media', 'beta', 'javascript:alert(1)'),++ { id: 'BAD ID', publicOrigin: 'https://x.example' },++ ]);++ assert.strictEqual(await dir.refresh(), true);++ assert.strictEqual(changes, 1, 'a new list rebuilds the catalog');++ assert.strictEqual(dir.origin('community'), 'https://community.example.org', 'origins come from the registry');++ assert.deepStrictEqual(dir.linkable().map(s => s.id), ['network', 'community'], 'placeholders, retired services and bad origins are not linked');++ assert.strictEqual(dir.origin('media'), 'https://openvibe.media', 'a service without a usable origin falls back to the known one');++ const snap = dir.snapshot();++ assert.strictEqual(snap.source, 'registry');++ assert.strictEqual(snap.registry, 'https://openvibe.network/api/v1/registry/services', 'the catalog names the public registry, not the internal address');++ assert.ok(snap.as_of);++ assert.ok(!JSON.stringify(snap).includes('runtime'), 'health readings are not frozen into the cached catalog');++++ await dir.refresh();++ assert.strictEqual(changes, 1, 'the same list does not rebuild anything');++ const asOf = dir.snapshot().as_of;++ fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });++ assert.strictEqual(await dir.refresh(), false);++ assert.strictEqual(dir.origin('community'), 'https://community.example.org', 'a failed refresh keeps the last good list');++ assert.strictEqual(dir.snapshot().as_of, asOf);++ fetchImpl = answer([]);++ assert.strictEqual(await dir.refresh(), false, 'an empty list is not accepted');++++ // ── Wired into the gateway catalog and pages ────────────++ const realFetch = globalThis.fetch;++ globalThis.fetch = answer([++ manifest('network', 'stable', 'https://openvibe.network', 'OpenVibe.Network'),++ manifest('community', 'alpha', 'https://community.example.org', 'OpenVibe.Community'),++ manifest('games', 'stable', 'https://openvibe.games', 'OpenVibe.Games'),++ manifest('media', 'beta', 'https://openvibe.media', 'OpenVibe.Media'),++ manifest('live', 'placeholder', 'https://openvibe.live', 'OpenVibe.Live'),++ ]);++ try {++ const registry = require('../server/registry');++ const site = require('../server/pages/site');++ const before = registry.catalog();++ assert.strictEqual(before.services.source, 'fallback');++ assert.strictEqual(before.tools.find(t => t.id === 'pastes').url, 'https://openvibe.community/pastes');++ await registry.services.refresh();++ const after = registry.catalog();++ assert.strictEqual(after.services.source, 'registry');++ assert.notStrictEqual(after.updated, before.updated, 'the catalog was rebuilt');++ assert.strictEqual(after.tools.find(t => t.id === 'pastes').url, 'https://community.example.org/pastes', 'the pastes tool points where the registry says Community is');++ const html = site.renderNotFound();++ assert.ok(html.includes('href="https://community.example.org/"'), 'the network links use registry origins');++ assert.ok(!html.includes('href="https://openvibe.live/"'), 'a service the registry calls a placeholder is not linked');++ } finally {++ globalThis.fetch = realFetch;++ }++++ console.log('registry-driven catalog: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -126,7 +126,7 @@ async function requireAuth(req, res, next) {
@@ -126,7 +126,7 @@ async function requireAuth(req, res, next) {
// ── Basic API ────────────────────────────────────────────────
// ── Basic API ────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
app.get('/api/health', (_req, res) => {
- res.json({ status: 'ok', service: 'openvibe-tools-gateway', version: '2.0.0' });
+ res.json({ status: 'ok', service: 'openvibe-tools-gateway', version: '2.0.0', registry: registry.services.status() });
});
});
app.get('/api/brand', (_req, res) => res.json(BRAND));
app.get('/api/brand', (_req, res) => res.json(BRAND));
@@ -256,7 +256,7 @@ app.use((req, res, next) => {
@@ -256,7 +256,7 @@ app.use((req, res, next) => {
if (isStaticPath(req.path)) return next();
if (isStaticPath(req.path)) return next();
// Pastes live on openvibe.community. Old links keep their paste: /<slug> and /p/<slug> → /p/<slug> there
// Pastes live on openvibe.community. Old links keep their paste: /<slug> and /p/<slug> → /p/<slug> there
// (the page used to drop the slug and show the index). Signed-in visitors go through its silent sign-in.
// (the page used to drop the slug and show the index). Signed-in visitors go through its silent sign-in.
- const COMMUNITY = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
+ const COMMUNITY = (process.env.OV_COMMUNITY_URL || registry.services.origin('community', 'https://openvibe.community')).replace(/\/$/, ''); // Community's origin from Network's registry
const m = /^\/(?:p\/)?([A-Za-z0-9][A-Za-z0-9_-]{1,80})\/?$/.exec(req.path);
const m = /^\/(?:p\/)?([A-Za-z0-9][A-Za-z0-9_-]{1,80})\/?$/.exec(req.path);
const target = m && !['new', 'my', 'pastes'].includes(m[1]) ? `/p/${m[1]}` : (req.path === '/new' ? '/new' : req.path === '/my' ? '/my' : '/pastes');
const target = m && !['new', 'my', 'pastes'].includes(m[1]) ? `/p/${m[1]}` : (req.path === '/new' ? '/new' : req.path === '/my' ? '/my' : '/pastes');
if (/(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''))) {
if (/(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''))) {
@@ -94,16 +94,24 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
@@ -94,16 +94,24 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
@media (prefers-reduced-motion:reduce){.tool{transition:none}}
@media (prefers-reduced-motion:reduce){.tool{transition:none}}
${icons.CSS}`;
${icons.CSS}`;
-const NETWORK = [
- ['live', 'OpenVibe.Live', 'Live streams, clips and chat', 'https://openvibe.live/'],
- ['community', 'OpenVibe.Community', 'Pastes, posts and people', 'https://openvibe.community/'],
- ['games', 'OpenVibe.Games', 'Browser games', 'https://openvibe.games/'],
- ['media', 'OpenVibe.Media', 'VODs, clips and files', 'https://openvibe.media/'],
- ['network', 'OpenVibe.Network', 'One account for every site', 'https://openvibe.network/'],
+// "Elsewhere on OpenVibe": which services, their names and origins come from Network's registry
+// (registry.services); only the one-line descriptions are ours. A service the registry lists as a
+// placeholder or retired is not linked.
+const NETWORK_COPY = [
+ ['live', 'Live streams, clips and chat'],
+ ['community', 'Pastes, posts and people'],
+ ['games', 'Browser games'],
+ ['media', 'VODs, clips and files'],
+ ['network', 'One account for every site'],
];
];
+function networkLinks() {
+ const up = new Map(registry.services.linkable().map(s => [s.id, s]));
+ return NETWORK_COPY.filter(([id]) => up.has(id)).map(([id, d]) => [id, up.get(id).name, d, `${up.get(id).origin}/`]);
+}
function shell({ head, body, families }) {
function shell({ head, body, families }) {
// Every icon on the page once, as a sprite; the markup above only references them.
// Every icon on the page once, as a sprite; the markup above only references them.
+ const NETWORK = networkLinks();
const spriteSvg = icons.sprite([..._used, ...NETWORK.map(n => n[0])]); _used = new Set();
const spriteSvg = icons.sprite([..._used, ...NETWORK.map(n => n[0])]); _used = new Set();
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
${head}
${head}
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
const { FAMILIES } = require('./families');
const { FAMILIES } = require('./families');
const { SATELLITE_COPY } = require('./copy-satellites');
const { SATELLITE_COPY } = require('./copy-satellites');
const { CATALOG: SEO } = require('../seo/catalog');
const { CATALOG: SEO } = require('../seo/catalog');
+const { createServiceDirectory } = require('./services');
const ZONE = 'openvibe.tools';
const ZONE = 'openvibe.tools';
const APEX = ZONE;
const APEX = ZONE;
@@ -79,6 +80,10 @@ const PLANNED = [
@@ -79,6 +80,10 @@ const PLANNED = [
];
];
const familyById = new Map(FAMILIES.map(f => [f.id, f]));
const familyById = new Map(FAMILIES.map(f => [f.id, f]));
+// Other services' origins come from Network's registry; a change rebuilds the catalog (and the page caches).
+const services = createServiceDirectory({ onChange: () => build() });
+// Tools that live on another service: where they are, by registry service id.
+const EXTERNAL = { pastes: { service: 'community', path: '/pastes' } };
let overrides = []; // [{ tool_id, host, role }]
let overrides = []; // [{ tool_id, host, role }]
let overridesAt = 0;
let overridesAt = 0;
let built = null; // { tools, families, byHost, updated }
let built = null; // { tools, families, byHost, updated }
@@ -110,7 +115,8 @@ function build() {
@@ -110,7 +115,8 @@ function build() {
const tools = all.filter(t => !MERGED[t.id] || !all.some(x => x.id === MERGED[t.id].into)).map(t => {
const tools = all.filter(t => !MERGED[t.id] || !all.some(x => x.id === MERGED[t.id].into)).map(t => {
const fam = familyById.get(t.family);
const fam = familyById.get(t.family);
const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);
const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);
- const external = t.id === 'pastes' ? 'https://openvibe.community/pastes' : null;
+ const ext = EXTERNAL[t.id];
+ const external = ext ? `${services.origin(ext.service)}${ext.path}` : null;
const folded = all.filter(x => MERGED[x.id] && MERGED[x.id].into === t.id);
const folded = all.filter(x => MERGED[x.id] && MERGED[x.id].into === t.id);
const alsoIn = [...new Set(folded.map(x => MERGED[x.id].alsoIn).filter(f => f && f !== t.family))];
const alsoIn = [...new Set(folded.map(x => MERGED[x.id].alsoIn).filter(f => f && f !== t.family))];
// Keywords from the folded build come along, so search finds the tool under either vocabulary.
// Keywords from the folded build come along, so search finds the tool under either vocabulary.
@@ -161,6 +167,7 @@ function catalog() {
@@ -161,6 +167,7 @@ function catalog() {
const r = get();
const r = get();
return {
return {
updated: r.updated,
updated: r.updated,
+ services: services.snapshot(),
families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),
families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),
planned: r.planned.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, icon: t.icon, status: 'planned' })),
planned: r.planned.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, icon: t.icon, status: 'planned' })),
tools: r.tools.map(t => ({ id: t.id, family: t.family, alsoIn: t.alsoIn, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),
tools: r.tools.map(t => ({ id: t.id, family: t.family, alsoIn: t.alsoIn, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),
@@ -188,6 +195,6 @@ async function refresh(fetchImpl = fetch) {
@@ -188,6 +195,6 @@ async function refresh(fetchImpl = fetch) {
} catch { return false; /* keep the last good copy */ }
} catch { return false; /* keep the last good copy */ }
}
}
-function start() { refresh(); const t = setInterval(refresh, 60_000); t.unref(); return t; }
+function start() { refresh(); services.start(); const t = setInterval(refresh, 60_000); t.unref(); return t; }
-module.exports = { get, catalog, resolveHost, setOverrides, refresh, start, ZONE, APEX, FAMILIES };
+module.exports = { get, catalog, resolveHost, setOverrides, refresh, start, services, ZONE, APEX, FAMILIES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Where the other OpenVibe services are — from OpenVibe.Network's registry
+// (GET /api/v1/registry/services, roadmap Wave 2), not from knowledge written into this gateway.
+//
+// The registry answers with every service manifest (id, name, status, publicOrigin, domains) plus a
+// health reading. It is fetched on boot and every 10 minutes; the last good answer is kept, and until
+// one arrives (or when Network is unreachable) the small local list below is used and the catalog
+// says so (`services.source: 'fallback'`). Placeholder and retired services are never linked.
+// ═══════════════════════════════════════════════════════════════
+
+const REFRESH_MS = 10 * 60 * 1000;
+const LINKABLE = new Set(['alpha', 'beta', 'stable', 'degraded']);
+const ORIGIN_RE = /^https:\/\/[a-z0-9.-]+$/;
+
+// Used only until the registry answers, or when it cannot be reached.
+const FALLBACK = [
+ { id: 'network', name: 'OpenVibe.Network', status: 'stable', origin: 'https://openvibe.network' },
+ { id: 'live', name: 'OpenVibe.Live', status: 'stable', origin: 'https://openvibe.live' },
+ { id: 'community', name: 'OpenVibe.Community', status: 'alpha', origin: 'https://openvibe.community' },
+ { id: 'games', name: 'OpenVibe.Games', status: 'stable', origin: 'https://openvibe.games' },
+ { id: 'media', name: 'OpenVibe.Media', status: 'beta', origin: 'https://openvibe.media' },
+ { id: 'tools', name: 'OpenVibe.Tools', status: 'stable', origin: 'https://openvibe.tools' },
+];
+
+/** The registry as the public knows it (named in the catalog; the fetch itself may go to the internal URL). */
+function publicRegistryUrl(env = process.env) {
+ return `${(env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/registry/services`;
+}
+
+function registryUrl(env = process.env) {
+ if (env.OV_REGISTRY_URL) return env.OV_REGISTRY_URL;
+ const base = (env.OV_NETWORK_INTERNAL_URL || env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/+$/, '');
+ return `${base}/api/v1/registry/services`;
+}
+
+/**
+ * @param {object} [o]
+ * @param {string} [o.url]
+ * @param {Function} [o.fetchImpl]
+ * @param {Function} [o.onChange] called after the list changes (the catalog rebuilds)
+ */
+function createServiceDirectory(o = {}) {
+ const url = o.url || registryUrl();
+ const fetchImpl = o.fetchImpl || ((...a) => globalThis.fetch(...a));
+ let state = { source: 'fallback', as_of: null, error: null, services: FALLBACK.map(s => ({ ...s })) };
+ let timer = null;
+
+ function normalise(list) {
+ const out = [];
+ for (const m of Array.isArray(list) ? list : []) {
+ if (!m || typeof m.id !== 'string' || !/^[a-z][a-z0-9-]{1,39}$/.test(m.id)) continue;
+ const origin = String(m.publicOrigin || '').replace(/\/+$/, '');
+ out.push({
+ id: m.id,
+ name: typeof m.name === 'string' ? m.name.slice(0, 80) : m.id,
+ status: typeof m.status === 'string' ? m.status : 'unknown',
+ origin: ORIGIN_RE.test(origin) ? origin : null,
+ });
+ }
+ return out;
+ }
+
+ /** One fetch; keeps the last good list on any failure. → true when the registry answered. */
+ async function refresh() {
+ try {
+ const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = await res.json();
+ const services = normalise(body && body.services);
+ if (!services.length) throw new Error('empty service list');
+ const changed = JSON.stringify(services.map(s => [s.id, s.name, s.status, s.origin])) !== JSON.stringify(state.services.map(s => [s.id, s.name, s.status, s.origin])) || state.source !== 'registry';
+ // as_of moves only when the list does, so cached pages and catalog.json stay stable between changes.
+ state = { source: 'registry', as_of: changed ? new Date().toISOString() : state.as_of, error: null, checked_at: Date.now(), services };
+ if (changed && o.onChange) o.onChange();
+ return true;
+ } catch (err) {
+ state = { ...state, error: err.message };
+ return false;
+ }
+ }
+
+ const get = (id) => state.services.find(s => s.id === id) || null;
+ /** The public origin of a service, or `fallback` when the registry has none for it. */
+ function origin(id, fallback = null) {
+ const s = get(id);
+ if (s && s.origin) return s.origin;
+ const f = FALLBACK.find(x => x.id === id);
+ return (f && f.origin) || fallback;
+ }
+ /** Services that may be linked as working products (not placeholders, not retired). */
+ const linkable = () => state.services.filter(s => s.origin && LINKABLE.has(s.status));
+
+ /** For catalog.json: where the list came from, and the linkable-or-not facts about each service. */
+ function snapshot() {
+ return {
+ source: state.source,
+ registry: o.publicUrl || publicRegistryUrl(),
+ as_of: state.as_of,
+ services: state.services.filter(s => s.origin).map(s => ({ id: s.id, name: s.name, status: s.status, origin: s.origin })),
+ };
+ }
+ /** For health checks: the last fetch's outcome. */
+ const status = () => ({ source: state.source, as_of: state.as_of, last_ok: state.checked_at ? new Date(state.checked_at).toISOString() : null, last_error: state.error });
+
+ function start() {
+ if (timer) return;
+ refresh();
+ timer = setInterval(refresh, o.refreshMs || REFRESH_MS);
+ if (timer.unref) timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+
+ return { refresh, origin, get, linkable, snapshot, status, start, stop, url };
+}
+
+module.exports = { createServiceDirectory, registryUrl, publicRegistryUrl, FALLBACK };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+// The catalog resolves other services through Network's registry (GET /api/v1/registry/services):
+// origins and names come from the registry, placeholders/retired services are not linked, the last
+// good answer survives a failed refresh, and until the registry answers the local list is used and
+// the catalog says so.
+const assert = require('assert');
+const { createServiceDirectory } = require('../server/registry/services');
+
+const manifest = (id, status, origin, name) => ({ id, name: name || `OpenVibe.${id}`, status, publicOrigin: origin, domains: [], runtime: { status: 'up', checked_at: '2026-09-22T00:00:00Z' } });
+const answer = (services) => async () => ({ ok: true, status: 200, json: async () => ({ services, contracts_version: '0.7.0' }) });
+const broken = async () => { throw new Error('connect ECONNREFUSED'); };
+
+(async () => {
+ // ── The directory on its own ────────────────────────────
+ let changes = 0;
+ let fetchImpl = broken;
+ const dir = createServiceDirectory({ url: 'http://registry.test/api/v1/registry/services', publicUrl: 'https://openvibe.network/api/v1/registry/services', fetchImpl: (...a) => fetchImpl(...a), onChange: () => { changes++; } });
+ assert.strictEqual(dir.snapshot().source, 'fallback', 'before the registry answers: the local list, and it says so');
+ assert.strictEqual(dir.origin('community'), 'https://openvibe.community');
+ assert.strictEqual(await dir.refresh(), false);
+ assert.strictEqual(dir.snapshot().source, 'fallback');
+ assert.match(dir.status().last_error, /ECONNREFUSED/);
+
+ fetchImpl = answer([
+ manifest('network', 'stable', 'https://openvibe.network'),
+ manifest('community', 'alpha', 'https://community.example.org', 'OpenVibe.Community'),
+ manifest('live', 'retired', 'https://openvibe.live'),
+ manifest('wiki', 'placeholder', 'https://openvibe.wiki'),
+ manifest('media', 'beta', 'javascript:alert(1)'),
+ { id: 'BAD ID', publicOrigin: 'https://x.example' },
+ ]);
+ assert.strictEqual(await dir.refresh(), true);
+ assert.strictEqual(changes, 1, 'a new list rebuilds the catalog');
+ assert.strictEqual(dir.origin('community'), 'https://community.example.org', 'origins come from the registry');
+ assert.deepStrictEqual(dir.linkable().map(s => s.id), ['network', 'community'], 'placeholders, retired services and bad origins are not linked');
+ assert.strictEqual(dir.origin('media'), 'https://openvibe.media', 'a service without a usable origin falls back to the known one');
+ const snap = dir.snapshot();
+ assert.strictEqual(snap.source, 'registry');
+ assert.strictEqual(snap.registry, 'https://openvibe.network/api/v1/registry/services', 'the catalog names the public registry, not the internal address');
+ assert.ok(snap.as_of);
+ assert.ok(!JSON.stringify(snap).includes('runtime'), 'health readings are not frozen into the cached catalog');
+
+ await dir.refresh();
+ assert.strictEqual(changes, 1, 'the same list does not rebuild anything');
+ const asOf = dir.snapshot().as_of;
+ fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
+ assert.strictEqual(await dir.refresh(), false);
+ assert.strictEqual(dir.origin('community'), 'https://community.example.org', 'a failed refresh keeps the last good list');
+ assert.strictEqual(dir.snapshot().as_of, asOf);
+ fetchImpl = answer([]);
+ assert.strictEqual(await dir.refresh(), false, 'an empty list is not accepted');
+
+ // ── Wired into the gateway catalog and pages ────────────
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = answer([
+ manifest('network', 'stable', 'https://openvibe.network', 'OpenVibe.Network'),
+ manifest('community', 'alpha', 'https://community.example.org', 'OpenVibe.Community'),
+ manifest('games', 'stable', 'https://openvibe.games', 'OpenVibe.Games'),
+ manifest('media', 'beta', 'https://openvibe.media', 'OpenVibe.Media'),
+ manifest('live', 'placeholder', 'https://openvibe.live', 'OpenVibe.Live'),
+ ]);
+ try {
+ const registry = require('../server/registry');
+ const site = require('../server/pages/site');
+ const before = registry.catalog();
+ assert.strictEqual(before.services.source, 'fallback');
+ assert.strictEqual(before.tools.find(t => t.id === 'pastes').url, 'https://openvibe.community/pastes');
+ await registry.services.refresh();
+ const after = registry.catalog();
+ assert.strictEqual(after.services.source, 'registry');
+ assert.notStrictEqual(after.updated, before.updated, 'the catalog was rebuilt');
+ assert.strictEqual(after.tools.find(t => t.id === 'pastes').url, 'https://community.example.org/pastes', 'the pastes tool points where the registry says Community is');
+ const html = site.renderNotFound();
+ assert.ok(html.includes('href="https://community.example.org/"'), 'the network links use registry origins');
+ assert.ok(!html.includes('href="https://openvibe.live/"'), 'a service the registry calls a placeholder is not linked');
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+
+ console.log('registry-driven catalog: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });