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)
Changes
@@@ -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 comefrom 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 thanthe 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).@@@ -67,7 +67,9 @@ function createChromeService(db, config, analytics, { privateKey = null, issuerconst 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, issuerfor (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, issuerconst 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 };@@@ -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]));@@@ -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);@@@ -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 = greturn { 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 = gconst 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 = greturn 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 };@@@ -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'];@@@ -12,6 +12,7 @@ const svc = createChromeService(db, { internalKey: 'change-me-in-production', seassert.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);@@@ -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); });@@@ -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) {
@@ -420,6 +420,15 @@ Google-style account management supporting up to 5 accounts:
@@ -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
openvibe-contracts 0.32.0 lists Live's, Media's and Network's), and Network's consumed topics come
from its own Events consumer.
from its own Events consumer.
Payload schemas are served at their `$id` (`/contracts/events/payloads/<type>.v1.json`).
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
- `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,
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).
`--registry https://openvibe.network` for what is running, `--package all` for sdk and shared too).
@@ -67,7 +67,9 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
@@ -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 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));
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 copy = (load('copy') || {}).value || { sites: {} };
let version = Date.now();
let version = Date.now();
@@ -104,7 +106,7 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
@@ -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; }
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 */ }
} catch { /* table appears on first history write */ }
rank = { scores, tools };
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);
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
@@ -205,7 +207,12 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
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 };
module.exports = { createChromeService, BANNED };
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const contracts = require('openvibe-contracts');
const { exposureOf } = require('../registry/exposure');
const { exposureOf } = require('../registry/exposure');
// Every OpenVibe property: what it is, whether it is open, and which legal documents apply.
// 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):
// 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');
@@ -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
// 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
// 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.
// (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 = [
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: '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 SITES = RAW_SITES.map((s) => {
const e = exposureOf(s.service);
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]));
const byHost = new Map(SITES.map(s => [s.host, s]));
@@ -383,7 +383,10 @@ const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publi
@@ -383,7 +383,10 @@ const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publi
// spec-compliant consumers) and the legacy `public_key` PEM shape that the
// spec-compliant consumers) and the legacy `public_key` PEM shape that the
// inherited service clients read.
// inherited service clients read.
// Ecosystem registry: services, capabilities, namespaces and contracts from openvibe-contracts, with polled health.
// 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());
app.use(ecosystem.router());
ecosystem.start();
ecosystem.start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
@@ -491,6 +494,8 @@ app.locals.avatarService = avatarService;
@@ -491,6 +494,8 @@ app.locals.avatarService = avatarService;
const chromeService = require('./chrome/service').createChromeService(db, config, analytics, { privateKey, issuer: config.jwt.issuer });
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);
app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);
chromeService.start();
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).
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
@@ -24,6 +24,10 @@
@@ -24,6 +24,10 @@
* current releases, with contracts/sdk/shared drift
* current releases, with contracts/sdk/shared drift
* GET /api/v1/registry/health one line per service: status, checked_at
* 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/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
* GET /contracts/<domain>/<name>.v<N>.json the schema at its $id URL
*/
*/
const express = require('express');
const express = require('express');
@@ -32,8 +36,13 @@ const contractsPkg = require('openvibe-contracts/package.json');
@@ -32,8 +36,13 @@ const contractsPkg = require('openvibe-contracts/package.json');
const { exposureOf, publicOriginOf, libraries } = require('./exposure');
const { exposureOf, publicOriginOf, libraries } = require('./exposure');
const { buildTopics, eventsOf } = require('./topics');
const { buildTopics, eventsOf } = require('./topics');
const { driftOf } = require('./versions');
const { driftOf } = require('./versions');
+const { SITES } = require('../chrome/sites');
// Where each running service answers health checks from this host (manifests carry the public origin).
// 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 = {
const INTERNAL = {
network: 'http://127.0.0.1:4000', live: 'http://127.0.0.1:3000', media: 'http://127.0.0.1:4100',
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',
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 = {
@@ -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',
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;
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
// 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).
// 0.30, where AI's manifest carries /api/health and /api/ready).
const HEALTH_PATHS = {};
const HEALTH_PATHS = {};
@@ -79,8 +90,45 @@ function readySummary(body) {
@@ -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 };
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 };
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 }
// id -> { status: up|degraded|down|not-running|unknown, basis, reason, http_status, latency_ms, checked_at, ready, release }
const health = new Map();
const health = new Map();
let timer = null;
let timer = null;
@@ -197,6 +245,47 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -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 };
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() {
function descriptor() {
return {
return {
name: 'OpenVibe',
name: 'OpenVibe',
@@ -221,7 +310,14 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -221,7 +310,14 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
const notFound = (res, code, detail) => contracts.http.sendProblem(res, 404, code, { detail });
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('/.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) => {
r.get('/api/v1/registry/services', (req, res) => {
let list = contracts.services.manifests.map(withHealth);
let list = contracts.services.manifests.map(withHealth);
if (req.query.status) list = list.filter(m => m.status === String(req.query.status));
if (req.query.status) list = list.filter(m => m.status === String(req.query.status));
@@ -294,7 +390,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -294,7 +390,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return r;
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 };
@@ -23,6 +23,12 @@
@@ -23,6 +23,12 @@
*
*
* When a service goes public, change its row here in the same commit that points its domain at it,
* 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".
* 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'];
const STATES = ['live', 'internal', 'library', 'repository', 'placeholder'];
@@ -12,6 +12,7 @@ const svc = createChromeService(db, { internalKey: 'change-me-in-production', se
@@ -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('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
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');
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.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');
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');
@@ -28,6 +29,8 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
await svc.refreshRank(); global.fetch = realFetch;
await svc.refreshRank(); global.fetch = realFetch;
p = svc.payloadFor('openvibe.network');
p = svc.payloadFor('openvibe.network');
assert.equal(p.nav[0].id, 'games', 'most viewed site comes first');
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(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.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);
assert.equal(p.footer.ai, false);
@@ -57,6 +57,60 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -57,6 +57,60 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
r = await get('/contracts/identity/nope.v1.json');
r = await get('/contracts/identity/nope.v1.json');
assert.strictEqual(r.status, 404);
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();
eco.stop(); srv.close(); up.close();
console.log('ecosystem registry: all checks passed');
console.log('ecosystem registry: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
@@ -18,7 +18,7 @@ const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
@@ -18,7 +18,7 @@ const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
'/verify-email', '/status', '/sso/fanout', '/terms', '/privacy', '/dmca', '/llms.txt',
'/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',
'/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',
'/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'),
...ACCOUNT_HUB_PATHS.filter(p => p !== '/my.html'),
];
];
for (const p of real) {
for (const p of real) {