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
Changes
@@@ -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 pushesjobs: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@@@ -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 arow 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@@@ -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"},nnew file mode 100644@@@ -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 };@@@ -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 || '');@@@ -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 = gtry {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 = greturn 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 = gconst notFound = (res, code, detail) => contracts.http.sendProblem(res, 404, code, { detail });r.get('/.well-known/openvibe', (_req, res) => cache(res, 300).json(descriptor()));-- r.get('/api/v1/registry', (_req, res) => cache(res).json({ services: '/api/v1/registry/services', capabilities: '/api/v1/registry/capabilities', namespaces: '/api/v1/registry/namespaces', contracts: '/api/v1/registry/contracts', topics: '/api/v1/registry/topics', 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}`);@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -12,7 +12,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');(async () => {// Path decisionfor (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',@@@ -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'));nnew file mode 100644@@@ -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); });
@@ -3,6 +3,8 @@ on:
@@ -3,6 +3,8 @@ on:
push:
push:
branches: [main]
branches: [main]
pull_request:
pull_request:
+ schedule:
+ - cron: '17 6 * * 1' # weekly: drift appears when another repository releases, not only on our pushes
jobs:
jobs:
test:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-latest
@@ -16,6 +18,11 @@ jobs:
@@ -16,6 +18,11 @@ jobs:
- run: npm test
- run: npm test
- name: contract check (openvibe-contracts)
- name: contract check (openvibe-contracts)
run: npx openvibe-contracts-check --service network --src server
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).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@83c998b65424fb97e6f6de55ad9cc5981767e89c
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@83c998b65424fb97e6f6de55ad9cc5981767e89c
@@ -327,6 +327,19 @@ Google-style account management supporting up to 5 accounts:
@@ -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
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
"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.
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
- `GET /api/v1/status/slo` returns the proposed SLO categories, as does
[docs/slo.md](docs/slo.md) / [docs/slo.json](docs/slo.json).
[docs/slo.md](docs/slo.md) / [docs/slo.json](docs/slo.json).
- [docs/patches/live-observability.diff](docs/patches/live-observability.diff) is the same change for
- [docs/patches/live-observability.diff](docs/patches/live-observability.diff) is the same change for
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -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 };
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
*
*
* GET /.well-known/openvibe
* GET /.well-known/openvibe
* GET /api/v1/registry and /api/v1/registry/<...>
* 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
* 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
* credentials (cookies are never needed and never sent). Every other route keeps the first-party
@@ -19,7 +19,8 @@ const MAX_AGE_S = 86400;
@@ -19,7 +19,8 @@ const MAX_AGE_S = 86400;
// A registry segment: URL-safe characters, never '.' or '..' and never an encoded dot or slash.
// A registry segment: URL-safe characters, never '.' or '..' and never an encoded dot or slash.
const SEGMENT_RE = /^[A-Za-z0-9_~:@!$&'()*+,;=.-]+$/;
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) {
function isPublicDiscoveryPath(p) {
const path = String(p || '');
const path = String(p || '');
@@ -16,13 +16,22 @@
@@ -16,13 +16,22 @@
* GET /api/v1/registry/capabilities/:id
* GET /api/v1/registry/capabilities/:id
* GET /api/v1/registry/namespaces user-module namespaces
* GET /api/v1/registry/namespaces user-module namespaces
* GET /api/v1/registry/contracts contract catalog
* 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
* GET /contracts/<domain>/<name>.v<N>.json the schema at its $id URL
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const contractsPkg = require('openvibe-contracts/package.json');
const contractsPkg = require('openvibe-contracts/package.json');
-const { exposureOf, publicOriginOf } = require('./exposure');
+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).
// Where each running service answers health checks from this host (manifests carry the public origin).
const INTERNAL = {
const INTERNAL = {
@@ -89,7 +98,14 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -89,7 +98,14 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
try {
try {
const { res, body } = await getJson(`${base}/release.json`);
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}` };
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) {
} catch (err) {
return { release: null, error: err.name === 'TimeoutError' ? 'timeout' : 'unreachable' };
return { release: null, error: err.name === 'TimeoutError' ? 'timeout' : 'unreachable' };
}
}
@@ -151,8 +167,35 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -151,8 +167,35 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return h;
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() {
function descriptor() {
return {
return {
@@ -178,7 +221,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -178,7 +221,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
const notFound = (res, code, detail) => contracts.http.sendProblem(res, 404, code, { detail });
const notFound = (res, code, detail) => contracts.http.sendProblem(res, 404, code, { detail });
r.get('/.well-known/openvibe', (_req, res) => cache(res, 300).json(descriptor()));
r.get('/.well-known/openvibe', (_req, res) => cache(res, 300).json(descriptor()));
- r.get('/api/v1/registry', (_req, res) => cache(res).json({ services: '/api/v1/registry/services', capabilities: '/api/v1/registry/capabilities', namespaces: '/api/v1/registry/namespaces', contracts: '/api/v1/registry/contracts', topics: '/api/v1/registry/topics', 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) => {
r.get('/api/v1/registry/services', (req, res) => {
let list = contracts.services.manifests.map(withHealth);
let list = contracts.services.manifests.map(withHealth);
if (req.query.status) list = list.filter(m => m.status === String(req.query.status));
if (req.query.status) list = list.filter(m => m.status === String(req.query.status));
@@ -208,8 +251,41 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -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/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/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 file = `${req.params[0]}/${req.params[1]}.json`;
const entry = contracts.catalog.find(c => c.schema === file);
const entry = contracts.catalog.find(c => c.schema === file);
if (!entry) return notFound(res, 'registry.unknown_contract', `no contract ${file}`);
if (!entry) return notFound(res, 'registry.unknown_contract', `no contract ${file}`);
@@ -66,9 +66,9 @@ const EXPOSURE = {
@@ -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' },
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' },
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' },
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 },
realtime: { state: 'placeholder', public_site: null },
@@ -78,7 +78,7 @@ const EXPOSURE = {
@@ -78,7 +78,7 @@ const EXPOSURE = {
function exposureOf(id) {
function exposureOf(id) {
const e = EXPOSURE[id];
const e = EXPOSURE[id];
if (!e) return { state: 'unknown', label: 'unknown (not classified by Network)', public_site: null, note: null };
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. */
/** A manifest's public origin only when its domain serves the service itself. */
@@ -87,4 +87,9 @@ function publicOriginOf(m) {
@@ -87,4 +87,9 @@ function publicOriginOf(m) {
return e && e.state === 'live' && e.public_site === 'service' ? m.publicOrigin || null : null;
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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 };
@@ -12,7 +12,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -12,7 +12,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
(async () => {
(async () => {
// Path decision
// Path decision
for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network',
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`);
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',
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',
@@ -31,7 +31,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -31,7 +31,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');
assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');
- assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime' && 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');
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).
// 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'));
assert.ok(r.body.services.length >= 1 && r.body.services.every(s => s.status === 'placeholder'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -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); });