openvibers/openvibe.network Commits

Registry (roadmap W2 D4, §5.1, §5.3): /api/v1/registry/categories and /featured answered 404. categories[/:id] groups every service by what it is, each with its rule, from Network's exposure overlay and site list: site, platform, library, repository, planned. featured lists the open sites whose last check (not stale) was up or degraded, in the navigation's usage order (7-day page views plus 14-day signed-in history, the chrome service's ranking, now exposed as ranking()), with ranked_at and stale; the hub is not listed and nothing is picked by hand. Hand-kept maps: site hosts now come from each manifest's publicOrigin (only chat, whose manifest has none, names its host; the derived list is identical); READY_PATHS drops tools, whose manifest carries ready, and a test keeps only entries whose manifest lacks one; INTERNAL takes OV_<ID>_INTERNAL_URL overrides (loopback URLs only, others ignored and logged). What cannot come from the manifests yet has a TODO with its plan section: INTERNAL (no internal address in service-manifest@1), EXPOSURE (the override half: what a domain serves today), the sites' presentation fields. Tests: registry-ecosystem (categories, featured, READY_PATHS, env overrides, hosts), chrome (ranking), unknown-paths (both routes answer on the real server)

OP OpenVibers committed 24 Sept 2026 · 9e64f600ec · 9 files, +222 −32


Changes

ddiff --git a/README.md b/README.md
iindex 939f406b..563f1f0e 100644
---- a/README.md
++++ b/README.md
@@@ -420,6 +420,15 @@ Google-style account management supporting up to 5 accounts:
openvibe-contracts 0.32.0 lists Live's, Media's and Network's), and Network's consumed topics come
from its own Events consumer.
Payload schemas are served at their `$id` (`/contracts/events/payloads/<type>.v1.json`).
++- `GET /api/v1/registry/categories[/:id]` groups every service by what it is, each with its rule:
++ `site` (in the network's site list, `server/chrome/sites.js`), `platform` (runs, but other services call
++ it), `library`, `repository`, `planned`. `GET /api/v1/registry/featured` lists the open sites whose last
++ check was up or degraded, in the navigation's usage order (7-day page views plus 14-day signed-in
++ history, recounted every 30 minutes), with `ranked_at` and `stale`; the hub is not listed and nothing
++ is featured by hand.
++- Loopback addresses for the health poll: the built-in ports in `server/registry/ecosystem.js`, each
++ overridable by `OV_<ID>_INTERNAL_URL` (loopback URLs only; anything else is ignored and logged). Site
++ hosts come from each manifest's `publicOrigin`.
- `node scripts/contracts-drift.js` warns about services that pin an older openvibe-contracts than
the latest tag, or a tag that was never published (`--dir ~/OpenVibers` for local checkouts,
`--registry https://openvibe.network` for what is running, `--package all` for sdk and shared too).
ddiff --git a/server/chrome/service.js b/server/chrome/service.js
iindex bb838e90..6babdaff 100644
---- a/server/chrome/service.js
++++ b/server/chrome/service.js
@@@ -67,7 +67,9 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
const load = (k) => { try { const r = db.prepare('SELECT value, updated_at FROM chrome_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };
const save = (k, v) => db.prepare('INSERT INTO chrome_cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(k, JSON.stringify(v));
-- let rank = (load('rank') || {}).value || { scores: {}, tools: [] };
++ const savedRank = load('rank') || {};
++ let rank = savedRank.value || { scores: {}, tools: [] };
++ let rankedAt = savedRank.value ? savedRank.at || null : null;
let copy = (load('copy') || {}).value || { sites: {} };
let version = Date.now();
@@@ -104,7 +106,7 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }
} catch { /* table appears on first history write */ }
rank = { scores, tools };
-- save('rank', rank); version = Date.now();
++ save('rank', rank); version = Date.now(); rankedAt = Date.now();
}
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
@@@ -205,7 +207,12 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
}
-- return { router, start, refreshRank, refreshCopy, payloadFor, _state: () => ({ rank, copy }) };
++ /** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */
++ function ranking() {
++ return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
++ }
++
++ return { router, start, refreshRank, refreshCopy, payloadFor, ranking, _state: () => ({ rank, copy }) };
}
module.exports = { createChromeService, BANNED };
ddiff --git a/server/chrome/sites.js b/server/chrome/sites.js
iindex e2a6e74d..e9681fde 100644
---- a/server/chrome/sites.js
++++ b/server/chrome/sites.js
@@@ -1,4 +1,5 @@
'use strict';
++const contracts = require('openvibe-contracts');
const { exposureOf } = require('../registry/exposure');
// Every OpenVibe property: what it is, whether it is open, and which legal documents apply.
// legal.profile decides the wording of /terms, /privacy and /dmca on that site (openvibe-shared/legal):
@@@ -9,30 +10,39 @@ const { exposureOf } = require('../registry/exposure');
// A site is 'open' (in the nav) only when its service's public domain serves the service itself, per
// Network's exposure overlay (server/registry/exposure.js); every other site is 'soon' and says why
// (state: internal = runs on loopback only, placeholder = planned). The two lists cannot disagree.
++// host comes from the service's manifest (openvibe-contracts publicOrigin); an entry names its host only
++// while its manifest has no public origin (chat). TODO(plan §5.1/§5.3): name, icon, tagline, what and the
++// legal profile are presentation with no manifest field yet; they move when the manifest schema has them.
const RAW_SITES = [
-- { id: 'live', name: 'Live', host: 'openvibe.live', icon: 'live', service: 'live', profile: 'streaming', tagline: 'Streams, clips and chat', what: 'Live streaming from a browser, OBS or a phone, with restreaming, clips, VODs, chat games and channel points.' },
-- { id: 'tools', name: 'Tools', host: 'openvibe.tools', icon: 'tools', service: 'tools', profile: 'tools', tagline: 'Every online tool', what: 'More than 160 online tools: converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own address.' },
-- { id: 'community', name: 'Community', host: 'openvibe.community', icon: 'community', service: 'community', profile: 'ugc', tagline: 'Pastes and posts', what: 'Share text and code with a link, comment, and follow what people on OpenVibe are making.' },
-- { id: 'games', name: 'Games', host: 'openvibe.games', icon: 'games', service: 'games', profile: 'games', tagline: 'Browser games', what: 'Scraplandia, a shared pixel canvas and other games that run in a tab and know your OpenVibe account.' },
-- { id: 'media', name: 'Media', host: 'openvibe.media', icon: 'media', service: 'media', profile: 'hosting', tagline: 'VODs, clips, files', what: 'The public media library behind every site: recorded streams, clips, images and uploads.' },
-- { id: 'network', name: 'Network', host: 'openvibe.network', icon: 'network', service: 'network', profile: 'account', tagline: 'Account and themes', what: 'The account behind every site: one sign-in, themes, notifications, history and linked services.' },
++ { id: 'live', name: 'Live', icon: 'live', service: 'live', profile: 'streaming', tagline: 'Streams, clips and chat', what: 'Live streaming from a browser, OBS or a phone, with restreaming, clips, VODs, chat games and channel points.' },
++ { id: 'tools', name: 'Tools', icon: 'tools', service: 'tools', profile: 'tools', tagline: 'Every online tool', what: 'More than 160 online tools: converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own address.' },
++ { id: 'community', name: 'Community', icon: 'community', service: 'community', profile: 'ugc', tagline: 'Pastes and posts', what: 'Share text and code with a link, comment, and follow what people on OpenVibe are making.' },
++ { id: 'games', name: 'Games', icon: 'games', service: 'games', profile: 'games', tagline: 'Browser games', what: 'Scraplandia, a shared pixel canvas and other games that run in a tab and know your OpenVibe account.' },
++ { id: 'media', name: 'Media', icon: 'media', service: 'media', profile: 'hosting', tagline: 'VODs, clips, files', what: 'The public media library behind every site: recorded streams, clips, images and uploads.' },
++ { id: 'network', name: 'Network', icon: 'network', service: 'network', profile: 'account', tagline: 'Account and themes', what: 'The account behind every site: one sign-in, themes, notifications, history and linked services.' },
{ id: 'chat', name: 'Chat', host: 'openvibe.chat', icon: 'chat', service: 'chat', profile: 'ugc', tagline: 'One chat for the network', what: 'Rooms, stream chat, direct messages, calls and text-to-speech under one identity.' },
-- { id: 'codes', name: 'Codes', host: 'openvibe.codes', icon: 'codes', service: 'codes', profile: 'ugc', tagline: 'Build on OpenVibe', what: 'The developer portal: API and SDK docs, the service and capability registry, event schemas, credentials, playgrounds and mod publishing.' },
-- { id: 'blog', name: 'Blog', host: 'openvibe.blog', icon: 'blog', service: 'blog', profile: 'info', tagline: 'Long-form, yours to keep', what: 'The official OpenVibe blog and a blog for every member: drafts, scheduling, media, comments and feeds.' },
-- { id: 'wiki', name: 'Wiki', host: 'openvibe.wiki', icon: 'wiki', service: 'wiki', profile: 'ugc', tagline: 'Knowledge, with sources', what: 'Wiki spaces with page trees, revision history, citations and discussion, editable together.' },
-- { id: 'news', name: 'News', host: 'openvibe.news', icon: 'news', service: 'news', profile: 'info', tagline: 'Sourced, not spun', what: 'Source-backed stories: clustered coverage, cited summaries, multiple perspectives and timelines.' },
-- { id: 'reviews', name: 'Reviews', host: 'openvibe.reviews', icon: 'reviews', service: 'reviews', profile: 'ugc', tagline: 'Reviews with receipts', what: 'Review signals gathered across sources with provenance, pros and cons, and community discussion. No invented ratings.' },
-- { id: 'tips', name: 'Tips', host: 'openvibe.tips', icon: 'tips', service: 'tips', profile: 'streaming', tagline: 'Support creators', what: 'Tips, goals, paid messages and alerts for creators, settled by the network\'s billing ledger.' },
-- { id: 'vip', name: 'VIP', host: 'openvibe.vip', icon: 'vip', service: 'vip', profile: 'account', tagline: 'Memberships and perks', what: 'Creator and network memberships: plans, perks and benefits recognised on every OpenVibe site.' },
-- { id: 'trade', name: 'Trade', host: 'openvibe.trade', icon: 'trade', service: 'trade', profile: 'ugc', tagline: 'Watchlists and alerts', what: 'Informational watchlists, sourced market context and alerts. No custody, no order execution.' },
-- { id: 'host', name: 'Host', host: 'openvibe.host', icon: 'host', service: 'host', profile: 'hosting', tagline: 'Deploy and host', what: 'The network\'s deployment plane first, then simple hosting for community sites, bots and mods.' },
-- { id: 'deals', name: 'Deals', host: 'openvibe.deals', icon: 'deals', service: 'deals', profile: 'info', tagline: 'Deals worth sharing', what: 'Deals submitted and voted on by the community, with source, price and freshness always shown.' },
-- { id: 'coupons', name: 'Coupons', host: 'openvibe.coupons', icon: 'coupons', service: 'coupons', profile: 'info', tagline: 'Codes that work', what: 'Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.' },
-- { id: 'stream', name: 'OpenRe.Stream', host: 'openre.stream', icon: 'stream', service: 'openre', profile: 'streaming', tagline: 'Restream anywhere', what: 'Ingest once and send the stream to every platform at once; keys, destinations and output health in one place.' },
++ { id: 'codes', name: 'Codes', icon: 'codes', service: 'codes', profile: 'ugc', tagline: 'Build on OpenVibe', what: 'The developer portal: API and SDK docs, the service and capability registry, event schemas, credentials, playgrounds and mod publishing.' },
++ { id: 'blog', name: 'Blog', icon: 'blog', service: 'blog', profile: 'info', tagline: 'Long-form, yours to keep', what: 'The official OpenVibe blog and a blog for every member: drafts, scheduling, media, comments and feeds.' },
++ { id: 'wiki', name: 'Wiki', icon: 'wiki', service: 'wiki', profile: 'ugc', tagline: 'Knowledge, with sources', what: 'Wiki spaces with page trees, revision history, citations and discussion, editable together.' },
++ { id: 'news', name: 'News', icon: 'news', service: 'news', profile: 'info', tagline: 'Sourced, not spun', what: 'Source-backed stories: clustered coverage, cited summaries, multiple perspectives and timelines.' },
++ { id: 'reviews', name: 'Reviews', icon: 'reviews', service: 'reviews', profile: 'ugc', tagline: 'Reviews with receipts', what: 'Review signals gathered across sources with provenance, pros and cons, and community discussion. No invented ratings.' },
++ { id: 'tips', name: 'Tips', icon: 'tips', service: 'tips', profile: 'streaming', tagline: 'Support creators', what: 'Tips, goals, paid messages and alerts for creators, settled by the network\'s billing ledger.' },
++ { id: 'vip', name: 'VIP', icon: 'vip', service: 'vip', profile: 'account', tagline: 'Memberships and perks', what: 'Creator and network memberships: plans, perks and benefits recognised on every OpenVibe site.' },
++ { id: 'trade', name: 'Trade', icon: 'trade', service: 'trade', profile: 'ugc', tagline: 'Watchlists and alerts', what: 'Informational watchlists, sourced market context and alerts. No custody, no order execution.' },
++ { id: 'host', name: 'Host', icon: 'host', service: 'host', profile: 'hosting', tagline: 'Deploy and host', what: 'The network\'s deployment plane first, then simple hosting for community sites, bots and mods.' },
++ { id: 'deals', name: 'Deals', icon: 'deals', service: 'deals', profile: 'info', tagline: 'Deals worth sharing', what: 'Deals submitted and voted on by the community, with source, price and freshness always shown.' },
++ { id: 'coupons', name: 'Coupons', icon: 'coupons', service: 'coupons', profile: 'info', tagline: 'Codes that work', what: 'Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.' },
++ { id: 'stream', name: 'OpenRe.Stream', icon: 'stream', service: 'openre', profile: 'streaming', tagline: 'Restream anywhere', what: 'Ingest once and send the stream to every platform at once; keys, destinations and output health in one place.' },
];
++function manifestHost(service) {
++ const m = contracts.services.get(service);
++ try { return m && m.publicOrigin ? new URL(m.publicOrigin).hostname : null; } catch { return null; }
++}
const SITES = RAW_SITES.map((s) => {
const e = exposureOf(s.service);
-- return { ...s, status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
++ const host = manifestHost(s.service) || s.host;
++ if (!host) throw new Error(`site ${s.id}: no host (its manifest has no publicOrigin and the entry names none)`);
++ return { ...s, host, status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
});
const byHost = new Map(SITES.map(s => [s.host, s]));
ddiff --git a/server/index.js b/server/index.js
iindex b1d06b2a..f2bf070d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -383,7 +383,10 @@ const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publi
// 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 });
++// Loopback addresses: the built-in ports, overridden by OV_<ID>_INTERNAL_URL (loopback URLs only).
++const ecosystemInternal = require('./registry/ecosystem').internalFromEnv(process.env);
++if (ecosystemInternal.ignored.length) console.warn(`[Registry] ignored (not a loopback URL): ${ecosystemInternal.ignored.join(', ')}`);
++const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issuer: config.jwt.issuer, internalOverrides: ecosystemInternal.overrides });
app.use(ecosystem.router());
ecosystem.start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
@@@ -491,6 +494,8 @@ app.locals.avatarService = avatarService;
const chromeService = require('./chrome/service').createChromeService(db, config, analytics, { privateKey, issuer: config.jwt.issuer });
app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);
chromeService.start();
++// /api/v1/registry/featured follows the navigation's usage ranking.
++ecosystem.setRanking(() => chromeService.ranking());
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex 13ba94d1..95580d97 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -24,6 +24,10 @@
* current releases, with contracts/sdk/shared drift
* GET /api/v1/registry/health one line per service: status, checked_at
* GET /api/v1/registry/search?q= services, capabilities, topics and contracts by text
++ * GET /api/v1/registry/categories[/:id] services grouped by what they are (site, platform,
++ * library, repository, planned), each rule stated
++ * GET /api/v1/registry/featured public sites that are up, most used first (the navbar's
++ * usage ranking), with when that use was counted
* GET /contracts/<domain>/<name>.v<N>.json the schema at its $id URL
*/
const express = require('express');
@@@ -32,8 +36,13 @@ const contractsPkg = require('openvibe-contracts/package.json');
const { exposureOf, publicOriginOf, libraries } = require('./exposure');
const { buildTopics, eventsOf } = require('./topics');
const { driftOf } = require('./versions');
++const { SITES } = require('../chrome/sites');
// Where each running service answers health checks from this host (manifests carry the public origin).
++// TODO(plan §5.1 "contract manifests plus validated environment overrides"): registry.service-manifest@1 has
++// no internal address, so these loopback ports stay here until Contracts adds one (or Network reads
++// OpenVibe.Host's host.json). Until then OV_<ID>_INTERNAL_URL overrides any of them (internalFromEnv below),
++// validated as a loopback http(s) URL, which is the environment half of that rule.
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',
@@@ -45,10 +54,12 @@ const INTERNAL = {
coupons: 'http://127.0.0.1:4850', trade: 'http://127.0.0.1:4860', codes: 'http://127.0.0.1:4900', host: 'http://127.0.0.1:4910',
};
const POLL_MS = 60 * 1000;
--// Readiness paths of services whose manifest (openvibe-contracts) does not carry `ready` yet. Each
--// answers with openvibe-shared/ready's shape; until a service deploys it, its health path is used
--// and the row says so (basis: 'health').
--const READY_PATHS = { network: '/api/ready', media: '/api/ready', tools: '/api/ready' };
++// Readiness paths of services whose manifest (openvibe-contracts) does not carry `ready` yet; a manifest's
++// own `ready` always wins. Each answers with openvibe-shared/ready's shape; until a service deploys it, its
++// health path is used and the row says so (basis: 'health'). Only entries whose manifest still lacks `ready`
++// may stay (test/registry-ecosystem.test.js): tools left when its manifest gained one. TODO(contracts): add
++// `ready: /api/ready` to the network and media manifests, then this map is empty.
++const READY_PATHS = { network: '/api/ready', media: '/api/ready' };
// Liveness paths of services whose manifest in the pinned openvibe-contracts has none (none since
// 0.30, where AI's manifest carries /api/health and /api/ready).
const HEALTH_PATHS = {};
@@@ -79,8 +90,45 @@ function readySummary(body) {
return { ready: body.ready, status: String(body.status || (body.ready ? 'ready' : 'not_ready')).slice(0, 20), checked_at: typeof body.checked_at === 'string' ? body.checked_at.slice(0, 40) : null, failed: names(body.failed), degraded: names(body.degraded), checks };
}
--function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = globalThis.fetch, pollMs = POLL_MS, readyPaths = READY_PATHS, healthPaths = HEALTH_PATHS, now = () => Date.now() } = {}) {
++/**
++ * OV_<ID>_INTERNAL_URL (e.g. OV_EVENTS_INTERNAL_URL, OV_AI_INTERNAL_URL) for each manifest id, when it is a
++ * loopback http(s) URL: health is polled on this host only. Anything else is ignored (and reported).
++ */
++function internalFromEnv(env = process.env) {
++ const out = {}; const ignored = [];
++ for (const m of contracts.services.manifests) {
++ const name = `OV_${m.id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_INTERNAL_URL`;
++ const v = env[name];
++ if (!v) continue;
++ try {
++ const u = new URL(String(v));
++ if ((u.protocol === 'http:' || u.protocol === 'https:') && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)) { out[m.id] = `${u.protocol}//${u.host}`; continue; }
++ } catch { /* fall through */ }
++ ignored.push(name);
++ }
++ return { overrides: out, ignored };
++}
++
++// What each category means; categoryOf() applies it. Derived from Network's exposure overlay and its site
++// list (server/chrome/sites.js), never set by hand per service.
++const CATEGORIES = [
++ { id: 'site', name: 'Sites', rule: 'a product people use in a browser: it has an entry in the network\'s site list (server/chrome/sites.js), open in the navigation once its public domain serves it' },
++ { id: 'platform', name: 'Platform services', rule: 'runs (publicly or on loopback) but is not a site people visit: other services call it' },
++ { id: 'library', name: 'Libraries', rule: 'exposure library: a released package other code installs' },
++ { id: 'repository', name: 'Repositories', rule: 'exposure repository: code with CI, neither released nor run' },
++ { id: 'planned', name: 'Planned', rule: 'exposure placeholder: charter only, nothing runs' },
++];
++function categoryOf(m) {
++ const e = exposureOf(m.id);
++ if (e.state === 'library') return 'library';
++ if (e.state === 'repository') return 'repository';
++ if (e.state === 'placeholder' || (e.state === 'unknown' && m.status === 'placeholder')) return 'planned';
++ return SITES.some(s => s.service === m.id) ? 'site' : 'platform';
++}
++
++function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = globalThis.fetch, pollMs = POLL_MS, readyPaths = READY_PATHS, healthPaths = HEALTH_PATHS, now = () => Date.now(), ranking = null } = {}) {
const internal = { ...INTERNAL, ...internalOverrides };
++ let rankingOf = ranking;
// id -> { status: up|degraded|down|not-running|unknown, basis, reason, http_status, latency_ms, checked_at, ready, release }
const health = new Map();
let timer = null;
@@@ -197,6 +245,47 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return { checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null, poll_interval_s: Math.round(pollMs / 1000), libraries: libs, services, behind };
}
++ /** Services grouped by category, each with its maturity, exposure and last check. */
++ function categories() {
++ const rowOf = (m) => { const h = current(m.id); return { id: m.id, name: m.name || m.id, status: m.status, state: exposureOf(m.id).state, origin: publicOriginOf(m), runtime: h.status, checked_at: h.checked_at || null }; };
++ return CATEGORIES.map((c) => {
++ const services = contracts.services.manifests.filter(m => categoryOf(m) === c.id).map(rowOf);
++ return { ...c, count: services.length, services };
++ });
++ }
++
++ /**
++ * Featured: the sites whose public domain serves them and whose last check (not stale) was up or degraded,
++ * in the navbar's usage order (server/chrome/service.js: page views over 7 days plus signed-in history
++ * over 14, recounted every 30 minutes). The hub does not feature itself. Nothing is picked by hand.
++ */
++ function featured() {
++ const r = typeof rankingOf === 'function' ? rankingOf() : null;
++ const open = SITES.filter(s => s.status === 'open');
++ const order = r && Array.isArray(r.order) ? r.order : open.map(s => s.id);
++ const bySite = new Map(open.map(s => [s.id, s]));
++ const list = [];
++ for (const siteId of order) {
++ const site = bySite.get(siteId);
++ if (!site || site.service === 'network') continue;
++ const m = contracts.services.get(site.service);
++ if (!m) continue;
++ const h = current(m.id);
++ if (h.status !== 'up' && h.status !== 'degraded') continue;
++ list.push({ rank: list.length + 1, id: m.id, name: m.name || m.id, site: site.name, origin: publicOriginOf(m) || `https://${site.host}`, tagline: site.tagline, status: h.status, checked_at: h.checked_at });
++ }
++ const at = r && r.at ? r.at : null;
++ const every = (r && r.everyMs) || 30 * 60 * 1000;
++ return {
++ derivation: 'Sites whose public domain serves the service itself and whose last health check (at most three poll intervals old) was up or degraded, ordered by use: page views over the last 7 days (the shared navbar\'s anonymous count, and Live\'s own analytics) plus signed-in cross-site history over 14 days, the order the network navigation uses. The hub itself is not listed; nothing is featured by hand.',
++ ordered_by: at ? 'usage' : 'site list order (no use counted yet)',
++ ranked_at: at ? new Date(at).toISOString() : null,
++ stale: !at || now() - at > 4 * every,
++ checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null,
++ featured: list,
++ };
++ }
++
function descriptor() {
return {
name: 'OpenVibe',
@@@ -221,7 +310,14 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
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', releases: '/api/v1/registry/releases', health: '/api/v1/registry/health', search: '/api/v1/registry/search?q=', domains: '/api/v1/registry/domains/:domain' }));
++ 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', releases: '/api/v1/registry/releases', health: '/api/v1/registry/health', search: '/api/v1/registry/search?q=', domains: '/api/v1/registry/domains/:domain', categories: '/api/v1/registry/categories', featured: '/api/v1/registry/featured' }));
++ r.get('/api/v1/registry/categories', (_req, res) => cache(res, 30).json({ categories: categories(), checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null }));
++ r.get('/api/v1/registry/categories/:id', (req, res) => {
++ const c = categories().find(x => x.id === String(req.params.id));
++ if (!c) return notFound(res, 'registry.unknown_category', `no category ${req.params.id}; categories: ${CATEGORIES.map(x => x.id).join(', ')}`);
++ cache(res, 30).json(c);
++ });
++ r.get('/api/v1/registry/featured', (_req, res) => cache(res, 60).json(featured()));
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));
@@@ -294,7 +390,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return r;
}
-- return { router, start, stop, pollAll, health, current, descriptor, lastPollAt: () => lastPollAt, pollMs, internal };
++ return { router, start, stop, pollAll, health, current, descriptor, categories, featured, setRanking: (fn) => { rankingOf = fn; }, lastPollAt: () => lastPollAt, pollMs, internal };
}
--module.exports = { createEcosystemRegistry, INTERNAL, READY_PATHS, HEALTH_PATHS, statusFromReady, readySummary };
++module.exports = { createEcosystemRegistry, INTERNAL, READY_PATHS, HEALTH_PATHS, CATEGORIES, categoryOf, internalFromEnv, statusFromReady, readySummary };
ddiff --git a/server/registry/exposure.js b/server/registry/exposure.js
iindex d1c5e5ac..c58104cb 100644
---- a/server/registry/exposure.js
++++ b/server/registry/exposure.js
@@@ -23,6 +23,12 @@
*
* When a service goes public, change its row here in the same commit that points its domain at it,
* after checking the domain no longer answers with "this page is a placeholder".
++ *
++ * TODO(plan §5.1 "data from contract manifests plus validated overrides"): this overlay is the override
++ * half. It cannot come from the manifests (they say maturity, and the question here is what a domain
++ * serves today), so it stays Network's, pinned id by id by test/registry-exposure.test.js. Candidates to
++ * derive it later: each repository's STATUS.json `deployed`, or OpenVibe.Sites' sites.json (which domains
++ * still get a placeholder page), once both are published somewhere Network can read.
*/
const STATES = ['live', 'internal', 'library', 'repository', 'placeholder'];
ddiff --git a/test/chrome.test.js b/test/chrome.test.js
iindex c87d1937..f0a73111 100644
---- a/test/chrome.test.js
++++ b/test/chrome.test.js
@@@ -12,6 +12,7 @@ const svc = createChromeService(db, { internalKey: 'change-me-in-production', se
assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
++assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
let p = svc.payloadFor('openvibe.media');
assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki'], 'cold start follows the base order');
assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
@@@ -28,6 +29,8 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
await svc.refreshRank(); global.fetch = realFetch;
p = svc.payloadFor('openvibe.network');
assert.equal(p.nav[0].id, 'games', 'most viewed site comes first');
++ assert.deepEqual(svc.ranking().order, p.nav.map(n => n.id), 'ranking() is the navigation order (the registry features by it)');
++ assert.ok(Date.now() - svc.ranking().at < 5000, 'and says when it was counted');
assert.ok(p.nav.findIndex(n => n.id === 'network') > 0, 'the Network is never first on shared-service traffic alone');
assert.ok(BANNED.test('Totally free tools') && BANNED.test('see https://x.y') && BANNED.test('<b>hi</b>') && !BANNED.test('Go live from a browser in seconds.'), 'AI copy screen');
assert.equal(p.footer.ai, false);
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex ba9fb232..f4e6d881 100644
---- a/test/registry-ecosystem.test.js
++++ b/test/registry-ecosystem.test.js
@@@ -57,6 +57,60 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
r = await get('/contracts/identity/nope.v1.json');
assert.strictEqual(r.status, 404);
++ // ── Categories: every manifest in exactly one, by the stated rule ──
++ r = await get('/api/v1/registry/categories');
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.cors, '*');
++ const cats = Object.fromEntries(r.body.categories.map(c => [c.id, c]));
++ assert.deepStrictEqual(Object.keys(cats), ['site', 'platform', 'library', 'repository', 'planned']);
++ for (const c of r.body.categories) assert.ok(c.rule && c.count === c.services.length, `${c.id} states its rule and count`);
++ const listed = r.body.categories.flatMap(c => c.services.map(x => x.id)).sort();
++ assert.deepStrictEqual(listed, contracts.services.manifests.map(m => m.id).sort(), 'every service in exactly one category');
++ const inCat = (id) => r.body.categories.find(c => c.services.some(x => x.id === id)).id;
++ assert.strictEqual(inCat('live'), 'site'); assert.strictEqual(inCat('network'), 'site'); assert.strictEqual(inCat('tips'), 'site', 'a site whose domain is still a placeholder is a site, not open yet');
++ assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');
++ assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');
++ assert.strictEqual(inCat('examples'), 'repository'); assert.strictEqual(inCat('realtime'), 'planned');
++ const liveRow = cats.site.services.find(x => x.id === 'live');
++ assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');
++ r = await get('/api/v1/registry/categories/library');
++ assert.strictEqual(r.status, 200); assert.ok(r.body.services.some(x => x.id === 'shared'));
++ r = await get('/api/v1/registry/categories/nope');
++ assert.strictEqual(r.status, 404); assert.strictEqual(r.body.code, 'registry.unknown_category');
++
++ // ── Featured: open sites that are up, in the navigation's usage order; stale or cold start says so ──
++ eco.health.set('media', { status: 'up', checked_at: new Date().toISOString() });
++ r = await get('/api/v1/registry/featured');
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.cors, '*');
++ assert.ok(r.body.derivation.includes('7 days'), 'the rule is stated');
++ assert.strictEqual(r.body.ordered_by, 'site list order (no use counted yet)'); assert.strictEqual(r.body.stale, true);
++ assert.deepStrictEqual(r.body.featured.map(f => f.id), ['media'], 'only a site that is up (media), never network itself or a down one (live)');
++ assert.strictEqual(r.body.featured[0].rank, 1); assert.strictEqual(r.body.featured[0].origin, 'https://openvibe.media');
++ // With a usage ranking: its order, and when it was counted.
++ eco.health.set('live', { status: 'degraded', checked_at: new Date().toISOString() });
++ eco.health.set('tools', { status: 'up', checked_at: new Date().toISOString() });
++ eco.health.set('tips', { status: 'up', checked_at: new Date().toISOString() });
++ eco.setRanking(() => ({ at: Date.now() - 60_000, order: ['tools', 'network', 'media', 'live', 'tips'], everyMs: 30 * 60_000 }));
++ r = await get('/api/v1/registry/featured');
++ assert.deepStrictEqual(r.body.featured.map(f => f.id), ['tools', 'media', 'live'], 'usage order; a site that is not open (tips) is never featured');
++ assert.deepStrictEqual(r.body.featured.map(f => f.rank), [1, 2, 3]);
++ assert.strictEqual(r.body.ordered_by, 'usage'); assert.strictEqual(r.body.stale, false); assert.ok(r.body.ranked_at);
++ eco.setRanking(() => ({ at: Date.now() - 3 * 3600_000, order: ['tools'], everyMs: 30 * 60_000 }));
++ assert.strictEqual((await get('/api/v1/registry/featured')).body.stale, true, 'a ranking four refreshes old is stale');
++ r = await get('/api/v1/registry');
++ assert.strictEqual(r.body.categories, '/api/v1/registry/categories'); assert.strictEqual(r.body.featured, '/api/v1/registry/featured');
++
++ // ── Hand-kept maps only where the manifests have nothing ──
++ const { READY_PATHS, internalFromEnv } = require('../server/registry/ecosystem');
++ for (const id of Object.keys(READY_PATHS)) assert.ok(!contracts.services.get(id).ready, `READY_PATHS.${id}: its manifest has no ready path (drop the entry once it does)`);
++ const env = internalFromEnv({ OV_EVENTS_INTERNAL_URL: 'http://127.0.0.1:4300/', OV_AI_INTERNAL_URL: 'http://localhost:4700', OV_LIVE_INTERNAL_URL: 'https://live.example.com', OV_NOPE_INTERNAL_URL: 'http://127.0.0.1:1' });
++ assert.deepStrictEqual(env.overrides, { events: 'http://127.0.0.1:4300', ai: 'http://localhost:4700' }, 'loopback URLs of known services override');
++ assert.deepStrictEqual(env.ignored, ['OV_LIVE_INTERNAL_URL'], 'a non-loopback address is ignored and reported');
++ const { SITES } = require('../server/chrome/sites');
++ for (const site of SITES) {
++ const m = contracts.services.get(site.service);
++ if (m && m.publicOrigin) assert.strictEqual(site.host, new URL(m.publicOrigin).hostname, `${site.id}: host from the manifest`);
++ }
++
eco.stop(); srv.close(); up.close();
console.log('ecosystem registry: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/unknown-paths.test.js b/test/unknown-paths.test.js
iindex c65b84eb..263d7374 100644
---- a/test/unknown-paths.test.js
++++ b/test/unknown-paths.test.js
@@@ -18,7 +18,7 @@ const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
'/verify-email', '/status', '/sso/fanout', '/terms', '/privacy', '/dmca', '/llms.txt',
'/robots.txt', '/sitemap.xml', '/manifest.webmanifest', '/assets/logo.svg', '/shared/navbar.js', '/shared/v1/navbar.js',
'/openvibe-sw.js', '/release.json', '/.well-known/openvibe', '/.well-known/web-identity', '/api/health', '/api/brand',
-- '/api/v1/registry/services', '/api/.well-known/jwks',
++ '/api/v1/registry/services', '/api/v1/registry/categories', '/api/v1/registry/featured', '/api/.well-known/jwks',
...ACCOUNT_HUB_PATHS.filter(p => p !== '/my.html'),
];
for (const p of real) {