openvibers/openvibe.network Commits

openvibe-shared v1.5.0 (Track R release manifest), openvibe-contracts v0.32.0, openvibe-sdk v0.5.0

OP OpenVibers committed 23 Sept 2026 · 0b75683391 · 10 files, +63 −53

- /release.json is mounted with release.mount(app, { registry }): it also answers POST
  /release-metrics, whose open-tab update reports land in /metrics as
  release_client_updates_total{outcome,reason}. On contracts 0.32.0 the manifest carries the
  registry.release-manifest 1.1.0 fields; no components are declared, so every release still
  prompts open tabs to reload as before. test/observability.test.js validates the served manifest
  against the contracts and checks the metrics round trip.
- chat.preferences is owned by chat in the contracts now: the OWNER_HANDOFFS entry (live -> chat)
  and the test line asserting the old owner are gone; ownership and grants are unchanged.
- The registry's OBSERVED rows (live.stream.*, media.*, network.module.updated) are gone: the Live,
  Media and Network manifests list every type they publish. /topics?producer=live now also lists
  live.release.deployed, from Live's manifest.
- live.lineage.resolve (new in 0.32.0) is granted to no one.

Changes

ddiff --git a/README.md b/README.md
iindex e5a39fd4..290964c2 100644
---- a/README.md
++++ b/README.md
@@@ -350,9 +350,14 @@ Google-style account management supporting up to 5 accounts:
Otherwise it is 200, with `status: "degraded"` when an optional check has failed.
- `GET /metrics`: Prometheus text for direct loopback callers only. Any proxied request gets a 404,
and `deploy/nginx` blocks the path too. Besides the HTTP golden signals by route template, it
-- exports process metrics and `release_info`. Network's own counters are
++ exports process metrics, `release_info` and `release_client_updates_total{outcome,reason}` (what
++ open tabs report to `POST /release-metrics`). Network's own counters are
`network_tokens_issued_total{grant_type}`, `network_token_failures_total{grant_type,error}` and
`network_principal_token_failures_total{code}`.
++- `GET /release.json` is Network's release manifest (ADR-016, `registry.release-manifest@1`, from
++ `openvibe-shared/release`'s `release.mount`): the deployed commit, the library versions and, since
++ openvibe-contracts 0.32.0, the 1.1.0 fields. No components are declared, so every release still
++ prompts open tabs to reload.
- `GET /status` is the operator page: server-rendered, no JavaScript needed, `noindex`.
`GET /api/v1/status` returns the same data as JSON. Each OpenVibe service shows as up, degraded,
down, not running (placeholder or no runtime) or unknown, with its release, boot time and
@@@ -371,9 +376,10 @@ Google-style account management supporting up to 5 accounts:
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.
++ releases), `/health` and `/search?q=`. A type a service's code publishes before its manifest lists
++ it is added from `server/registry/topics.js` OBSERVED (marked `observed` on the service; empty since
++ openvibe-contracts 0.32.0 lists Live's, Media's and Network's), 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,
ddiff --git a/package-lock.json b/package-lock.json
iindex 6e3eb73c..1f5d31b1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,9 +20,9 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.32.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
}
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.30.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1",
-- "integrity": "sha512-JAOna8MA/+4iABEZ5qnzGjW27z9UgAHDF1Pj4VE+R3tb1UVyRUVZX06yuE+BggTdhZPGZRq5mFFIyPNHp8N3GA==",
++ "version": "0.32.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.32.0",
++ "integrity": "sha512-ntN58SIG03YDxbhsiIzOM5GRH19jRMHZUYWI3xU2VwovExagF5DdwApLVfezsu84UTJ+V2Jfn+LWDc+Oz8ab+g==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1481,18 +1481,18 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.4.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
-- "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==",
++ "version": "0.5.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "integrity": "sha512-QVJkR5iazQX0ZwIrDp2A88ZeYYBSXEmSzhpgURPUSnzezIM3jz0HBhVKowyhkvg2aBO61XNf77eb4zJGkMjZiw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/openvibe-shared": {
-- "version": "1.4.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.4.0",
-- "integrity": "sha512-H7jAsUklS1p57d9y6ozME++HmsX18iPYZ/U3P6bLoL1E2oSl6DbSPzLsVUkA7RYoYmAtcI3AwJ4hTNlAr66Vdw==",
++ "version": "1.5.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.0",
++ "integrity": "sha512-cehnscuYMkJwuCYGcI6wPSswbUn0bhTyGAaD5feNCoO//2e9gyC7cgjkEcIOtM8DQF3yTuUNyXnOwckIeM4fUg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex c018edcc..6bcc5d20 100644
---- a/package.json
++++ b/package.json
@@@ -23,9 +23,9 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.32.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
}
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
iindex 0beb8707..9b841e53 100644
---- a/server/identity/modules.js
++++ b/server/identity/modules.js
@@@ -37,14 +37,12 @@ const moduleEvents = require('./module-events');
const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
/**
-- * Namespaces whose owner moved before openvibe-contracts caught up. The manifest of chat.preferences
-- * (0.30.x) still names live and says it "moves to OpenVibe.Chat in Wave 6"; Chat has served Live's chat
-- * since the Wave 6 cutover (2026-09-23). The handoff applies only while the installed manifest still
-- * names `from`: once Contracts publishes owner `to` it is a no-op, and the entry can be deleted.
++ * Namespaces whose owner moved before openvibe-contracts caught up: `{ [namespace]: { from, to } }`.
++ * A handoff applies only while the installed manifest still names `from`: once Contracts publishes
++ * owner `to` it is a no-op, and the entry can be deleted. None is pending: chat.preferences, the last
++ * one (live → chat, the Wave 6 cutover), is owned by chat in the contracts since openvibe-contracts 0.32.0.
*/
--const OWNER_HANDOFFS = Object.freeze({
-- 'chat.preferences': Object.freeze({ from: 'live', to: 'chat' }),
--});
++const OWNER_HANDOFFS = Object.freeze({});
/** The service that owns a namespace now (the manifest's owner unless a handoff moved it). */
function ownerOf(namespace) {
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 1ef636c7..e5c808bb 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -22,7 +22,7 @@ const DEFAULT_GRANTS = [
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
// User modules: each service reads and writes the namespaces it owns (openvibe-contracts manifests/namespaces).
-- // chat.preferences moved to Chat with the Wave 6 cutover (modules.js OWNER_HANDOFFS); Live keeps reading it.
++ // chat.preferences moved to Chat with the Wave 6 cutover (its owner in the contracts); Live keeps reading it.
['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],
['live', 'network.modules.write', SELF_AUDIENCE, ['chat.tts_defaults', 'live.profile']],
['chat', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences']],
ddiff --git a/server/index.js b/server/index.js
iindex 6512c435..4a147efb 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -681,7 +681,9 @@ function serveShared(req, res, next) {
app.use('/shared/v1', serveShared);
app.use('/shared', serveShared);
--app.get('/release.json', release.handler);
++// GET /release.json, and POST /release-metrics: open tabs' update outcomes into /metrics
++// (release_client_updates_total, openvibe-shared 1.5.0).
++release.mount(app, { registry: observability.registry });
// Avatar serving
const avatarDir = path.resolve(config.avatars.path);
ddiff --git a/server/registry/topics.js b/server/registry/topics.js
iindex eb2f852d..72daaead 100644
---- a/server/registry/topics.js
++++ b/server/registry/topics.js
@@@ -6,10 +6,10 @@
* 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.
++ * OBSERVED types a service publishes today that its manifest does not list yet, as
++ * `{ [service]: { produced: [types], source: 'the code that emits them' } }`. A row is
++ * deleted once the manifest lists the type. Empty since openvibe-contracts 0.32.0: the
++ * Live, Media and Network manifests list every type they publish.
* 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.
*
@@@ -18,20 +18,7 @@
*/
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)',
-- },
-- network: {
-- produced: ['network.module.updated'],
-- source: 'OpenVibe.Network server/identity/module-events.js (network_event_outbox, visibility internal)',
-- },
--};
++const OBSERVED = {};
const SEGMENT = /^[a-z0-9_]+$/;
function patternRe(p) {
ddiff --git a/test/module-events.test.js b/test/module-events.test.js
iindex 810b9922..bf043bcc 100644
---- a/test/module-events.test.js
++++ b/test/module-events.test.js
@@@ -2,7 +2,7 @@
// User modules, Wave 1 item 24 (server/identity/modules.js, module-events.js):
// - every change emits network.module.updated in the same transaction (network_event_outbox), with the
// owner, namespace, changed keys and revision, never a private value; revisions survive deletes
--// - chat.preferences is owned by Chat (handoff over the 0.30.x manifest); Live's old write grant narrows
++// - chat.preferences is owned by Chat (the contracts' owner since 0.32.0); Live's old write grant narrows
// - owner-service DELETE; account removal and merge (onSubjectRemoved/onSubjectMerged) with events, and
// triggers that refuse deleting or re-keying an account while module rows remain; guest -> account link
// - onOwnerRemoved: a retired owner makes its namespace read-only; delete-after-retention sweeps
@@@ -80,8 +80,7 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
const chat = { authorization: `Bearer ${chatTok.access_token}` };
const live = { authorization: `Bearer ${(await svc('live')).access_token}` };
-- // ── Handoff: chat.preferences belongs to Chat while the manifest still says live ──
-- assert.strictEqual(contracts.modules.get('chat.preferences').owner, 'live', 'Contracts moved the owner: delete the OWNER_HANDOFFS entry and this line');
++ // ── Ownership: chat.preferences belongs to Chat ──
assert.strictEqual(modulesLib.ownerOf('chat.preferences'), 'chat');
assert.strictEqual(modulesLib.ownerOf('chat.tts_defaults'), 'live');
let r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: live, body: { data: { timestamps: true } } });
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex 4f55aecf..c59d8757 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -44,6 +44,9 @@ const ready = observability.createNetworkReadiness({
discordService: { isReady: () => discordReady, _getSetting: () => 'configured-token' },
});
app.get('/api/ready', ready.handler);
++// The release manifest as server/index.js mounts it: GET /release.json, POST /release-metrics into /metrics.
++const release = require('openvibe-shared/release').createRelease({ service: 'network', root: path.join(__dirname, '..') });
++release.mount(app, { registry: observability.registry });
const server = http.createServer(app);
(async () => {
@@@ -72,8 +75,20 @@ const server = http.createServer(app);
r = await fetch(`${base}/internal/coins/credit`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer not-a-token' }, body: '{}' });
assert.strictEqual(r.status, 401);
++ // ── Release manifest (ADR-016) and open tabs' update reports ──
++ const rel = await get('/release.json');
++ assert.strictEqual(rel.status, 200);
++ const manifest = JSON.parse(rel.text);
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', manifest).errors, []);
++ assert.strictEqual(release.validate().valid, true);
++ assert.strictEqual(manifest.metrics_url, '/release-metrics');
++ r = await fetch(`${base}/release-metrics`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: JSON.stringify({ counts: { applied: { style: 2 }, reloaded: { nonsense: 1 } } }) });
++ assert.strictEqual(r.status, 204);
++
const m = await get('/metrics');
assert.strictEqual(m.status, 200);
++ assert.ok(m.text.includes('release_client_updates_total{outcome="applied",reason="style"} 2\n'), m.text);
++ assert.ok(m.text.includes('release_client_updates_total{outcome="reloaded",reason="other"} 1\n'), 'unknown reasons never become labels');
assert.ok(m.text.includes('network_tokens_issued_total{grant_type="client_credentials"} 2\n'), m.text);
assert.ok(m.text.includes('network_token_failures_total{grant_type="client_credentials",error="invalid_client"} 1\n'));
assert.ok(/network_token_failures_total\{grant_type="refresh_token",error="[a-z_]+"\} 1\n/.test(m.text));
ddiff --git a/test/registry-topics.test.js b/test/registry-topics.test.js
iindex 04125a5c..c17f9c90 100644
---- a/test/registry-topics.test.js
++++ b/test/registry-topics.test.js
@@@ -35,14 +35,17 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
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.
++ // 2. topics: every produced type, payload contract and consumer; Live and Media from their manifests.
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 id of ['live', 'media']) for (const t of contracts.services.get(id).eventsProduced) {
++ assert.deepStrictEqual(by[t].producers, [{ service: id, declared: 'manifest' }]);
++ }
++ for (const [id, o] of Object.entries(OBSERVED)) for (const t of o.produced) {
++ assert.ok(!(contracts.services.get(id).eventsProduced || []).includes(t), `${id}'s manifest lists ${t}: delete it from 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');
@@@ -65,7 +68,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
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']);
++ assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.release.deployed', '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.');