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

OP OpenVibers committed 26 Sept 2026 · 11f2ea5ae9 · 6 files, +270 −7


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 23050ac8..1ea1a0d4 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.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",
ddiff --git a/package.json b/package.json
iindex 0b18af6b..0ec688fb 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/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",
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 77df2a01..2e796f88 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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', []],
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex beccdfe0..3e12cc73 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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/:userId
router.get('/notifications/unread/:userId', (req, res) => {
ddiff --git a/server/operator/alerts.js b/server/operator/alerts.js
nnew file mode 100644
iindex 00000000..8be4e492
---- /dev/null
++++ b/server/operator/alerts.js
@@@ -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 };
ddiff --git a/test/operator-alerts.test.js b/test/operator-alerts.test.js
nnew file mode 100644
iindex 00000000..5391eea8
---- /dev/null
++++ b/test/operator-alerts.test.js
@@@ -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); });