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

OP OpenVibers committed 23 Sept 2026 · 7feff8ee53 · 5 files, +224 −11


Changes

ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 858d9d0..de43a0e 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -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 registry
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');
if (/(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''))) {
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex fb512c9..5c32899 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -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}
ddiff --git a/apps/gateway/server/registry/index.js b/apps/gateway/server/registry/index.js
iindex 26e68bf..a4af64f 100644
---- a/apps/gateway/server/registry/index.js
++++ b/apps/gateway/server/registry/index.js
@@@ -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 };
ddiff --git a/apps/gateway/server/registry/services.js b/apps/gateway/server/registry/services.js
nnew file mode 100644
iindex 0000000..af56ff0
---- /dev/null
++++ b/apps/gateway/server/registry/services.js
@@@ -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 };
ddiff --git a/apps/gateway/test/services-registry.test.js b/apps/gateway/test/services-registry.test.js
nnew file mode 100644
iindex 0000000..62673a1
---- /dev/null
++++ b/apps/gateway/test/services-registry.test.js
@@@ -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); });