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

OP OpenVibers committed 26 Sept 2026 · 1a22cd0834 · 8 files, +286 −9


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 09b42772..06359a9b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex d32e4d19..f8f8a7f6 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/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",
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 87c5c15f..f132ddd7 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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;
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex e8936197..d583cbb9 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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', []],
ddiff --git a/server/index.js b/server/index.js
iindex 5ab8e917..872e02eb 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/server/status/incidents.js b/server/status/incidents.js
nnew file mode 100644
iindex 00000000..a80e437c
---- /dev/null
++++ b/server/status/incidents.js
@@@ -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 };
ddiff --git a/server/status/routes.js b/server/status/routes.js
iindex 82533139..a2f471ac 100644
---- a/server/status/routes.js
++++ b/server/status/routes.js
@@@ -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;
}
ddiff --git a/test/status-incidents.test.js b/test/status-incidents.test.js
nnew file mode 100644
iindex 00000000..12267908
---- /dev/null
++++ b/test/status-incidents.test.js
@@@ -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); });