openvibers/openvibe.network Commits

Registry completeness: /api/v1/registry/topics lists every event type from the manifests' eventsProduced/eventsConsumed and the v0.30 payload contracts (producers, consumers with the Events-syntax pattern that matched, payload contract with its $id and status; filters service/producer/consumer/prefix, plus /topics/:topic). Live and Media list no eventsProduced in contracts 0.30.1, so server/registry/topics.js OBSERVED declares what their code publishes (live.stream.started/ended from stream-events.js; media.vod/clip ready|failed, media.object.uploaded, media.storage.alert|recovered from Media server/events.js), marked observed on the service row, and Network's consumed topics come from its own Events consumer TOPICS. New /releases (each running service's /release.json from the loopback health poll, now keeping contracts_version and openvibe-* package versions, with drift against the libraries' current releases), /health and /search?q=. Payload schemas are served at their $id (/contracts/events/payloads/<type>.v1.json) and are CORS-public. scripts/contracts-drift.js warns (::warning:: in Actions, exit 0 unless --strict) about services pinning an older openvibe-contracts/sdk/shared than the latest tag or an unpublished tag, from GitHub, local checkouts (--dir) or the running registry (--registry); CI runs it as a continue-on-error step and weekly. test/registry-topics.test.js

OP OpenVibers committed 23 Sept 2026 · 5ad26eca43 · 12 files, +610 −18


Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 762cb17f..e7ccaaab 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -3,6 +3,8 @@ on:
push:
branches: [main]
pull_request:
++ schedule:
++ - cron: '17 6 * * 1' # weekly: drift appears when another repository releases, not only on our pushes
jobs:
test:
runs-on: ubuntu-latest
@@@ -16,6 +18,11 @@ jobs:
- run: npm test
- name: contract check (openvibe-contracts)
run: npx openvibe-contracts-check --service network --src server
++ - name: contracts version drift (warning only)
++ continue-on-error: true
++ env:
++ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
++ run: node scripts/contracts-drift.js --package all
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@83c998b65424fb97e6f6de55ad9cc5981767e89c
ddiff --git a/README.md b/README.md
iindex 139f238d..df467e62 100644
---- a/README.md
++++ b/README.md
@@@ -327,6 +327,19 @@ Google-style account management supporting up to 5 accounts:
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.
++- The registry also answers `GET /api/v1/registry/topics` (every event type from the manifests'
++ `eventsProduced`/`eventsConsumed` and the payload contracts, with producers, consumers, the matching
++ consumer pattern and the payload contract; filters `service`, `producer`, `consumer`, `prefix`),
++ `/topics/:topic`, `/releases` (each running service's `/release.json` from the same loopback poll,
++ with its installed openvibe-contracts/sdk/shared versions and drift against the libraries' current
++ releases), `/health` and `/search?q=`. Live and Media list no `eventsProduced` in openvibe-contracts
++ 0.30.1; the registry adds what their code publishes (`server/registry/topics.js` OBSERVED, marked
++ `observed` on the service), and Network's consumed topics come from its own Events consumer.
++ Payload schemas are served at their `$id` (`/contracts/events/payloads/<type>.v1.json`).
++- `node scripts/contracts-drift.js` warns about services that pin an older openvibe-contracts than
++ the latest tag, or a tag that was never published (`--dir ~/OpenVibers` for local checkouts,
++ `--registry https://openvibe.network` for what is running, `--package all` for sdk and shared too).
++ It exits 0 unless `--strict`; CI runs it as a warning-only step.
- `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 7c902267..618d914b 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/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/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",
++ "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/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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/scripts/contracts-drift.js b/scripts/contracts-drift.js
nnew file mode 100644
iindex 00000000..602ab49d
---- /dev/null
++++ b/scripts/contracts-drift.js
@@@ -0,0 +1,167 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Version drift: which OpenVibe services pin an older openvibe-contracts (or openvibe-sdk /
++ * openvibe-shared) than the latest release tag. It WARNS; it never fails a build (exit 0), unless
++ * --strict is given. A pin to a tag that was never published is also reported.
++ *
++ * node scripts/contracts-drift.js each manifest repository's package.json on GitHub
++ * node scripts/contracts-drift.js --dir ~/OpenVibers local checkouts (<dir>/<Repo>/package.json)
++ * node scripts/contracts-drift.js --registry https://openvibe.network
++ * what the running services report (/api/v1/registry/releases)
++ * options: --package openvibe-sdk (default openvibe-contracts; "all" = contracts, sdk and shared)
++ * --latest v0.30.1 (skip the tag lookup) --json --strict
++ *
++ * Repositories come from the installed openvibe-contracts service manifests (`repository`). The
++ * latest release is the highest vX.Y.Z tag of the package's repository. GITHUB_TOKEN is used when
++ * set (CI). Under GitHub Actions each finding is also printed as a ::warning:: annotation.
++ */
++const fs = require('fs');
++const path = require('path');
++const { parsePin, latestOf, driftOf } = require('../server/registry/versions');
++
++const PACKAGES = {
++ 'openvibe-contracts': 'OpenVibers/OpenVibe.Contracts',
++ 'openvibe-sdk': 'OpenVibers/OpenVibe.SDK',
++ 'openvibe-shared': 'OpenVibers/OpenVibe.Shared',
++};
++
++function ghHeaders(env = process.env) {
++ const h = { 'User-Agent': 'openvibe-network-drift', Accept: 'application/vnd.github+json' };
++ if (env.GITHUB_TOKEN) h.Authorization = `Bearer ${env.GITHUB_TOKEN}`;
++ return h;
++}
++
++async function fetchTags(repo, { fetchImpl = fetch, env = process.env } = {}) {
++ const tags = [];
++ for (let page = 1; page <= 5; page++) {
++ const r = await fetchImpl(`https://api.github.com/repos/${repo}/tags?per_page=100&page=${page}`, { headers: ghHeaders(env), signal: AbortSignal.timeout(15000) });
++ if (!r.ok) throw new Error(`GitHub tags for ${repo} answered ${r.status}`);
++ const body = await r.json();
++ tags.push(...body.map(t => t.name));
++ if (body.length < 100) break;
++ }
++ return tags;
++}
++
++/** The repositories to check: every service manifest with a repository, minus the libraries themselves. */
++function repositories(manifests = require('openvibe-contracts').services.manifests) {
++ const libs = new Set(Object.values(PACKAGES));
++ const out = [];
++ for (const m of manifests) {
++ if (!m.repository || libs.has(m.repository)) continue;
++ if (!out.some(r => r.repo === m.repository)) out.push({ service: m.id, repo: m.repository });
++ }
++ return out;
++}
++
++/** The dependency spec of `pkg` in a package.json object (dependencies, then devDependencies). */
++function specIn(pkgJson, pkg) {
++ if (!pkgJson || typeof pkgJson !== 'object') return null;
++ return (pkgJson.dependencies && pkgJson.dependencies[pkg]) || (pkgJson.devDependencies && pkgJson.devDependencies[pkg]) || null;
++}
++
++async function pinsFromGithub(pkgs, { fetchImpl = fetch, env = process.env, manifests } = {}) {
++ const rows = [];
++ for (const { service, repo } of repositories(manifests)) {
++ let json = null; let error = null;
++ try {
++ const r = await fetchImpl(`https://raw.githubusercontent.com/${repo}/HEAD/package.json`, { headers: { 'User-Agent': 'openvibe-network-drift' }, signal: AbortSignal.timeout(15000) });
++ if (r.status === 404) error = 'no package.json at the repository root';
++ else if (!r.ok) error = `package.json answered ${r.status}`;
++ else json = await r.json();
++ } catch (err) { error = err.message; }
++ for (const p of pkgs) rows.push({ service, repo, package: p, spec: specIn(json, p), error });
++ }
++ return rows;
++}
++
++function pinsFromDir(dir, pkgs, { manifests } = {}) {
++ const rows = [];
++ for (const { service, repo } of repositories(manifests)) {
++ const file = path.join(dir, repo.split('/')[1], 'package.json');
++ let json = null; let error = null;
++ try { json = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { error = err.code === 'ENOENT' ? 'no local checkout' : err.message; }
++ for (const p of pkgs) rows.push({ service, repo, package: p, spec: specIn(json, p), error });
++ }
++ return rows;
++}
++
++async function pinsFromRegistry(base, pkgs, { fetchImpl = fetch } = {}) {
++ const r = await fetchImpl(`${String(base).replace(/\/+$/, '')}/api/v1/registry/releases`, { signal: AbortSignal.timeout(15000) });
++ if (!r.ok) throw new Error(`registry answered ${r.status}`);
++ const body = await r.json();
++ const rows = [];
++ for (const s of body.services || []) {
++ for (const p of pkgs) {
++ const installed = p === 'openvibe-contracts' ? s.contracts_version : (s.packages || {})[p];
++ rows.push({ service: s.id, repo: null, package: p, spec: installed || null, error: s.release ? null : (s.error || 'no release.json') });
++ }
++ }
++ return rows;
++}
++
++/**
++ * Findings for each pin against the published tags. tagsByPackage[pkg] is the full tag list, or
++ * { tags, complete: false } when only the latest is known (--latest): then no pin is called unpublished.
++ */
++function assess(rows, tagsByPackage) {
++ return rows.map((row) => {
++ const t = tagsByPackage[row.package];
++ const tags = Array.isArray(t) ? t : (t && t.tags) || [];
++ const complete = Array.isArray(t) ? tags.length > 0 : !!(t && t.complete && t.tags.length);
++ const latest = latestOf(tags);
++ if (row.error && !row.spec) return { ...row, latest: latest && latest.tag, state: 'unknown' };
++ if (!row.spec) return { ...row, latest: latest && latest.tag, state: 'not-pinned' };
++ const pin = parsePin(row.spec);
++ if (!pin) return { ...row, latest: latest && latest.tag, state: 'unparsed' };
++ const published = !complete || tags.some(x => (parsePin(x) || {}).version === pin.version);
++ return { ...row, pinned: pin.tag, latest: latest && latest.tag, state: published ? driftOf(pin.parts, latest ? latest.parts : null) : 'unpublished' };
++ });
++}
++
++function report(findings, { json = false, actions = !!process.env.GITHUB_ACTIONS, out = console } = {}) {
++ if (json) { out.log(JSON.stringify({ findings }, null, 2)); return; }
++ const warn = findings.filter(f => f.state === 'behind' || f.state === 'unpublished');
++ for (const f of findings) {
++ const where = f.repo ? `${f.service} (${f.repo})` : f.service;
++ const what = f.state === 'behind' ? `pins ${f.pinned}, latest is ${f.latest}`
++ : f.state === 'unpublished' ? `pins ${f.pinned}, which is not a published tag (latest ${f.latest})`
++ : f.state === 'current' ? `pins ${f.pinned} (latest)`
++ : f.state === 'ahead' ? `pins ${f.pinned}, newer than the latest tag ${f.latest}`
++ : f.state === 'not-pinned' ? 'does not depend on it at the root'
++ : `${f.state}${f.error ? ': ' + f.error : ''}`;
++ out.log(`${f.state === 'behind' || f.state === 'unpublished' ? 'WARN' : ' '} ${f.package} ${where}: ${what}`);
++ if (actions && (f.state === 'behind' || f.state === 'unpublished')) out.log(`::warning title=${f.package} drift::${where} ${what}`);
++ }
++ out.log(`${warn.length} warning(s) across ${new Set(findings.map(f => f.service)).size} service(s).`);
++}
++
++async function main(argv = process.argv.slice(2), { fetchImpl = fetch, env = process.env, out = console } = {}) {
++ const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
++ const pkgOpt = opt('package') || 'openvibe-contracts';
++ const pkgs = pkgOpt === 'all' ? Object.keys(PACKAGES) : [pkgOpt];
++ for (const p of pkgs) if (!PACKAGES[p]) throw new Error(`unknown package ${p}`);
++ const tagsByPackage = {};
++ for (const p of pkgs) {
++ if (opt('latest') && pkgs.length === 1) { tagsByPackage[p] = { tags: [opt('latest')], complete: false }; continue; }
++ try { tagsByPackage[p] = await fetchTags(PACKAGES[p], { fetchImpl, env }); } catch (err) { out.log(`WARN could not list ${PACKAGES[p]} tags: ${err.message}`); tagsByPackage[p] = []; }
++ }
++ const rows = opt('registry') ? await pinsFromRegistry(opt('registry'), pkgs, { fetchImpl })
++ : opt('dir') ? pinsFromDir(opt('dir'), pkgs)
++ : await pinsFromGithub(pkgs, { fetchImpl, env });
++ const findings = assess(rows, tagsByPackage);
++ report(findings, { json: argv.includes('--json'), actions: !!env.GITHUB_ACTIONS, out });
++ const warned = findings.some(f => f.state === 'behind' || f.state === 'unpublished');
++ return argv.includes('--strict') && warned ? 1 : 0;
++}
++
++if (require.main === module) {
++ main().then((code) => process.exit(code)).catch((err) => {
++ // A drift check that cannot run is itself only a warning.
++ console.log(`::warning title=contracts drift::drift check could not run: ${err.message}`);
++ process.exit(process.argv.includes('--strict') ? 1 : 0);
++ });
++}
++
++module.exports = { main, assess, repositories, specIn, pinsFromDir, pinsFromGithub, pinsFromRegistry, fetchTags, PACKAGES };
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex 23051f37..4b20e634 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -6,7 +6,7 @@
*
* GET /.well-known/openvibe
* GET /api/v1/registry and /api/v1/registry/<...>
-- * GET /contracts/<domain>/<name>.v<N>.json
++ * GET /contracts/<domain>/<name>.v<N>.json (and /contracts/events/payloads/<event_type>.v<N>.json)
*
* These answer every origin, preflight included, with `Access-Control-Allow-Origin: *` and no
* credentials (cookies are never needed and never sent). Every other route keeps the first-party
@@@ -19,7 +19,8 @@ const MAX_AGE_S = 86400;
// A registry segment: URL-safe characters, never '.' or '..' and never an encoded dot or slash.
const SEGMENT_RE = /^[A-Za-z0-9_~:@!$&'()*+,;=.-]+$/;
--const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+\/[a-z0-9-]+\.v\d+\.json$/;
++// Event payload contracts sit one level deeper: /contracts/events/payloads/<event_type>.v<N>.json.
++const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\d+\.json$/;
function isPublicDiscoveryPath(p) {
const path = String(p || '');
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex 2b744466..e43b9062 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -16,13 +16,22 @@
* GET /api/v1/registry/capabilities/:id
* GET /api/v1/registry/namespaces user-module namespaces
* GET /api/v1/registry/contracts contract catalog
-- * GET /api/v1/registry/topics event topics (Events, Wave 3)
++ * GET /api/v1/registry/topics[?service=&producer=&consumer=&prefix=]
++ * event topics: producers, consumers, payload contract
++ * GET /api/v1/registry/topics/:topic
++ * GET /api/v1/registry/releases what each running service runs (its /release.json,
++ * polled on loopback with health) and the libraries'
++ * current releases, with contracts/sdk/shared drift
++ * GET /api/v1/registry/health one line per service: status, checked_at
++ * GET /api/v1/registry/search?q= services, capabilities, topics and contracts by text
* GET /contracts/<domain>/<name>.v<N>.json the schema at its $id URL
*/
const express = require('express');
const contracts = require('openvibe-contracts');
const contractsPkg = require('openvibe-contracts/package.json');
--const { exposureOf, publicOriginOf } = require('./exposure');
++const { exposureOf, publicOriginOf, libraries } = require('./exposure');
++const { buildTopics, eventsOf } = require('./topics');
++const { driftOf } = require('./versions');
// Where each running service answers health checks from this host (manifests carry the public origin).
const INTERNAL = {
@@@ -89,7 +98,14 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
try {
const { res, body } = await getJson(`${base}/release.json`);
if (!res.ok || !body || typeof body.release !== 'string') return { release: null, error: `release.json answered ${res.status}` };
-- return { release: body.release.slice(0, 40), released_at: body.released_at || null, booted_at: body.booted_at || null };
++ // registry.release-manifest@1 (openvibe-shared/release): the commit, when it was made and booted,
++ // and the installed openvibe-contracts / openvibe-* package versions (names and x.y.z only).
++ const packages = {};
++ if (body.packages && typeof body.packages === 'object') {
++ for (const [k, v] of Object.entries(body.packages).slice(0, 10)) if (/^openvibe-[a-z-]{1,30}$/.test(k) && /^\d+\.\d+\.\d+/.test(String(v))) packages[k] = String(v).slice(0, 20);
++ }
++ const ver = (v) => (typeof v === 'string' && /^\d+\.\d+\.\d+/.test(v) ? v.slice(0, 20) : null);
++ return { release: body.release.slice(0, 40), released_at: body.released_at || null, booted_at: body.booted_at || null, contracts_version: ver(body.contracts_version), packages };
} catch (err) {
return { release: null, error: err.name === 'TimeoutError' ? 'timeout' : 'unreachable' };
}
@@@ -151,8 +167,35 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return h;
}
-- // 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) });
++ // The manifest as published, with the event types Network observed it producing or consuming merged
++ // in (./topics.js; `observed` names them), plus where it can be reached (exposure) and what the last
++ // check saw (runtime).
++ const withHealth = (m) => {
++ const ev = eventsOf(m);
++ return { ...m, eventsProduced: ev.eventsProduced, eventsConsumed: ev.eventsConsumed, ...(ev.observed ? { observed: ev.observed } : {}), exposure: exposureOf(m.id), runtime: current(m.id) };
++ };
++
++ /** Each service's running release (from the health poll) and drift against the libraries' releases. */
++ function releases() {
++ const libs = libraries();
++ const services = contracts.services.manifests.map((m) => {
++ const e = exposureOf(m.id);
++ if (e.state === 'library') return null;
++ const h = current(m.id);
++ const r = h.release && typeof h.release === 'object' ? h.release : null;
++ const row = { id: m.id, state: e.state, status: h.status, checked_at: h.checked_at || null, release: r ? r.release : null };
++ if (!r || !r.release) { row.error = (r && r.error) || h.reason || null; return row; }
++ Object.assign(row, { released_at: r.released_at, booted_at: r.booted_at, contracts_version: r.contracts_version || null, packages: r.packages || {} });
++ row.drift = {};
++ for (const l of libs) {
++ const installed = l.package === 'openvibe-contracts' ? r.contracts_version : (r.packages || {})[l.package];
++ if (installed) row.drift[l.package] = { installed, latest: l.release, state: driftOf(installed, l.release) };
++ }
++ return row;
++ }).filter(Boolean);
++ const behind = services.filter(s => s.drift && Object.values(s.drift).some(d => d.state === 'behind')).map(s => s.id);
++ return { checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null, poll_interval_s: Math.round(pollMs / 1000), libraries: libs, services, behind };
++ }
function descriptor() {
return {
@@@ -178,7 +221,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
const notFound = (res, code, detail) => contracts.http.sendProblem(res, 404, code, { detail });
r.get('/.well-known/openvibe', (_req, res) => cache(res, 300).json(descriptor()));
-- r.get('/api/v1/registry', (_req, res) => cache(res).json({ services: '/api/v1/registry/services', capabilities: '/api/v1/registry/capabilities', namespaces: '/api/v1/registry/namespaces', contracts: '/api/v1/registry/contracts', topics: '/api/v1/registry/topics', domains: '/api/v1/registry/domains/:domain' }));
++ r.get('/api/v1/registry', (_req, res) => cache(res).json({ services: '/api/v1/registry/services', capabilities: '/api/v1/registry/capabilities', namespaces: '/api/v1/registry/namespaces', contracts: '/api/v1/registry/contracts', topics: '/api/v1/registry/topics', releases: '/api/v1/registry/releases', health: '/api/v1/registry/health', search: '/api/v1/registry/search?q=', domains: '/api/v1/registry/domains/:domain' }));
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));
@@@ -208,8 +251,41 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
});
r.get('/api/v1/registry/namespaces', (_req, res) => cache(res).json({ namespaces: contracts.modules.namespaces }));
r.get('/api/v1/registry/contracts', (_req, res) => cache(res).json({ version: contractsPkg.version, contracts: contracts.catalog.map(c => ({ ...c, $id: contracts.schema(c.id).$id })) }));
-- r.get('/api/v1/registry/topics', (_req, res) => cache(res).json({ topics: [], note: 'Durable event topics are published by OpenVibe.Events (roadmap Wave 3).' }));
-- r.get(/^\/contracts\/([a-z0-9-]+)\/([a-z0-9-]+\.v\d+)\.json$/, (req, res) => {
++ r.get('/api/v1/registry/topics', (req, res) => {
++ const built = buildTopics();
++ let list = built.topics;
++ const q = (k) => (req.query[k] ? String(req.query[k]).toLowerCase().slice(0, 100) : null);
++ const svc = q('service'), producer = q('producer'), consumer = q('consumer'), prefix = q('prefix');
++ if (svc) list = list.filter(t => t.services.includes(svc));
++ if (producer) list = list.filter(t => t.producers.some(p => p.service === producer));
++ if (consumer) list = list.filter(t => t.consumers.some(c => c.service === consumer));
++ if (prefix) list = list.filter(t => t.topic.startsWith(prefix));
++ cache(res).json({ topics: list, patterns: built.patterns, contracts_version: contractsPkg.version, delivery: 'OpenVibe.Events: subscribe with POST /api/v1/subscriptions { topic_pattern, endpoint }' });
++ });
++ r.get('/api/v1/registry/topics/:topic', (req, res) => {
++ const t = buildTopics().topics.find(x => x.topic === String(req.params.topic));
++ if (!t) return notFound(res, 'registry.unknown_topic', `no topic ${req.params.topic}`);
++ cache(res).json(t);
++ });
++ r.get('/api/v1/registry/releases', (_req, res) => cache(res, 30).json(releases()));
++ r.get('/api/v1/registry/health', (_req, res) => {
++ const services = contracts.services.manifests.map((m) => { const h = current(m.id); return { id: m.id, state: exposureOf(m.id).state, status: h.status, checked_at: h.checked_at || null, ...(h.stale ? { stale: true } : {}) }; });
++ const summary = {};
++ for (const s of services) summary[s.status] = (summary[s.status] || 0) + 1;
++ cache(res, 30).json({ checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null, summary, services });
++ });
++ r.get('/api/v1/registry/search', (req, res) => {
++ const q = String(req.query.q || '').toLowerCase().trim().slice(0, 100);
++ if (q.length < 2) return contracts.http.sendProblem(res, 400, 'registry.bad_query', { detail: 'q must be at least 2 characters' });
++ const hit = (...fields) => fields.some(f => typeof f === 'string' && f.toLowerCase().includes(q));
++ const services = contracts.services.manifests.filter(m => hit(m.id, m.name, m.notes, ...(m.domains || []))).map(m => ({ id: m.id, name: m.name, status: m.status, state: exposureOf(m.id).state }));
++ const capabilities = contracts.capabilities.manifests.filter(c => hit(c.id, c.description, c.owner)).slice(0, 50).map(c => ({ id: c.id, owner: c.owner, status: c.status, visibility: c.visibility }));
++ const topics = buildTopics().topics.filter(t => hit(t.topic)).slice(0, 50).map(t => ({ topic: t.topic, status: t.status, producers: t.producers.map(p => p.service) }));
++ const found = contracts.catalog.filter(c => hit(c.id, c.owner)).slice(0, 50).map(c => ({ id: c.id, version: c.version, owner: c.owner, status: c.status }));
++ cache(res).json({ q, services, capabilities, topics, contracts: found });
++ });
++ // Event payload contracts sit one level deeper (events/payloads/<event_type>.v<N>.json).
++ r.get(/^\/contracts\/([a-z0-9-]+(?:\/[a-z0-9-]+)?)\/([a-z0-9_.-]+\.v\d+)\.json$/, (req, res) => {
const file = `${req.params[0]}/${req.params[1]}.json`;
const entry = contracts.catalog.find(c => c.schema === file);
if (!entry) return notFound(res, 'registry.unknown_contract', `no contract ${file}`);
ddiff --git a/server/registry/exposure.js b/server/registry/exposure.js
iindex 8e4fe811..624e33c4 100644
---- a/server/registry/exposure.js
++++ b/server/registry/exposure.js
@@@ -66,9 +66,9 @@ const EXPOSURE = {
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.30.1', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1', note: 'schemas are also served by this registry at their $id URLs' },
++ sdk: { state: 'library', public_site: null, package: 'openvibe-sdk', 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, package: 'openvibe-shared', 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, package: 'openvibe-contracts', release: 'v0.30.1', distribution: 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1', 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 },
@@@ -78,7 +78,7 @@ const EXPOSURE = {
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 } : {}) };
++ return { state: e.state, label: LABEL[e.state], public_site: e.public_site, note: e.note || null, ...(e.release ? { package: e.package || null, release: e.release, distribution: e.distribution || null } : {}) };
}
/** A manifest's public origin only when its domain serves the service itself. */
@@@ -87,4 +87,9 @@ function publicOriginOf(m) {
return e && e.state === 'live' && e.public_site === 'service' ? m.publicOrigin || null : null;
}
--module.exports = { STATES, LABEL, EXPOSURE, exposureOf, publicOriginOf };
++/** The released libraries (sdk, shared, contracts): { id, package, release, distribution }. */
++function libraries() {
++ return Object.entries(EXPOSURE).filter(([, e]) => e.state === 'library').map(([id, e]) => ({ id, package: e.package, release: e.release, distribution: e.distribution || null }));
++}
++
++module.exports = { STATES, LABEL, EXPOSURE, exposureOf, publicOriginOf, libraries };
ddiff --git a/server/registry/topics.js b/server/registry/topics.js
nnew file mode 100644
iindex 00000000..98840548
---- /dev/null
++++ b/server/registry/topics.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * Event topics for the ecosystem registry (GET /api/v1/registry/topics).
++ *
++ * A topic is an event type (`live.stream.started`). The list is built from the openvibe-contracts
++ * service manifests (eventsProduced / eventsConsumed) and the event payload contracts
++ * (contracts/events/payloads/<type>.v<N>.json), plus two things Network itself knows:
++ *
++ * OBSERVED types a service publishes today that its manifest (openvibe-contracts 0.30.1) does not
++ * list yet. Live and Media declare no eventsProduced there, although both relay these
++ * through their outboxes to OpenVibe.Events. Each row names the code that emits it, so
++ * the row can be deleted once the manifest lists the type.
++ * Network's own consumer the topics server/notifications/events-consumer.js subscribes to (its
++ * TOPICS), so the registry cannot disagree with what Network actually consumes.
++ *
++ * Consumer patterns use OpenVibe.Events' syntax: `*` stands for one or more whole segments
++ * (`*.index_document.upserted`). A pattern is listed on every topic it matches.
++ */
++const contracts = require('openvibe-contracts');
++
++const OBSERVED = {
++ live: {
++ produced: ['live.stream.started', 'live.stream.ended'],
++ source: 'OpenVibe.Live server/events/stream-events.js (event_outbox, visibility public, priority important)',
++ },
++ media: {
++ produced: ['media.vod.ready', 'media.vod.failed', 'media.clip.ready', 'media.clip.failed', 'media.object.uploaded', 'media.storage.alert', 'media.storage.recovered'],
++ source: 'OpenVibe.Media server/events.js TYPES (event_outbox, visibility internal)',
++ },
++};
++
++const SEGMENT = /^[a-z0-9_]+$/;
++function patternRe(p) {
++ const body = String(p).split('.').map(seg => (seg === '*' ? '[a-z0-9_]+(?:\\.[a-z0-9_]+)*' : SEGMENT.test(seg) ? seg : null));
++ if (body.includes(null)) return null;
++ return new RegExp(`^${body.join('\\.')}$`);
++}
++const isPattern = (p) => String(p).includes('*');
++const uniq = (a) => [...new Set(a)];
++
++/** Network's consumed topics: its Events consumer's TOPICS (required lazily; it pulls in the SDK). */
++function networkConsumed() {
++ try { return [...require('../notifications/events-consumer').TOPICS]; } catch { return []; }
++}
++
++/**
++ * The manifest's event lists with Network's observations merged in. `observed` names what the
++ * manifest itself lacks, so a reader can tell declared from observed.
++ */
++function eventsOf(m, { consumedByNetwork = networkConsumed() } = {}) {
++ const produced = m.eventsProduced || [];
++ const consumed = m.eventsConsumed || [];
++ const o = OBSERVED[m.id];
++ const addP = o ? o.produced.filter(t => !produced.includes(t)) : [];
++ const addC = m.id === 'network' ? consumedByNetwork.filter(t => !consumed.includes(t)) : [];
++ const observed = addP.length || addC.length
++ ? { ...(addP.length ? { eventsProduced: addP } : {}), ...(addC.length ? { eventsConsumed: addC } : {}), source: addC.length && !addP.length ? 'OpenVibe.Network server/notifications/events-consumer.js TOPICS' : o.source }
++ : null;
++ return { eventsProduced: uniq([...produced, ...addP]), eventsConsumed: uniq([...consumed, ...addC]), observed };
++}
++
++function payloadContracts() {
++ const out = new Map();
++ for (const c of contracts.catalog) {
++ if (!String(c.schema || '').startsWith('events/payloads/')) continue;
++ const major = Number(String(c.version).split('.')[0]) || 1;
++ let $id = null;
++ try { $id = contracts.schema(c.id).$id || null; } catch { /* listed without a schema */ }
++ const prev = out.get(c.id);
++ if (prev && prev.major > major) continue;
++ out.set(c.id, { id: `${c.id}@${major}`, version: c.version, major, owner: c.owner, status: c.status, visibility: c.visibility, adr: c.adr || null, $id });
++ }
++ return out;
++}
++
++/** Every topic: producers, consumers (with the pattern that matched), and its payload contract. */
++function buildTopics({ manifests = contracts.services.manifests, consumedByNetwork } = {}) {
++ const payloads = payloadContracts();
++ const topics = new Map();
++ const row = (t) => {
++ if (!topics.has(t)) topics.set(t, { topic: t, status: null, producers: [], consumers: [], services: [], payload_contract: null });
++ return topics.get(t);
++ };
++ const events = manifests.map(m => ({ id: m.id, ...eventsOf(m, consumedByNetwork ? { consumedByNetwork } : undefined) }));
++ for (const e of events) {
++ for (const t of e.eventsProduced) {
++ const r = row(t);
++ const how = e.observed && (e.observed.eventsProduced || []).includes(t) ? 'observed' : 'manifest';
++ r.producers.push({ service: e.id, declared: how });
++ }
++ }
++ for (const [t, p] of payloads) { const r = row(t); r.payload_contract = p; }
++ const patterns = [];
++ for (const e of events) {
++ for (const t of e.eventsConsumed) {
++ const how = e.observed && (e.observed.eventsConsumed || []).includes(t) ? 'observed' : 'manifest';
++ if (isPattern(t)) { const re = patternRe(t); if (re) patterns.push({ service: e.id, pattern: t, re, declared: how }); continue; }
++ row(t).consumers.push({ service: e.id, pattern: t, declared: how });
++ }
++ }
++ for (const r of topics.values()) {
++ for (const p of patterns) if (p.re.test(r.topic)) r.consumers.push({ service: p.service, pattern: p.pattern, declared: p.declared });
++ r.services = uniq([...r.producers.map(p => p.service), ...r.consumers.map(c => c.service)]);
++ r.status = r.producers.length ? (r.payload_contract && r.payload_contract.status === 'planned' ? 'planned' : 'produced') : 'consumed-only';
++ }
++ return {
++ topics: [...topics.values()].sort((a, b) => a.topic.localeCompare(b.topic)),
++ patterns: patterns.map(({ re, ...p }) => p),
++ };
++}
++
++module.exports = { OBSERVED, buildTopics, eventsOf, patternRe, payloadContracts };
ddiff --git a/server/registry/versions.js b/server/registry/versions.js
nnew file mode 100644
iindex 00000000..e05821a6
---- /dev/null
++++ b/server/registry/versions.js
@@@ -0,0 +1,47 @@
++'use strict';
++/**
++ * Version helpers for the registry's /releases view and scripts/contracts-drift.js.
++ *
++ * Network's packages are pinned as tarball tags
++ * (https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1); a running
++ * service reports the installed version ("0.30.1") in its /release.json. Both reduce to [major, minor, patch].
++ */
++
++const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
++
++/** '0.30.1', 'v0.30.1', a codeload/github tag URL, or 'github:Org/Repo#v0.30.1' → { version, parts } or null. */
++function parsePin(spec) {
++ const s = String(spec == null ? '' : spec).trim();
++ if (!s) return null;
++ let tag = s;
++ const m = s.match(/refs\/tags\/(v?\d+\.\d+\.\d+[^/?#]*)/) || s.match(/#(?:semver:)?[~^]?(v?\d+\.\d+\.\d+\S*)$/) || s.match(/\/archive\/(?:refs\/tags\/)?(v?\d+\.\d+\.\d+)\.tar\.gz$/);
++ if (m) tag = m[1];
++ else tag = s.replace(/^[~^=]+/, '');
++ const v = tag.match(SEMVER);
++ if (!v) return null;
++ return { version: `${v[1]}.${v[2]}.${v[3]}`, tag: tag.startsWith('v') ? tag : `v${tag}`, parts: [Number(v[1]), Number(v[2]), Number(v[3])] };
++}
++
++function compare(a, b) {
++ const x = typeof a === 'string' ? (parsePin(a) || {}).parts : a;
++ const y = typeof b === 'string' ? (parsePin(b) || {}).parts : b;
++ if (!x || !y) return NaN;
++ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
++ return 0;
++}
++
++/** The newest of a list of tags ('v0.30.1', ...), ignoring anything that is not x.y.z. */
++function latestOf(tags) {
++ let best = null;
++ for (const t of tags || []) { const p = parsePin(t); if (p && (!best || compare(p.parts, best.parts) > 0)) best = p; }
++ return best;
++}
++
++/** 'current' | 'behind' | 'ahead' | 'unknown' for a pinned/installed version against the latest release. */
++function driftOf(pinned, latest) {
++ const c = compare(pinned, latest);
++ if (Number.isNaN(c)) return 'unknown';
++ return c === 0 ? 'current' : c < 0 ? 'behind' : 'ahead';
++}
++
++module.exports = { parsePin, compare, latestOf, driftOf };
ddiff --git a/test/public-cors.test.js b/test/public-cors.test.js
iindex e81c03d5..c4aeba42 100644
---- a/test/public-cors.test.js
++++ b/test/public-cors.test.js
@@@ -12,7 +12,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
(async () => {
// Path decision
for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network',
-- '/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json']) {
++ '/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json', '/contracts/events/payloads/deals.watch.matched.v1.json']) {
assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is public`);
}
for (const p of ['/api/v1/registryx', '/api/v1/registry-admin', '/api/v1/registry/../projects', '/api/v1/registry/%2e%2e/projects', '/api/v1/registry/a%2Fb',
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex dd7bb331..ba9fb232 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' && k !== 'exposure'))).valid));
++ assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => !['runtime', 'exposure', 'observed'].includes(k)))).valid));
r = await get('/api/v1/registry/services?status=placeholder');
// openvibe-contracts 0.30: realtime is the only placeholder manifest left (AI, SDK, Shared, Examples are alpha).
assert.ok(r.body.services.length >= 1 && r.body.services.every(s => s.status === 'placeholder'));
ddiff --git a/test/registry-topics.test.js b/test/registry-topics.test.js
nnew file mode 100644
iindex 00000000..04125a5c
---- /dev/null
++++ b/test/registry-topics.test.js
@@@ -0,0 +1,164 @@
++'use strict';
++// Registry completeness (roadmap Wave 2, D4): /topics from the manifests, payload contracts and what
++// Network observed; /releases from each service's /release.json with library drift; /health; /search;
++// payload schemas at their $id; and scripts/contracts-drift.js, which warns and never fails.
++// node test/registry-topics.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { createEcosystemRegistry } = require('../server/registry/ecosystem');
++const { buildTopics, OBSERVED } = require('../server/registry/topics');
++const { parsePin, compare, latestOf, driftOf } = require('../server/registry/versions');
++const { TOPICS } = require('../server/notifications/events-consumer');
++const drift = require('../scripts/contracts-drift');
++
++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 } } }];
++const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcdef123456', released_at: '2026-09-23T00:00:00.000Z', booted_at: '2026-09-23T01:00:00.000Z', contracts_version: contractsVersion, packages }];
++
++(async () => {
++ // 1. versions
++ assert.deepStrictEqual(parsePin('https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1').parts, [0, 30, 1]);
++ assert.strictEqual(parsePin('0.5.1').tag, 'v0.5.1');
++ assert.strictEqual(parsePin('github:OpenVibers/OpenVibe.Contracts#v0.13.0').version, '0.13.0');
++ assert.strictEqual(parsePin('file:../x'), null);
++ assert.strictEqual(compare('v0.9.0', '0.30.1'), -1, 'numeric, not string, order');
++ assert.strictEqual(latestOf(['v0.9.0', 'v0.30.1', 'v0.30.0', 'nightly']).tag, 'v0.30.1');
++ assert.strictEqual(driftOf('0.28.0', 'v0.30.1'), 'behind');
++ assert.strictEqual(driftOf('0.30.1', 'v0.30.1'), 'current');
++
++ // 2. topics: every produced type, payload contract and consumer; Live and Media from Network's observation.
++ const built = buildTopics();
++ const by = Object.fromEntries(built.topics.map(t => [t.topic, t]));
++ const produced = new Set(contracts.services.manifests.flatMap(m => m.eventsProduced || []));
++ for (const t of produced) assert.ok(by[t], `${t} is listed`);
++ for (const c of contracts.catalog.filter(c => c.schema.startsWith('events/payloads/'))) assert.strictEqual(by[c.id].payload_contract.id, `${c.id}@1`);
++ for (const id of ['live', 'media']) for (const t of OBSERVED[id].produced) {
++ assert.deepStrictEqual(by[t].producers, [{ service: id, declared: (contracts.services.get(id).eventsProduced || []).includes(t) ? 'manifest' : 'observed' }]);
++ }
++ for (const t of TOPICS) assert.ok(by[t].consumers.some(c => c.service === 'network'), `Network consumes ${t}`);
++ assert.ok(by['wiki.index_document.upserted'].consumers.some(c => c.service === 'search' && c.pattern === '*.index_document.upserted'), 'a consumer pattern is listed on each topic it matches');
++ assert.ok(!by['wiki.page.published'].consumers.some(c => c.service === 'search'));
++ assert.strictEqual(by['ai.run.queued'].status, 'planned', 'a planned payload contract says so');
++ assert.strictEqual(by['deals.watch.matched'].payload_contract.$id, 'https://openvibe.network/contracts/events/payloads/deals.watch.matched.v1.json');
++
++ // 3. over HTTP
++ const svc = await fakeService({ '/api/ready': ready, '/ready': ready, '/api/health': [200, {}], '/health': [200, {}], '/healthz': [200, {}], '/release.json': rel('0.28.0', { 'openvibe-shared': '1.3.0', 'openvibe-sdk': '0.3.1', 'evil-pkg': '9.9.9' }) });
++ const cur = await fakeService({ '/api/ready': ready, '/api/health': [200, {}], '/release.json': rel('0.30.1', { 'openvibe-shared': '1.3.0', 'openvibe-sdk': '0.4.0' }) });
++ const eco = createEcosystemRegistry({ issuer: 'https://openvibe.network', internalOverrides: { network: cur.url, live: svc.url, media: 'http://127.0.0.1:1', events: svc.url } });
++ await eco.pollAll();
++ const app = express();
++ 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 get = (p) => fetch(base + p).then(async r => ({ status: r.status, type: r.headers.get('content-type'), body: await r.json() }));
++
++ let r = await get('/api/v1/registry/topics');
++ assert.ok(r.body.topics.length >= produced.size, 'no longer empty');
++ r = await get('/api/v1/registry/topics?producer=live');
++ assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.stream.ended', 'live.stream.started']);
++ r = await get('/api/v1/registry/topics?consumer=network');
++ assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), [...TOPICS].sort());
++ r = await get('/api/v1/registry/topics?prefix=media.vod.');
++ assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['media.vod.failed', 'media.vod.ready']);
++ r = await get('/api/v1/registry/topics/live.stream.started');
++ assert.strictEqual(r.body.producers[0].service, 'live');
++ r = await get('/api/v1/registry/topics/nope.nope');
++ assert.strictEqual(r.status, 404); assert.strictEqual(r.body.code, 'registry.unknown_topic');
++
++ // The services listing carries the merged event lists and says which were observed, not declared.
++ r = await get('/api/v1/registry/services/live');
++ assert.ok(r.body.eventsProduced.includes('live.stream.started'));
++ if (!(contracts.services.get('live').eventsProduced || []).includes('live.stream.started')) assert.ok(r.body.observed.eventsProduced.includes('live.stream.started'));
++ r = await get('/api/v1/registry/services/network');
++ for (const t of TOPICS) assert.ok(r.body.eventsConsumed.includes(t));
++ r = await get('/api/v1/registry/services');
++ for (const s of r.body.services) {
++ const bare = Object.fromEntries(Object.entries(s).filter(([k]) => !['runtime', 'exposure', 'observed'].includes(k)));
++ assert.ok(contracts.validate('registry.service-manifest@1', bare).valid, `${s.id} still validates as a manifest`);
++ }
++
++ // Payload schemas are served at their $id.
++ r = await get('/contracts/events/payloads/deals.watch.matched.v1.json');
++ assert.strictEqual(r.status, 200); assert.ok(r.type.startsWith('application/schema+json'));
++ assert.strictEqual(r.body.$id, 'https://openvibe.network/contracts/events/payloads/deals.watch.matched.v1.json');
++ assert.strictEqual((await get('/contracts/events/payloads/nope.v1.json')).status, 404);
++
++ // /releases: running releases from /release.json, drift against the libraries' current releases.
++ r = await get('/api/v1/registry/releases');
++ const rel2 = Object.fromEntries(r.body.services.map(s => [s.id, s]));
++ assert.ok(r.body.checked_at);
++ assert.deepStrictEqual(r.body.libraries.map(l => l.id).sort(), ['contracts', 'sdk', 'shared']);
++ assert.ok(!rel2.sdk && !rel2.contracts, 'libraries are not services');
++ assert.strictEqual(rel2.live.release, 'abcdef123456');
++ assert.strictEqual(rel2.live.contracts_version, '0.28.0');
++ assert.strictEqual(rel2.live.drift['openvibe-contracts'].state, 'behind');
++ assert.strictEqual(rel2.live.drift['openvibe-sdk'].state, 'behind');
++ assert.strictEqual(rel2.live.drift['openvibe-shared'].state, 'current');
++ assert.ok(!('evil-pkg' in rel2.live.packages), 'only openvibe-* packages are repeated');
++ assert.strictEqual(rel2.network.drift['openvibe-contracts'].state, 'current');
++ assert.ok(r.body.behind.includes('live') && !r.body.behind.includes('network'));
++ assert.strictEqual(rel2.media.release, null); assert.ok(rel2.media.error);
++ assert.strictEqual(rel2.realtime.release, null, 'a placeholder has no release');
++
++ r = await get('/api/v1/registry/health');
++ const h = Object.fromEntries(r.body.services.map(s => [s.id, s]));
++ assert.strictEqual(h.network.status, 'up'); assert.strictEqual(h.media.status, 'down'); assert.strictEqual(h.realtime.status, 'not-running');
++ assert.ok(h.network.checked_at);
++ assert.strictEqual(Object.values(r.body.summary).reduce((a, b) => a + b, 0), contracts.services.manifests.length);
++
++ r = await get('/api/v1/registry/search?q=stream.started');
++ assert.deepStrictEqual(r.body.topics.map(t => t.topic), ['live.stream.started']);
++ r = await get('/api/v1/registry/search?q=openvibe.community');
++ assert.ok(r.body.services.some(s => s.id === 'community'));
++ assert.strictEqual((await get('/api/v1/registry/search?q=a')).status, 400);
++ r = await get('/api/v1/registry');
++ for (const k of ['topics', 'releases', 'health', 'search']) assert.ok(r.body[k]);
++
++ // 4. scripts/contracts-drift.js: warns, never fails; reports an unpublished pin.
++ const tags = ['v0.28.0', 'v0.29.0', 'v0.30.0', 'v0.30.1'];
++ const found = drift.assess([
++ { service: 'a', repo: 'O/A', package: 'openvibe-contracts', spec: 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0' },
++ { service: 'b', repo: 'O/B', package: 'openvibe-contracts', spec: 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1' },
++ { service: 'c', repo: 'O/C', package: 'openvibe-contracts', spec: 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.27.5' },
++ { service: 'd', repo: 'O/D', package: 'openvibe-contracts', spec: null },
++ { service: 'e', repo: 'O/E', package: 'openvibe-contracts', spec: null, error: 'no local checkout' },
++ ], { 'openvibe-contracts': tags });
++ assert.deepStrictEqual(found.map(f => f.state), ['behind', 'current', 'unpublished', 'not-pinned', 'unknown']);
++ assert.strictEqual(drift.assess([{ service: 'x', package: 'openvibe-contracts', spec: '0.29.0' }], { 'openvibe-contracts': { tags: ['v0.30.1'], complete: false } })[0].state, 'behind', '--latest never calls a pin unpublished');
++
++ const lines = [];
++ const out = { log: (s) => lines.push(s) };
++ const fakeGithub = async (url) => {
++ const u = String(url);
++ if (u.startsWith('https://api.github.com/repos/OpenVibers/OpenVibe.Contracts/tags')) return new Response(JSON.stringify(tags.map(name => ({ name }))), { status: 200 });
++ if (u.includes('/OpenVibers/OpenVibe.Live/HEAD/package.json')) return new Response(JSON.stringify({ dependencies: { 'openvibe-contracts': 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0' } }), { status: 200 });
++ if (u.includes('raw.githubusercontent.com')) return new Response(JSON.stringify({ dependencies: { 'openvibe-contracts': 'https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1' } }), { status: 200 });
++ return new Response('{}', { status: 404 });
++ };
++ let code = await drift.main([], { fetchImpl: fakeGithub, env: { GITHUB_ACTIONS: 'true' }, out });
++ assert.strictEqual(code, 0, 'drift is a warning, not a failure');
++ assert.ok(lines.some(l => l.startsWith('::warning title=openvibe-contracts drift::live (OpenVibers/OpenVibe.Live) pins v0.28.0, latest is v0.30.1')), lines.join('\n'));
++ assert.ok(!lines.some(l => l.includes('::warning') && l.includes('OpenVibe.Media')));
++ lines.length = 0;
++ code = await drift.main(['--strict'], { fetchImpl: fakeGithub, env: {}, out });
++ assert.strictEqual(code, 1, '--strict fails on a warning');
++ assert.ok(!lines.some(l => l.startsWith('::warning')), 'annotations only under GitHub Actions');
++ lines.length = 0;
++ code = await drift.main(['--registry', base], { fetchImpl: (u, o) => (String(u).startsWith(base) ? fetch(u, o) : fakeGithub(u, o)), env: {}, out });
++ assert.strictEqual(code, 0);
++ assert.ok(lines.some(l => l.startsWith('WARN openvibe-contracts live: pins v0.28.0, latest is v0.30.1')), lines.join('\n'));
++ assert.ok(lines.some(l => l.includes('network: pins v0.30.1 (latest)')));
++
++ eco.stop(); srv.close(); svc.srv.close(); cur.srv.close();
++ console.log('registry topics, releases and drift: all checks passed');
++})().catch(err => { console.error(err); process.exit(1); });