openvibers/openvibe.network Commits
Incidents and maintenance on /status (roadmap WS-N task 12; Contracts v0.66.0, pinned from 0.65.0). server/status/incidents.js stores incidents (investigating, identified, monitoring, resolved) and maintenance windows (scheduled, in_progress, completed) with their updates. The public GET /api/v1/status/incidents lists active ones and those closed in the last 30 days, and /status shows the open ones at the top. Staff admins, or the Host principal with network.status.incident (granted; ovhost incident / maintenance), open and update them. Closed ones refuse updates, and bodies follow network.status-incident-request@1. test/status-incidents.test.js
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.65.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0",@@@ -1465,9 +1465,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.65.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.65.0",-- "integrity": "sha512-oBWCCxa0iThdh28hVCutTVyf91EDsK9+O54xHAW4XNi0CVGQaynLGckE08GWoMCd4T9Q5JEDDPfCA2TIssf1ew==",++ "version": "0.66.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",++ "integrity": "sha512-iArup+k2iUI4ddMDVgVrf8xTCbEB5LaV0Da/1Bt5k3eH2TJpJ7yIEtBgBee0UZ6taNd2bZsZ6zJOkamqCAa4nA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -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/operator-alerts.test.js && node test/follows.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/admin-role-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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.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/operator-alerts.test.js && node test/follows.test.js && node test/status-incidents.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/admin-role-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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.65.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0",@@@ -750,6 +750,8 @@ function initDb(dbPath) {require('../identity/blocks').ensureSchema(db);// The follow graph, keyed by subjects (server/identity/follows.js; ADR-030).require('../identity/follows').ensureSchema(db);++ // Incidents and maintenance on /status (server/status/incidents.js).++ require('../status/incidents').ensureSchema(db);console.log('[DB] Central database initialized');return db;@@@ -125,6 +125,8 @@ const DEFAULT_GRANTS = [['live', 'network.follows.read', SELF_AUDIENCE, []],// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).['host', 'network.operator.alert', SELF_AUDIENCE, []],++ // WS-N task 12: ovhost incident / maintenance post to the status page.++ ['host', 'network.status.incident', SELF_AUDIENCE, []],// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples// scripts/tools-job-proof.js) submits a converter job as `probe` and finds its tools.job.* events.['probe', 'tools.job.create', 'openvibe.tools', []],@@@ -444,6 +444,8 @@ ecosystem.start();const libraryTags = require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: () => require('./integrations/github').tokenOf(db) });libraryTags.start();// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.++// Incidents and maintenance (WS-N task 12): public list; staff admins or network.status.incident (ovhost) write.++app.use('/api/v1/status/incidents', rateLimit({ windowMs: 60_000, max: 120 }), require('./status/incidents').router({ requireAuth, incidentGuard: require('./identity/principals').guard('network.status.incident', { legacy: false }) }));app.use(require('./status/routes').createStatusRoutes({ ecosystem }));// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++/**++ * Incidents and maintenance windows on the public status page (roadmap WS-N task 12; Contracts 0.66.0++ * network.status-incident@1, network.status-incident-list@1, network.status-incident-request@1,++ * capability network.status.incident).++ *++ * GET /api/v1/status/incidents public: { active, recent } (recent = closed in the last 30 days)++ * POST /api/v1/status/incidents open one } staff admins (session), or a service token with++ * POST /api/v1/status/incidents/:id/updates add an update } network.status.incident (the Host principal:++ * ovhost incident / ovhost maintenance)++ *++ * An incident moves investigating → identified → monitoring → resolved; a maintenance window++ * scheduled → in_progress → completed. Each update keeps its message; the newest state is the incident's,++ * and resolved / completed close it (further updates are refused). Messages are plain text for people.++ * /status shows the active ones at the top (server/status/routes.js).++ */++const express = require('express');++const { ids, validate, http } = require('openvibe-contracts');++++const INCIDENT_STATES = ['investigating', 'identified', 'monitoring', 'resolved'];++const MAINTENANCE_STATES = ['scheduled', 'in_progress', 'completed'];++const CLOSED = new Set(['resolved', 'completed']);++const RECENT_DAYS = 30;++++class IncidentError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++function ensureSchema(db) {++ db.exec(`CREATE TABLE IF NOT EXISTS status_incidents (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL CHECK (kind IN ('incident', 'maintenance')),++ title TEXT NOT NULL,++ severity TEXT CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),++ state TEXT NOT NULL,++ services TEXT NOT NULL DEFAULT '[]',++ starts_at TEXT NOT NULL,++ ends_at TEXT,++ created_by TEXT NOT NULL,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL++ );++ CREATE TABLE IF NOT EXISTS status_incident_updates (++ incident_id TEXT NOT NULL REFERENCES status_incidents(id),++ at TEXT NOT NULL,++ state TEXT NOT NULL,++ message TEXT NOT NULL,++ author TEXT NOT NULL++ );++ CREATE INDEX IF NOT EXISTS idx_status_incident_updates ON status_incident_updates(incident_id, at);`);++}++++const statesOf = (kind) => (kind === 'maintenance' ? MAINTENANCE_STATES : INCIDENT_STATES);++++function present(db, row) {++ if (!row) return null;++ const out = {++ id: row.id, kind: row.kind, title: row.title, ...(row.severity ? { severity: row.severity } : {}), state: row.state,++ services: JSON.parse(row.services || '[]'), starts_at: row.starts_at, ends_at: row.ends_at || null,++ updates: db.prepare('SELECT at, state, message FROM status_incident_updates WHERE incident_id = ? ORDER BY at, rowid').all(row.id),++ created_at: row.created_at, updated_at: row.updated_at,++ };++ return out;++}++++function get(db, id) { ensureSchema(db); return present(db, db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id))); }++++/** Active: open incidents, and maintenance windows not completed. Recent: closed within 30 days. */++function list(db, { now = Date.now() } = {}) {++ ensureSchema(db);++ const since = new Date(now - RECENT_DAYS * 86400000).toISOString();++ const active = db.prepare("SELECT * FROM status_incidents WHERE state NOT IN ('resolved', 'completed') ORDER BY starts_at DESC").all().map((r) => present(db, r));++ const recent = db.prepare("SELECT * FROM status_incidents WHERE state IN ('resolved', 'completed') AND updated_at >= ? ORDER BY updated_at DESC LIMIT 50").all(since).map((r) => present(db, r));++ return { active, recent };++}++++function check(schema, body) {++ const v = validate(schema, body);++ if (!v.valid) throw new IncidentError(400, 'status.invalid_request', `the body does not match ${schema}: ${JSON.stringify(v.errors || []).slice(0, 300)}`);++}++++/** Open an incident or maintenance window (body: network.status-incident-request@1, the open form). */++function open(db, body, author, { now = new Date().toISOString() } = {}) {++ ensureSchema(db);++ check('network.status-incident-request@1', body);++ if (!body.kind) throw new IncidentError(400, 'status.invalid_request', 'kind, title, services and message open an incident');++ const kind = body.kind;++ const state = body.state || (kind === 'maintenance' ? 'scheduled' : 'investigating');++ if (!statesOf(kind).includes(state)) throw new IncidentError(400, 'status.invalid_state', `a ${kind} starts in one of ${statesOf(kind).filter((s) => !CLOSED.has(s)).join(', ')}`);++ if (kind === 'maintenance' && body.severity) throw new IncidentError(400, 'status.invalid_request', 'maintenance has no severity');++ const id = `inc_${ids.ulid(Date.parse(now) || Date.now())}`;++ const startsAt = body.starts_at ? new Date(body.starts_at).toISOString() : now;++ const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : null;++ if (endsAt && endsAt < startsAt) throw new IncidentError(400, 'status.invalid_window', 'ends_at is before starts_at');++ db.transaction(() => {++ db.prepare(`INSERT INTO status_incidents (id, kind, title, severity, state, services, starts_at, ends_at, created_by, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, kind, body.title.trim(), kind === 'incident' ? (body.severity || 'minor') : null, state,++ JSON.stringify([...new Set(body.services)]), startsAt, endsAt, author, now, now);++ db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(id, now, state, body.message.trim(), author);++ })();++ return get(db, id);++}++++/** Add an update (body: the update form): a new state and message; ends_at for a window. Closed ones refuse. */++function update(db, id, body, author, { now = new Date().toISOString() } = {}) {++ ensureSchema(db);++ check('network.status-incident-request@1', body);++ if (!body.state || body.kind) throw new IncidentError(400, 'status.invalid_request', 'an update is { state, message, ends_at? }');++ const row = db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id));++ if (!row) throw new IncidentError(404, 'status.incident_not_found', 'no such incident');++ if (CLOSED.has(row.state)) throw new IncidentError(409, 'status.incident_closed', `this ${row.kind} is ${row.state}`);++ if (!statesOf(row.kind).includes(body.state)) throw new IncidentError(400, 'status.invalid_state', `a ${row.kind} is one of ${statesOf(row.kind).join(', ')}`);++ const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : (CLOSED.has(body.state) ? now : row.ends_at);++ db.transaction(() => {++ db.prepare('UPDATE status_incidents SET state = ?, ends_at = ?, updated_at = ? WHERE id = ?').run(body.state, endsAt, now, row.id);++ db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(row.id, now, body.state, body.message.trim(), author);++ })();++ return get(db, row.id);++}++++/**++ * Routes. Writers: a staff admin's session (requireAuth + role admin), or a service token holding++ * network.status.incident (incidentGuard, service tokens only).++ */++function router({ requireAuth, incidentGuard }) {++ const r = express.Router();++ r.use(http.middleware());++ r.get('/', (req, res) => {++ res.set('Cache-Control', 'public, max-age=30').json(list(req.app.locals.db));++ });++ const writer = (req, res, next) => {++ const h = String(req.headers.authorization || '');++ const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);++ let user = null;++ if (token) {++ try { const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config }); user = out && out.user; } catch { user = null; }++ }++ if (user) {++ if (user.role !== 'admin') return http.sendProblem(res, 403, 'status.staff_only', { detail: 'only staff admins post incidents', ctx: req.ov });++ req.statusAuthor = `user:${user.username}`;++ return requireAuth(req, res, next);++ }++ return incidentGuard(req, res, () => { req.statusAuthor = req.principal ? String(req.principal.sub) : 'service'; next(); });++ };++ const send = (res, req, fn, status = 200) => {++ res.set('Cache-Control', 'no-store');++ try { res.status(status).json(fn()); } catch (err) {++ if (err instanceof IncidentError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ console.error('[Status incidents]', err.message);++ return http.sendProblem(res, 500, 'status.failed', { detail: 'the incident could not be saved', ctx: req.ov });++ }++ };++ r.post('/', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => open(req.app.locals.db, req.body || {}, req.statusAuthor), 201));++ r.post('/:id/updates', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => update(req.app.locals.db, req.params.id, req.body || {}, req.statusAuthor)));++ return r;++}++++module.exports = { ensureSchema, open, update, list, get, router, IncidentError, INCIDENT_STATES, MAINTENANCE_STATES };@@@ -135,7 +135,7 @@ function exposureCell(r) {const CSS = `.st{max-width:1080px;margin:32px auto 64px;padding:0 20px}.st h1{font-size:clamp(24px,3vw,32px);letter-spacing:-.02em;margin:0 0 6px}.st p.lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:780px;line-height:1.5}--.st-sum{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 18px;padding:0;list-style:none}.st-sum li{padding:6px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);font-size:13.5px}++.st-incidents{margin:0 0 20px}.st-incident{border:1px solid var(--border,#1f2d47);border-left:4px solid var(--warning,#f59e0b);border-radius:10px;padding:10px 14px;margin:0 0 10px}.st-incident h3{margin:0 0 4px;font-size:16px}.st-incident p{margin:4px 0}.st-maintenance{border-left-color:var(--info,#38bdf8)}.st-sev-critical,.st-sev-major{border-left-color:var(--danger,#ef4444)}.st-quiet{color:var(--text-muted,#8b93ad);margin:0 0 14px}.st-sum{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 18px;padding:0;list-style:none}.st-sum li{padding:6px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);font-size:13.5px}.st table{width:100%;border-collapse:collapse;font-size:14px}.st th,.st td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--border,#1f2d47);vertical-align:top}.st th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted,#7386a3)}.st td small{display:block;color:var(--text-muted,#7386a3);font-size:12.5px;margin-top:2px}@@@ -157,7 +157,20 @@ function proofSteps(rec) {}const lastRun = (rec) => `Last run <time datetime="${esc(rec.finished_at)}">${esc(rec.finished_at)}</time>: <b>${rec.ok ? 'passed' : 'failed'}</b> (${rec.steps.filter((st) => st.ok).length}/${rec.steps.length} steps).`;--function renderPage(list, slo, generatedAt, devPath = null, toolsJob = null, healthSince = null) {++/** Open incidents and maintenance windows (server/status/incidents.js), at the top of /status. */++function incidentsSection(incidents) {++ const active = (incidents && incidents.active) || [];++ if (!active.length) return '<p class="st-quiet">No open incidents or maintenance. <a href="/api/v1/status/incidents">History</a></p>';++ const LBL = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', scheduled: 'Scheduled', in_progress: 'In progress' };++ return `<section class="st-incidents" aria-labelledby="h-incidents"><h2 id="h-incidents">Incidents and maintenance</h2>${active.map((i) => {++ const last = i.updates[i.updates.length - 1] || {};++ const when = i.kind === 'maintenance' ? `${esc(i.starts_at)}${i.ends_at ? ` – ${esc(i.ends_at)}` : ''}` : `since ${esc(i.starts_at)}`;++ return `<article class="st-incident st-${esc(i.kind)}${i.severity ? ` st-sev-${esc(i.severity)}` : ''}"><h3>${esc(i.title)}</h3>++<p><strong>${esc(LBL[i.state] || i.state)}</strong>${i.severity ? ` · ${esc(i.severity)}` : ''} · ${esc(i.services.join(', '))} · ${when}</p><p>${esc(last.message || '')}</p></article>`;++ }).join('')}<p><a href="/api/v1/status/incidents">History (JSON)</a></p></section>`;++}++++function renderPage(list, slo, generatedAt, devPath = null, toolsJob = null, healthSince = null, incidents = null) {const counts = summary(list);const tr = list.map((r) => `<tr id="svc-${esc(r.id)}"><td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td>@@@ -191,6 +204,7 @@ ${CSS}${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}<main class="st" id="main"><h1>Service status</h1>++${incidentsSection(incidents)}<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders, libraries and repositories show as not running. <b>Where</b> says whether the public domain serves the service itself (live), or the service runs on this host's loopback only while its domain still serves a placeholder page (internal). Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p><ul class="st-sum">${STATES.map((s) => `<li class="st-${s}">${esc(LABEL[s])}: ${counts[s]}</li>`).join('')}</ul><table>@@@ -248,7 +262,9 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {r.get('/status', (_req, res) => {const slo = { ...loadSlo(), pollSeconds: ecosystem.pollMs / 1000 };res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-cache, max-age=0').set('X-Robots-Tag', 'noindex, nofollow');-- res.send(renderPage(rows(ecosystem), slo, now().toISOString(), loadDevPath(), loadDevPath(TOOLSJOB_FILE), typeof ecosystem.releaseHealthSince === 'function' ? ecosystem.releaseHealthSince() : null));++ let incidents = null;++ try { incidents = require('./incidents').list(_req.app.locals.db); } catch { incidents = null; }++ res.send(renderPage(rows(ecosystem), slo, now().toISOString(), loadDevPath(), loadDevPath(TOOLSJOB_FILE), typeof ecosystem.releaseHealthSince === 'function' ? ecosystem.releaseHealthSince() : null, incidents));});return r;}nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++// Incidents and maintenance on /status (roadmap WS-N task 12; Contracts 0.66.0): Host's token or a staff admin++// opens and updates them; others cannot; states follow the kind; closed ones refuse updates; the public list++// and /status show them.++// node test/status-incidents.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const { validate } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-incidents-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++require('../server/identity/principals').ensureSchema(db);++for (const c of ['host', 'live']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);++db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'boss', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', 'admin'), (2, 'pat', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'user')").run();++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++const { signToken } = require('../server/auth/routes');++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);++const app = express();++app.use(require('cookie-parser')());++app.use(express.urlencoded({ extended: true }));++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use('/api/v1/status/incidents', require('../server/status/incidents').router({ requireAuth, incidentGuard: require('../server/identity/principals').guard('network.status.incident', { legacy: false }) }));++const ecosystem = { current: () => [], lastPollAt: () => null, pollMs: 60000, releaseHealthSince: () => null };++app.use(require('../server/status/routes').createStatusRoutes({ ecosystem }));++const server = http.createServer(app);++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })++ .then(async (r) => ({ status: r.status, text: await r.clone().text(), body: await r.json().catch(() => null) }));++ const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;++ const as = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });++ try {++ const host = { authorization: `Bearer ${await svc('host')}` };++ // Who may write.++ const open = { kind: 'incident', title: 'Live streams stall on start', severity: 'major', services: ['live'], message: 'New streams take a minute to start.' };++ assert.ok([401, 403].includes((await call('POST', '/api/v1/status/incidents', { body: open })).status), 'nobody');++ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: as(2) })).status, 403, 'a person who is not staff');++ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: { authorization: `Bearer ${await svc('live')}` } })).status, 403, 'a service without network.status.incident');++ let r = await call('POST', '/api/v1/status/incidents', { body: open, headers: host });++ assert.strictEqual(r.status, 201, r.text);++ assert.ok(validate('network.status-incident@1', r.body).valid, JSON.stringify(validate('network.status-incident@1', r.body).errors));++ assert.deepStrictEqual([r.body.state, r.body.severity, r.body.updates.length], ['investigating', 'major', 1]);++ const inc = r.body.id;++ assert.match(inc, /^inc_[0-9A-HJKMNP-TV-Z]{26}$/);++++ // A staff admin updates it through its states; closed refuses more.++ r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'identified', message: 'A restart left the ingest socket unbound.' }, headers: as(1) });++ assert.deepStrictEqual([r.status, r.body.state, r.body.updates.length], [200, 'identified', 2]);++ assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'completed', message: 'x' }, headers: host })).status, 400, 'a maintenance state on an incident');++ assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'resolved', message: 'ok', title: 'renamed' }, headers: host })).status, 400, 'the request contract');++++ // A maintenance window.++ r = await call('POST', '/api/v1/status/incidents', { body: { kind: 'maintenance', title: 'Media storage move', services: ['media'], message: 'Uploads pause for up to an hour.', starts_at: '2026-09-28T03:00:00Z', ends_at: '2026-09-28T04:00:00Z' }, headers: host });++ assert.deepStrictEqual([r.status, r.body.state, r.body.ends_at], [201, 'scheduled', '2026-09-28T04:00:00.000Z']);++ const win = r.body.id;++ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: { kind: 'maintenance', title: 'Backwards', services: ['media'], message: 'x', starts_at: '2026-09-28T04:00:00Z', ends_at: '2026-09-28T03:00:00Z' }, headers: host })).status, 400);++ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: { kind: 'maintenance', severity: 'major', title: 'Sev', services: ['media'], message: 'x' }, headers: host })).status, 400, 'maintenance has no severity');++++ // Public list and /status.++ r = await call('GET', '/api/v1/status/incidents');++ assert.strictEqual(r.status, 200);++ assert.ok(validate('network.status-incident-list@1', r.body).valid);++ assert.deepStrictEqual(r.body.active.map((i) => i.id).sort(), [inc, win].sort());++ r = await call('GET', '/status');++ assert.match(r.text, /Incidents and maintenance/);++ assert.match(r.text, /Live streams stall on start/);++ assert.match(r.text, /Identified/);++ assert.match(r.text, /A restart left the ingest socket unbound\./);++++ // Resolve: closed, out of active, in recent; further updates refused.++ r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'resolved', message: 'Rebound; streams start at once again.' }, headers: host });++ assert.deepStrictEqual([r.status, r.body.state, typeof r.body.ends_at], [200, 'resolved', 'string']);++ assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'monitoring', message: 'again' }, headers: host })).status, 409);++ r = await call('GET', '/api/v1/status/incidents');++ assert.deepStrictEqual([r.body.active.map((i) => i.id), r.body.recent.map((i) => i.id)], [[win], [inc]]);++ assert.strictEqual((await call('POST', '/api/v1/status/incidents/inc_01JAB2C3D4E5F6G7H8J9K0MNPQ/updates', { body: { state: 'resolved', message: 'x' }, headers: host })).status, 404);++ await call('POST', `/api/v1/status/incidents/${win}/updates`, { body: { state: 'completed', message: 'Done.' }, headers: host });++ assert.match((await call('GET', '/status')).text, /No open incidents or maintenance/);++ } finally {++ server.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('status incidents: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.65.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.65.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.65.0",
- "integrity": "sha512-oBWCCxa0iThdh28hVCutTVyf91EDsK9+O54xHAW4XNi0CVGQaynLGckE08GWoMCd4T9Q5JEDDPfCA2TIssf1ew==",
+ "version": "0.66.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
+ "integrity": "sha512-iArup+k2iUI4ddMDVgVrf8xTCbEB5LaV0Da/1Bt5k3eH2TJpJ7yIEtBgBee0UZ6taNd2bZsZ6zJOkamqCAa4nA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.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/operator-alerts.test.js && node test/follows.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/admin-role-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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.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/operator-alerts.test.js && node test/follows.test.js && node test/status-incidents.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/admin-role-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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.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"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.65.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -750,6 +750,8 @@ function initDb(dbPath) {
@@ -750,6 +750,8 @@ function initDb(dbPath) {
require('../identity/blocks').ensureSchema(db);
require('../identity/blocks').ensureSchema(db);
// The follow graph, keyed by subjects (server/identity/follows.js; ADR-030).
// The follow graph, keyed by subjects (server/identity/follows.js; ADR-030).
require('../identity/follows').ensureSchema(db);
require('../identity/follows').ensureSchema(db);
+ // Incidents and maintenance on /status (server/status/incidents.js).
+ require('../status/incidents').ensureSchema(db);
console.log('[DB] Central database initialized');
console.log('[DB] Central database initialized');
return db;
return db;
@@ -125,6 +125,8 @@ const DEFAULT_GRANTS = [
@@ -125,6 +125,8 @@ const DEFAULT_GRANTS = [
['live', 'network.follows.read', SELF_AUDIENCE, []],
['live', 'network.follows.read', SELF_AUDIENCE, []],
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
['host', 'network.operator.alert', SELF_AUDIENCE, []],
['host', 'network.operator.alert', SELF_AUDIENCE, []],
+ // WS-N task 12: ovhost incident / maintenance post to the status page.
+ ['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
// scripts/tools-job-proof.js) submits a converter job as `probe` and finds its tools.job.* events.
// scripts/tools-job-proof.js) submits a converter job as `probe` and finds its tools.job.* events.
['probe', 'tools.job.create', 'openvibe.tools', []],
['probe', 'tools.job.create', 'openvibe.tools', []],
@@ -444,6 +444,8 @@ ecosystem.start();
@@ -444,6 +444,8 @@ ecosystem.start();
const libraryTags = require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: () => require('./integrations/github').tokenOf(db) });
const libraryTags = require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: () => require('./integrations/github').tokenOf(db) });
libraryTags.start();
libraryTags.start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
+// Incidents and maintenance (WS-N task 12): public list; staff admins or network.status.incident (ovhost) write.
+app.use('/api/v1/status/incidents', rateLimit({ windowMs: 60_000, max: 120 }), require('./status/incidents').router({ requireAuth, incidentGuard: require('./identity/principals').guard('network.status.incident', { legacy: false }) }));
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+/**
+ * Incidents and maintenance windows on the public status page (roadmap WS-N task 12; Contracts 0.66.0
+ * network.status-incident@1, network.status-incident-list@1, network.status-incident-request@1,
+ * capability network.status.incident).
+ *
+ * GET /api/v1/status/incidents public: { active, recent } (recent = closed in the last 30 days)
+ * POST /api/v1/status/incidents open one } staff admins (session), or a service token with
+ * POST /api/v1/status/incidents/:id/updates add an update } network.status.incident (the Host principal:
+ * ovhost incident / ovhost maintenance)
+ *
+ * An incident moves investigating → identified → monitoring → resolved; a maintenance window
+ * scheduled → in_progress → completed. Each update keeps its message; the newest state is the incident's,
+ * and resolved / completed close it (further updates are refused). Messages are plain text for people.
+ * /status shows the active ones at the top (server/status/routes.js).
+ */
+const express = require('express');
+const { ids, validate, http } = require('openvibe-contracts');
+
+const INCIDENT_STATES = ['investigating', 'identified', 'monitoring', 'resolved'];
+const MAINTENANCE_STATES = ['scheduled', 'in_progress', 'completed'];
+const CLOSED = new Set(['resolved', 'completed']);
+const RECENT_DAYS = 30;
+
+class IncidentError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+function ensureSchema(db) {
+ db.exec(`CREATE TABLE IF NOT EXISTS status_incidents (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('incident', 'maintenance')),
+ title TEXT NOT NULL,
+ severity TEXT CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),
+ state TEXT NOT NULL,
+ services TEXT NOT NULL DEFAULT '[]',
+ starts_at TEXT NOT NULL,
+ ends_at TEXT,
+ created_by TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS status_incident_updates (
+ incident_id TEXT NOT NULL REFERENCES status_incidents(id),
+ at TEXT NOT NULL,
+ state TEXT NOT NULL,
+ message TEXT NOT NULL,
+ author TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_status_incident_updates ON status_incident_updates(incident_id, at);`);
+}
+
+const statesOf = (kind) => (kind === 'maintenance' ? MAINTENANCE_STATES : INCIDENT_STATES);
+
+function present(db, row) {
+ if (!row) return null;
+ const out = {
+ id: row.id, kind: row.kind, title: row.title, ...(row.severity ? { severity: row.severity } : {}), state: row.state,
+ services: JSON.parse(row.services || '[]'), starts_at: row.starts_at, ends_at: row.ends_at || null,
+ updates: db.prepare('SELECT at, state, message FROM status_incident_updates WHERE incident_id = ? ORDER BY at, rowid').all(row.id),
+ created_at: row.created_at, updated_at: row.updated_at,
+ };
+ return out;
+}
+
+function get(db, id) { ensureSchema(db); return present(db, db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id))); }
+
+/** Active: open incidents, and maintenance windows not completed. Recent: closed within 30 days. */
+function list(db, { now = Date.now() } = {}) {
+ ensureSchema(db);
+ const since = new Date(now - RECENT_DAYS * 86400000).toISOString();
+ const active = db.prepare("SELECT * FROM status_incidents WHERE state NOT IN ('resolved', 'completed') ORDER BY starts_at DESC").all().map((r) => present(db, r));
+ const recent = db.prepare("SELECT * FROM status_incidents WHERE state IN ('resolved', 'completed') AND updated_at >= ? ORDER BY updated_at DESC LIMIT 50").all(since).map((r) => present(db, r));
+ return { active, recent };
+}
+
+function check(schema, body) {
+ const v = validate(schema, body);
+ if (!v.valid) throw new IncidentError(400, 'status.invalid_request', `the body does not match ${schema}: ${JSON.stringify(v.errors || []).slice(0, 300)}`);
+}
+
+/** Open an incident or maintenance window (body: network.status-incident-request@1, the open form). */
+function open(db, body, author, { now = new Date().toISOString() } = {}) {
+ ensureSchema(db);
+ check('network.status-incident-request@1', body);
+ if (!body.kind) throw new IncidentError(400, 'status.invalid_request', 'kind, title, services and message open an incident');
+ const kind = body.kind;
+ const state = body.state || (kind === 'maintenance' ? 'scheduled' : 'investigating');
+ if (!statesOf(kind).includes(state)) throw new IncidentError(400, 'status.invalid_state', `a ${kind} starts in one of ${statesOf(kind).filter((s) => !CLOSED.has(s)).join(', ')}`);
+ if (kind === 'maintenance' && body.severity) throw new IncidentError(400, 'status.invalid_request', 'maintenance has no severity');
+ const id = `inc_${ids.ulid(Date.parse(now) || Date.now())}`;
+ const startsAt = body.starts_at ? new Date(body.starts_at).toISOString() : now;
+ const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : null;
+ if (endsAt && endsAt < startsAt) throw new IncidentError(400, 'status.invalid_window', 'ends_at is before starts_at');
+ db.transaction(() => {
+ db.prepare(`INSERT INTO status_incidents (id, kind, title, severity, state, services, starts_at, ends_at, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, kind, body.title.trim(), kind === 'incident' ? (body.severity || 'minor') : null, state,
+ JSON.stringify([...new Set(body.services)]), startsAt, endsAt, author, now, now);
+ db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(id, now, state, body.message.trim(), author);
+ })();
+ return get(db, id);
+}
+
+/** Add an update (body: the update form): a new state and message; ends_at for a window. Closed ones refuse. */
+function update(db, id, body, author, { now = new Date().toISOString() } = {}) {
+ ensureSchema(db);
+ check('network.status-incident-request@1', body);
+ if (!body.state || body.kind) throw new IncidentError(400, 'status.invalid_request', 'an update is { state, message, ends_at? }');
+ const row = db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id));
+ if (!row) throw new IncidentError(404, 'status.incident_not_found', 'no such incident');
+ if (CLOSED.has(row.state)) throw new IncidentError(409, 'status.incident_closed', `this ${row.kind} is ${row.state}`);
+ if (!statesOf(row.kind).includes(body.state)) throw new IncidentError(400, 'status.invalid_state', `a ${row.kind} is one of ${statesOf(row.kind).join(', ')}`);
+ const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : (CLOSED.has(body.state) ? now : row.ends_at);
+ db.transaction(() => {
+ db.prepare('UPDATE status_incidents SET state = ?, ends_at = ?, updated_at = ? WHERE id = ?').run(body.state, endsAt, now, row.id);
+ db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(row.id, now, body.state, body.message.trim(), author);
+ })();
+ return get(db, row.id);
+}
+
+/**
+ * Routes. Writers: a staff admin's session (requireAuth + role admin), or a service token holding
+ * network.status.incident (incidentGuard, service tokens only).
+ */
+function router({ requireAuth, incidentGuard }) {
+ const r = express.Router();
+ r.use(http.middleware());
+ r.get('/', (req, res) => {
+ res.set('Cache-Control', 'public, max-age=30').json(list(req.app.locals.db));
+ });
+ const writer = (req, res, next) => {
+ const h = String(req.headers.authorization || '');
+ const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);
+ let user = null;
+ if (token) {
+ try { const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config }); user = out && out.user; } catch { user = null; }
+ }
+ if (user) {
+ if (user.role !== 'admin') return http.sendProblem(res, 403, 'status.staff_only', { detail: 'only staff admins post incidents', ctx: req.ov });
+ req.statusAuthor = `user:${user.username}`;
+ return requireAuth(req, res, next);
+ }
+ return incidentGuard(req, res, () => { req.statusAuthor = req.principal ? String(req.principal.sub) : 'service'; next(); });
+ };
+ const send = (res, req, fn, status = 200) => {
+ res.set('Cache-Control', 'no-store');
+ try { res.status(status).json(fn()); } catch (err) {
+ if (err instanceof IncidentError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ console.error('[Status incidents]', err.message);
+ return http.sendProblem(res, 500, 'status.failed', { detail: 'the incident could not be saved', ctx: req.ov });
+ }
+ };
+ r.post('/', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => open(req.app.locals.db, req.body || {}, req.statusAuthor), 201));
+ r.post('/:id/updates', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => update(req.app.locals.db, req.params.id, req.body || {}, req.statusAuthor)));
+ return r;
+}
+
+module.exports = { ensureSchema, open, update, list, get, router, IncidentError, INCIDENT_STATES, MAINTENANCE_STATES };
@@ -135,7 +135,7 @@ function exposureCell(r) {
@@ -135,7 +135,7 @@ function exposureCell(r) {
const CSS = `
const CSS = `
.st{max-width:1080px;margin:32px auto 64px;padding:0 20px}.st h1{font-size:clamp(24px,3vw,32px);letter-spacing:-.02em;margin:0 0 6px}
.st{max-width:1080px;margin:32px auto 64px;padding:0 20px}.st h1{font-size:clamp(24px,3vw,32px);letter-spacing:-.02em;margin:0 0 6px}
.st p.lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:780px;line-height:1.5}
.st p.lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:780px;line-height:1.5}
-.st-sum{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 18px;padding:0;list-style:none}.st-sum li{padding:6px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);font-size:13.5px}
+.st-incidents{margin:0 0 20px}.st-incident{border:1px solid var(--border,#1f2d47);border-left:4px solid var(--warning,#f59e0b);border-radius:10px;padding:10px 14px;margin:0 0 10px}.st-incident h3{margin:0 0 4px;font-size:16px}.st-incident p{margin:4px 0}.st-maintenance{border-left-color:var(--info,#38bdf8)}.st-sev-critical,.st-sev-major{border-left-color:var(--danger,#ef4444)}.st-quiet{color:var(--text-muted,#8b93ad);margin:0 0 14px}.st-sum{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 18px;padding:0;list-style:none}.st-sum li{padding:6px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);font-size:13.5px}
.st table{width:100%;border-collapse:collapse;font-size:14px}.st th,.st td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--border,#1f2d47);vertical-align:top}
.st table{width:100%;border-collapse:collapse;font-size:14px}.st th,.st td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--border,#1f2d47);vertical-align:top}
.st th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted,#7386a3)}
.st th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted,#7386a3)}
.st td small{display:block;color:var(--text-muted,#7386a3);font-size:12.5px;margin-top:2px}
.st td small{display:block;color:var(--text-muted,#7386a3);font-size:12.5px;margin-top:2px}
@@ -157,7 +157,20 @@ function proofSteps(rec) {
@@ -157,7 +157,20 @@ function proofSteps(rec) {
}
}
const lastRun = (rec) => `Last run <time datetime="${esc(rec.finished_at)}">${esc(rec.finished_at)}</time>: <b>${rec.ok ? 'passed' : 'failed'}</b> (${rec.steps.filter((st) => st.ok).length}/${rec.steps.length} steps).`;
const lastRun = (rec) => `Last run <time datetime="${esc(rec.finished_at)}">${esc(rec.finished_at)}</time>: <b>${rec.ok ? 'passed' : 'failed'}</b> (${rec.steps.filter((st) => st.ok).length}/${rec.steps.length} steps).`;
-function renderPage(list, slo, generatedAt, devPath = null, toolsJob = null, healthSince = null) {
+/** Open incidents and maintenance windows (server/status/incidents.js), at the top of /status. */
+function incidentsSection(incidents) {
+ const active = (incidents && incidents.active) || [];
+ if (!active.length) return '<p class="st-quiet">No open incidents or maintenance. <a href="/api/v1/status/incidents">History</a></p>';
+ const LBL = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', scheduled: 'Scheduled', in_progress: 'In progress' };
+ return `<section class="st-incidents" aria-labelledby="h-incidents"><h2 id="h-incidents">Incidents and maintenance</h2>${active.map((i) => {
+ const last = i.updates[i.updates.length - 1] || {};
+ const when = i.kind === 'maintenance' ? `${esc(i.starts_at)}${i.ends_at ? ` – ${esc(i.ends_at)}` : ''}` : `since ${esc(i.starts_at)}`;
+ return `<article class="st-incident st-${esc(i.kind)}${i.severity ? ` st-sev-${esc(i.severity)}` : ''}"><h3>${esc(i.title)}</h3>
+<p><strong>${esc(LBL[i.state] || i.state)}</strong>${i.severity ? ` · ${esc(i.severity)}` : ''} · ${esc(i.services.join(', '))} · ${when}</p><p>${esc(last.message || '')}</p></article>`;
+ }).join('')}<p><a href="/api/v1/status/incidents">History (JSON)</a></p></section>`;
+}
+
+function renderPage(list, slo, generatedAt, devPath = null, toolsJob = null, healthSince = null, incidents = null) {
const counts = summary(list);
const counts = summary(list);
const tr = list.map((r) => `<tr id="svc-${esc(r.id)}">
const tr = list.map((r) => `<tr id="svc-${esc(r.id)}">
<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>
@@ -191,6 +204,7 @@ ${CSS}
@@ -191,6 +204,7 @@ ${CSS}
${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}
${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}
<main class="st" id="main">
<main class="st" id="main">
<h1>Service status</h1>
<h1>Service status</h1>
+${incidentsSection(incidents)}
<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders, libraries and repositories show as not running. <b>Where</b> says whether the public domain serves the service itself (live), or the service runs on this host's loopback only while its domain still serves a placeholder page (internal). Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p>
<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders, libraries and repositories show as not running. <b>Where</b> says whether the public domain serves the service itself (live), or the service runs on this host's loopback only while its domain still serves a placeholder page (internal). Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p>
<ul class="st-sum">${STATES.map((s) => `<li class="st-${s}">${esc(LABEL[s])}: ${counts[s]}</li>`).join('')}</ul>
<ul class="st-sum">${STATES.map((s) => `<li class="st-${s}">${esc(LABEL[s])}: ${counts[s]}</li>`).join('')}</ul>
<table>
<table>
@@ -248,7 +262,9 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {
@@ -248,7 +262,9 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {
r.get('/status', (_req, res) => {
r.get('/status', (_req, res) => {
const slo = { ...loadSlo(), pollSeconds: ecosystem.pollMs / 1000 };
const slo = { ...loadSlo(), pollSeconds: ecosystem.pollMs / 1000 };
res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-cache, max-age=0').set('X-Robots-Tag', 'noindex, nofollow');
res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-cache, max-age=0').set('X-Robots-Tag', 'noindex, nofollow');
- res.send(renderPage(rows(ecosystem), slo, now().toISOString(), loadDevPath(), loadDevPath(TOOLSJOB_FILE), typeof ecosystem.releaseHealthSince === 'function' ? ecosystem.releaseHealthSince() : null));
+ let incidents = null;
+ try { incidents = require('./incidents').list(_req.app.locals.db); } catch { incidents = null; }
+ res.send(renderPage(rows(ecosystem), slo, now().toISOString(), loadDevPath(), loadDevPath(TOOLSJOB_FILE), typeof ecosystem.releaseHealthSince === 'function' ? ecosystem.releaseHealthSince() : null, incidents));
});
});
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+// Incidents and maintenance on /status (roadmap WS-N task 12; Contracts 0.66.0): Host's token or a staff admin
+// opens and updates them; others cannot; states follow the kind; closed ones refuse updates; the public list
+// and /status show them.
+// node test/status-incidents.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const { validate } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-incidents-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+require('../server/identity/principals').ensureSchema(db);
+for (const c of ['host', 'live']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
+db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'boss', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', 'admin'), (2, 'pat', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'user')").run();
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+const { signToken } = require('../server/auth/routes');
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
+const app = express();
+app.use(require('cookie-parser')());
+app.use(express.urlencoded({ extended: true }));
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use('/api/v1/status/incidents', require('../server/status/incidents').router({ requireAuth, incidentGuard: require('../server/identity/principals').guard('network.status.incident', { legacy: false }) }));
+const ecosystem = { current: () => [], lastPollAt: () => null, pollMs: 60000, releaseHealthSince: () => null };
+app.use(require('../server/status/routes').createStatusRoutes({ ecosystem }));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (r) => ({ status: r.status, text: await r.clone().text(), body: await r.json().catch(() => null) }));
+ const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
+ const as = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });
+ try {
+ const host = { authorization: `Bearer ${await svc('host')}` };
+ // Who may write.
+ const open = { kind: 'incident', title: 'Live streams stall on start', severity: 'major', services: ['live'], message: 'New streams take a minute to start.' };
+ assert.ok([401, 403].includes((await call('POST', '/api/v1/status/incidents', { body: open })).status), 'nobody');
+ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: as(2) })).status, 403, 'a person who is not staff');
+ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: { authorization: `Bearer ${await svc('live')}` } })).status, 403, 'a service without network.status.incident');
+ let r = await call('POST', '/api/v1/status/incidents', { body: open, headers: host });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.ok(validate('network.status-incident@1', r.body).valid, JSON.stringify(validate('network.status-incident@1', r.body).errors));
+ assert.deepStrictEqual([r.body.state, r.body.severity, r.body.updates.length], ['investigating', 'major', 1]);
+ const inc = r.body.id;
+ assert.match(inc, /^inc_[0-9A-HJKMNP-TV-Z]{26}$/);
+
+ // A staff admin updates it through its states; closed refuses more.
+ r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'identified', message: 'A restart left the ingest socket unbound.' }, headers: as(1) });
+ assert.deepStrictEqual([r.status, r.body.state, r.body.updates.length], [200, 'identified', 2]);
+ assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'completed', message: 'x' }, headers: host })).status, 400, 'a maintenance state on an incident');
+ assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'resolved', message: 'ok', title: 'renamed' }, headers: host })).status, 400, 'the request contract');
+
+ // A maintenance window.
+ r = await call('POST', '/api/v1/status/incidents', { body: { kind: 'maintenance', title: 'Media storage move', services: ['media'], message: 'Uploads pause for up to an hour.', starts_at: '2026-09-28T03:00:00Z', ends_at: '2026-09-28T04:00:00Z' }, headers: host });
+ assert.deepStrictEqual([r.status, r.body.state, r.body.ends_at], [201, 'scheduled', '2026-09-28T04:00:00.000Z']);
+ const win = r.body.id;
+ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: { kind: 'maintenance', title: 'Backwards', services: ['media'], message: 'x', starts_at: '2026-09-28T04:00:00Z', ends_at: '2026-09-28T03:00:00Z' }, headers: host })).status, 400);
+ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: { kind: 'maintenance', severity: 'major', title: 'Sev', services: ['media'], message: 'x' }, headers: host })).status, 400, 'maintenance has no severity');
+
+ // Public list and /status.
+ r = await call('GET', '/api/v1/status/incidents');
+ assert.strictEqual(r.status, 200);
+ assert.ok(validate('network.status-incident-list@1', r.body).valid);
+ assert.deepStrictEqual(r.body.active.map((i) => i.id).sort(), [inc, win].sort());
+ r = await call('GET', '/status');
+ assert.match(r.text, /Incidents and maintenance/);
+ assert.match(r.text, /Live streams stall on start/);
+ assert.match(r.text, /Identified/);
+ assert.match(r.text, /A restart left the ingest socket unbound\./);
+
+ // Resolve: closed, out of active, in recent; further updates refused.
+ r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'resolved', message: 'Rebound; streams start at once again.' }, headers: host });
+ assert.deepStrictEqual([r.status, r.body.state, typeof r.body.ends_at], [200, 'resolved', 'string']);
+ assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'monitoring', message: 'again' }, headers: host })).status, 409);
+ r = await call('GET', '/api/v1/status/incidents');
+ assert.deepStrictEqual([r.body.active.map((i) => i.id), r.body.recent.map((i) => i.id)], [[win], [inc]]);
+ assert.strictEqual((await call('POST', '/api/v1/status/incidents/inc_01JAB2C3D4E5F6G7H8J9K0MNPQ/updates', { body: { state: 'resolved', message: 'x' }, headers: host })).status, 404);
+ await call('POST', `/api/v1/status/incidents/${win}/updates`, { body: { state: 'completed', message: 'Done.' }, headers: host });
+ assert.match((await call('GET', '/status')).text, /No open incidents or maintenance/);
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('status incidents: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });