openvibers/openvibe.network Commits
Operator alerts (roadmap WS-H task 11; Contracts v0.62.0 network.operator.alert): POST /internal/operator/alerts takes Host's service token only (legacy: false) and the complete set of alerts firing on the production host (network.operator-alerts-request@1). server/operator/alerts.js keeps one row per fingerprint and pages the operator (OWNER_USERNAME, plus OPERATOR_ALERT_USERNAMES) with an OPERATOR_ALERT admin notification (bell, web push, realtime; no block hides it) when an alert opens, once a day while it stays open, and when it resolves. Host is granted network.operator.alert. Why: Prometheus evaluated the backup, developer-path and Tools-job alerts, but nothing delivered them. test/operator-alerts.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.61.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.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.61.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0",-- "integrity": "sha512-8hpxyckfVwShA7Tku7mb7zHJBJsj9p3mXL7vM1QHmVnzKfuvUoX+I9JfX3uWPbDVAU/TcbPv53PKyCEUUWUK7g==",++ "version": "0.62.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",++ "integrity": "sha512-mmVQToHBY77NrZEHqOBoPuRdZ/bnAzp11E7VkBrN9maPdGCoG8YIJqIq/Hi/dFiDrLJtAcdXRKl/tahdWrkkdQ==","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/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/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/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/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.61.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.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",@@@ -121,6 +121,8 @@ const DEFAULT_GRANTS = [['coupons', 'events.event.publish', 'openvibe.events', []],['coupons', 'sources.item.read', 'openvibe.sources', []],['host', 'events.event.publish', 'openvibe.events', []],++ // WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).++ ['host', 'network.operator.alert', 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', []],@@@ -58,7 +58,9 @@ function requireInternalKey(req, res, next) {const principals = require('../identity/principals');const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',// Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.-- 'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account']);++ 'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account',++ // Token only (legacy: false): Host's relay of the alerts firing on the production host (WS-H task 11).++ 'POST /operator/alerts']);const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);@@@ -492,6 +494,24 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.}});++// ── Operator alerts (roadmap WS-H task 11) ───────────────────++// POST /internal/operator/alerts — network.operator-alerts-request@1 → network.operator-alerts-result@1.++// Host's relay (ovhost alerts relay) sends the complete set of alerts firing now; server/operator/alerts.js++// pages the owner when one opens, once a day while it stays open, and when it resolves. Token only.++router.post('/operator/alerts', principals.guard('network.operator.alert', { legacy: false }), (req, res) => {++ const contracts = require('openvibe-contracts');++ const v = contracts.validate('network.operator-alerts-request@1', req.body);++ if (!v.valid) return res.status(400).json({ error: 'The body does not match network.operator-alerts-request@1', details: (v.errors || []).slice(0, 5) });++ const notifService = req.app.locals.notificationService;++ const notify = notifService ? (userId, n) => notifService.create({ user_id: userId, ...n }) : null;++ try {++ res.json(require('../operator/alerts').receive(getDb(req), req.body, { notify }));++ } catch (err) {++ console.error('[Internal] Operator alerts error:', err.message);++ res.status(500).json({ error: 'Operator alerts failed' });++ }++});++// ── Get Unread Count for User ────────────────────────────────// GET /internal/notifications/unread/:userIdrouter.get('/notifications/unread/:userId', (req, res) => {nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * Operator alerts (roadmap WS-H task 11; Contracts network.operator.alert, network.operator-alerts-request@1).++ *++ * The production host's Prometheus evaluates alert rules (backups, the developer path, the Tools job proof,++ * the browser check), but nothing delivered them. Host relays the complete set of alerts firing now++ * (`ovhost alerts relay`, every two minutes) to POST /internal/operator/alerts, and this module turns the++ * difference into notifications for the operator:++ *++ * opened an alert not firing before critical → priority critical, else high++ * reminded still firing 24 h after the last page same priority++ * resolved firing before, absent from this set priority normal++ *++ * The operator is the owner account (OWNER_USERNAME), plus any usernames in OPERATOR_ALERT_USERNAMES++ * (comma-separated). The notification is type OPERATOR_ALERT in category `admin`, so it reaches the bell,++ * web push and the realtime topic, and no block hides it. State lives in `operator_alerts`, one row per++ * fingerprint and source; resolved rows are kept 30 days for the admin view.++ */++++const REMIND_MS = 24 * 3600 * 1000;++const KEEP_RESOLVED_MS = 30 * 24 * 3600 * 1000;++const STATUS_URL = 'https://openvibe.network/status';++++function ensureTables(db) {++ db.exec(`CREATE TABLE IF NOT EXISTS operator_alerts (++ source TEXT NOT NULL,++ fingerprint TEXT NOT NULL,++ name TEXT NOT NULL,++ severity TEXT NOT NULL,++ summary TEXT NOT NULL,++ description TEXT,++ service TEXT,++ state TEXT NOT NULL CHECK (state IN ('firing', 'resolved')),++ started_at TEXT NOT NULL,++ opened_at INTEGER NOT NULL,++ last_seen_at INTEGER NOT NULL,++ notified_at INTEGER,++ resolved_at INTEGER,++ PRIMARY KEY (source, fingerprint)++ )`);++}++++function operatorUserIds(db, env = process.env) {++ const names = new Set([(env.OWNER_USERNAME || 'goosely').toLowerCase()]);++ for (const n of String(env.OPERATOR_ALERT_USERNAMES || '').split(',')) if (n.trim()) names.add(n.trim().toLowerCase());++ const q = db.prepare('SELECT id FROM users WHERE lower(username) = ?');++ return [...names].map((n) => q.get(n)).filter(Boolean).map((u) => u.id);++}++++function message(a) {++ return [a.description || a.summary, a.service ? `Service: ${a.service}.` : null, `Firing since ${a.started_at}.`].filter(Boolean).join(' ');++}++++/**++ * Apply one report. `body` has passed the request contract. Answers the result document.++ * notify(userId, notification) creates one notification (the notification service's create()).++ */++function receive(db, body, { notify, now = Date.now(), env = process.env } = {}) {++ ensureTables(db);++ const source = body.source;++ const current = new Map(body.alerts.map((a) => [a.fingerprint, a]));++ const rows = db.prepare('SELECT * FROM operator_alerts WHERE source = ?').all(source);++ const byFp = new Map(rows.map((r) => [r.fingerprint, r]));++ const pages = [];++ const out = { ok: true, firing: current.size, opened: 0, reminded: 0, resolved: 0, notified: 0 };++++ const upsert = db.prepare(`INSERT INTO operator_alerts (source, fingerprint, name, severity, summary, description, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at)++ VALUES (@source, @fingerprint, @name, @severity, @summary, @description, @service, 'firing', @started_at, @now, @now, @notified_at, NULL)++ ON CONFLICT (source, fingerprint) DO UPDATE SET name = @name, severity = @severity, summary = @summary, description = @description, service = @service,++ state = 'firing', started_at = @started_at, last_seen_at = @now, notified_at = @notified_at, resolved_at = NULL,++ opened_at = CASE WHEN operator_alerts.state = 'resolved' THEN @now ELSE operator_alerts.opened_at END`);++ const resolve = db.prepare("UPDATE operator_alerts SET state = 'resolved', resolved_at = ?, notified_at = ? WHERE source = ? AND fingerprint = ?");++++ db.transaction(() => {++ for (const a of current.values()) {++ const prev = byFp.get(a.fingerprint);++ let kind = null;++ if (!prev || prev.state === 'resolved') kind = 'opened';++ else if (!prev.notified_at || now - prev.notified_at >= REMIND_MS) kind = 'reminded';++ upsert.run({ source, fingerprint: a.fingerprint, name: a.name, severity: a.severity, summary: a.summary, description: a.description || null,++ service: a.service || null, started_at: a.started_at, now, notified_at: kind ? now : (prev && prev.notified_at) || null });++ if (kind) { out[kind] += 1; pages.push({ kind, a }); }++ }++ for (const r of rows) {++ if (r.state !== 'firing' || current.has(r.fingerprint)) continue;++ resolve.run(now, now, source, r.fingerprint);++ out.resolved += 1;++ pages.push({ kind: 'resolved', a: r });++ }++ db.prepare("DELETE FROM operator_alerts WHERE state = 'resolved' AND resolved_at < ?").run(now - KEEP_RESOLVED_MS);++ })();++++ if (pages.length && typeof notify === 'function') {++ const users = operatorUserIds(db, env);++ for (const { kind, a } of pages) {++ const resolved = kind === 'resolved';++ const n = {++ type: 'OPERATOR_ALERT', category: 'admin', service: 'host', url: STATUS_URL,++ priority: resolved ? 'normal' : (a.severity === 'critical' ? 'critical' : 'high'),++ icon: resolved ? '✅' : (a.severity === 'critical' ? '🚨' : '⚠️'),++ title: `${resolved ? 'Resolved' : kind === 'reminded' ? 'Still firing' : 'Alert'}: ${a.name}`.slice(0, 120),++ message: (resolved ? `${a.summary} (resolved).` : `${a.summary}. ${message(a)}`).slice(0, 500),++ };++ for (const uid of users) {++ try { if (notify(uid, n)) out.notified += 1; } catch (e) { console.warn('[operator-alerts] notify failed:', e.message); }++ }++ }++ }++ return out;++}++++/** Firing and recently resolved alerts, newest first (the admin view and /status). */++function list(db, { limit = 100 } = {}) {++ ensureTables(db);++ return db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);++}++++module.exports = { ensureTables, receive, list, operatorUserIds, REMIND_MS };nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++// Operator alerts (roadmap WS-H task 11; Contracts network.operator.alert): POST /internal/operator/alerts++// takes Host's token only, validates the request contract, and pages the owner when an alert opens, once a++// day while it stays open, and when it resolves.++// node test/operator-alerts.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 alerts = require('../server/operator/alerts');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++process.env.OWNER_USERNAME = 'Boss';++process.env.OPERATOR_ALERT_USERNAMES = 'deputy, nobody-here';++db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();++db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'boss', 'x'), (2, 'deputy', 'x'), (3, 'someone', 'x')").run();++++const sent = [];++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const app = express();++app.use(express.json());++app.use(express.urlencoded({ extended: true }));++app.locals.db = db;++app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.locals.notificationService = { create: (n) => { sent.push(n); return { id: `n${sent.length}` }; } };++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use('/internal', require('../server/internal/routes'));++const server = http.createServer(app);++++const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').update(name).digest('hex').slice(0, 16), name, severity: 'critical', summary: `${name} summary`, started_at: '2026-09-26T05:00:00Z', ...extra });++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const token = async (id, secret) => {++ const r = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }) });++ return (await r.json()).access_token;++ };++ const post = (body, headers) => fetch(`${base}/internal/operator/alerts`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++ try {++ const host = await token('host', 'host-secret');++ assert.ok(host, 'Host gets a token for openvibe.network');++ const auth = { authorization: `Bearer ${host}` };++++ // Who may call it: Host's token; not the shared key, not another service, not nobody.++ assert.strictEqual((await post({ source: 'prometheus', alerts: [] })).status, 403);++ assert.ok([401, 403].includes((await post({ source: 'prometheus', alerts: [] }, { 'x-internal-key': 'legacy-key' })).status), 'the shared key is refused (legacy: false)');++ const live = await token('live', 'live-secret');++ assert.strictEqual((await post({ source: 'prometheus', alerts: [] }, { authorization: `Bearer ${live}` })).status, 403, 'Live lacks network.operator.alert');++++ // The request contract.++ let r = await post({ source: 'prometheus', alerts: [alert('X', { severity: 'page' })] }, auth);++ assert.strictEqual(r.status, 400);++ r = await post({ source: 'grafana', alerts: [] }, auth);++ assert.strictEqual(r.status, 400);++++ // Opens: both operators (owner by OWNER_USERNAME, case-insensitive; the extra username) are paged.++ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed', { service: 'host', description: 'No successful backup in 26 hours.' }), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.ok(validate('network.operator-alerts-result@1', r.body).valid);++ assert.deepStrictEqual(r.body, { ok: true, firing: 2, opened: 2, reminded: 0, resolved: 0, notified: 4 });++ assert.deepStrictEqual(sent.map((n) => n.user_id).sort(), [1, 1, 2, 2]);++ const backup = sent.find((n) => n.title === 'Alert: OpenVibeBackupMissed' && n.user_id === 1);++ assert.deepStrictEqual([backup.type, backup.category, backup.priority, backup.url], ['OPERATOR_ALERT', 'admin', 'critical', 'https://openvibe.network/status']);++ assert.match(backup.message, /No successful backup in 26 hours\. Service: host\. Firing since 2026-09-26T05:00:00Z\./);++ assert.strictEqual(sent.find((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed').priority, 'high', 'a warning pages at high');++++ // The same set again: nothing new.++ sent.length = 0;++ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);++ assert.deepStrictEqual(r.body, { ok: true, firing: 2, opened: 0, reminded: 0, resolved: 0, notified: 0 });++ assert.strictEqual(sent.length, 0);++++ // One drops out: resolved notice.++ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, auth);++ assert.deepStrictEqual(r.body, { ok: true, firing: 1, opened: 0, reminded: 0, resolved: 1, notified: 2 });++ assert.deepStrictEqual([...new Set(sent.map((n) => `${n.title}|${n.priority}`))], ['Resolved: OpenVibeBrowserCheckFailed|normal']);++++ // A day later, still firing: one reminder; then quiet again.++ sent.length = 0;++ const later = Date.now() + alerts.REMIND_MS + 1000;++ let out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return true; }, now: later });++ assert.deepStrictEqual(out, { ok: true, firing: 1, opened: 0, reminded: 1, resolved: 0, notified: 2 });++ assert.strictEqual(sent[0].title, 'Still firing: OpenVibeBackupMissed');++ out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => true, now: later + 60000 });++ assert.strictEqual(out.reminded, 0);++++ // An alert that resolved and fires again opens again.++ sent.length = 0;++ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);++ assert.strictEqual(r.body.opened, 1);++ assert.ok(sent.some((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed'));++++ // An empty set resolves everything from that source; the list keeps them.++ r = await post({ source: 'prometheus', alerts: [] }, auth);++ assert.deepStrictEqual([r.body.firing, r.body.resolved], [0, 2]);++ const rows = alerts.list(db);++ assert.deepStrictEqual(rows.map((x) => x.state), ['resolved', 'resolved']);++ assert.ok(rows.every((x) => !('description' in x) || x.description === undefined));++++ // No operator account: nothing is sent, nothing breaks.++ process.env.OWNER_USERNAME = 'ghost'; process.env.OPERATOR_ALERT_USERNAMES = '';++ r = await post({ source: 'prometheus', alerts: [alert('Lonely')] }, auth);++ assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);++ } finally {++ server.close();++ db.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('operator alerts: 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.61.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.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.61.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0",
- "integrity": "sha512-8hpxyckfVwShA7Tku7mb7zHJBJsj9p3mXL7vM1QHmVnzKfuvUoX+I9JfX3uWPbDVAU/TcbPv53PKyCEUUWUK7g==",
+ "version": "0.62.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",
+ "integrity": "sha512-mmVQToHBY77NrZEHqOBoPuRdZ/bnAzp11E7VkBrN9maPdGCoG8YIJqIq/Hi/dFiDrLJtAcdXRKl/tahdWrkkdQ==",
"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/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/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/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/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.61.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.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",
@@ -121,6 +121,8 @@ const DEFAULT_GRANTS = [
@@ -121,6 +121,8 @@ const DEFAULT_GRANTS = [
['coupons', 'events.event.publish', 'openvibe.events', []],
['coupons', 'events.event.publish', 'openvibe.events', []],
['coupons', 'sources.item.read', 'openvibe.sources', []],
['coupons', 'sources.item.read', 'openvibe.sources', []],
['host', 'events.event.publish', 'openvibe.events', []],
['host', 'events.event.publish', 'openvibe.events', []],
+ // WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
+ ['host', 'network.operator.alert', 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', []],
@@ -58,7 +58,9 @@ function requireInternalKey(req, res, next) {
@@ -58,7 +58,9 @@ function requireInternalKey(req, res, next) {
const principals = require('../identity/principals');
const principals = require('../identity/principals');
const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',
const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',
// Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.
// Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.
- 'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account']);
+ 'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account',
+ // Token only (legacy: false): Host's relay of the alerts firing on the production host (WS-H task 11).
+ 'POST /operator/alerts']);
const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);
const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);
const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);
const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);
@@ -492,6 +494,24 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
@@ -492,6 +494,24 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
}
}
});
});
+// ── Operator alerts (roadmap WS-H task 11) ───────────────────
+// POST /internal/operator/alerts — network.operator-alerts-request@1 → network.operator-alerts-result@1.
+// Host's relay (ovhost alerts relay) sends the complete set of alerts firing now; server/operator/alerts.js
+// pages the owner when one opens, once a day while it stays open, and when it resolves. Token only.
+router.post('/operator/alerts', principals.guard('network.operator.alert', { legacy: false }), (req, res) => {
+ const contracts = require('openvibe-contracts');
+ const v = contracts.validate('network.operator-alerts-request@1', req.body);
+ if (!v.valid) return res.status(400).json({ error: 'The body does not match network.operator-alerts-request@1', details: (v.errors || []).slice(0, 5) });
+ const notifService = req.app.locals.notificationService;
+ const notify = notifService ? (userId, n) => notifService.create({ user_id: userId, ...n }) : null;
+ try {
+ res.json(require('../operator/alerts').receive(getDb(req), req.body, { notify }));
+ } catch (err) {
+ console.error('[Internal] Operator alerts error:', err.message);
+ res.status(500).json({ error: 'Operator alerts failed' });
+ }
+});
+
// ── Get Unread Count for User ────────────────────────────────
// ── Get Unread Count for User ────────────────────────────────
// GET /internal/notifications/unread/:userId
// GET /internal/notifications/unread/:userId
router.get('/notifications/unread/:userId', (req, res) => {
router.get('/notifications/unread/:userId', (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * Operator alerts (roadmap WS-H task 11; Contracts network.operator.alert, network.operator-alerts-request@1).
+ *
+ * The production host's Prometheus evaluates alert rules (backups, the developer path, the Tools job proof,
+ * the browser check), but nothing delivered them. Host relays the complete set of alerts firing now
+ * (`ovhost alerts relay`, every two minutes) to POST /internal/operator/alerts, and this module turns the
+ * difference into notifications for the operator:
+ *
+ * opened an alert not firing before critical → priority critical, else high
+ * reminded still firing 24 h after the last page same priority
+ * resolved firing before, absent from this set priority normal
+ *
+ * The operator is the owner account (OWNER_USERNAME), plus any usernames in OPERATOR_ALERT_USERNAMES
+ * (comma-separated). The notification is type OPERATOR_ALERT in category `admin`, so it reaches the bell,
+ * web push and the realtime topic, and no block hides it. State lives in `operator_alerts`, one row per
+ * fingerprint and source; resolved rows are kept 30 days for the admin view.
+ */
+
+const REMIND_MS = 24 * 3600 * 1000;
+const KEEP_RESOLVED_MS = 30 * 24 * 3600 * 1000;
+const STATUS_URL = 'https://openvibe.network/status';
+
+function ensureTables(db) {
+ db.exec(`CREATE TABLE IF NOT EXISTS operator_alerts (
+ source TEXT NOT NULL,
+ fingerprint TEXT NOT NULL,
+ name TEXT NOT NULL,
+ severity TEXT NOT NULL,
+ summary TEXT NOT NULL,
+ description TEXT,
+ service TEXT,
+ state TEXT NOT NULL CHECK (state IN ('firing', 'resolved')),
+ started_at TEXT NOT NULL,
+ opened_at INTEGER NOT NULL,
+ last_seen_at INTEGER NOT NULL,
+ notified_at INTEGER,
+ resolved_at INTEGER,
+ PRIMARY KEY (source, fingerprint)
+ )`);
+}
+
+function operatorUserIds(db, env = process.env) {
+ const names = new Set([(env.OWNER_USERNAME || 'goosely').toLowerCase()]);
+ for (const n of String(env.OPERATOR_ALERT_USERNAMES || '').split(',')) if (n.trim()) names.add(n.trim().toLowerCase());
+ const q = db.prepare('SELECT id FROM users WHERE lower(username) = ?');
+ return [...names].map((n) => q.get(n)).filter(Boolean).map((u) => u.id);
+}
+
+function message(a) {
+ return [a.description || a.summary, a.service ? `Service: ${a.service}.` : null, `Firing since ${a.started_at}.`].filter(Boolean).join(' ');
+}
+
+/**
+ * Apply one report. `body` has passed the request contract. Answers the result document.
+ * notify(userId, notification) creates one notification (the notification service's create()).
+ */
+function receive(db, body, { notify, now = Date.now(), env = process.env } = {}) {
+ ensureTables(db);
+ const source = body.source;
+ const current = new Map(body.alerts.map((a) => [a.fingerprint, a]));
+ const rows = db.prepare('SELECT * FROM operator_alerts WHERE source = ?').all(source);
+ const byFp = new Map(rows.map((r) => [r.fingerprint, r]));
+ const pages = [];
+ const out = { ok: true, firing: current.size, opened: 0, reminded: 0, resolved: 0, notified: 0 };
+
+ const upsert = db.prepare(`INSERT INTO operator_alerts (source, fingerprint, name, severity, summary, description, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at)
+ VALUES (@source, @fingerprint, @name, @severity, @summary, @description, @service, 'firing', @started_at, @now, @now, @notified_at, NULL)
+ ON CONFLICT (source, fingerprint) DO UPDATE SET name = @name, severity = @severity, summary = @summary, description = @description, service = @service,
+ state = 'firing', started_at = @started_at, last_seen_at = @now, notified_at = @notified_at, resolved_at = NULL,
+ opened_at = CASE WHEN operator_alerts.state = 'resolved' THEN @now ELSE operator_alerts.opened_at END`);
+ const resolve = db.prepare("UPDATE operator_alerts SET state = 'resolved', resolved_at = ?, notified_at = ? WHERE source = ? AND fingerprint = ?");
+
+ db.transaction(() => {
+ for (const a of current.values()) {
+ const prev = byFp.get(a.fingerprint);
+ let kind = null;
+ if (!prev || prev.state === 'resolved') kind = 'opened';
+ else if (!prev.notified_at || now - prev.notified_at >= REMIND_MS) kind = 'reminded';
+ upsert.run({ source, fingerprint: a.fingerprint, name: a.name, severity: a.severity, summary: a.summary, description: a.description || null,
+ service: a.service || null, started_at: a.started_at, now, notified_at: kind ? now : (prev && prev.notified_at) || null });
+ if (kind) { out[kind] += 1; pages.push({ kind, a }); }
+ }
+ for (const r of rows) {
+ if (r.state !== 'firing' || current.has(r.fingerprint)) continue;
+ resolve.run(now, now, source, r.fingerprint);
+ out.resolved += 1;
+ pages.push({ kind: 'resolved', a: r });
+ }
+ db.prepare("DELETE FROM operator_alerts WHERE state = 'resolved' AND resolved_at < ?").run(now - KEEP_RESOLVED_MS);
+ })();
+
+ if (pages.length && typeof notify === 'function') {
+ const users = operatorUserIds(db, env);
+ for (const { kind, a } of pages) {
+ const resolved = kind === 'resolved';
+ const n = {
+ type: 'OPERATOR_ALERT', category: 'admin', service: 'host', url: STATUS_URL,
+ priority: resolved ? 'normal' : (a.severity === 'critical' ? 'critical' : 'high'),
+ icon: resolved ? '✅' : (a.severity === 'critical' ? '🚨' : '⚠️'),
+ title: `${resolved ? 'Resolved' : kind === 'reminded' ? 'Still firing' : 'Alert'}: ${a.name}`.slice(0, 120),
+ message: (resolved ? `${a.summary} (resolved).` : `${a.summary}. ${message(a)}`).slice(0, 500),
+ };
+ for (const uid of users) {
+ try { if (notify(uid, n)) out.notified += 1; } catch (e) { console.warn('[operator-alerts] notify failed:', e.message); }
+ }
+ }
+ }
+ return out;
+}
+
+/** Firing and recently resolved alerts, newest first (the admin view and /status). */
+function list(db, { limit = 100 } = {}) {
+ ensureTables(db);
+ return db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);
+}
+
+module.exports = { ensureTables, receive, list, operatorUserIds, REMIND_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+// Operator alerts (roadmap WS-H task 11; Contracts network.operator.alert): POST /internal/operator/alerts
+// takes Host's token only, validates the request contract, and pages the owner when an alert opens, once a
+// day while it stays open, and when it resolves.
+// node test/operator-alerts.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 alerts = require('../server/operator/alerts');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+process.env.OWNER_USERNAME = 'Boss';
+process.env.OPERATOR_ALERT_USERNAMES = 'deputy, nobody-here';
+db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
+db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'boss', 'x'), (2, 'deputy', 'x'), (3, 'someone', 'x')").run();
+
+const sent = [];
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const app = express();
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.locals.db = db;
+app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.locals.notificationService = { create: (n) => { sent.push(n); return { id: `n${sent.length}` }; } };
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use('/internal', require('../server/internal/routes'));
+const server = http.createServer(app);
+
+const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').update(name).digest('hex').slice(0, 16), name, severity: 'critical', summary: `${name} summary`, started_at: '2026-09-26T05:00:00Z', ...extra });
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const token = async (id, secret) => {
+ const r = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }) });
+ return (await r.json()).access_token;
+ };
+ const post = (body, headers) => fetch(`${base}/internal/operator/alerts`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+ try {
+ const host = await token('host', 'host-secret');
+ assert.ok(host, 'Host gets a token for openvibe.network');
+ const auth = { authorization: `Bearer ${host}` };
+
+ // Who may call it: Host's token; not the shared key, not another service, not nobody.
+ assert.strictEqual((await post({ source: 'prometheus', alerts: [] })).status, 403);
+ assert.ok([401, 403].includes((await post({ source: 'prometheus', alerts: [] }, { 'x-internal-key': 'legacy-key' })).status), 'the shared key is refused (legacy: false)');
+ const live = await token('live', 'live-secret');
+ assert.strictEqual((await post({ source: 'prometheus', alerts: [] }, { authorization: `Bearer ${live}` })).status, 403, 'Live lacks network.operator.alert');
+
+ // The request contract.
+ let r = await post({ source: 'prometheus', alerts: [alert('X', { severity: 'page' })] }, auth);
+ assert.strictEqual(r.status, 400);
+ r = await post({ source: 'grafana', alerts: [] }, auth);
+ assert.strictEqual(r.status, 400);
+
+ // Opens: both operators (owner by OWNER_USERNAME, case-insensitive; the extra username) are paged.
+ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed', { service: 'host', description: 'No successful backup in 26 hours.' }), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.ok(validate('network.operator-alerts-result@1', r.body).valid);
+ assert.deepStrictEqual(r.body, { ok: true, firing: 2, opened: 2, reminded: 0, resolved: 0, notified: 4 });
+ assert.deepStrictEqual(sent.map((n) => n.user_id).sort(), [1, 1, 2, 2]);
+ const backup = sent.find((n) => n.title === 'Alert: OpenVibeBackupMissed' && n.user_id === 1);
+ assert.deepStrictEqual([backup.type, backup.category, backup.priority, backup.url], ['OPERATOR_ALERT', 'admin', 'critical', 'https://openvibe.network/status']);
+ assert.match(backup.message, /No successful backup in 26 hours\. Service: host\. Firing since 2026-09-26T05:00:00Z\./);
+ assert.strictEqual(sent.find((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed').priority, 'high', 'a warning pages at high');
+
+ // The same set again: nothing new.
+ sent.length = 0;
+ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);
+ assert.deepStrictEqual(r.body, { ok: true, firing: 2, opened: 0, reminded: 0, resolved: 0, notified: 0 });
+ assert.strictEqual(sent.length, 0);
+
+ // One drops out: resolved notice.
+ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, auth);
+ assert.deepStrictEqual(r.body, { ok: true, firing: 1, opened: 0, reminded: 0, resolved: 1, notified: 2 });
+ assert.deepStrictEqual([...new Set(sent.map((n) => `${n.title}|${n.priority}`))], ['Resolved: OpenVibeBrowserCheckFailed|normal']);
+
+ // A day later, still firing: one reminder; then quiet again.
+ sent.length = 0;
+ const later = Date.now() + alerts.REMIND_MS + 1000;
+ let out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return true; }, now: later });
+ assert.deepStrictEqual(out, { ok: true, firing: 1, opened: 0, reminded: 1, resolved: 0, notified: 2 });
+ assert.strictEqual(sent[0].title, 'Still firing: OpenVibeBackupMissed');
+ out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => true, now: later + 60000 });
+ assert.strictEqual(out.reminded, 0);
+
+ // An alert that resolved and fires again opens again.
+ sent.length = 0;
+ r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);
+ assert.strictEqual(r.body.opened, 1);
+ assert.ok(sent.some((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed'));
+
+ // An empty set resolves everything from that source; the list keeps them.
+ r = await post({ source: 'prometheus', alerts: [] }, auth);
+ assert.deepStrictEqual([r.body.firing, r.body.resolved], [0, 2]);
+ const rows = alerts.list(db);
+ assert.deepStrictEqual(rows.map((x) => x.state), ['resolved', 'resolved']);
+ assert.ok(rows.every((x) => !('description' in x) || x.description === undefined));
+
+ // No operator account: nothing is sent, nothing breaks.
+ process.env.OWNER_USERNAME = 'ghost'; process.env.OPERATOR_ALERT_USERNAMES = '';
+ r = await post({ source: 'prometheus', alerts: [alert('Lonely')] }, auth);
+ assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);
+ } finally {
+ server.close();
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('operator alerts: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });