openvibers/openvibe.network Commits
Production drift (WS-S task 7): hourly, each running service's deployed commit is compared with its repository's main on GitHub (current, behind by N commits since the oldest undeployed one, diverged, unknown); shown under each release on /status, in /api/v1/registry/releases (main, and undeployed: services behind main for over a day), and as openvibe_deploy_drift_seconds{service} for the 24-hour alert
Changes
@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -408,6 +408,11 @@ app.get('/internal/integrations/github-token', require('./identity/principals').const ecosystemInternal = require('./registry/ecosystem').internalFromEnv(process.env);if (ecosystemInternal.ignored.length) console.warn(`[Registry] ignored (not a loopback URL): ${ecosystemInternal.ignored.join(', ')}`);const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issuer: config.jwt.issuer, internalOverrides: ecosystemInternal.overrides });++// Production drift: each running service's deployed commit against its repository's main (WS-S task 7).++require('./registry/deploy-drift').createDeployDrift({++ services: () => ecosystem.releases().services.filter((r) => r.release).map((r) => ({ id: r.id, release: r.release, repository: ((require('openvibe-contracts').services.manifests.find((m) => m.id === r.id)) || {}).repository })),++ token: () => process.env.GITHUB_TOKEN || require('./integrations/github').tokenOf(db) || '',++}).start();app.use(ecosystem.router());ecosystem.start();// The released libraries' latest published tags (not the versions Network installs) for the registry.@@@ -21,6 +21,9 @@ const grantLabel = (g) => (g === 'urn:ietf:params:oauth:grant-type:jwt-bearer' ?const tokensIssued = registry.counter({ name: 'network_tokens_issued_total', help: 'Tokens issued by POST /oauth/token, by grant type', labelNames: ['grant_type'] });const tokenFailures = registry.counter({ name: 'network_token_failures_total', help: 'POST /oauth/token requests refused, by grant type and OAuth error code', labelNames: ['grant_type', 'error'] });++// Seconds each service's main has been ahead of what runs (registry/deploy-drift.js); alert at 24 h.++registry.gauge({ name: 'openvibe_deploy_drift_seconds', help: 'Seconds since the oldest commit on main that the running service does not have (0 when current)', labelNames: ['service'], maxSeries: 60,++ collect: () => require('./registry/deploy-drift').driftSeconds() });const principalFailures = registry.counter({ name: 'network_principal_token_failures_total', help: 'Service (principal) tokens refused at capability-guarded Network routes, by problem code', labelNames: ['code'], maxSeries: 50 });/** Middleware for POST /oauth/token: counts the outcome without reading or logging any secret. */nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * Production drift (roadmap WS-S task 7): how far each running service's deployed commit (its++ * /release.json `release`) is behind its repository's main branch on GitHub, and since when.++ *++ * state current the deployed commit is main's head (or main is not ahead of it)++ * behind main has commits that are not deployed: behind_by, since (the first of them)++ * diverged the deployed commit is not an ancestor of main (a local or force-pushed build)++ * unknown GitHub did not answer, or the commit is not on GitHub++ *++ * Read hourly with GitHub's compare API (GET /repos/{repo}/compare/{deployed}...main, one call per++ * running service; GITHUB_TOKEN or the admin GitHub token raises the rate limit). A failure keeps the++ * last answer. `since` is the committer date of the oldest undeployed commit, so the drift age is how++ * long main has had something production does not run. Shown on /status and in++ * /api/v1/registry/releases; openvibe_deploy_drift_seconds{service} feeds the Prometheus alert++ * OpenVibeDeployDrift (24 hours; OpenVibe.Host deploy/prometheus/openvibe-rules.yml).++ */++const SHA_RE = /^[0-9a-f]{7,40}$/;++const REPO_RE = /^OpenVibers\/[A-Za-z0-9._-]{1,100}$/;++++const state = new Map(); // service id -> { repo, deployed, state, behind_by, since, main, checked_at, error }++let timer = null;++++function current(id) { return state.get(id) || null; }++function all() { return Object.fromEntries(state); }++++/** Seconds main has been ahead of production, per service (0 when current; nothing when unknown). */++function driftSeconds(now = Date.now()) {++ const out = [];++ for (const [id, d] of state) {++ if (d.state === 'current') out.push({ labels: { service: id }, value: 0 });++ else if (d.state === 'behind' && d.since) out.push({ labels: { service: id }, value: Math.max(0, Math.round((now - Date.parse(d.since)) / 1000)) });++ }++ return out;++}++++/**++ * services(): [{ id, release, repository }] for what runs now (the registry's releases view).++ */++function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = () => process.env.GITHUB_TOKEN || '', intervalMs = 3600e3, now = () => Date.now(), log = console } = {}) {++ const tokenNow = () => (typeof token === 'function' ? token() : token) || '';++ async function checkOne({ id, release, repository }) {++ const at = new Date(now()).toISOString();++ if (!SHA_RE.test(String(release || '')) || !REPO_RE.test(String(repository || ''))) { state.delete(id); return null; }++ const prev = state.get(id);++ try {++ const res = await fetchImpl(`https://api.github.com/repos/${repository}/compare/${release}...main`, {++ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network registry', ...(tokenNow() ? { Authorization: `Bearer ${tokenNow()}` } : {}) },++ signal: AbortSignal.timeout(10000),++ });++ if (res.status === 404) { state.set(id, { repo: repository, deployed: release, state: 'unknown', error: 'commit not on GitHub', checked_at: at }); return state.get(id); }++ if (!res.ok) throw new Error(`GitHub answered ${res.status}`);++ const body = await res.json();++ const ahead = Number(body.ahead_by) || 0;++ const behind = Number(body.behind_by) || 0;++ const commits = Array.isArray(body.commits) ? body.commits : [];++ const main = commits.length ? String(commits[commits.length - 1].sha || '').slice(0, 12) : String(release).slice(0, 12);++ let row;++ if (behind > 0 && ahead === 0) row = { state: 'diverged', behind_by: 0 };++ else if (ahead === 0) row = { state: 'current', behind_by: 0 };++ else {++ const first = commits[0] && commits[0].commit && (commits[0].commit.committer || commits[0].commit.author);++ row = { state: 'behind', behind_by: ahead, since: first && first.date ? new Date(first.date).toISOString() : null };++ }++ state.set(id, { repo: repository, deployed: release, main, checked_at: at, ...row });++ } catch (err) {++ // Keep the last answer; say it is stale.++ state.set(id, prev ? { ...prev, stale: true, error: err.message } : { repo: repository, deployed: release, state: 'unknown', error: err.message, checked_at: at });++ if (log && log.warn) log.warn(`[drift] ${id}: ${err.message}`);++ }++ return state.get(id);++ }++ async function refresh() {++ const list = (typeof services === 'function' ? services() : services) || [];++ const seen = new Set();++ for (const s of list) { seen.add(s.id); await checkOne(s); }++ for (const id of [...state.keys()]) if (!seen.has(id)) state.delete(id);++ return all();++ }++ function start() {++ if (timer) return;++ const first = setTimeout(() => { refresh().catch(() => {}); }, 60 * 1000);++ if (first.unref) first.unref();++ timer = setInterval(() => { refresh().catch(() => {}); }, intervalMs);++ if (timer.unref) timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++ return { refresh, checkOne, start, stop };++}++++function _reset() { state.clear(); if (timer) clearInterval(timer); timer = null; }++++module.exports = { createDeployDrift, current, all, driftSeconds, _reset };@@@ -218,6 +218,9 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = gconst 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 || {} });++ // How far the deployed commit is behind the repository's main branch (./deploy-drift.js).++ const dd = require('./deploy-drift').current(m.id);++ if (dd) row.main = { state: dd.state, behind_by: dd.behind_by ?? null, since: dd.since || null, head: dd.main || null, checked_at: dd.checked_at, ...(dd.error ? { error: dd.error } : {}) };row.drift = {};for (const l of libs) {const installed = l.package === 'openvibe-contracts' ? r.contracts_version : (r.packages || {})[l.package];@@@ -226,7 +229,10 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = greturn 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 };++ // Deployed behind main for more than a day (WS-S task 7).++ const dayAgo = now() - 24 * 3600e3;++ const undeployed = services.filter(s => s.main && s.main.state === 'behind' && s.main.since && Date.parse(s.main.since) < dayAgo).map(s => s.id);++ return { checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null, poll_interval_s: Math.round(pollMs / 1000), libraries: libs, services, behind, undeployed };}/** Services grouped by category, each with its maturity, exposure and last check. */@@@ -28,6 +28,15 @@ const esc = seo.esc;const STATES = ['up', 'degraded', 'down', 'not-running', 'unknown'];const LABEL = { up: 'Up', degraded: 'Degraded', down: 'Down', 'not-running': 'Not running', unknown: 'Unknown' };++/** The deployed commit against main (registry/deploy-drift.js), as a short note under the release. */++function mainNote(d) {++ if (!d) return '';++ if (d.state === 'current') return '<small>main: current</small>';++ if (d.state === 'behind') return `<small>main: ${esc(String(d.behind_by))} commit${d.behind_by === 1 ? '' : 's'} not deployed${d.since ? ` since <time datetime="${esc(d.since)}">${esc(d.since.slice(0, 16).replace('T', ' '))} UTC</time>` : ''}</small>`;++ if (d.state === 'diverged') return '<small>main: not on main (a local build)</small>';++ return '';++}++function loadSlo() { return JSON.parse(fs.readFileSync(SLO_FILE, 'utf8')); }function rows(ecosystem) {@@@ -52,6 +61,7 @@ function rows(ecosystem) {http_status: r.http_status ?? null,latency_ms: r.latency_ms ?? null,release: r.release && r.release.release ? { release: r.release.release, released_at: r.release.released_at || null, booted_at: r.release.booted_at || null } : null,++ main: (() => { const d = require('../registry/deploy-drift').current(m.id); return d ? { state: d.state, behind_by: d.behind_by ?? null, since: d.since || null } : null; })(),release_error: r.release && !r.release.release ? r.release.error || 'unknown' : null,ready: r.ready || null,...(r.stale ? { stale: true, last_status: r.last_status } : {}),@@@ -103,7 +113,7 @@ function renderPage(list, slo, generatedAt) {<td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td><td>${exposureCell(r)}</td><td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status] + (r.label.endsWith('(loopback only)') ? ' · loopback only' : ''))}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td>--<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td>++<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}${mainNote(r.main)}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td><td>${r.checked_at ? `<time datetime="${esc(r.checked_at)}">${esc(r.checked_at)}</time>` : '<small>not checked yet</small>'}${r.latency_ms != null ? `<small>${esc(r.latency_ms)} ms</small>` : ''}</td></tr>`).join('\n');const sloRows = slo.categories.map((c) => `<dt>${esc(c.name)}</dt><dd>${esc(c.sli)} <br><small>Proposed: ${esc(Object.entries(c.proposed_target).map(([k, v]) => `${k.replace(/_/g, ' ')} ${v}`).join('; '))} · priority: ${esc(c.priority)} · ${esc(c.measurement_status || (c.measured_by.length ? 'measured' : 'not instrumented'))}</small></dd>`).join('\n');nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++// Production drift (WS-S task 7): the deployed commit against main through GitHub's compare API —++// current, behind (how many commits and since the oldest undeployed one), diverged, unknown; a failed++// lookup keeps the last answer; the gauge gives seconds behind (0 when current); services that stop++// running are dropped; a non-commit release is skipped.++const assert = require('assert');++const drift = require('../server/registry/deploy-drift');++++const NOW = Date.parse('2026-09-26T12:00:00Z');++const answers = {++ 'OpenVibers/OpenVibe.Live/compare/aaaaaaa1...main': { status: 200, body: { ahead_by: 0, behind_by: 0, commits: [] } },++ 'OpenVibers/OpenVibe.Chat/compare/bbbbbbb2...main': { status: 200, body: { ahead_by: 3, behind_by: 0, commits: [++ { sha: 'c1', commit: { committer: { date: '2026-09-25T10:00:00Z' } } },++ { sha: 'c2', commit: { committer: { date: '2026-09-25T11:00:00Z' } } },++ { sha: 'c3ffffffffffff', commit: { committer: { date: '2026-09-26T09:00:00Z' } } },++ ] } },++ 'OpenVibers/OpenVibe.Media/compare/ccccccc3...main': { status: 200, body: { ahead_by: 0, behind_by: 2, commits: [] } },++ 'OpenVibers/OpenVibe.Tools/compare/ddddddd4...main': { status: 404, body: { message: 'Not Found' } },++};++let failing = false;++const asked = [];++const fetchImpl = async (url, opts) => {++ const key = url.replace('https://api.github.com/repos/', '');++ asked.push({ key, auth: opts.headers.Authorization });++ if (failing) throw new Error('network down');++ const a = answers[key];++ return { ok: a.status === 200, status: a.status, json: async () => a.body };++};++++(async () => {++ let running = [++ { id: 'live', release: 'aaaaaaa1', repository: 'OpenVibers/OpenVibe.Live' },++ { id: 'chat', release: 'bbbbbbb2', repository: 'OpenVibers/OpenVibe.Chat' },++ { id: 'media', release: 'ccccccc3', repository: 'OpenVibers/OpenVibe.Media' },++ { id: 'tools', release: 'ddddddd4', repository: 'OpenVibers/OpenVibe.Tools' },++ { id: 'sites', release: 'not-a-sha', repository: 'OpenVibers/OpenVibe.Sites' },++ ];++ const d = drift.createDeployDrift({ services: () => running, fetchImpl, token: 'ghp_test', now: () => NOW, log: null });++ await d.refresh();++ assert.strictEqual(asked.length, 4, 'a release that is not a commit is not looked up');++ assert.strictEqual(asked[0].auth, 'Bearer ghp_test');++ assert.strictEqual(drift.current('live').state, 'current');++ const chat = drift.current('chat');++ assert.deepStrictEqual([chat.state, chat.behind_by, chat.since, chat.main], ['behind', 3, '2026-09-25T10:00:00.000Z', 'c3ffffffffff']);++ assert.strictEqual(drift.current('media').state, 'diverged');++ assert.deepStrictEqual([drift.current('tools').state, drift.current('tools').error], ['unknown', 'commit not on GitHub']);++ assert.strictEqual(drift.current('sites'), null);++ const g = Object.fromEntries(drift.driftSeconds(NOW).map((x) => [x.labels.service, x.value]));++ assert.deepStrictEqual(g, { live: 0, chat: 26 * 3600 }, 'seconds since the oldest undeployed commit; unknown and diverged are not measured');++++ failing = true;++ await d.refresh();++ assert.strictEqual(drift.current('chat').state, 'behind', 'a failed lookup keeps the last answer');++ assert.strictEqual(drift.current('chat').stale, true);++ failing = false;++++ running = running.filter((r) => r.id !== 'media');++ await d.refresh();++ assert.strictEqual(drift.current('media'), null, 'a service that stopped running is dropped');++ drift._reset();++ console.log('deploy drift: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -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/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -408,6 +408,11 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
@@ -408,6 +408,11 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
const ecosystemInternal = require('./registry/ecosystem').internalFromEnv(process.env);
const ecosystemInternal = require('./registry/ecosystem').internalFromEnv(process.env);
if (ecosystemInternal.ignored.length) console.warn(`[Registry] ignored (not a loopback URL): ${ecosystemInternal.ignored.join(', ')}`);
if (ecosystemInternal.ignored.length) console.warn(`[Registry] ignored (not a loopback URL): ${ecosystemInternal.ignored.join(', ')}`);
const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issuer: config.jwt.issuer, internalOverrides: ecosystemInternal.overrides });
const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issuer: config.jwt.issuer, internalOverrides: ecosystemInternal.overrides });
+// Production drift: each running service's deployed commit against its repository's main (WS-S task 7).
+require('./registry/deploy-drift').createDeployDrift({
+ services: () => ecosystem.releases().services.filter((r) => r.release).map((r) => ({ id: r.id, release: r.release, repository: ((require('openvibe-contracts').services.manifests.find((m) => m.id === r.id)) || {}).repository })),
+ token: () => process.env.GITHUB_TOKEN || require('./integrations/github').tokenOf(db) || '',
+}).start();
app.use(ecosystem.router());
app.use(ecosystem.router());
ecosystem.start();
ecosystem.start();
// The released libraries' latest published tags (not the versions Network installs) for the registry.
// The released libraries' latest published tags (not the versions Network installs) for the registry.
@@ -21,6 +21,9 @@ const grantLabel = (g) => (g === 'urn:ietf:params:oauth:grant-type:jwt-bearer' ?
@@ -21,6 +21,9 @@ const grantLabel = (g) => (g === 'urn:ietf:params:oauth:grant-type:jwt-bearer' ?
const tokensIssued = registry.counter({ name: 'network_tokens_issued_total', help: 'Tokens issued by POST /oauth/token, by grant type', labelNames: ['grant_type'] });
const tokensIssued = registry.counter({ name: 'network_tokens_issued_total', help: 'Tokens issued by POST /oauth/token, by grant type', labelNames: ['grant_type'] });
const tokenFailures = registry.counter({ name: 'network_token_failures_total', help: 'POST /oauth/token requests refused, by grant type and OAuth error code', labelNames: ['grant_type', 'error'] });
const tokenFailures = registry.counter({ name: 'network_token_failures_total', help: 'POST /oauth/token requests refused, by grant type and OAuth error code', labelNames: ['grant_type', 'error'] });
+// Seconds each service's main has been ahead of what runs (registry/deploy-drift.js); alert at 24 h.
+registry.gauge({ name: 'openvibe_deploy_drift_seconds', help: 'Seconds since the oldest commit on main that the running service does not have (0 when current)', labelNames: ['service'], maxSeries: 60,
+ collect: () => require('./registry/deploy-drift').driftSeconds() });
const principalFailures = registry.counter({ name: 'network_principal_token_failures_total', help: 'Service (principal) tokens refused at capability-guarded Network routes, by problem code', labelNames: ['code'], maxSeries: 50 });
const principalFailures = registry.counter({ name: 'network_principal_token_failures_total', help: 'Service (principal) tokens refused at capability-guarded Network routes, by problem code', labelNames: ['code'], maxSeries: 50 });
/** Middleware for POST /oauth/token: counts the outcome without reading or logging any secret. */
/** Middleware for POST /oauth/token: counts the outcome without reading or logging any secret. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * Production drift (roadmap WS-S task 7): how far each running service's deployed commit (its
+ * /release.json `release`) is behind its repository's main branch on GitHub, and since when.
+ *
+ * state current the deployed commit is main's head (or main is not ahead of it)
+ * behind main has commits that are not deployed: behind_by, since (the first of them)
+ * diverged the deployed commit is not an ancestor of main (a local or force-pushed build)
+ * unknown GitHub did not answer, or the commit is not on GitHub
+ *
+ * Read hourly with GitHub's compare API (GET /repos/{repo}/compare/{deployed}...main, one call per
+ * running service; GITHUB_TOKEN or the admin GitHub token raises the rate limit). A failure keeps the
+ * last answer. `since` is the committer date of the oldest undeployed commit, so the drift age is how
+ * long main has had something production does not run. Shown on /status and in
+ * /api/v1/registry/releases; openvibe_deploy_drift_seconds{service} feeds the Prometheus alert
+ * OpenVibeDeployDrift (24 hours; OpenVibe.Host deploy/prometheus/openvibe-rules.yml).
+ */
+const SHA_RE = /^[0-9a-f]{7,40}$/;
+const REPO_RE = /^OpenVibers\/[A-Za-z0-9._-]{1,100}$/;
+
+const state = new Map(); // service id -> { repo, deployed, state, behind_by, since, main, checked_at, error }
+let timer = null;
+
+function current(id) { return state.get(id) || null; }
+function all() { return Object.fromEntries(state); }
+
+/** Seconds main has been ahead of production, per service (0 when current; nothing when unknown). */
+function driftSeconds(now = Date.now()) {
+ const out = [];
+ for (const [id, d] of state) {
+ if (d.state === 'current') out.push({ labels: { service: id }, value: 0 });
+ else if (d.state === 'behind' && d.since) out.push({ labels: { service: id }, value: Math.max(0, Math.round((now - Date.parse(d.since)) / 1000)) });
+ }
+ return out;
+}
+
+/**
+ * services(): [{ id, release, repository }] for what runs now (the registry's releases view).
+ */
+function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = () => process.env.GITHUB_TOKEN || '', intervalMs = 3600e3, now = () => Date.now(), log = console } = {}) {
+ const tokenNow = () => (typeof token === 'function' ? token() : token) || '';
+ async function checkOne({ id, release, repository }) {
+ const at = new Date(now()).toISOString();
+ if (!SHA_RE.test(String(release || '')) || !REPO_RE.test(String(repository || ''))) { state.delete(id); return null; }
+ const prev = state.get(id);
+ try {
+ const res = await fetchImpl(`https://api.github.com/repos/${repository}/compare/${release}...main`, {
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network registry', ...(tokenNow() ? { Authorization: `Bearer ${tokenNow()}` } : {}) },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 404) { state.set(id, { repo: repository, deployed: release, state: 'unknown', error: 'commit not on GitHub', checked_at: at }); return state.get(id); }
+ if (!res.ok) throw new Error(`GitHub answered ${res.status}`);
+ const body = await res.json();
+ const ahead = Number(body.ahead_by) || 0;
+ const behind = Number(body.behind_by) || 0;
+ const commits = Array.isArray(body.commits) ? body.commits : [];
+ const main = commits.length ? String(commits[commits.length - 1].sha || '').slice(0, 12) : String(release).slice(0, 12);
+ let row;
+ if (behind > 0 && ahead === 0) row = { state: 'diverged', behind_by: 0 };
+ else if (ahead === 0) row = { state: 'current', behind_by: 0 };
+ else {
+ const first = commits[0] && commits[0].commit && (commits[0].commit.committer || commits[0].commit.author);
+ row = { state: 'behind', behind_by: ahead, since: first && first.date ? new Date(first.date).toISOString() : null };
+ }
+ state.set(id, { repo: repository, deployed: release, main, checked_at: at, ...row });
+ } catch (err) {
+ // Keep the last answer; say it is stale.
+ state.set(id, prev ? { ...prev, stale: true, error: err.message } : { repo: repository, deployed: release, state: 'unknown', error: err.message, checked_at: at });
+ if (log && log.warn) log.warn(`[drift] ${id}: ${err.message}`);
+ }
+ return state.get(id);
+ }
+ async function refresh() {
+ const list = (typeof services === 'function' ? services() : services) || [];
+ const seen = new Set();
+ for (const s of list) { seen.add(s.id); await checkOne(s); }
+ for (const id of [...state.keys()]) if (!seen.has(id)) state.delete(id);
+ return all();
+ }
+ function start() {
+ if (timer) return;
+ const first = setTimeout(() => { refresh().catch(() => {}); }, 60 * 1000);
+ if (first.unref) first.unref();
+ timer = setInterval(() => { refresh().catch(() => {}); }, intervalMs);
+ if (timer.unref) timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+ return { refresh, checkOne, start, stop };
+}
+
+function _reset() { state.clear(); if (timer) clearInterval(timer); timer = null; }
+
+module.exports = { createDeployDrift, current, all, driftSeconds, _reset };
@@ -218,6 +218,9 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -218,6 +218,9 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
const row = { id: m.id, state: e.state, status: h.status, checked_at: h.checked_at || null, release: r ? r.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; }
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 || {} });
Object.assign(row, { released_at: r.released_at, booted_at: r.booted_at, contracts_version: r.contracts_version || null, packages: r.packages || {} });
+ // How far the deployed commit is behind the repository's main branch (./deploy-drift.js).
+ const dd = require('./deploy-drift').current(m.id);
+ if (dd) row.main = { state: dd.state, behind_by: dd.behind_by ?? null, since: dd.since || null, head: dd.main || null, checked_at: dd.checked_at, ...(dd.error ? { error: dd.error } : {}) };
row.drift = {};
row.drift = {};
for (const l of libs) {
for (const l of libs) {
const installed = l.package === 'openvibe-contracts' ? r.contracts_version : (r.packages || {})[l.package];
const installed = l.package === 'openvibe-contracts' ? r.contracts_version : (r.packages || {})[l.package];
@@ -226,7 +229,10 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -226,7 +229,10 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return row;
return row;
}).filter(Boolean);
}).filter(Boolean);
const behind = services.filter(s => s.drift && Object.values(s.drift).some(d => d.state === 'behind')).map(s => s.id);
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 };
+ // Deployed behind main for more than a day (WS-S task 7).
+ const dayAgo = now() - 24 * 3600e3;
+ const undeployed = services.filter(s => s.main && s.main.state === 'behind' && s.main.since && Date.parse(s.main.since) < dayAgo).map(s => s.id);
+ return { checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null, poll_interval_s: Math.round(pollMs / 1000), libraries: libs, services, behind, undeployed };
}
}
/** Services grouped by category, each with its maturity, exposure and last check. */
/** Services grouped by category, each with its maturity, exposure and last check. */
@@ -28,6 +28,15 @@ const esc = seo.esc;
@@ -28,6 +28,15 @@ const esc = seo.esc;
const STATES = ['up', 'degraded', 'down', 'not-running', 'unknown'];
const STATES = ['up', 'degraded', 'down', 'not-running', 'unknown'];
const LABEL = { up: 'Up', degraded: 'Degraded', down: 'Down', 'not-running': 'Not running', unknown: 'Unknown' };
const LABEL = { up: 'Up', degraded: 'Degraded', down: 'Down', 'not-running': 'Not running', unknown: 'Unknown' };
+/** The deployed commit against main (registry/deploy-drift.js), as a short note under the release. */
+function mainNote(d) {
+ if (!d) return '';
+ if (d.state === 'current') return '<small>main: current</small>';
+ if (d.state === 'behind') return `<small>main: ${esc(String(d.behind_by))} commit${d.behind_by === 1 ? '' : 's'} not deployed${d.since ? ` since <time datetime="${esc(d.since)}">${esc(d.since.slice(0, 16).replace('T', ' '))} UTC</time>` : ''}</small>`;
+ if (d.state === 'diverged') return '<small>main: not on main (a local build)</small>';
+ return '';
+}
+
function loadSlo() { return JSON.parse(fs.readFileSync(SLO_FILE, 'utf8')); }
function loadSlo() { return JSON.parse(fs.readFileSync(SLO_FILE, 'utf8')); }
function rows(ecosystem) {
function rows(ecosystem) {
@@ -52,6 +61,7 @@ function rows(ecosystem) {
@@ -52,6 +61,7 @@ function rows(ecosystem) {
http_status: r.http_status ?? null,
http_status: r.http_status ?? null,
latency_ms: r.latency_ms ?? null,
latency_ms: r.latency_ms ?? null,
release: r.release && r.release.release ? { release: r.release.release, released_at: r.release.released_at || null, booted_at: r.release.booted_at || null } : null,
release: r.release && r.release.release ? { release: r.release.release, released_at: r.release.released_at || null, booted_at: r.release.booted_at || null } : null,
+ main: (() => { const d = require('../registry/deploy-drift').current(m.id); return d ? { state: d.state, behind_by: d.behind_by ?? null, since: d.since || null } : null; })(),
release_error: r.release && !r.release.release ? r.release.error || 'unknown' : null,
release_error: r.release && !r.release.release ? r.release.error || 'unknown' : null,
ready: r.ready || null,
ready: r.ready || null,
...(r.stale ? { stale: true, last_status: r.last_status } : {}),
...(r.stale ? { stale: true, last_status: r.last_status } : {}),
@@ -103,7 +113,7 @@ function renderPage(list, slo, generatedAt) {
@@ -103,7 +113,7 @@ function renderPage(list, slo, generatedAt) {
<td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td>
<td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td>
<td>${exposureCell(r)}</td>
<td>${exposureCell(r)}</td>
<td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status] + (r.label.endsWith('(loopback only)') ? ' · loopback only' : ''))}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td>
<td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status] + (r.label.endsWith('(loopback only)') ? ' · loopback only' : ''))}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td>
-<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td>
+<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}${mainNote(r.main)}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td>
<td>${r.checked_at ? `<time datetime="${esc(r.checked_at)}">${esc(r.checked_at)}</time>` : '<small>not checked yet</small>'}${r.latency_ms != null ? `<small>${esc(r.latency_ms)} ms</small>` : ''}</td>
<td>${r.checked_at ? `<time datetime="${esc(r.checked_at)}">${esc(r.checked_at)}</time>` : '<small>not checked yet</small>'}${r.latency_ms != null ? `<small>${esc(r.latency_ms)} ms</small>` : ''}</td>
</tr>`).join('\n');
</tr>`).join('\n');
const sloRows = slo.categories.map((c) => `<dt>${esc(c.name)}</dt><dd>${esc(c.sli)} <br><small>Proposed: ${esc(Object.entries(c.proposed_target).map(([k, v]) => `${k.replace(/_/g, ' ')} ${v}`).join('; '))} · priority: ${esc(c.priority)} · ${esc(c.measurement_status || (c.measured_by.length ? 'measured' : 'not instrumented'))}</small></dd>`).join('\n');
const sloRows = slo.categories.map((c) => `<dt>${esc(c.name)}</dt><dd>${esc(c.sli)} <br><small>Proposed: ${esc(Object.entries(c.proposed_target).map(([k, v]) => `${k.replace(/_/g, ' ')} ${v}`).join('; '))} · priority: ${esc(c.priority)} · ${esc(c.measurement_status || (c.measured_by.length ? 'measured' : 'not instrumented'))}</small></dd>`).join('\n');
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+// Production drift (WS-S task 7): the deployed commit against main through GitHub's compare API —
+// current, behind (how many commits and since the oldest undeployed one), diverged, unknown; a failed
+// lookup keeps the last answer; the gauge gives seconds behind (0 when current); services that stop
+// running are dropped; a non-commit release is skipped.
+const assert = require('assert');
+const drift = require('../server/registry/deploy-drift');
+
+const NOW = Date.parse('2026-09-26T12:00:00Z');
+const answers = {
+ 'OpenVibers/OpenVibe.Live/compare/aaaaaaa1...main': { status: 200, body: { ahead_by: 0, behind_by: 0, commits: [] } },
+ 'OpenVibers/OpenVibe.Chat/compare/bbbbbbb2...main': { status: 200, body: { ahead_by: 3, behind_by: 0, commits: [
+ { sha: 'c1', commit: { committer: { date: '2026-09-25T10:00:00Z' } } },
+ { sha: 'c2', commit: { committer: { date: '2026-09-25T11:00:00Z' } } },
+ { sha: 'c3ffffffffffff', commit: { committer: { date: '2026-09-26T09:00:00Z' } } },
+ ] } },
+ 'OpenVibers/OpenVibe.Media/compare/ccccccc3...main': { status: 200, body: { ahead_by: 0, behind_by: 2, commits: [] } },
+ 'OpenVibers/OpenVibe.Tools/compare/ddddddd4...main': { status: 404, body: { message: 'Not Found' } },
+};
+let failing = false;
+const asked = [];
+const fetchImpl = async (url, opts) => {
+ const key = url.replace('https://api.github.com/repos/', '');
+ asked.push({ key, auth: opts.headers.Authorization });
+ if (failing) throw new Error('network down');
+ const a = answers[key];
+ return { ok: a.status === 200, status: a.status, json: async () => a.body };
+};
+
+(async () => {
+ let running = [
+ { id: 'live', release: 'aaaaaaa1', repository: 'OpenVibers/OpenVibe.Live' },
+ { id: 'chat', release: 'bbbbbbb2', repository: 'OpenVibers/OpenVibe.Chat' },
+ { id: 'media', release: 'ccccccc3', repository: 'OpenVibers/OpenVibe.Media' },
+ { id: 'tools', release: 'ddddddd4', repository: 'OpenVibers/OpenVibe.Tools' },
+ { id: 'sites', release: 'not-a-sha', repository: 'OpenVibers/OpenVibe.Sites' },
+ ];
+ const d = drift.createDeployDrift({ services: () => running, fetchImpl, token: 'ghp_test', now: () => NOW, log: null });
+ await d.refresh();
+ assert.strictEqual(asked.length, 4, 'a release that is not a commit is not looked up');
+ assert.strictEqual(asked[0].auth, 'Bearer ghp_test');
+ assert.strictEqual(drift.current('live').state, 'current');
+ const chat = drift.current('chat');
+ assert.deepStrictEqual([chat.state, chat.behind_by, chat.since, chat.main], ['behind', 3, '2026-09-25T10:00:00.000Z', 'c3ffffffffff']);
+ assert.strictEqual(drift.current('media').state, 'diverged');
+ assert.deepStrictEqual([drift.current('tools').state, drift.current('tools').error], ['unknown', 'commit not on GitHub']);
+ assert.strictEqual(drift.current('sites'), null);
+ const g = Object.fromEntries(drift.driftSeconds(NOW).map((x) => [x.labels.service, x.value]));
+ assert.deepStrictEqual(g, { live: 0, chat: 26 * 3600 }, 'seconds since the oldest undeployed commit; unknown and diverged are not measured');
+
+ failing = true;
+ await d.refresh();
+ assert.strictEqual(drift.current('chat').state, 'behind', 'a failed lookup keeps the last answer');
+ assert.strictEqual(drift.current('chat').stale, true);
+ failing = false;
+
+ running = running.filter((r) => r.id !== 'media');
+ await d.refresh();
+ assert.strictEqual(drift.current('media'), null, 'a service that stopped running is dropped');
+ drift._reset();
+ console.log('deploy drift: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });