openvibers/openvibe.network Commits

Manifest-derived registry (WS-C task 1, Contracts 0.42.0): the loopback addresses, exposure states and the frame's site list come from the service manifests (internalOrigin, exposure, site); INTERNAL, READY_PATHS, EXPOSURE and RAW_SITES are no longer written here, OV_<ID>_INTERNAL_URL still overrides; test/registry-manifest-fixture.test.js: a new manifest appears in discovery, the registry, the status page and the site list with no code edit; npm test also runs staff-claims.test.js, which it had missed

OP OpenVibers committed 25 Sept 2026 · ca0b63ef79 · 7 files, +111 −108


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 45506e0a..0b8d48d7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.42.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.41.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.1",
-- "integrity": "sha512-PdXYQK9Nt9LBv45ZR8hdIW6xt9zrSJAETDP5roRWQ3J3c7+TP6iTBZp/X1bjdxNDnNzxMPthkGsO7+E5vSXrbw==",
++ "version": "0.42.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.42.0",
++ "integrity": "sha512-lQUPg3bkpusZmFRyJHaARwnA4J+WcmQfVdM28yOHYhVChiMs/3rr3hl10JucKOlxxx9Cii1Go0iv+IfxqVO8UA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex a79ea6a2..ae7df58a 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/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.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/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.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/events-consumer.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 && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.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/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.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/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/registry-topics.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/events-consumer.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 && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.42.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
iindex 96bbfc4d..f94309ea 100644
---- a/server/frame/sites.js
++++ b/server/frame/sites.js
@@@ -10,30 +10,13 @@ const { exposureOf } = require('../registry/exposure');
// A site is 'open' (in the nav) only when its service's public domain serves the service itself, per
// Network's exposure overlay (server/registry/exposure.js); every other site is 'soon' and says why
// (state: internal = runs on loopback only, placeholder = planned). The two lists cannot disagree.
--// host comes from the service's manifest (openvibe-contracts publicOrigin); an entry names its host only
--// while its manifest has no public origin. TODO(plan §5.1/§5.3): name, icon, tagline, what and the
--// legal profile are presentation with no manifest field yet; they move when the manifest schema has them.
--const RAW_SITES = [
-- { id: 'live', name: 'Live', icon: 'live', service: 'live', profile: 'streaming', tagline: 'Streams, clips and chat', what: 'Live streaming from a browser, OBS or a phone, with restreaming, clips, VODs, chat games and channel points.' },
-- { id: 'tools', name: 'Tools', icon: 'tools', service: 'tools', profile: 'tools', tagline: 'Every online tool', what: 'More than 160 online tools: converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own address.' },
-- { id: 'community', name: 'Community', icon: 'community', service: 'community', profile: 'ugc', tagline: 'Pastes and posts', what: 'Share text and code with a link, comment, and follow what people on OpenVibe are making.' },
-- { id: 'games', name: 'Games', icon: 'games', service: 'games', profile: 'games', tagline: 'Browser games', what: 'Scraplandia, a shared pixel canvas and other games that run in a tab and know your OpenVibe account.' },
-- { id: 'media', name: 'Media', icon: 'media', service: 'media', profile: 'hosting', tagline: 'VODs, clips, files', what: 'The public media library behind every site: recorded streams, clips, images and uploads.' },
-- { id: 'network', name: 'Network', icon: 'network', service: 'network', profile: 'account', tagline: 'Account and themes', what: 'The account behind every site: one sign-in, themes, notifications, history and linked services.' },
-- { id: 'chat', name: 'Chat', icon: 'chat', service: 'chat', profile: 'ugc', tagline: 'One chat for the network', what: 'Global chat, rooms, stream chat and direct messages under one account, with or without JavaScript.' },
-- { id: 'codes', name: 'Codes', icon: 'codes', service: 'codes', profile: 'ugc', tagline: 'Build on OpenVibe', what: 'The developer portal: API and SDK docs, the service and capability registry, event schemas, credentials, playgrounds and mod publishing.' },
-- { id: 'blog', name: 'Blog', icon: 'blog', service: 'blog', profile: 'info', tagline: 'Long-form, yours to keep', what: 'The official OpenVibe blog and a blog for every member: drafts, scheduling, media, comments and feeds.' },
-- { id: 'wiki', name: 'Wiki', icon: 'wiki', service: 'wiki', profile: 'ugc', tagline: 'Knowledge, with sources', what: 'Wiki spaces with page trees, revision history, citations and discussion, editable together.' },
-- { id: 'news', name: 'News', icon: 'news', service: 'news', profile: 'info', tagline: 'Sourced, not spun', what: 'Source-backed stories: clustered coverage, cited summaries, multiple perspectives and timelines.' },
-- { id: 'reviews', name: 'Reviews', icon: 'reviews', service: 'reviews', profile: 'ugc', tagline: 'Reviews with receipts', what: 'Review signals gathered across sources with provenance, pros and cons, and community discussion. No invented ratings.' },
-- { id: 'tips', name: 'Tips', icon: 'tips', service: 'tips', profile: 'streaming', tagline: 'Support creators', what: 'Tips, goals, paid messages and alerts for creators, settled by the network\'s billing ledger.' },
-- { id: 'vip', name: 'VIP', icon: 'vip', service: 'vip', profile: 'account', tagline: 'Memberships and perks', what: 'Creator and network memberships: plans, perks and benefits recognised on every OpenVibe site.' },
-- { id: 'trade', name: 'Trade', icon: 'trade', service: 'trade', profile: 'ugc', tagline: 'Watchlists and alerts', what: 'Informational watchlists, sourced market context and alerts. No custody, no order execution.' },
-- { id: 'host', name: 'Host', icon: 'host', service: 'host', profile: 'hosting', tagline: 'Deploy and host', what: 'The network\'s deployment plane first, then simple hosting for community sites, bots and mods.' },
-- { id: 'deals', name: 'Deals', icon: 'deals', service: 'deals', profile: 'info', tagline: 'Deals worth sharing', what: 'Deals submitted and voted on by the community, with source, price and freshness always shown.' },
-- { id: 'coupons', name: 'Coupons', icon: 'coupons', service: 'coupons', profile: 'info', tagline: 'Codes that work', what: 'Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.' },
-- { id: 'stream', name: 'OpenRe.Stream', icon: 'stream', service: 'openre', profile: 'streaming', tagline: 'Restream anywhere', what: 'Ingest once and send the stream to every platform at once; keys, destinations and output health in one place.' },
--];
++// Since openvibe-contracts 0.42.0 (WS-C task 1) the list is the manifests' `site` blocks, in `position` order:
++// name, icon, tagline, what, the legal profile, and a host only while the manifest has no publicOrigin. A
++// site's id is its icon name (live, tools, …, stream for OpenRe.Stream).
++const RAW_SITES = contracts.services.manifests.filter((m) => m.site).sort((a, b) => (a.site.position ?? 999) - (b.site.position ?? 999)).map((m) => ({
++ id: m.site.icon, name: m.site.name, icon: m.site.icon, service: m.id, profile: m.site.legalProfile, tagline: m.site.tagline, what: m.site.what,
++ ...(m.site.host ? { host: m.site.host } : {}),
++}));
function manifestHost(service) {
const m = contracts.services.get(service);
try { return m && m.publicOrigin ? new URL(m.publicOrigin).hostname : null; } catch { return null; }
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex c2c898d6..eeef1d6e 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -38,30 +38,14 @@ const { buildTopics, eventsOf } = require('./topics');
const { driftOf } = require('./versions');
const { SITES } = require('../frame/sites');
--// Where each running service answers health checks from this host (manifests carry the public origin).
--// TODO(plan §5.1 "contract manifests plus validated environment overrides"): registry.service-manifest@1 has
--// no internal address, so these loopback ports stay here until Contracts adds one (or Network reads
--// OpenVibe.Host's host.json). Until then OV_<ID>_INTERNAL_URL overrides any of them (internalFromEnv below),
--// validated as a loopback http(s) URL, which is the environment half of that rule.
--const INTERNAL = {
-- network: 'http://127.0.0.1:4000', live: 'http://127.0.0.1:3000', media: 'http://127.0.0.1:4100',
-- tools: 'http://127.0.0.1:4001', games: 'http://127.0.0.1:8000', community: 'http://127.0.0.1:4200',
-- events: 'http://127.0.0.1:4300', billing: 'http://127.0.0.1:4600',
-- // Assigned ports of services that are placeholders or new; a placeholder is never polled.
-- chat: 'http://127.0.0.1:4400', openre: 'http://127.0.0.1:4500', tips: 'http://127.0.0.1:4610', vip: 'http://127.0.0.1:4620',
-- ai: 'http://127.0.0.1:4700', search: 'http://127.0.0.1:4710', sources: 'http://127.0.0.1:4720', wiki: 'http://127.0.0.1:4800',
-- blog: 'http://127.0.0.1:4810', news: 'http://127.0.0.1:4820', reviews: 'http://127.0.0.1:4830', deals: 'http://127.0.0.1:4840',
-- coupons: 'http://127.0.0.1:4850', trade: 'http://127.0.0.1:4860', codes: 'http://127.0.0.1:4900', host: 'http://127.0.0.1:4910',
--};
++// Where each running service answers health checks from this host: its manifest's `internalOrigin`
++// (openvibe-contracts ≥ 0.42.0, WS-C task 1). OV_<ID>_INTERNAL_URL overrides any of them (internalFromEnv
++// below), validated as a loopback http(s) URL: the environment half of "manifests plus validated overrides".
++const INTERNAL = Object.fromEntries(contracts.services.manifests.filter((m) => m.internalOrigin).map((m) => [m.id, m.internalOrigin]));
const POLL_MS = 60 * 1000;
--// Readiness paths of services whose manifest (openvibe-contracts) does not carry `ready` yet; a manifest's
--// own `ready` always wins. Each answers with openvibe-shared/ready's shape; until a service deploys it, its
--// health path is used and the row says so (basis: 'health'). Only entries whose manifest still lacks `ready`
--// may stay (test/registry-ecosystem.test.js): tools left when its manifest gained one. TODO(contracts): add
--// `ready: /api/ready` to the network and media manifests, then this map is empty.
--const READY_PATHS = { network: '/api/ready', media: '/api/ready' };
--// Liveness paths of services whose manifest in the pinned openvibe-contracts has none (none since
--// 0.30, where AI's manifest carries /api/health and /api/ready).
++// Readiness and liveness paths come from the manifests (`ready`, `health`); these maps are for a manifest
++// that lacks one and stay empty (test/registry-ecosystem.test.js).
++const READY_PATHS = {};
const HEALTH_PATHS = {};
/** Row status from a readiness body (openvibe-shared/ready shape, or an older ad-hoc one). */
ddiff --git a/server/registry/exposure.js b/server/registry/exposure.js
iindex 881e8806..52af46ab 100644
---- a/server/registry/exposure.js
++++ b/server/registry/exposure.js
@@@ -24,75 +24,41 @@
* When a service goes public, change its row here in the same commit that points its domain at it,
* after checking the domain no longer answers with "this page is a placeholder".
*
-- * TODO(plan §5.1 "data from contract manifests plus validated overrides"): this overlay is the override
-- * half. It cannot come from the manifests (they say maturity, and the question here is what a domain
-- * serves today), so it stays Network's, pinned id by id by test/registry-exposure.test.js. Candidates to
-- * derive it later: each repository's STATUS.json `deployed`, or OpenVibe.Sites' sites.json (which domains
-- * still get a placeholder page), once both are published somewhere Network can read.
++ * Since openvibe-contracts 0.42.0 (WS-C task 1) the states are the manifests' `exposure`: a service going
++ * public is one manifest edit in Contracts (in the release that points its domain at it), no Network code.
*/
--const STATES = ['live', 'internal', 'library', 'repository', 'placeholder'];
++const contracts = require('openvibe-contracts');
++
++const STATES = ['live', 'internal', 'library', 'repository', 'placeholder', 'retired'];
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)',
++ retired: 'retired',
};
--// 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';
--
--// The libraries' release is the version Network itself installs (its pins move with every release),
--// so this never goes stale the way hand-written numbers did.
--function library(pkg, repo, note) {
++// The libraries' release starts as the version Network itself installs (its pins move with every release)
++// and becomes the latest published tag once library-tags.js has one (setLibraryReleases).
++function libraryRelease(pkg, repo) {
let version = null;
try { version = require(`${pkg}/package.json`).version; } catch { /* not installed */ }
const release = version ? `v${version}` : null;
-- const out = { state: 'library', public_site: null, package: pkg, release,
-- distribution: release ? `https://codeload.github.com/OpenVibers/${repo}/tar.gz/refs/tags/${release}` : null };
-- if (note) out.note = note;
-- return out;
++ return { release, distribution: release && repo ? `https://codeload.github.com/OpenVibers/${repo}/tar.gz/refs/tags/${release}` : null };
}
--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: 'live', public_site: 'service', note: 'public search page and query API at search.openvibe.network (since 2026-09-23)' },
-- sources: { state: 'internal', public_site: null, note: 'internal ingestion service; sources.openvibe.network says so (its API is loopback-only)' },
-- chat: { state: 'live', public_site: 'service', note: 'openvibe.chat since 2026-09-24 (global chat, messages, settings); also serves Live\'s chat paths' },
-- 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: library('openvibe-sdk', 'OpenVibe.SDK'),
-- shared: library('openvibe-shared', 'OpenVibe.Shared', 'also served at /shared/* by each site'),
-- contracts: library('openvibe-contracts', 'OpenVibe.Contracts', 'schemas are also served by this registry at their $id URLs'),
-- publishing: library('openvibe-publishing', 'OpenVibe.Publishing', 'the shared publishing runtime used by Wiki, Blog, News, Reviews, Deals, Coupons and Trade'),
-- 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 },
--};
++/**
++ * Each manifest's `exposure` (openvibe-contracts ≥ 0.42.0, WS-C task 1): { state, public_site, note } plus,
++ * for libraries, package / release / distribution. A service going public is a manifest change in Contracts.
++ */
++const EXPOSURE = Object.fromEntries(contracts.services.manifests.filter((m) => m.exposure && STATES.includes(m.exposure.state)).map((m) => {
++ const e = { state: m.exposure.state, public_site: m.exposure.publicSite == null ? null : m.exposure.publicSite };
++ if (m.exposure.note) e.note = m.exposure.note;
++ if (e.state === 'library') Object.assign(e, { package: m.exposure.package, ...libraryRelease(m.exposure.package, m.exposure.repo) });
++ return [m.id, e];
++}));
/** The exposure of one service id; an id nobody classified is 'unknown', never assumed public. */
function exposureOf(id) {
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex ebec0b76..cbe5020f 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -101,7 +101,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
assert.strictEqual(sBy.live.label, 'up');
assert.strictEqual(sBy.live.origin, 'https://openvibe.live');
assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);
-- assert.deepStrictEqual(st.exposure_summary, { live: 14, internal: 11, library: 4, repository: 1, placeholder: 1 });
++ assert.deepStrictEqual(st.exposure_summary, { live: 14, internal: 11, library: 4, repository: 1, placeholder: 1, retired: 0 });
// 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());
ddiff --git a/test/registry-manifest-fixture.test.js b/test/registry-manifest-fixture.test.js
nnew file mode 100644
iindex 00000000..3896e7fa
---- /dev/null
++++ b/test/registry-manifest-fixture.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++// WS-C task 1, the manifest-derived registry: a new service manifest (a fixture added the way Contracts adds
++// one) appears in the discovery document, the registry's service list, the status page and the frame's site
++// list, polled at its internalOrigin and linked at its publicOrigin, with no Network code edit; one whose
++// exposure is internal stays out of the navigation and is polled on loopback only.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const contracts = require('openvibe-contracts');
++
++const base = { version: '0.1.0', status: 'alpha', repository: 'OpenVibers/OpenVibe.Zines', health: '/api/health', ready: '/api/ready',
++ capabilities: [], eventsProduced: [], eventsConsumed: [], namespacesOwned: [], contractRanges: {} };
++
++(async () => {
++ const svc = http.createServer((req, res) => {
++ res.setHeader('content-type', 'application/json');
++ if (req.url === '/api/ready') return res.end(JSON.stringify({ ready: true, status: 'ready', failed: [], degraded: [], checks: { db: { status: 'ok', required: true } } }));
++ if (req.url === '/api/health') return res.end('{"status":"ok"}');
++ res.statusCode = 404; res.end('{}');
++ });
++ await new Promise((r) => svc.listen(0, '127.0.0.1', r));
++ const origin = `http://127.0.0.1:${svc.address().port}`;
++ const fixtures = [
++ { ...base, id: 'zines', name: 'OpenVibe.Zines', domains: ['openvibe.zines'], publicOrigin: 'https://openvibe.zines', internalOrigin: origin,
++ exposure: { state: 'live', publicSite: 'service' },
++ site: { name: 'Zines', icon: 'zines', tagline: 'Small magazines', what: 'Make and read small magazines.', legalProfile: 'ugc', position: 90 } },
++ { ...base, id: 'almanac', name: 'OpenVibe.Almanac', domains: ['openvibe.almanac'], publicOrigin: 'https://openvibe.almanac', internalOrigin: origin,
++ exposure: { state: 'internal', publicSite: 'placeholder', note: 'the domain serves a placeholder' },
++ site: { name: 'Almanac', icon: 'almanac', tagline: 'Dates', what: 'What happened when.', legalProfile: 'info', position: 91 } },
++ ];
++ for (const m of fixtures) {
++ const v = contracts.validate('registry.service-manifest@1', m);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ contracts.services.manifests.push(m);
++ }
++ // Loaded after the fixtures, like a Network release that installs a Contracts release with them.
++ const { createEcosystemRegistry, INTERNAL } = require('../server/registry/ecosystem');
++ const { exposureOf } = require('../server/registry/exposure');
++ const { SITES, siteForHost } = require('../server/frame/sites');
++ const { createStatusRoutes } = require('../server/status/routes');
++
++ assert.strictEqual(INTERNAL.zines, origin, 'polled at its internalOrigin');
++ assert.strictEqual(exposureOf('zines').state, 'live');
++ assert.deepStrictEqual(SITES.filter((s) => ['zines', 'almanac'].includes(s.service)).map((s) => [s.id, s.status, s.host, s.profile]), [['zines', 'open', 'openvibe.zines', 'ugc'], ['almanac', 'soon', 'openvibe.almanac', 'info']]);
++ assert.strictEqual(siteForHost('openvibe.zines').service, 'zines');
++
++ const eco = createEcosystemRegistry({ issuer: 'https://openvibe.network' });
++ 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 url = `http://127.0.0.1:${srv.address().port}`;
++ const json = (p) => fetch(url + p).then((r) => r.json());
++
++ const disc = Object.fromEntries((await json('/.well-known/openvibe')).services.map((s) => [s.id, s]));
++ assert.strictEqual(disc.zines.state, 'live'); assert.strictEqual(disc.almanac.state, 'internal');
++ const listed = await json('/api/v1/registry/services');
++ const rows = Object.fromEntries((listed.services || listed).map((s) => [s.id, s]));
++ assert.ok(rows.zines && rows.almanac, 'both in the registry');
++ const status = Object.fromEntries((await json('/api/v1/status')).services.map((s) => [s.id, s]));
++ assert.strictEqual(status.zines.label, 'up'); assert.strictEqual(status.zines.origin, 'https://openvibe.zines');
++ assert.strictEqual(status.almanac.label, 'up (loopback only)'); assert.strictEqual(status.almanac.origin, null);
++ assert.strictEqual(status.almanac.planned_origin, 'https://openvibe.almanac');
++
++ srv.close(); svc.close(); eco.stop && eco.stop();
++ console.log('registry manifest fixture: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });