openvibers/openvibe.network Commits
Registry, status and chrome say where each service really answers: an exposure overlay (server/registry/exposure.js) gives every manifest one state — live (public), internal (loopback only, no public site yet), library (released), repository or placeholder — from curl checks of every public domain on 2026-09-23. News, Reviews, Deals, Coupons, Trade, Host, Tips, VIP, OpenRe, Search and Sources are internal (their domains serve a placeholder page); AI, Chat internal; SDK v0.4.0, Shared v1.3.0, Contracts v0.29.0 libraries; Examples a repository. The descriptor gives an origin only to live services (planned_origin otherwise), AI is polled on loopback, /status reads "up (loopback only)" and never a bare Up for a placeholder domain, and the chrome nav derives open/soon from the same table. test/registry-exposure.test.js pins the states
Changes
@@@ -280,6 +280,13 @@ Google-style account management supporting up to 5 accounts:`checked_at`. The data comes from the ecosystem registry poll (`server/registry/ecosystem.js`),which reads each service's readiness endpoint and `/release.json` about once a minute. A servicenot checked yet, or whose last check is more than three intervals old, shows as unknown.++- Where each service can be reached is Network's exposure overlay (`server/registry/exposure.js`), not++ the manifest's maturity label: `live` (its public domain serves it), `internal` (loopback only, the++ domain still serves a placeholder page), `library` (released package), `repository` (code with CI,++ nothing to run) or `placeholder` (planned). The registry adds `exposure` to each service, the++ descriptor gives an `origin` only to `live` services (`planned_origin` otherwise), `/status` reads++ "up (loopback only)" for an internal service, and the chrome nav lists only `live` sites. Change a++ row when a domain stops serving its placeholder; `test/registry-exposure.test.js` pins the states.- `GET /api/v1/status/slo` returns the proposed SLO categories, as does[docs/slo.md](docs/slo.md) / [docs/slo.json](docs/slo.json).- [docs/patches/live-observability.diff](docs/patches/live-observability.diff) is the same change for@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -4,7 +4,7 @@//// GET /api/chrome?host=<hostname> public, CORS *, max-age=600, ETag// → { updated, nav: [{ id, name, url, icon, tagline }], open sites, most used first--// soon: [{ id, name, url, icon }],++// soon: [{ id, name, url, icon, state }], state: internal | placeholder// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }//// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in@@@ -159,9 +159,10 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer// Legal documents live on the site's own apex, so each domain answers for itself.const legalBase = `https://${site.host}`;return {-- updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status },++ updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },nav: open.map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, tagline: s.tagline })),-- soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon })),++ // state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).++ soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },};}@@@ -1,31 +1,39 @@'use strict';++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):// streaming user video + chat + payments tools files processed and discarded// ugc user posts/pastes games accounts + game state// hosting stored user media account identity provider// info read-only placeholder/editorial site--const SITES = [-- { id: 'live', name: 'Live', host: 'openvibe.live', icon: 'live', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'soon', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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.' },++// 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.++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: '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.' },];++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 byHost = new Map(SITES.map(s => [s.host, s]));/** The site a hostname belongs to (subdomains included), or null. */@@@ -5,10 +5,11 @@* What exists, what each service can do, and whether it is up — built from the versioned manifests in* openvibe-contracts (services, capabilities, user-module namespaces, contract catalog) instead of route* maps hard-coded into each product. Health is polled here and every value says when it was checked;-- * nothing is presented as live without a timestamp.++ * nothing is presented as live without a timestamp. Whether a service's public domain serves it, or it++ * runs on loopback only, is Network's exposure overlay (./exposure.js): the manifests say maturity.** GET /.well-known/openvibe platform descriptor-- * GET /api/v1/registry/services[?status=] manifests + health++ * GET /api/v1/registry/services[?status=&state=] manifests + exposure + health* GET /api/v1/registry/services/:id* GET /api/v1/registry/domains/:domain which service answers for a host* GET /api/v1/registry/capabilities[?owner=] capability manifests@@@ -21,6 +22,7 @@const express = require('express');const contracts = require('openvibe-contracts');const contractsPkg = require('openvibe-contracts/package.json');++const { exposureOf, publicOriginOf } = require('./exposure');// Where each running service answers health checks from this host (manifests carry the public origin).const INTERNAL = {@@@ -37,7 +39,10 @@ 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' };++const READY_PATHS = { network: '/api/ready', media: '/api/ready', tools: '/api/ready', ai: '/api/ready' };++// Liveness paths of services whose manifest in the pinned openvibe-contracts has none (AI is a++// placeholder there but runs on loopback; see ./exposure.js).++const HEALTH_PATHS = { ai: '/api/health' };/** Row status from a readiness body (openvibe-shared/ready shape, or an older ad-hoc one). */function statusFromReady(res, body) {@@@ -65,7 +70,7 @@ 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, now = () => Date.now() } = {}) {++function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = globalThis.fetch, pollMs = POLL_MS, readyPaths = READY_PATHS, healthPaths = HEALTH_PATHS, now = () => Date.now() } = {}) {const internal = { ...INTERNAL, ...internalOverrides };// id -> { status: up|degraded|down|not-running|unknown, basis, reason, http_status, latency_ms, checked_at, ready, release }const health = new Map();@@@ -93,11 +98,16 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = gasync function checkOne(m) {const at = () => new Date(now()).toISOString();const base = internal[m.id];-- if (m.status === 'placeholder') { health.set(m.id, { status: 'not-running', reason: 'placeholder', checked_at: at() }); return; }++ // What runs is decided by Network's exposure overlay, not the manifest's maturity label.++ const exp = exposureOf(m.id);++ if (exp.state === 'placeholder' || (exp.state === 'unknown' && m.status === 'placeholder')) { health.set(m.id, { status: 'not-running', reason: 'placeholder', checked_at: at() }); return; }++ if (exp.state === 'library') { health.set(m.id, { status: 'not-running', reason: `library, released ${exp.release}`, checked_at: at() }); return; }++ if (exp.state === 'repository') { health.set(m.id, { status: 'not-running', reason: 'repository, nothing to run', checked_at: at() }); return; }const readyPath = m.ready || readyPaths[m.id] || null;-- if (!m.health && !readyPath && !(m.domains || []).length) { health.set(m.id, { status: 'not-running', reason: 'no runtime', checked_at: at() }); return; }++ const healthPath = m.health || healthPaths[m.id] || null;++ if (!healthPath && !readyPath && !(m.domains || []).length) { health.set(m.id, { status: 'not-running', reason: 'no runtime', checked_at: at() }); return; }if (!base) { health.set(m.id, { status: 'unknown', reason: 'no internal address known to Network', checked_at: at() }); return; }-- if (!readyPath && !m.health) { health.set(m.id, { status: 'unknown', reason: 'no health or readiness path in its manifest', checked_at: at() }); return; }++ if (!readyPath && !healthPath) { health.set(m.id, { status: 'unknown', reason: 'no health or readiness path in its manifest', checked_at: at() }); return; }const releaseP = releaseOf(base);let entry;try {@@@ -106,14 +116,18 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = gentry = { ...statusFromReady(got.res, got.body), ready: readySummary(got.body) };} else {// No readiness endpoint (yet): liveness is all that can be said.-- got = await getJson(`${base}${m.health}`);++ if (!healthPath) throw Object.assign(new Error('readiness endpoint answered 404'), { name: 'NoReadiness' });++ got = await getJson(`${base}${healthPath}`);entry = { status: got.res.ok ? 'up' : 'down', basis: 'health', reason: 'liveness only: no readiness endpoint answered', ready: null };}entry.http_status = got.res.status;entry.latency_ms = got.latency;} catch (err) {-- entry = { status: 'down', basis: 'unreachable', error: err.name === 'TimeoutError' ? 'timeout' : 'unreachable', ready: null };++ entry = { status: 'down', basis: 'unreachable', error: err.name === 'TimeoutError' ? 'timeout' : err.name === 'NoReadiness' ? err.message : 'unreachable', ready: null };}++ // Polled on loopback: a running internal service is up there, not at its public domain.++ entry.scope = exp.state === 'live' ? 'public' : 'loopback';++ if (exp.state === 'internal') entry.reason = ['loopback only, no public site yet', entry.reason].filter(Boolean).join('; ');entry.release = await releaseP;entry.checked_at = at();health.set(m.id, entry);@@@ -137,7 +151,8 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = greturn h;}-- const withHealth = (m) => ({ ...m, runtime: current(m.id) });++ // The manifest as published, plus where it can be reached (exposure) and what the last check saw (runtime).++ const withHealth = (m) => ({ ...m, exposure: exposureOf(m.id), runtime: current(m.id) });function descriptor() {return {@@@ -148,7 +163,12 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = gopenid_configuration: `${issuer}/oauth/.well-known/openid-configuration`,registry: `${issuer}/api/v1/registry`,contracts: { package: 'openvibe-contracts', version: contractsPkg.version, repository: 'https://github.com/OpenVibers/OpenVibe.Contracts', catalog: `${issuer}/api/v1/registry/contracts` },-- services: contracts.services.manifests.map(m => ({ id: m.id, status: m.status, origin: m.publicOrigin || null })),++ // origin is set only where the public domain serves the service itself; a domain that still++ // serves a placeholder is planned_origin, so a client never routes calls to a placeholder page.++ services: contracts.services.manifests.map((m) => {++ const e = exposureOf(m.id); const origin = publicOriginOf(m);++ return { id: m.id, status: m.status, state: e.state, origin, ...(!origin && m.publicOrigin ? { planned_origin: m.publicOrigin } : {}) };++ }),};}@@@ -162,6 +182,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = gr.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));++ if (req.query.state) list = list.filter(m => m.exposure.state === String(req.query.state));cache(res, 30).json({ services: list, contracts_version: contractsPkg.version });});r.get('/api/v1/registry/services/:id', (req, res) => {@@@ -200,4 +221,4 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = greturn { router, start, stop, pollAll, health, current, descriptor, lastPollAt: () => lastPollAt, pollMs, internal };}--module.exports = { createEcosystemRegistry, INTERNAL, READY_PATHS, statusFromReady, readySummary };++module.exports = { createEcosystemRegistry, INTERNAL, READY_PATHS, HEALTH_PATHS, statusFromReady, readySummary };nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++/**++ * Where each OpenVibe service can actually be reached — Network's own, observed overlay on the++ * openvibe-contracts manifests.++ *++ * A manifest's `status` (alpha, beta, stable, placeholder) is maturity, and its `publicOrigin` is where++ * the service is meant to answer. Neither says whether that public domain serves the service today:++ * on 2026-09-23 openvibe.news, .reviews, .deals, .coupons, .trade, .host, .tips, .vip and openre.stream++ * served the OpenVibe.Sites "coming soon" placeholder, search. and sources.openvibe.network served the++ * admin placeholder, while each of those services ran on this host's loopback only. And AI, SDK,++ * Shared and Examples existed although their manifests (openvibe-contracts 0.28.0) said placeholder.++ *++ * Every manifest id has exactly one state here (test/registry-exposure.test.js pins them):++ *++ * live live (public) its public domain serves the service itself++ * internal internal runs on loopback only; no public site yet (the domain, if any,++ * still serves a placeholder page)++ * library library (released) a package other code installs; nothing to run++ * repository repository code with CI, neither released nor run (examples)++ * placeholder placeholder (planned) charter only; nothing runs++ *++ * 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".++ */++++const STATES = ['live', 'internal', 'library', 'repository', 'placeholder'];++const LABEL = {++ live: 'live (public)',++ internal: 'internal (loopback only, no public site yet)',++ library: 'library (released)',++ repository: 'repository (code with CI, nothing released or running)',++ placeholder: 'placeholder (planned)',++};++++// public_site: what the service's public domain(s) answer today — 'service' (the service itself),++// 'placeholder' (a Sites/admin placeholder page) or null (no public domain).++const SITE_PLACEHOLDER = 'the domain serves the OpenVibe.Sites placeholder page';++const ADMIN_PLACEHOLDER = 'the domain serves the admin.openvibe.network placeholder page';++const EXPOSURE = {++ network: { state: 'live', public_site: 'service' },++ live: { state: 'live', public_site: 'service' },++ tools: { state: 'live', public_site: 'service' },++ media: { state: 'live', public_site: 'service' },++ games: { state: 'live', public_site: 'service' },++ community: { state: 'live', public_site: 'service' },++ events: { state: 'live', public_site: 'service' },++ billing: { state: 'live', public_site: 'service', note: 'the public domain is the staff console; accounts pay through each product' },++ codes: { state: 'live', public_site: 'service' },++ blog: { state: 'live', public_site: 'service' },++ wiki: { state: 'live', public_site: 'service' },++ sites: { state: 'live', public_site: 'service', note: 'serves the placeholder pages of domains whose service is not public yet' },++++ news: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ reviews: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ deals: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ coupons: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ trade: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ host: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ tips: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ vip: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ openre: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },++ search: { state: 'internal', public_site: 'placeholder', note: ADMIN_PLACEHOLDER },++ sources: { state: 'internal', public_site: 'placeholder', note: ADMIN_PLACEHOLDER },++ chat: { state: 'internal', public_site: null, note: 'serves Live\'s chat through Live; openvibe.chat serves the OpenVibe.Sites placeholder page' },++ ai: { state: 'internal', public_site: null, note: 'called by other services at 127.0.0.1:4700; ai.openvibe.network serves the OpenVibe.Sites placeholder page' },++++ sdk: { state: 'library', public_site: null, release: 'v0.4.0', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0' },++ shared: { state: 'library', public_site: null, release: 'v1.3.0', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0', note: 'also served at /shared/* by each site' },++ contracts: { state: 'library', public_site: null, release: 'v0.29.0', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.29.0', note: 'schemas are also served by this registry at their $id URLs' },++ examples: { state: 'repository', public_site: null, note: 'example apps with CI in OpenVibers/OpenVibe.Examples; not published as a package' },++++ realtime: { state: 'placeholder', public_site: null },++};++++/** The exposure of one service id; an id nobody classified is 'unknown', never assumed public. */++function exposureOf(id) {++ const e = EXPOSURE[id];++ if (!e) return { state: 'unknown', label: 'unknown (not classified by Network)', public_site: null, note: null };++ return { state: e.state, label: LABEL[e.state], public_site: e.public_site, note: e.note || null, ...(e.release ? { release: e.release, distribution: e.distribution || null } : {}) };++}++++/** A manifest's public origin only when its domain serves the service itself. */++function publicOriginOf(m) {++ const e = EXPOSURE[m.id];++ return e && e.state === 'live' && e.public_site === 'service' ? m.publicOrigin || null : null;++}++++module.exports = { STATES, LABEL, EXPOSURE, exposureOf, publicOriginOf };@@@ -11,12 +11,17 @@* service's readiness (openvibe-shared/ready shape) and /release.json. Every row is one of* up | degraded | down | not-running | unknown and carries checked_at; a service not checked yet, or* whose last check is stale, is 'unknown' — never an optimistic default.++ *++ * Each row also carries its exposure (server/registry/exposure.js): live (public), internal (loopback++ * only), library, repository or placeholder. A service that is up on loopback while its public domain++ * serves a placeholder reads "up (loopback only)" and has no public origin, never a bare "up".*/const fs = require('fs');const path = require('path');const express = require('express');const contracts = require('openvibe-contracts');const seo = require('openvibe-shared/seo');++const exposure = require('../registry/exposure');const SLO_FILE = path.join(__dirname, '..', '..', 'docs', 'slo.json');const esc = seo.esc;@@@ -29,13 +34,18 @@ function rows(ecosystem) {return contracts.services.manifests.map((m) => {const r = ecosystem.current(m.id);const status = STATES.includes(r.status) ? r.status : 'unknown';++ const exp = exposure.exposureOf(m.id);++ const origin = exposure.publicOriginOf(m);++ const loopback = exp.state !== 'live' && !['not-running', 'unknown'].includes(status);return {id: m.id,name: m.name,manifest_status: m.status,-- origin: m.publicOrigin || null,++ exposure: exp,++ origin,++ ...(!origin && m.publicOrigin ? { planned_origin: m.publicOrigin } : {}),status,-- label: status === 'not-running' && r.reason ? `not running (${r.reason})` : status,++ label: status === 'not-running' && r.reason ? `not running (${r.reason})` : loopback ? `${status} (loopback only)` : status,basis: r.basis || null,reason: r.reason || r.error || null,checked_at: r.checked_at || null,@@@ -55,6 +65,19 @@ function summary(list) {return counts;}++function exposureSummary(list) {++ const counts = Object.fromEntries(exposure.STATES.map((s) => [s, 0]));++ for (const r of list) counts[r.exposure.state] = (counts[r.exposure.state] || 0) + 1;++ return counts;++}++++function exposureCell(r) {++ const e = r.exposure;++ const where = r.origin ? `<small><a href="${esc(r.origin)}" rel="noopener">${esc(r.origin.replace(/^https:\/\//, ''))}</a></small>`++ : r.planned_origin ? `<small>${esc(r.planned_origin.replace(/^https:\/\//, ''))}: not this service yet</small>` : '';++ return `${esc(e.label)}${e.release ? ` <code>${esc(e.release)}</code>` : ''}${where}${e.note ? `<small>${esc(e.note)}</small>` : ''}`;++}++const CSS = `.st{max-width:1080px;margin:32px auto 64px;padding:0 20px}.st h1{font-size:clamp(24px,3vw,32px);letter-spacing:-.02em;margin:0 0 6px}.st p.lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:780px;line-height:1.5}@@@ -78,7 +101,8 @@ function renderPage(list, slo, generatedAt) {const counts = summary(list);const tr = list.map((r) => `<tr id="svc-${esc(r.id)}"><td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td>--<td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status])}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td>++<td>${exposureCell(r)}</td>++<td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status] + (r.label.endsWith('(loopback only)') ? ' · loopback only' : ''))}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td><td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td><td>${r.checked_at ? `<time datetime="${esc(r.checked_at)}">${esc(r.checked_at)}</time>` : '<small>not checked yet</small>'}${r.latency_ms != null ? `<small>${esc(r.latency_ms)} ms</small>` : ''}</td></tr>`).join('\n');@@@ -107,10 +131,10 @@ ${CSS}${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}<main class="st" id="main"><h1>Service status</h1>--<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders show as not running. Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p>++<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders, libraries and repositories show as not running. <b>Where</b> says whether the public domain serves the service itself (live), or the service runs on this host's loopback only while its domain still serves a placeholder page (internal). Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p><ul class="st-sum">${STATES.map((s) => `<li class="st-${s}">${esc(LABEL[s])}: ${counts[s]}</li>`).join('')}</ul><table>--<thead><tr><th>Service</th><th>Status</th><th>Release</th><th>Checked</th></tr></thead>++<thead><tr><th>Service</th><th>Where</th><th>Status</th><th>Release</th><th>Checked</th></tr></thead><tbody>${tr}</tbody>@@@ -139,6 +163,8 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {poll_seconds: Math.round(ecosystem.pollMs / 1000),states: STATES,summary: summary(list),++ exposure_states: exposure.STATES,++ exposure_summary: exposureSummary(list),services: list,});});@@@ -31,7 +31,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');-- assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime'))).valid));++ assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime' && k !== 'exposure'))).valid));r = await get('/api/v1/registry/services?status=placeholder');assert.ok(r.body.services.length >= 2 && r.body.services.every(s => s.status === 'placeholder'));nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++// Exposure overlay (server/registry/exposure.js): every service has one honest state, and the++// registry, the status page and the chrome nav all say the same thing. A service that runs on++// loopback while its public domain still serves a placeholder is never "up" at that domain.++// node test/registry-exposure.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++const contracts = require('openvibe-contracts');++const exposure = require('../server/registry/exposure');++const { createEcosystemRegistry } = require('../server/registry/ecosystem');++const { createStatusRoutes } = require('../server/status/routes');++const { SITES } = require('../server/chrome/sites');++++// Observed 2026-09-23 with curl against every public domain. Change a row only after the domain++// stops answering "this page is a placeholder" (or a library ships a new release).++const PINNED = {++ network: 'live', live: 'live', tools: 'live', media: 'live', games: 'live', community: 'live', events: 'live',++ billing: 'live', codes: 'live', blog: 'live', wiki: 'live', sites: 'live',++ news: 'internal', reviews: 'internal', deals: 'internal', coupons: 'internal', trade: 'internal', host: 'internal',++ tips: 'internal', vip: 'internal', openre: 'internal', search: 'internal', sources: 'internal', chat: 'internal', ai: 'internal',++ sdk: 'library', shared: 'library', contracts: 'library',++ examples: 'repository',++ realtime: 'placeholder',++};++const PLACEHOLDER_DOMAINS = ['news', 'reviews', 'deals', 'coupons', 'trade', 'host', 'tips', 'vip', 'openre', 'search', 'sources'];++++function fakeService(routes) {++ const srv = http.createServer((req, res) => {++ const h = routes[req.url];++ if (!h) { res.statusCode = 404; return res.end('{}'); }++ res.statusCode = h[0]; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(h[1]));++ });++ return new Promise(r => srv.listen(0, '127.0.0.1', () => r({ srv, url: `http://127.0.0.1:${srv.address().port}` })));++}++const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], checks: { db: { status: 'ok', required: true } } }];++++(async () => {++ // 1. Every manifest is classified, and the classification is the pinned one.++ for (const m of contracts.services.manifests) assert.notStrictEqual(exposure.exposureOf(m.id).state, 'unknown', `${m.id} has no exposure state`);++ const actual = Object.fromEntries(contracts.services.manifests.map(m => [m.id, exposure.exposureOf(m.id).state]));++ assert.deepStrictEqual(actual, PINNED);++ assert.deepStrictEqual(Object.keys(exposure.EXPOSURE).sort(), Object.keys(PINNED).sort(), 'no stale rows for services that no longer exist');++ for (const id of PLACEHOLDER_DOMAINS) {++ assert.strictEqual(exposure.EXPOSURE[id].public_site, 'placeholder', `${id}'s domain serves a placeholder`);++ assert.strictEqual(exposure.publicOriginOf(contracts.services.get(id)), null, `${id} has no public origin`);++ }++ assert.strictEqual(exposure.exposureOf('sdk').release, 'v0.4.0');++ assert.strictEqual(exposure.exposureOf('shared').release, 'v1.3.0');++ assert.strictEqual(exposure.exposureOf('nope').state, 'unknown', 'an unclassified id is unknown, not public');++++ // 2. Registry and status over fake loopback services.++ const up = await fakeService({ '/api/ready': ready, '/ready': ready, '/api/health': [200, { status: 'ok' }], '/health': [200, {}], '/healthz': [200, {}] });++ const ai = await fakeService({ '/api/ready': ready, '/api/health': [200, { status: 'ok' }] }); // AI's manifest (contracts 0.28.0) has no paths++ const overrides = {};++ for (const id of Object.keys(exposure.EXPOSURE)) overrides[id] = up.url;++ overrides.ai = ai.url;++ const eco = createEcosystemRegistry({ issuer: 'https://openvibe.network', internalOverrides: overrides });++ await eco.pollAll();++ const app = express();++ app.use(createStatusRoutes({ ecosystem: eco }));++ app.use(eco.router());++ const srv = http.createServer(app);++ await new Promise(r => srv.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${srv.address().port}`;++ const json = (p) => fetch(base + p).then(r => r.json());++++ const d = await json('/.well-known/openvibe');++ const dBy = Object.fromEntries(d.services.map(s => [s.id, s]));++ assert.strictEqual(dBy.news.state, 'internal');++ assert.strictEqual(dBy.news.origin, null, 'clients never route to a placeholder page');++ assert.strictEqual(dBy.news.planned_origin, 'https://openvibe.news');++ assert.strictEqual(dBy.live.origin, 'https://openvibe.live');++ assert.strictEqual(dBy.live.state, 'live');++ for (const s of d.services) assert.strictEqual(s.state, PINNED[s.id]);++++ let r = await json('/api/v1/registry/services');++ const by = Object.fromEntries(r.services.map(s => [s.id, s]));++ assert.strictEqual(by.deals.exposure.state, 'internal');++ assert.strictEqual(by.deals.exposure.public_site, 'placeholder');++ assert.strictEqual(by.deals.runtime.status, 'up');++ assert.strictEqual(by.deals.runtime.scope, 'loopback');++ assert.match(by.deals.runtime.reason, /loopback only/);++ assert.strictEqual(by.ai.runtime.status, 'up', 'AI is polled on loopback although its pinned manifest says placeholder');++ assert.strictEqual(by.ai.runtime.basis, 'ready');++ assert.strictEqual(by.sdk.runtime.status, 'not-running');++ assert.strictEqual(by.sdk.runtime.reason, 'library, released v0.4.0');++ assert.strictEqual(by.examples.runtime.reason, 'repository, nothing to run');++ assert.strictEqual(by.realtime.runtime.reason, 'placeholder');++ assert.strictEqual(by.live.runtime.scope, 'public');++ r = await json('/api/v1/registry/services?state=internal');++ assert.deepStrictEqual(r.services.map(s => s.id).sort(), Object.keys(PINNED).filter(k => PINNED[k] === 'internal').sort());++ r = await json('/api/v1/registry/domains/openvibe.coupons');++ assert.strictEqual(r.service.exposure.state, 'internal');++++ const st = await json('/api/v1/status');++ const sBy = Object.fromEntries(st.services.map(s => [s.id, s]));++ assert.strictEqual(sBy.news.label, 'up (loopback only)');++ assert.strictEqual(sBy.news.origin, null);++ assert.strictEqual(sBy.news.planned_origin, 'https://openvibe.news');++ assert.strictEqual(sBy.live.label, 'up');++ assert.strictEqual(sBy.live.origin, 'https://openvibe.live');++ assert.strictEqual(sBy.shared.label, 'not running (library, released v1.3.0)');++ assert.deepStrictEqual(st.exposure_summary, { live: 12, internal: 13, library: 3, repository: 1, placeholder: 1 });++++ // The page: no row for a service whose domain serves a placeholder shows a bare "Up".++ const html = await fetch(base + '/status').then(x => x.text());++ for (const id of PLACEHOLDER_DOMAINS.concat(['chat', 'ai'])) {++ const row = html.match(new RegExp(`<tr id="svc-${id}">[\\s\\S]*?</tr>`))[0];++ assert.ok(!/>Up<\/span>/.test(row), `${id} row must not read a bare Up`);++ assert.ok(row.includes('Up · loopback only'), `${id} row says loopback only`);++ assert.ok(row.includes('internal (loopback only, no public site yet)'), `${id} row names its exposure`);++ }++ assert.ok(/<tr id="svc-news">[\s\S]*?openvibe\.news: not this service yet/.test(html));++ assert.ok(/<tr id="svc-live">[\s\S]*?>Up<\/span>/.test(html), 'a public service still reads Up');++++ // 3. Chrome: the nav lists only sites whose domain serves the service; the rest are soon with a reason.++ const open = SITES.filter(s => s.status === 'open').map(s => s.id);++ assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki']);++ for (const s of SITES) {++ assert.ok(s.service && PINNED[s.service], `${s.id} names its service`);++ assert.strictEqual(s.status === 'open', PINNED[s.service] === 'live', `${s.id} nav status follows exposure`);++ }++ for (const id of ['news', 'reviews', 'deals', 'coupons', 'trade', 'host', 'tips', 'vip', 'stream', 'chat']) {++ const s = SITES.find(x => x.id === id);++ assert.strictEqual(s.status, 'soon'); assert.strictEqual(s.state, 'internal');++ }++++ eco.stop(); srv.close(); up.srv.close(); ai.srv.close();++ console.log('registry exposure: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });@@@ -75,7 +75,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22assert.strictEqual(by.realtime.status, 'not-running');assert.strictEqual(by.realtime.label, 'not running (placeholder)');assert.strictEqual(by.contracts.status, 'not-running');-- assert.strictEqual(by.contracts.label, 'not running (no runtime)');++ assert.strictEqual(by.contracts.label, 'not running (library, released v0.29.0)');for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);assert.strictEqual(r.body.summary.up + r.body.summary.degraded + r.body.summary.down + r.body.summary['not-running'] + r.body.summary.unknown, contracts.services.manifests.length);
@@ -280,6 +280,13 @@ Google-style account management supporting up to 5 accounts:
@@ -280,6 +280,13 @@ Google-style account management supporting up to 5 accounts:
`checked_at`. The data comes from the ecosystem registry poll (`server/registry/ecosystem.js`),
`checked_at`. The data comes from the ecosystem registry poll (`server/registry/ecosystem.js`),
which reads each service's readiness endpoint and `/release.json` about once a minute. A service
which reads each service's readiness endpoint and `/release.json` about once a minute. A service
not checked yet, or whose last check is more than three intervals old, shows as unknown.
not checked yet, or whose last check is more than three intervals old, shows as unknown.
+- Where each service can be reached is Network's exposure overlay (`server/registry/exposure.js`), not
+ the manifest's maturity label: `live` (its public domain serves it), `internal` (loopback only, the
+ domain still serves a placeholder page), `library` (released package), `repository` (code with CI,
+ nothing to run) or `placeholder` (planned). The registry adds `exposure` to each service, the
+ descriptor gives an `origin` only to `live` services (`planned_origin` otherwise), `/status` reads
+ "up (loopback only)" for an internal service, and the chrome nav lists only `live` sites. Change a
+ row when a domain stops serving its placeholder; `test/registry-exposure.test.js` pins the states.
- `GET /api/v1/status/slo` returns the proposed SLO categories, as does
- `GET /api/v1/status/slo` returns the proposed SLO categories, as does
[docs/slo.md](docs/slo.md) / [docs/slo.json](docs/slo.json).
[docs/slo.md](docs/slo.md) / [docs/slo.json](docs/slo.json).
- [docs/patches/live-observability.diff](docs/patches/live-observability.diff) is the same change for
- [docs/patches/live-observability.diff](docs/patches/live-observability.diff) is the same change for
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
//
//
// GET /api/chrome?host=<hostname> public, CORS *, max-age=600, ETag
// GET /api/chrome?host=<hostname> public, CORS *, max-age=600, ETag
// → { updated, nav: [{ id, name, url, icon, tagline }], open sites, most used first
// → { updated, nav: [{ id, name, url, icon, tagline }], open sites, most used first
-// soon: [{ id, name, url, icon }],
+// soon: [{ id, name, url, icon, state }], state: internal | placeholder
// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }
// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }
//
//
// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in
// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in
@@ -159,9 +159,10 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
@@ -159,9 +159,10 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
// Legal documents live on the site's own apex, so each domain answers for itself.
// Legal documents live on the site's own apex, so each domain answers for itself.
const legalBase = `https://${site.host}`;
const legalBase = `https://${site.host}`;
return {
return {
- updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status },
+ updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
nav: open.map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, tagline: s.tagline })),
nav: open.map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, tagline: s.tagline })),
- soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon })),
+ // state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
+ soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
};
};
}
}
@@ -1,31 +1,39 @@
@@ -1,31 +1,39 @@
'use strict';
'use strict';
+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):
// streaming user video + chat + payments tools files processed and discarded
// streaming user video + chat + payments tools files processed and discarded
// ugc user posts/pastes games accounts + game state
// ugc user posts/pastes games accounts + game state
// hosting stored user media account identity provider
// hosting stored user media account identity provider
// info read-only placeholder/editorial site
// info read-only placeholder/editorial site
-const SITES = [
- { id: 'live', name: 'Live', host: 'openvibe.live', icon: 'live', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'soon', 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', status: 'open', 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', status: 'open', 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', status: 'open', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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', status: 'soon', 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.' },
+// 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.
+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: '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.' },
];
];
+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 byHost = new Map(SITES.map(s => [s.host, s]));
const byHost = new Map(SITES.map(s => [s.host, s]));
/** The site a hostname belongs to (subdomains included), or null. */
/** The site a hostname belongs to (subdomains included), or null. */
@@ -5,10 +5,11 @@
@@ -5,10 +5,11 @@
* What exists, what each service can do, and whether it is up — built from the versioned manifests in
* What exists, what each service can do, and whether it is up — built from the versioned manifests in
* openvibe-contracts (services, capabilities, user-module namespaces, contract catalog) instead of route
* openvibe-contracts (services, capabilities, user-module namespaces, contract catalog) instead of route
* maps hard-coded into each product. Health is polled here and every value says when it was checked;
* maps hard-coded into each product. Health is polled here and every value says when it was checked;
- * nothing is presented as live without a timestamp.
+ * nothing is presented as live without a timestamp. Whether a service's public domain serves it, or it
+ * runs on loopback only, is Network's exposure overlay (./exposure.js): the manifests say maturity.
*
*
* GET /.well-known/openvibe platform descriptor
* GET /.well-known/openvibe platform descriptor
- * GET /api/v1/registry/services[?status=] manifests + health
+ * GET /api/v1/registry/services[?status=&state=] manifests + exposure + health
* GET /api/v1/registry/services/:id
* GET /api/v1/registry/services/:id
* GET /api/v1/registry/domains/:domain which service answers for a host
* GET /api/v1/registry/domains/:domain which service answers for a host
* GET /api/v1/registry/capabilities[?owner=] capability manifests
* GET /api/v1/registry/capabilities[?owner=] capability manifests
@@ -21,6 +22,7 @@
@@ -21,6 +22,7 @@
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const contractsPkg = require('openvibe-contracts/package.json');
const contractsPkg = require('openvibe-contracts/package.json');
+const { exposureOf, publicOriginOf } = require('./exposure');
// 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).
const INTERNAL = {
const INTERNAL = {
@@ -37,7 +39,10 @@ const POLL_MS = 60 * 1000;
@@ -37,7 +39,10 @@ const POLL_MS = 60 * 1000;
// Readiness paths of services whose manifest (openvibe-contracts) does not carry `ready` yet. Each
// 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
// answers with openvibe-shared/ready's shape; until a service deploys it, its health path is used
// and the row says so (basis: 'health').
// and the row says so (basis: 'health').
-const READY_PATHS = { network: '/api/ready', media: '/api/ready', tools: '/api/ready' };
+const READY_PATHS = { network: '/api/ready', media: '/api/ready', tools: '/api/ready', ai: '/api/ready' };
+// Liveness paths of services whose manifest in the pinned openvibe-contracts has none (AI is a
+// placeholder there but runs on loopback; see ./exposure.js).
+const HEALTH_PATHS = { ai: '/api/health' };
/** Row status from a readiness body (openvibe-shared/ready shape, or an older ad-hoc one). */
/** Row status from a readiness body (openvibe-shared/ready shape, or an older ad-hoc one). */
function statusFromReady(res, body) {
function statusFromReady(res, body) {
@@ -65,7 +70,7 @@ function readySummary(body) {
@@ -65,7 +70,7 @@ 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, now = () => Date.now() } = {}) {
+function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = globalThis.fetch, pollMs = POLL_MS, readyPaths = READY_PATHS, healthPaths = HEALTH_PATHS, now = () => Date.now() } = {}) {
const internal = { ...INTERNAL, ...internalOverrides };
const internal = { ...INTERNAL, ...internalOverrides };
// 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();
@@ -93,11 +98,16 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -93,11 +98,16 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
async function checkOne(m) {
async function checkOne(m) {
const at = () => new Date(now()).toISOString();
const at = () => new Date(now()).toISOString();
const base = internal[m.id];
const base = internal[m.id];
- if (m.status === 'placeholder') { health.set(m.id, { status: 'not-running', reason: 'placeholder', checked_at: at() }); return; }
+ // What runs is decided by Network's exposure overlay, not the manifest's maturity label.
+ const exp = exposureOf(m.id);
+ if (exp.state === 'placeholder' || (exp.state === 'unknown' && m.status === 'placeholder')) { health.set(m.id, { status: 'not-running', reason: 'placeholder', checked_at: at() }); return; }
+ if (exp.state === 'library') { health.set(m.id, { status: 'not-running', reason: `library, released ${exp.release}`, checked_at: at() }); return; }
+ if (exp.state === 'repository') { health.set(m.id, { status: 'not-running', reason: 'repository, nothing to run', checked_at: at() }); return; }
const readyPath = m.ready || readyPaths[m.id] || null;
const readyPath = m.ready || readyPaths[m.id] || null;
- if (!m.health && !readyPath && !(m.domains || []).length) { health.set(m.id, { status: 'not-running', reason: 'no runtime', checked_at: at() }); return; }
+ const healthPath = m.health || healthPaths[m.id] || null;
+ if (!healthPath && !readyPath && !(m.domains || []).length) { health.set(m.id, { status: 'not-running', reason: 'no runtime', checked_at: at() }); return; }
if (!base) { health.set(m.id, { status: 'unknown', reason: 'no internal address known to Network', checked_at: at() }); return; }
if (!base) { health.set(m.id, { status: 'unknown', reason: 'no internal address known to Network', checked_at: at() }); return; }
- if (!readyPath && !m.health) { health.set(m.id, { status: 'unknown', reason: 'no health or readiness path in its manifest', checked_at: at() }); return; }
+ if (!readyPath && !healthPath) { health.set(m.id, { status: 'unknown', reason: 'no health or readiness path in its manifest', checked_at: at() }); return; }
const releaseP = releaseOf(base);
const releaseP = releaseOf(base);
let entry;
let entry;
try {
try {
@@ -106,14 +116,18 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -106,14 +116,18 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
entry = { ...statusFromReady(got.res, got.body), ready: readySummary(got.body) };
entry = { ...statusFromReady(got.res, got.body), ready: readySummary(got.body) };
} else {
} else {
// No readiness endpoint (yet): liveness is all that can be said.
// No readiness endpoint (yet): liveness is all that can be said.
- got = await getJson(`${base}${m.health}`);
+ if (!healthPath) throw Object.assign(new Error('readiness endpoint answered 404'), { name: 'NoReadiness' });
+ got = await getJson(`${base}${healthPath}`);
entry = { status: got.res.ok ? 'up' : 'down', basis: 'health', reason: 'liveness only: no readiness endpoint answered', ready: null };
entry = { status: got.res.ok ? 'up' : 'down', basis: 'health', reason: 'liveness only: no readiness endpoint answered', ready: null };
}
}
entry.http_status = got.res.status;
entry.http_status = got.res.status;
entry.latency_ms = got.latency;
entry.latency_ms = got.latency;
} catch (err) {
} catch (err) {
- entry = { status: 'down', basis: 'unreachable', error: err.name === 'TimeoutError' ? 'timeout' : 'unreachable', ready: null };
+ entry = { status: 'down', basis: 'unreachable', error: err.name === 'TimeoutError' ? 'timeout' : err.name === 'NoReadiness' ? err.message : 'unreachable', ready: null };
}
}
+ // Polled on loopback: a running internal service is up there, not at its public domain.
+ entry.scope = exp.state === 'live' ? 'public' : 'loopback';
+ if (exp.state === 'internal') entry.reason = ['loopback only, no public site yet', entry.reason].filter(Boolean).join('; ');
entry.release = await releaseP;
entry.release = await releaseP;
entry.checked_at = at();
entry.checked_at = at();
health.set(m.id, entry);
health.set(m.id, entry);
@@ -137,7 +151,8 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -137,7 +151,8 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return h;
return h;
}
}
- const withHealth = (m) => ({ ...m, runtime: current(m.id) });
+ // The manifest as published, plus where it can be reached (exposure) and what the last check saw (runtime).
+ const withHealth = (m) => ({ ...m, exposure: exposureOf(m.id), runtime: current(m.id) });
function descriptor() {
function descriptor() {
return {
return {
@@ -148,7 +163,12 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -148,7 +163,12 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
openid_configuration: `${issuer}/oauth/.well-known/openid-configuration`,
openid_configuration: `${issuer}/oauth/.well-known/openid-configuration`,
registry: `${issuer}/api/v1/registry`,
registry: `${issuer}/api/v1/registry`,
contracts: { package: 'openvibe-contracts', version: contractsPkg.version, repository: 'https://github.com/OpenVibers/OpenVibe.Contracts', catalog: `${issuer}/api/v1/registry/contracts` },
contracts: { package: 'openvibe-contracts', version: contractsPkg.version, repository: 'https://github.com/OpenVibers/OpenVibe.Contracts', catalog: `${issuer}/api/v1/registry/contracts` },
- services: contracts.services.manifests.map(m => ({ id: m.id, status: m.status, origin: m.publicOrigin || null })),
+ // origin is set only where the public domain serves the service itself; a domain that still
+ // serves a placeholder is planned_origin, so a client never routes calls to a placeholder page.
+ services: contracts.services.manifests.map((m) => {
+ const e = exposureOf(m.id); const origin = publicOriginOf(m);
+ return { id: m.id, status: m.status, state: e.state, origin, ...(!origin && m.publicOrigin ? { planned_origin: m.publicOrigin } : {}) };
+ }),
};
};
}
}
@@ -162,6 +182,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -162,6 +182,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
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));
+ if (req.query.state) list = list.filter(m => m.exposure.state === String(req.query.state));
cache(res, 30).json({ services: list, contracts_version: contractsPkg.version });
cache(res, 30).json({ services: list, contracts_version: contractsPkg.version });
});
});
r.get('/api/v1/registry/services/:id', (req, res) => {
r.get('/api/v1/registry/services/:id', (req, res) => {
@@ -200,4 +221,4 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -200,4 +221,4 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return { router, start, stop, pollAll, health, current, descriptor, lastPollAt: () => lastPollAt, pollMs, internal };
return { router, start, stop, pollAll, health, current, descriptor, lastPollAt: () => lastPollAt, pollMs, internal };
}
}
-module.exports = { createEcosystemRegistry, INTERNAL, READY_PATHS, statusFromReady, readySummary };
+module.exports = { createEcosystemRegistry, INTERNAL, READY_PATHS, HEALTH_PATHS, statusFromReady, readySummary };
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * Where each OpenVibe service can actually be reached — Network's own, observed overlay on the
+ * openvibe-contracts manifests.
+ *
+ * A manifest's `status` (alpha, beta, stable, placeholder) is maturity, and its `publicOrigin` is where
+ * the service is meant to answer. Neither says whether that public domain serves the service today:
+ * on 2026-09-23 openvibe.news, .reviews, .deals, .coupons, .trade, .host, .tips, .vip and openre.stream
+ * served the OpenVibe.Sites "coming soon" placeholder, search. and sources.openvibe.network served the
+ * admin placeholder, while each of those services ran on this host's loopback only. And AI, SDK,
+ * Shared and Examples existed although their manifests (openvibe-contracts 0.28.0) said placeholder.
+ *
+ * Every manifest id has exactly one state here (test/registry-exposure.test.js pins them):
+ *
+ * live live (public) its public domain serves the service itself
+ * internal internal runs on loopback only; no public site yet (the domain, if any,
+ * still serves a placeholder page)
+ * library library (released) a package other code installs; nothing to run
+ * repository repository code with CI, neither released nor run (examples)
+ * placeholder placeholder (planned) charter only; nothing runs
+ *
+ * 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".
+ */
+
+const STATES = ['live', 'internal', 'library', 'repository', 'placeholder'];
+const LABEL = {
+ live: 'live (public)',
+ internal: 'internal (loopback only, no public site yet)',
+ library: 'library (released)',
+ repository: 'repository (code with CI, nothing released or running)',
+ placeholder: 'placeholder (planned)',
+};
+
+// public_site: what the service's public domain(s) answer today — 'service' (the service itself),
+// 'placeholder' (a Sites/admin placeholder page) or null (no public domain).
+const SITE_PLACEHOLDER = 'the domain serves the OpenVibe.Sites placeholder page';
+const ADMIN_PLACEHOLDER = 'the domain serves the admin.openvibe.network placeholder page';
+const EXPOSURE = {
+ network: { state: 'live', public_site: 'service' },
+ live: { state: 'live', public_site: 'service' },
+ tools: { state: 'live', public_site: 'service' },
+ media: { state: 'live', public_site: 'service' },
+ games: { state: 'live', public_site: 'service' },
+ community: { state: 'live', public_site: 'service' },
+ events: { state: 'live', public_site: 'service' },
+ billing: { state: 'live', public_site: 'service', note: 'the public domain is the staff console; accounts pay through each product' },
+ codes: { state: 'live', public_site: 'service' },
+ blog: { state: 'live', public_site: 'service' },
+ wiki: { state: 'live', public_site: 'service' },
+ sites: { state: 'live', public_site: 'service', note: 'serves the placeholder pages of domains whose service is not public yet' },
+
+ news: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ reviews: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ deals: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ coupons: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ trade: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ host: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ tips: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ vip: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ openre: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER },
+ search: { state: 'internal', public_site: 'placeholder', note: ADMIN_PLACEHOLDER },
+ sources: { state: 'internal', public_site: 'placeholder', note: ADMIN_PLACEHOLDER },
+ chat: { state: 'internal', public_site: null, note: 'serves Live\'s chat through Live; openvibe.chat serves the OpenVibe.Sites placeholder page' },
+ ai: { state: 'internal', public_site: null, note: 'called by other services at 127.0.0.1:4700; ai.openvibe.network serves the OpenVibe.Sites placeholder page' },
+
+ sdk: { state: 'library', public_site: null, release: 'v0.4.0', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0' },
+ shared: { state: 'library', public_site: null, release: 'v1.3.0', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0', note: 'also served at /shared/* by each site' },
+ contracts: { state: 'library', public_site: null, release: 'v0.29.0', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.29.0', note: 'schemas are also served by this registry at their $id URLs' },
+ examples: { state: 'repository', public_site: null, note: 'example apps with CI in OpenVibers/OpenVibe.Examples; not published as a package' },
+
+ realtime: { state: 'placeholder', public_site: null },
+};
+
+/** The exposure of one service id; an id nobody classified is 'unknown', never assumed public. */
+function exposureOf(id) {
+ const e = EXPOSURE[id];
+ if (!e) return { state: 'unknown', label: 'unknown (not classified by Network)', public_site: null, note: null };
+ return { state: e.state, label: LABEL[e.state], public_site: e.public_site, note: e.note || null, ...(e.release ? { release: e.release, distribution: e.distribution || null } : {}) };
+}
+
+/** A manifest's public origin only when its domain serves the service itself. */
+function publicOriginOf(m) {
+ const e = EXPOSURE[m.id];
+ return e && e.state === 'live' && e.public_site === 'service' ? m.publicOrigin || null : null;
+}
+
+module.exports = { STATES, LABEL, EXPOSURE, exposureOf, publicOriginOf };
@@ -11,12 +11,17 @@
@@ -11,12 +11,17 @@
* service's readiness (openvibe-shared/ready shape) and /release.json. Every row is one of
* service's readiness (openvibe-shared/ready shape) and /release.json. Every row is one of
* up | degraded | down | not-running | unknown and carries checked_at; a service not checked yet, or
* up | degraded | down | not-running | unknown and carries checked_at; a service not checked yet, or
* whose last check is stale, is 'unknown' — never an optimistic default.
* whose last check is stale, is 'unknown' — never an optimistic default.
+ *
+ * Each row also carries its exposure (server/registry/exposure.js): live (public), internal (loopback
+ * only), library, repository or placeholder. A service that is up on loopback while its public domain
+ * serves a placeholder reads "up (loopback only)" and has no public origin, never a bare "up".
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const exposure = require('../registry/exposure');
const SLO_FILE = path.join(__dirname, '..', '..', 'docs', 'slo.json');
const SLO_FILE = path.join(__dirname, '..', '..', 'docs', 'slo.json');
const esc = seo.esc;
const esc = seo.esc;
@@ -29,13 +34,18 @@ function rows(ecosystem) {
@@ -29,13 +34,18 @@ function rows(ecosystem) {
return contracts.services.manifests.map((m) => {
return contracts.services.manifests.map((m) => {
const r = ecosystem.current(m.id);
const r = ecosystem.current(m.id);
const status = STATES.includes(r.status) ? r.status : 'unknown';
const status = STATES.includes(r.status) ? r.status : 'unknown';
+ const exp = exposure.exposureOf(m.id);
+ const origin = exposure.publicOriginOf(m);
+ const loopback = exp.state !== 'live' && !['not-running', 'unknown'].includes(status);
return {
return {
id: m.id,
id: m.id,
name: m.name,
name: m.name,
manifest_status: m.status,
manifest_status: m.status,
- origin: m.publicOrigin || null,
+ exposure: exp,
+ origin,
+ ...(!origin && m.publicOrigin ? { planned_origin: m.publicOrigin } : {}),
status,
status,
- label: status === 'not-running' && r.reason ? `not running (${r.reason})` : status,
+ label: status === 'not-running' && r.reason ? `not running (${r.reason})` : loopback ? `${status} (loopback only)` : status,
basis: r.basis || null,
basis: r.basis || null,
reason: r.reason || r.error || null,
reason: r.reason || r.error || null,
checked_at: r.checked_at || null,
checked_at: r.checked_at || null,
@@ -55,6 +65,19 @@ function summary(list) {
@@ -55,6 +65,19 @@ function summary(list) {
return counts;
return counts;
}
}
+function exposureSummary(list) {
+ const counts = Object.fromEntries(exposure.STATES.map((s) => [s, 0]));
+ for (const r of list) counts[r.exposure.state] = (counts[r.exposure.state] || 0) + 1;
+ return counts;
+}
+
+function exposureCell(r) {
+ const e = r.exposure;
+ const where = r.origin ? `<small><a href="${esc(r.origin)}" rel="noopener">${esc(r.origin.replace(/^https:\/\//, ''))}</a></small>`
+ : r.planned_origin ? `<small>${esc(r.planned_origin.replace(/^https:\/\//, ''))}: not this service yet</small>` : '';
+ return `${esc(e.label)}${e.release ? ` <code>${esc(e.release)}</code>` : ''}${where}${e.note ? `<small>${esc(e.note)}</small>` : ''}`;
+}
+
const CSS = `
const CSS = `
.st{max-width:1080px;margin:32px auto 64px;padding:0 20px}.st h1{font-size:clamp(24px,3vw,32px);letter-spacing:-.02em;margin:0 0 6px}
.st{max-width:1080px;margin:32px auto 64px;padding:0 20px}.st h1{font-size:clamp(24px,3vw,32px);letter-spacing:-.02em;margin:0 0 6px}
.st p.lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:780px;line-height:1.5}
.st p.lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:780px;line-height:1.5}
@@ -78,7 +101,8 @@ function renderPage(list, slo, generatedAt) {
@@ -78,7 +101,8 @@ function renderPage(list, slo, generatedAt) {
const counts = summary(list);
const counts = summary(list);
const tr = list.map((r) => `<tr id="svc-${esc(r.id)}">
const tr = list.map((r) => `<tr id="svc-${esc(r.id)}">
<td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td>
<td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td>
-<td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status])}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td>
+<td>${exposureCell(r)}</td>
+<td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status] + (r.label.endsWith('(loopback only)') ? ' · loopback only' : ''))}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td>
<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td>
<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td>
<td>${r.checked_at ? `<time datetime="${esc(r.checked_at)}">${esc(r.checked_at)}</time>` : '<small>not checked yet</small>'}${r.latency_ms != null ? `<small>${esc(r.latency_ms)} ms</small>` : ''}</td>
<td>${r.checked_at ? `<time datetime="${esc(r.checked_at)}">${esc(r.checked_at)}</time>` : '<small>not checked yet</small>'}${r.latency_ms != null ? `<small>${esc(r.latency_ms)} ms</small>` : ''}</td>
</tr>`).join('\n');
</tr>`).join('\n');
@@ -107,10 +131,10 @@ ${CSS}
@@ -107,10 +131,10 @@ ${CSS}
${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}
${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}
<main class="st" id="main">
<main class="st" id="main">
<h1>Service status</h1>
<h1>Service status</h1>
-<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders show as not running. Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p>
+<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders, libraries and repositories show as not running. <b>Where</b> says whether the public domain serves the service itself (live), or the service runs on this host's loopback only while its domain still serves a placeholder page (internal). Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p>
<ul class="st-sum">${STATES.map((s) => `<li class="st-${s}">${esc(LABEL[s])}: ${counts[s]}</li>`).join('')}</ul>
<ul class="st-sum">${STATES.map((s) => `<li class="st-${s}">${esc(LABEL[s])}: ${counts[s]}</li>`).join('')}</ul>
<table>
<table>
-<thead><tr><th>Service</th><th>Status</th><th>Release</th><th>Checked</th></tr></thead>
+<thead><tr><th>Service</th><th>Where</th><th>Status</th><th>Release</th><th>Checked</th></tr></thead>
<tbody>
<tbody>
${tr}
${tr}
</tbody>
</tbody>
@@ -139,6 +163,8 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {
@@ -139,6 +163,8 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {
poll_seconds: Math.round(ecosystem.pollMs / 1000),
poll_seconds: Math.round(ecosystem.pollMs / 1000),
states: STATES,
states: STATES,
summary: summary(list),
summary: summary(list),
+ exposure_states: exposure.STATES,
+ exposure_summary: exposureSummary(list),
services: list,
services: list,
});
});
});
});
@@ -31,7 +31,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -31,7 +31,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');
assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');
- assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime'))).valid));
+ assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime' && k !== 'exposure'))).valid));
r = await get('/api/v1/registry/services?status=placeholder');
r = await get('/api/v1/registry/services?status=placeholder');
assert.ok(r.body.services.length >= 2 && r.body.services.every(s => s.status === 'placeholder'));
assert.ok(r.body.services.length >= 2 && r.body.services.every(s => s.status === 'placeholder'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+// Exposure overlay (server/registry/exposure.js): every service has one honest state, and the
+// registry, the status page and the chrome nav all say the same thing. A service that runs on
+// loopback while its public domain still serves a placeholder is never "up" at that domain.
+// node test/registry-exposure.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const exposure = require('../server/registry/exposure');
+const { createEcosystemRegistry } = require('../server/registry/ecosystem');
+const { createStatusRoutes } = require('../server/status/routes');
+const { SITES } = require('../server/chrome/sites');
+
+// Observed 2026-09-23 with curl against every public domain. Change a row only after the domain
+// stops answering "this page is a placeholder" (or a library ships a new release).
+const PINNED = {
+ network: 'live', live: 'live', tools: 'live', media: 'live', games: 'live', community: 'live', events: 'live',
+ billing: 'live', codes: 'live', blog: 'live', wiki: 'live', sites: 'live',
+ news: 'internal', reviews: 'internal', deals: 'internal', coupons: 'internal', trade: 'internal', host: 'internal',
+ tips: 'internal', vip: 'internal', openre: 'internal', search: 'internal', sources: 'internal', chat: 'internal', ai: 'internal',
+ sdk: 'library', shared: 'library', contracts: 'library',
+ examples: 'repository',
+ realtime: 'placeholder',
+};
+const PLACEHOLDER_DOMAINS = ['news', 'reviews', 'deals', 'coupons', 'trade', 'host', 'tips', 'vip', 'openre', 'search', 'sources'];
+
+function fakeService(routes) {
+ const srv = http.createServer((req, res) => {
+ const h = routes[req.url];
+ if (!h) { res.statusCode = 404; return res.end('{}'); }
+ res.statusCode = h[0]; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(h[1]));
+ });
+ return new Promise(r => srv.listen(0, '127.0.0.1', () => r({ srv, url: `http://127.0.0.1:${srv.address().port}` })));
+}
+const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], checks: { db: { status: 'ok', required: true } } }];
+
+(async () => {
+ // 1. Every manifest is classified, and the classification is the pinned one.
+ for (const m of contracts.services.manifests) assert.notStrictEqual(exposure.exposureOf(m.id).state, 'unknown', `${m.id} has no exposure state`);
+ const actual = Object.fromEntries(contracts.services.manifests.map(m => [m.id, exposure.exposureOf(m.id).state]));
+ assert.deepStrictEqual(actual, PINNED);
+ assert.deepStrictEqual(Object.keys(exposure.EXPOSURE).sort(), Object.keys(PINNED).sort(), 'no stale rows for services that no longer exist');
+ for (const id of PLACEHOLDER_DOMAINS) {
+ assert.strictEqual(exposure.EXPOSURE[id].public_site, 'placeholder', `${id}'s domain serves a placeholder`);
+ assert.strictEqual(exposure.publicOriginOf(contracts.services.get(id)), null, `${id} has no public origin`);
+ }
+ assert.strictEqual(exposure.exposureOf('sdk').release, 'v0.4.0');
+ assert.strictEqual(exposure.exposureOf('shared').release, 'v1.3.0');
+ assert.strictEqual(exposure.exposureOf('nope').state, 'unknown', 'an unclassified id is unknown, not public');
+
+ // 2. Registry and status over fake loopback services.
+ const up = await fakeService({ '/api/ready': ready, '/ready': ready, '/api/health': [200, { status: 'ok' }], '/health': [200, {}], '/healthz': [200, {}] });
+ const ai = await fakeService({ '/api/ready': ready, '/api/health': [200, { status: 'ok' }] }); // AI's manifest (contracts 0.28.0) has no paths
+ const overrides = {};
+ for (const id of Object.keys(exposure.EXPOSURE)) overrides[id] = up.url;
+ overrides.ai = ai.url;
+ const eco = createEcosystemRegistry({ issuer: 'https://openvibe.network', internalOverrides: overrides });
+ await eco.pollAll();
+ const app = express();
+ app.use(createStatusRoutes({ ecosystem: eco }));
+ app.use(eco.router());
+ const srv = http.createServer(app);
+ await new Promise(r => srv.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${srv.address().port}`;
+ const json = (p) => fetch(base + p).then(r => r.json());
+
+ const d = await json('/.well-known/openvibe');
+ const dBy = Object.fromEntries(d.services.map(s => [s.id, s]));
+ assert.strictEqual(dBy.news.state, 'internal');
+ assert.strictEqual(dBy.news.origin, null, 'clients never route to a placeholder page');
+ assert.strictEqual(dBy.news.planned_origin, 'https://openvibe.news');
+ assert.strictEqual(dBy.live.origin, 'https://openvibe.live');
+ assert.strictEqual(dBy.live.state, 'live');
+ for (const s of d.services) assert.strictEqual(s.state, PINNED[s.id]);
+
+ let r = await json('/api/v1/registry/services');
+ const by = Object.fromEntries(r.services.map(s => [s.id, s]));
+ assert.strictEqual(by.deals.exposure.state, 'internal');
+ assert.strictEqual(by.deals.exposure.public_site, 'placeholder');
+ assert.strictEqual(by.deals.runtime.status, 'up');
+ assert.strictEqual(by.deals.runtime.scope, 'loopback');
+ assert.match(by.deals.runtime.reason, /loopback only/);
+ assert.strictEqual(by.ai.runtime.status, 'up', 'AI is polled on loopback although its pinned manifest says placeholder');
+ assert.strictEqual(by.ai.runtime.basis, 'ready');
+ assert.strictEqual(by.sdk.runtime.status, 'not-running');
+ assert.strictEqual(by.sdk.runtime.reason, 'library, released v0.4.0');
+ assert.strictEqual(by.examples.runtime.reason, 'repository, nothing to run');
+ assert.strictEqual(by.realtime.runtime.reason, 'placeholder');
+ assert.strictEqual(by.live.runtime.scope, 'public');
+ r = await json('/api/v1/registry/services?state=internal');
+ assert.deepStrictEqual(r.services.map(s => s.id).sort(), Object.keys(PINNED).filter(k => PINNED[k] === 'internal').sort());
+ r = await json('/api/v1/registry/domains/openvibe.coupons');
+ assert.strictEqual(r.service.exposure.state, 'internal');
+
+ const st = await json('/api/v1/status');
+ const sBy = Object.fromEntries(st.services.map(s => [s.id, s]));
+ assert.strictEqual(sBy.news.label, 'up (loopback only)');
+ assert.strictEqual(sBy.news.origin, null);
+ assert.strictEqual(sBy.news.planned_origin, 'https://openvibe.news');
+ assert.strictEqual(sBy.live.label, 'up');
+ assert.strictEqual(sBy.live.origin, 'https://openvibe.live');
+ assert.strictEqual(sBy.shared.label, 'not running (library, released v1.3.0)');
+ assert.deepStrictEqual(st.exposure_summary, { live: 12, internal: 13, library: 3, repository: 1, placeholder: 1 });
+
+ // The page: no row for a service whose domain serves a placeholder shows a bare "Up".
+ const html = await fetch(base + '/status').then(x => x.text());
+ for (const id of PLACEHOLDER_DOMAINS.concat(['chat', 'ai'])) {
+ const row = html.match(new RegExp(`<tr id="svc-${id}">[\\s\\S]*?</tr>`))[0];
+ assert.ok(!/>Up<\/span>/.test(row), `${id} row must not read a bare Up`);
+ assert.ok(row.includes('Up · loopback only'), `${id} row says loopback only`);
+ assert.ok(row.includes('internal (loopback only, no public site yet)'), `${id} row names its exposure`);
+ }
+ assert.ok(/<tr id="svc-news">[\s\S]*?openvibe\.news: not this service yet/.test(html));
+ assert.ok(/<tr id="svc-live">[\s\S]*?>Up<\/span>/.test(html), 'a public service still reads Up');
+
+ // 3. Chrome: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
+ const open = SITES.filter(s => s.status === 'open').map(s => s.id);
+ assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki']);
+ for (const s of SITES) {
+ assert.ok(s.service && PINNED[s.service], `${s.id} names its service`);
+ assert.strictEqual(s.status === 'open', PINNED[s.service] === 'live', `${s.id} nav status follows exposure`);
+ }
+ for (const id of ['news', 'reviews', 'deals', 'coupons', 'trade', 'host', 'tips', 'vip', 'stream', 'chat']) {
+ const s = SITES.find(x => x.id === id);
+ assert.strictEqual(s.status, 'soon'); assert.strictEqual(s.state, 'internal');
+ }
+
+ eco.stop(); srv.close(); up.srv.close(); ai.srv.close();
+ console.log('registry exposure: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });
@@ -75,7 +75,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
@@ -75,7 +75,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
assert.strictEqual(by.realtime.status, 'not-running');
assert.strictEqual(by.realtime.status, 'not-running');
assert.strictEqual(by.realtime.label, 'not running (placeholder)');
assert.strictEqual(by.realtime.label, 'not running (placeholder)');
assert.strictEqual(by.contracts.status, 'not-running');
assert.strictEqual(by.contracts.status, 'not-running');
- assert.strictEqual(by.contracts.label, 'not running (no runtime)');
+ assert.strictEqual(by.contracts.label, 'not running (library, released v0.29.0)');
for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);
for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);
assert.strictEqual(r.body.summary.up + r.body.summary.degraded + r.body.summary.down + r.body.summary['not-running'] + r.body.summary.unknown, contracts.services.manifests.length);
assert.strictEqual(r.body.summary.up + r.body.summary.degraded + r.body.summary.down + r.body.summary['not-running'] + r.body.summary.unknown, contracts.services.manifests.length);