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

OP OpenVibers committed 23 Sept 2026 · 29cdb09aef · 10 files, +325 −43


Changes

ddiff --git a/README.md b/README.md
iindex e61a3851..64b5c719 100644
---- a/README.md
++++ b/README.md
@@@ -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 service
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
[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
ddiff --git a/package.json b/package.json
iindex 23ffbcd7..75fd6a29 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/chrome/service.js b/server/chrome/service.js
iindex 3f65b1aa..5467b0c2 100644
---- a/server/chrome/service.js
++++ b/server/chrome/service.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' } },
};
}
ddiff --git a/server/chrome/sites.js b/server/chrome/sites.js
iindex a1ae6ed2..e2a6e74d 100644
---- a/server/chrome/sites.js
++++ b/server/chrome/sites.js
@@@ -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. */
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex 73b4f06f..2583296b 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -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 = g
async 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 = g
entry = { ...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 = g
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() {
return {
@@@ -148,7 +163,12 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
openid_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 = g
r.get('/api/v1/registry/services', (req, res) => {
let list = contracts.services.manifests.map(withHealth);
if (req.query.status) list = list.filter(m => m.status === String(req.query.status));
++ 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 = g
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 };
ddiff --git a/server/registry/exposure.js b/server/registry/exposure.js
nnew file mode 100644
iindex 00000000..4f8b6de4
---- /dev/null
++++ b/server/registry/exposure.js
@@@ -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 };
ddiff --git a/server/status/routes.js b/server/status/routes.js
iindex a3e0dbe5..7d4099ad 100644
---- a/server/status/routes.js
++++ b/server/status/routes.js
@@@ -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,
});
});
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex eb8fc777..b7e2e2ab 100644
---- a/test/registry-ecosystem.test.js
++++ b/test/registry-ecosystem.test.js
@@@ -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'));
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
nnew file mode 100644
iindex 00000000..df3e5cb1
---- /dev/null
++++ b/test/registry-exposure.test.js
@@@ -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); });
ddiff --git a/test/status.test.js b/test/status.test.js
iindex f4ad06e8..d8b58a2f 100644
---- a/test/status.test.js
++++ b/test/status.test.js
@@@ -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.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);