openvibers/openvibe.network Commits
Operator alerts: say what the browser check found, with the facts and links (#768)
Changes
@@@ -22,7 +22,7 @@"iovalkey": "^0.4.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.391.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0","otpauth": "9.6.0",@@@ -2679,9 +2679,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.391.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",-- "integrity": "sha512-YZZlJcszGWyy1TpEoVFj+mprnedvDWflOtkXWsLV406jVrCuStJXgK82ooJPr6llmC2KbAS55sGIxiYAieyqbg==",++ "version": "0.399.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"iovalkey": "^0.4.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.391.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0","otpauth": "9.6.0",@@@ -9,6 +9,14 @@const https = require('https');++/** A notification's structured data, whether the queue handed it JSON or an object. */++function richOf(notification) {++ const rc = notification && notification.rich_content;++ if (!rc) return {};++ if (typeof rc === 'object') return rc;++ try { return JSON.parse(rc) || {}; } catch { return {}; }++}++/*** Send an email via the Resend REST API.* @param {string} apiKey - Resend API key (re_xxxx)@@@ -343,6 +351,9 @@ class EmailService {// ─── Email Templates ───────────────────────────────────────_buildEmailHtml({ username, notification }) {++ // The facts, actions and next steps are passed through for the notification template; it renders them++ // (and escapes every string) rather than this mailer reading the host's words into its own markup.++ notification.rich_content = richOf(notification);const priorityColor = { low: '#888', normal: '#8b5cf6', high: '#f39c12', critical: '#e74c3c' };const color = priorityColor[notification.priority] || '#8b5cf6';@@@ -387,6 +398,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}}_buildEmailText({ username, notification }) {++ const rich = richOf(notification);++ const facts = Array.isArray(rich.facts) ? rich.facts.filter((f) => f && f.label && f.value) : [];++ const actions = Array.isArray(rich.actions) ? rich.actions.filter((a) => a && a.label && a.href) : [];return [`Hey ${username || 'there'},`,'',@@@ -394,6 +408,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}'',`${notification.icon || '🔔'} ${notification.title}`,notification.message || '',++ ...(facts.length ? ['', ...facts.map((f) => `${f.label}: ${f.value}${f.href ? ` (${f.href})` : ''}`)] : []),++ ...(actions.length ? ['', ...actions.map((a) => `${a.label}: ${a.href}`)] : []),++ ...(rich.next ? ['', String(rich.next)] : []),'',notification.url ? `View details: ${notification.url}` : '','',@@@ -48,6 +48,154 @@ function humanize(name) {return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';}++++// ── What a browser-check alert says (network.operator-alerts-request@1 details) ───────────────────++// The host sends the result file's facts; this is the one place they become words a person reads. Every++// string here is text and every link is https on an allowed host, whatever the host sent.++const LINK_HOSTS = new Set(['git.openvibe.codes', 'github.com', 'dequeuniversity.com']);++const NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five'];++let domainsCache = null;++++const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);++const count = (n, one, many) => `${NUMBER_WORDS[n] || String(n)} ${n === 1 ? one : many}`;++const has = (n) => (n === 1 ? 'has' : 'have');++const is = (n) => (n === 1 ? 'is' : 'are');++++/** The network's own public domains, from the contracts service manifests; no other site host may be linked. */++function networkDomains() {++ if (domainsCache) return domainsCache;++ let lib = null;++ try { lib = require('openvibe-contracts'); } catch { lib = null; }++ const out = new Set();++ for (const m of (lib && lib.services && lib.services.manifests) || []) {++ for (const d of m.domains || []) if (d) out.add(String(d).toLowerCase());++ try { const h = new URL(m.publicOrigin).hostname.toLowerCase(); if (h) out.add(h); } catch { /* no origin */ }++ }++ domainsCache = out;++ return out;++}++++/** An https link on an allowed host, else null. The host builds them; this refuses anything else it is sent. */++function linked(href) {++ try {++ const u = new URL(String(href));++ if (u.protocol !== 'https:') return null;++ const host = u.hostname.toLowerCase();++ if (!LINK_HOSTS.has(host) && !networkDomains().has(host)) return null;++ return u.href;++ } catch { return null; }++}++++/** Rule id or check → one sentence a person understands. An unknown rule falls back to its id and axe's help. */++const RULE_SENTENCES = {++ 'summary-name': (n) => `${cap(count(n, 'fold heading', 'fold headings'))} ${has(n)} no name a screen reader can say`,++ 'color-contrast': (n) => `${cap(count(n, 'piece', 'pieces'))} of text ${is(n)} too faint to read comfortably`,++ 'image-alt': (n) => `${cap(count(n, 'image', 'images'))} ${has(n)} no text description`,++ 'link-name': (n) => `${cap(count(n, 'link', 'links'))} ${has(n)} no name a screen reader can say`,++ 'button-name': (n) => `${cap(count(n, 'button', 'buttons'))} ${has(n)} no name a screen reader can say`,++ 'label': (n) => `${cap(count(n, 'form field', 'form fields'))} ${has(n)} no label`,++ 'aria-hidden-focus': (n) => `${cap(count(n, 'hidden element', 'hidden elements'))} can still take keyboard focus`,++ 'landmark-one-main': () => 'The page has no main landmark',++ 'region': (n) => `${cap(count(n, 'part', 'parts'))} of the page ${is(n)} outside a landmark`,++};++++function detailWhat(d) {++ const n = Math.max(1, (Array.isArray(d.targets) && d.targets.length) || 1);++ if (d.check === 'axe') {++ const rule = String(d.rule || '').toLowerCase();++ const sentence = RULE_SENTENCES[rule];++ if (sentence) return sentence(n);++ return `The page breaks the accessibility rule ${d.rule || 'it names'}${d.help ? ` (${d.help})` : ''}`;++ }++ if (d.check === 'errors') return d.message ? `The page logged a console error: ${d.message}` : 'The page logged a console error';++ if (d.check === 'overflow') {++ const offenders = Array.isArray(d.offenders) && d.offenders.length ? ` (${d.offenders.join(', ')})` : '';++ return `${d.width ? `At ${d.width} px, the` : 'The'} page scrolls sideways${offenders}`;++ }++ if (d.check === 'scripts') return `The page loads a script twice${d.message ? `: ${d.message}` : ''}`;++ if (d.check === 'jsonld') {++ const m = String(d.message || '');++ if (/^does not parse/i.test(m)) return `The page's structured data ${m.replace(/^does not parse:\s*/i, 'does not parse: ')}`;++ return `The page's structured data names ${m ? `"${m}"` : 'a value'} but the page text does not show it`;++ }++ if (d.check === 'status') return `The page answered ${d.status}${d.width ? ` at ${d.width} px` : ''}`;++ if (d.check === 'run') return 'The browser could not finish the check';++ return `The ${d.check || 'browser'} check failed`;++}++++function detailWhere(d) {++ if (Array.isArray(d.targets) && d.targets.length) return d.targets.join(', ');++ if (Array.isArray(d.offenders) && d.offenders.length) return d.offenders.join(', ');++ return d.width ? `${d.width} px` : '';++}++++const shortSha = (a) => {++ const s = String(a.release || '');++ if (/^[0-9a-f]{7,40}$/i.test(s)) return s.slice(0, 7);++ return (/release ([0-9a-f]{7,40})/i.exec(String(a.summary || '')) || [])[1]?.slice(0, 7) || null;++};++++function siteName(a, details) {++ for (const d of details) {++ const href = linked(d.page_url);++ if (href) { try { return new URL(href).hostname; } catch { /* fall through */ } }++ }++ return String(a.service || 'the site');++}++++function when(iso) {++ const t = Date.parse(iso);++ if (!Number.isFinite(t)) return null;++ return new Date(t).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');++}++++/** The notification's words and stored data for a browser-check alert, or null when it has no details. */++function browserCheck(a) {++ const details = (Array.isArray(a.details) ? a.details : []).filter((d) => d && typeof d === 'object').slice(0, 5);++ if (!details.length) return null;++ const site = siteName(a, details);++ const release = shortSha(a);++ const routes = [...new Set(details.map((d) => d.route).filter((r) => typeof r === 'string' && r))];++ const countPhrase = details.length === 1 ? 'a problem' : `${details.length} problems`;++ const where = routes.length === 1 ? ` on the page ${routes[0]}`++ : routes.length > 1 ? ` on ${routes.length} pages (${routes.join(', ')})` : '';++ const head = `${release ? `After release ${release}, the` : 'The'} automatic check of ${site} found ${countPhrase}${where}.`;++ const sentences = details.map((d) => {++ const targets = d.check === 'axe' && Array.isArray(d.targets) && d.targets.length ? ` (${d.targets.join(', ')})` : '';++ return `${detailWhat(d)}${targets}.`;++ });++ const message = `${head} ${sentences.join(' ')}`.replace(/\s+/g, ' ').trim().slice(0, 500);++++ const first = details[0];++ const targets = detailWhere(first);++ const facts = [++ { label: 'Site', value: site },++ { label: 'Page', value: first.route || '/', href: linked(first.page_url) },++ { label: 'What', value: `${detailWhat(first)}${details.length > 1 ? ` (and ${details.length - 1} more)` : ''}` },++ ...(targets ? [{ label: 'Where', value: targets }] : []),++ ...(release ? [{ label: 'Release', value: release, href: linked(a.release_url) }] : []),++ ...(when(a.checked_at) ? [{ label: 'Checked', value: when(a.checked_at) }] : []),++ ...(when(a.failing_since || a.started_at) ? [{ label: 'Failing since', value: when(a.failing_since || a.started_at) }] : []),++ ].map((f) => (f.href ? f : { label: f.label, value: f.value }));++++ const page = linked((details.find((d) => d.page_url) || {}).page_url);++ const help = linked((details.find((d) => d.check === 'axe' && d.help_url) || {}).help_url);++ const releaseUrl = linked(a.release_url);++ const actions = [++ ...(page ? [{ label: 'Open the page', href: page }] : []),++ ...(releaseUrl ? [{ label: 'See the release', href: releaseUrl }] : []),++ ...(help ? [{ label: 'What this rule means', href: help }] : []),++ ];++++ return {++ title: `${site}: ${details.length} problem${details.length === 1 ? '' : 's'} found by the browser check`,++ message,++ facts,++ actions,++ next: 'The browser check runs again after every release and once a day. This alert closes itself when the check passes.',++ };++}++/*** The parts of an alert a person reads, kept apart from the shell command its rule may carry (owner 10-28: raw* "run: sudo ovhost …" text in the notification body). `rich_content` gives Shared's notification UI the name,@@@ -67,8 +215,10 @@ function readable(a) {}function opsContent(a, state, r = readable(a)) {++ const browser = browserCheck(a);return { kind: 'ops_alert', name: a.name, state, severity: a.severity, sites: r.sites, release: r.release, summary: r.summary,-- command: r.command, service: a.service || null, since: a.started_at || null, runbook_url: STATUS_URL };++ command: r.command, service: a.service || null, since: a.started_at || null, runbook_url: STATUS_URL,++ ...(browser ? { facts: browser.facts, actions: browser.actions, next: browser.next } : {}) };}/**@@@ -128,11 +278,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), envif (kind === 'opened' || kind === 'reminded') {if (typeof notify !== 'function') continue;const r = readable(a);++ const browser = browserCheck(a);const n = {type: 'OPERATOR_ALERT', category: 'admin', service: 'host', url: STATUS_URL,priority: critical ? 'critical' : 'high', icon: critical ? '🚨' : '⚠️', silent: !critical,-- title: `${kind === 'reminded' ? 'Still failing: ' : ''}${humanize(a.name)}`.slice(0, 120),-- message: r.message || `${a.summary}. ${message(a)}`.slice(0, 500),++ title: `${kind === 'reminded' ? 'Still failing: ' : ''}${browser ? browser.title : humanize(a.name)}`.slice(0, 120),++ message: (browser ? browser.message : (r.message || `${a.summary}. ${message(a)}`)).slice(0, 500),rich_content: opsContent(a, kind === 'reminded' ? 'reminded' : 'firing', r),};const kept = kind === 'reminded' ? notices(prev) : [];@@@ -148,11 +299,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env}if (typeof revise !== 'function') continue;const r = readable(a);++ const browser = browserCheck(a);const fields = kind === 'resolved'? { title: `Resolved: ${humanize(a.name)}`.slice(0, 120), icon: '✅', is_read: 1, rich_content: opsContent(a, 'resolved', r),message: `${r.message || a.summary} (resolved after ${ago(now - (prev.opened_at || now))}${prev.reopened ? `; it fired ${prev.reopened + 1} times` : ''}).`.slice(0, 500) }-- : { title: humanize(a.name).slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, rich_content: opsContent(a, 'firing', r),-- message: `${r.message || a.summary}. Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };++ : { title: (browser ? browser.title : humanize(a.name)).slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, rich_content: opsContent(a, 'firing', r),++ message: `${browser ? browser.message : (r.message || a.summary)}. Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };for (const [uid, id] of notices(prev)) {try { await revise(uid, id, fields); } catch (e) { console.warn('[operator-alerts] revise failed:', e.message); }}@@@ -166,4 +318,4 @@ async function list(db, { limit = 100 } = {}) {return await 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, humanize, readable, REMIND_MS, FLAP_MS };++module.exports = { ensureTables, receive, list, operatorUserIds, humanize, readable, browserCheck, REMIND_MS, FLAP_MS };@@@ -13,6 +13,7 @@ const express = require('express');const { validate } = require('openvibe-contracts');const { getDb } = require('../server/db/database');const alerts = require('../server/operator/alerts');++const { EmailService } = require('../server/notifications/email-service');(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));@@@ -42,6 +43,18 @@ 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 });++const browserAlert = {++ fingerprint: 'f'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical',++ summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s)); run: sudo ovhost browser-watch --sites domains --force',++ description: 'The automatic check of openvibe.domains found a problem after release 74bdff4.',++ service: 'domains', started_at: '2026-11-10T09:12:30Z', failing_since: '2026-11-10T09:12:30Z',++ release: '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b',++ release_url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Domains/commit/74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b',++ checked_at: '2026-11-10T09:12:00Z',++ details: [{ route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold', '#faq-fold'],++ help: 'Ensures the summary element has a non-empty accessible name',++ help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name', page_url: 'https://openvibe.domains/' }],++};(async () => {await new Promise((r) => server.listen(0, '127.0.0.1', r));@@@ -71,6 +84,9 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').assert.strictEqual(r.status, 400);r = await post({ source: 'grafana', alerts: [] }, auth);assert.strictEqual(r.status, 400);++ assert.ok(validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [browserAlert] }).valid, 'the browser-check details are part of the contract now');++ assert.ok(!validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [{ ...browserAlert, details: [{ route: '/' }] }] }).valid, 'a detail must say which check it is');++ assert.ok(!validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [{ ...browserAlert, details: [{ check: 'axe', page_url: 'not a url' }] }] }).valid, 'a link is a URI');// 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);@@@ -155,6 +171,66 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').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]);++++ // ── A browser-check alert says it in words (network.operator-alerts-request@1 details) ─────────++ process.env.OWNER_USERNAME = 'Boss'; process.env.OPERATOR_ALERT_USERNAMES = '';++ const bc = alerts.browserCheck(browserAlert);++ assert.strictEqual(bc.title, 'openvibe.domains: 1 problem found by the browser check');++ assert.strictEqual(bc.message, 'After release 74bdff4, the automatic check of openvibe.domains found a problem on the page /. Two fold headings have no name a screen reader can say (#dev-fold, #faq-fold).');++ assert.deepStrictEqual(bc.facts.map((f) => f.label), ['Site', 'Page', 'What', 'Where', 'Release', 'Checked', 'Failing since']);++ assert.deepStrictEqual(bc.facts.find((f) => f.label === 'Page'), { label: 'Page', value: '/', href: 'https://openvibe.domains/' });++ assert.deepStrictEqual(bc.facts.find((f) => f.label === 'Release'), { label: 'Release', value: '74bdff4', href: browserAlert.release_url });++ assert.deepStrictEqual(bc.actions.map((a) => a.label), ['Open the page', 'See the release', 'What this rule means']);++ assert.match(bc.next, /runs again after every release and once a day.*closes itself when the check passes/);++++ // The notification itself carries the words and the stored facts/actions/next.++ sent.length = 0;++ out = await alerts.receive(db, { source: 'prometheus', alerts: [browserAlert] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: 'browser-1' }; } });++ assert.deepStrictEqual([out.opened, out.notified], [1, 1]);++ assert.strictEqual(sent[0].title, bc.title);++ assert.strictEqual(sent[0].message, bc.message);++ assert.deepStrictEqual(sent[0].rich_content.facts, bc.facts);++ assert.deepStrictEqual(sent[0].rich_content.actions, bc.actions);++ assert.strictEqual(sent[0].rich_content.next, bc.next);++++ // A reminder says "Still failing" in front, and keeps the details.++ sent.length = 0;++ out = await alerts.receive(db, { source: 'prometheus', alerts: [browserAlert] }, { notify: (u, n) => { sent.push(n); return { id: 'browser-2' }; }, now: Date.now() + alerts.REMIND_MS + 1000 });++ assert.strictEqual(out.reminded, 1);++ assert.strictEqual(sent[0].title, 'Still failing: openvibe.domains: 1 problem found by the browser check');++++ // An unknown axe rule falls back to its id and axe's help text.++ const unknown = alerts.browserCheck({ ...browserAlert, details: [{ route: '/x', check: 'axe', rule: 'mystery-rule', help: 'Makes sure the widget has a name', targets: ['#widget'], page_url: 'https://openvibe.domains/x' }] });++ assert.match(unknown.message, /The page breaks the accessibility rule mystery-rule \(Makes sure the widget has a name\) \(#widget\)\./);++ const consoleError = alerts.browserCheck({ ...browserAlert, details: [{ route: '/', check: 'errors', message: 'WebSocket closed', width: 390, page_url: 'https://openvibe.domains/' }, { route: '/', check: 'overflow', width: 390, offenders: ['.wide'], page_url: 'https://openvibe.domains/' }] });++ assert.match(consoleError.message, /found 2 problems on the page \/.*console error: WebSocket closed.*At 390 px, the page scrolls sideways \(\.wide\)/);++++ // Links that are not https or not on an allowed host are dropped; text is kept as text.++ const unsafe = alerts.browserCheck({ ...browserAlert,++ release_url: 'http://github.com/OpenVibers/OpenVibe.Domains/commit/74bdff4',++ details: [{ route: '/', check: 'axe', rule: 'summary-name', targets: ['<img onerror=alert(1)>'], help: 'x', help_url: 'javascript:alert(1)', page_url: 'https://evil.example/' }] });++ assert.deepStrictEqual(unsafe.actions, [], 'no http, javascript: or off-network link becomes an action');++ assert.strictEqual(unsafe.facts.find((f) => f.label === 'Page').href, undefined);++ assert.strictEqual(unsafe.facts.find((f) => f.label === 'Site').value, 'domains', 'an off-network page does not rename the site');++ assert.ok(unsafe.message.includes('<img onerror=alert(1)>'), 'the target stays text (the UI escapes it)');++++ // A plain Prometheus alert with no details still reads as before.++ sent.length = 0;++ out = await alerts.receive(db, { source: 'prometheus', alerts: [{ fingerprint: '0'.repeat(16), name: 'OpenVibeDiskFull', severity: 'warning', summary: 'The host disk is 95% full', description: 'over 90% for an hour', service: 'host', started_at: '2026-11-10T09:00:00Z' }] }, { notify: (u, n) => { sent.push(n); return { id: 'plain-1' }; } });++ assert.deepStrictEqual([out.opened, out.notified], [1, 1]);++ assert.strictEqual(sent[0].title, 'Disk full');++ assert.strictEqual(sent[0].message, 'host · The host disk is 95% full · over 90% for an hour');++ assert.strictEqual(sent[0].rich_content.facts, undefined);++ assert.strictEqual(sent[0].rich_content.actions, undefined);++ assert.strictEqual(sent[0].rich_content.next, undefined);++++ // The email's plain-text part shows the facts, the actions' links and the next step.++ const text = EmailService.prototype._buildEmailText({ username: 'Ada', notification: { title: bc.title, message: bc.message, rich_content: bc } });++ assert.match(text, /Site: openvibe\.domains/);++ assert.match(text, /Open the page: https:\/\/openvibe\.domains\//);++ assert.match(text, /See the release: https:\/\/git\.openvibe\.codes\//);++ assert.match(text, /What this rule means: https:\/\/dequeuniversity\.com\//);++ assert.match(text, /The browser check runs again after every release/);} finally {server.close();fs.rmSync(dir, { recursive: true, force: true });
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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.391.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -2679,9 +2679,9 @@
@@ -2679,9 +2679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.391.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
- "integrity": "sha512-YZZlJcszGWyy1TpEoVFj+mprnedvDWflOtkXWsLV406jVrCuStJXgK82ooJPr6llmC2KbAS55sGIxiYAieyqbg==",
+ "version": "0.399.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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.391.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -9,6 +9,14 @@
@@ -9,6 +9,14 @@
const https = require('https');
const https = require('https');
+/** A notification's structured data, whether the queue handed it JSON or an object. */
+function richOf(notification) {
+ const rc = notification && notification.rich_content;
+ if (!rc) return {};
+ if (typeof rc === 'object') return rc;
+ try { return JSON.parse(rc) || {}; } catch { return {}; }
+}
+
/**
/**
* Send an email via the Resend REST API.
* Send an email via the Resend REST API.
* @param {string} apiKey - Resend API key (re_xxxx)
* @param {string} apiKey - Resend API key (re_xxxx)
@@ -343,6 +351,9 @@ class EmailService {
@@ -343,6 +351,9 @@ class EmailService {
// ─── Email Templates ───────────────────────────────────────
// ─── Email Templates ───────────────────────────────────────
_buildEmailHtml({ username, notification }) {
_buildEmailHtml({ username, notification }) {
+ // The facts, actions and next steps are passed through for the notification template; it renders them
+ // (and escapes every string) rather than this mailer reading the host's words into its own markup.
+ notification.rich_content = richOf(notification);
const priorityColor = { low: '#888', normal: '#8b5cf6', high: '#f39c12', critical: '#e74c3c' };
const priorityColor = { low: '#888', normal: '#8b5cf6', high: '#f39c12', critical: '#e74c3c' };
const color = priorityColor[notification.priority] || '#8b5cf6';
const color = priorityColor[notification.priority] || '#8b5cf6';
@@ -387,6 +398,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
@@ -387,6 +398,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
}
}
_buildEmailText({ username, notification }) {
_buildEmailText({ username, notification }) {
+ const rich = richOf(notification);
+ const facts = Array.isArray(rich.facts) ? rich.facts.filter((f) => f && f.label && f.value) : [];
+ const actions = Array.isArray(rich.actions) ? rich.actions.filter((a) => a && a.label && a.href) : [];
return [
return [
`Hey ${username || 'there'},`,
`Hey ${username || 'there'},`,
'',
'',
@@ -394,6 +408,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
@@ -394,6 +408,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
'',
'',
`${notification.icon || '🔔'} ${notification.title}`,
`${notification.icon || '🔔'} ${notification.title}`,
notification.message || '',
notification.message || '',
+ ...(facts.length ? ['', ...facts.map((f) => `${f.label}: ${f.value}${f.href ? ` (${f.href})` : ''}`)] : []),
+ ...(actions.length ? ['', ...actions.map((a) => `${a.label}: ${a.href}`)] : []),
+ ...(rich.next ? ['', String(rich.next)] : []),
'',
'',
notification.url ? `View details: ${notification.url}` : '',
notification.url ? `View details: ${notification.url}` : '',
'',
'',
@@ -48,6 +48,154 @@ function humanize(name) {
@@ -48,6 +48,154 @@ function humanize(name) {
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';
}
}
+
+// ── What a browser-check alert says (network.operator-alerts-request@1 details) ───────────────────
+// The host sends the result file's facts; this is the one place they become words a person reads. Every
+// string here is text and every link is https on an allowed host, whatever the host sent.
+const LINK_HOSTS = new Set(['git.openvibe.codes', 'github.com', 'dequeuniversity.com']);
+const NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five'];
+let domainsCache = null;
+
+const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
+const count = (n, one, many) => `${NUMBER_WORDS[n] || String(n)} ${n === 1 ? one : many}`;
+const has = (n) => (n === 1 ? 'has' : 'have');
+const is = (n) => (n === 1 ? 'is' : 'are');
+
+/** The network's own public domains, from the contracts service manifests; no other site host may be linked. */
+function networkDomains() {
+ if (domainsCache) return domainsCache;
+ let lib = null;
+ try { lib = require('openvibe-contracts'); } catch { lib = null; }
+ const out = new Set();
+ for (const m of (lib && lib.services && lib.services.manifests) || []) {
+ for (const d of m.domains || []) if (d) out.add(String(d).toLowerCase());
+ try { const h = new URL(m.publicOrigin).hostname.toLowerCase(); if (h) out.add(h); } catch { /* no origin */ }
+ }
+ domainsCache = out;
+ return out;
+}
+
+/** An https link on an allowed host, else null. The host builds them; this refuses anything else it is sent. */
+function linked(href) {
+ try {
+ const u = new URL(String(href));
+ if (u.protocol !== 'https:') return null;
+ const host = u.hostname.toLowerCase();
+ if (!LINK_HOSTS.has(host) && !networkDomains().has(host)) return null;
+ return u.href;
+ } catch { return null; }
+}
+
+/** Rule id or check → one sentence a person understands. An unknown rule falls back to its id and axe's help. */
+const RULE_SENTENCES = {
+ 'summary-name': (n) => `${cap(count(n, 'fold heading', 'fold headings'))} ${has(n)} no name a screen reader can say`,
+ 'color-contrast': (n) => `${cap(count(n, 'piece', 'pieces'))} of text ${is(n)} too faint to read comfortably`,
+ 'image-alt': (n) => `${cap(count(n, 'image', 'images'))} ${has(n)} no text description`,
+ 'link-name': (n) => `${cap(count(n, 'link', 'links'))} ${has(n)} no name a screen reader can say`,
+ 'button-name': (n) => `${cap(count(n, 'button', 'buttons'))} ${has(n)} no name a screen reader can say`,
+ 'label': (n) => `${cap(count(n, 'form field', 'form fields'))} ${has(n)} no label`,
+ 'aria-hidden-focus': (n) => `${cap(count(n, 'hidden element', 'hidden elements'))} can still take keyboard focus`,
+ 'landmark-one-main': () => 'The page has no main landmark',
+ 'region': (n) => `${cap(count(n, 'part', 'parts'))} of the page ${is(n)} outside a landmark`,
+};
+
+function detailWhat(d) {
+ const n = Math.max(1, (Array.isArray(d.targets) && d.targets.length) || 1);
+ if (d.check === 'axe') {
+ const rule = String(d.rule || '').toLowerCase();
+ const sentence = RULE_SENTENCES[rule];
+ if (sentence) return sentence(n);
+ return `The page breaks the accessibility rule ${d.rule || 'it names'}${d.help ? ` (${d.help})` : ''}`;
+ }
+ if (d.check === 'errors') return d.message ? `The page logged a console error: ${d.message}` : 'The page logged a console error';
+ if (d.check === 'overflow') {
+ const offenders = Array.isArray(d.offenders) && d.offenders.length ? ` (${d.offenders.join(', ')})` : '';
+ return `${d.width ? `At ${d.width} px, the` : 'The'} page scrolls sideways${offenders}`;
+ }
+ if (d.check === 'scripts') return `The page loads a script twice${d.message ? `: ${d.message}` : ''}`;
+ if (d.check === 'jsonld') {
+ const m = String(d.message || '');
+ if (/^does not parse/i.test(m)) return `The page's structured data ${m.replace(/^does not parse:\s*/i, 'does not parse: ')}`;
+ return `The page's structured data names ${m ? `"${m}"` : 'a value'} but the page text does not show it`;
+ }
+ if (d.check === 'status') return `The page answered ${d.status}${d.width ? ` at ${d.width} px` : ''}`;
+ if (d.check === 'run') return 'The browser could not finish the check';
+ return `The ${d.check || 'browser'} check failed`;
+}
+
+function detailWhere(d) {
+ if (Array.isArray(d.targets) && d.targets.length) return d.targets.join(', ');
+ if (Array.isArray(d.offenders) && d.offenders.length) return d.offenders.join(', ');
+ return d.width ? `${d.width} px` : '';
+}
+
+const shortSha = (a) => {
+ const s = String(a.release || '');
+ if (/^[0-9a-f]{7,40}$/i.test(s)) return s.slice(0, 7);
+ return (/release ([0-9a-f]{7,40})/i.exec(String(a.summary || '')) || [])[1]?.slice(0, 7) || null;
+};
+
+function siteName(a, details) {
+ for (const d of details) {
+ const href = linked(d.page_url);
+ if (href) { try { return new URL(href).hostname; } catch { /* fall through */ } }
+ }
+ return String(a.service || 'the site');
+}
+
+function when(iso) {
+ const t = Date.parse(iso);
+ if (!Number.isFinite(t)) return null;
+ return new Date(t).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
+}
+
+/** The notification's words and stored data for a browser-check alert, or null when it has no details. */
+function browserCheck(a) {
+ const details = (Array.isArray(a.details) ? a.details : []).filter((d) => d && typeof d === 'object').slice(0, 5);
+ if (!details.length) return null;
+ const site = siteName(a, details);
+ const release = shortSha(a);
+ const routes = [...new Set(details.map((d) => d.route).filter((r) => typeof r === 'string' && r))];
+ const countPhrase = details.length === 1 ? 'a problem' : `${details.length} problems`;
+ const where = routes.length === 1 ? ` on the page ${routes[0]}`
+ : routes.length > 1 ? ` on ${routes.length} pages (${routes.join(', ')})` : '';
+ const head = `${release ? `After release ${release}, the` : 'The'} automatic check of ${site} found ${countPhrase}${where}.`;
+ const sentences = details.map((d) => {
+ const targets = d.check === 'axe' && Array.isArray(d.targets) && d.targets.length ? ` (${d.targets.join(', ')})` : '';
+ return `${detailWhat(d)}${targets}.`;
+ });
+ const message = `${head} ${sentences.join(' ')}`.replace(/\s+/g, ' ').trim().slice(0, 500);
+
+ const first = details[0];
+ const targets = detailWhere(first);
+ const facts = [
+ { label: 'Site', value: site },
+ { label: 'Page', value: first.route || '/', href: linked(first.page_url) },
+ { label: 'What', value: `${detailWhat(first)}${details.length > 1 ? ` (and ${details.length - 1} more)` : ''}` },
+ ...(targets ? [{ label: 'Where', value: targets }] : []),
+ ...(release ? [{ label: 'Release', value: release, href: linked(a.release_url) }] : []),
+ ...(when(a.checked_at) ? [{ label: 'Checked', value: when(a.checked_at) }] : []),
+ ...(when(a.failing_since || a.started_at) ? [{ label: 'Failing since', value: when(a.failing_since || a.started_at) }] : []),
+ ].map((f) => (f.href ? f : { label: f.label, value: f.value }));
+
+ const page = linked((details.find((d) => d.page_url) || {}).page_url);
+ const help = linked((details.find((d) => d.check === 'axe' && d.help_url) || {}).help_url);
+ const releaseUrl = linked(a.release_url);
+ const actions = [
+ ...(page ? [{ label: 'Open the page', href: page }] : []),
+ ...(releaseUrl ? [{ label: 'See the release', href: releaseUrl }] : []),
+ ...(help ? [{ label: 'What this rule means', href: help }] : []),
+ ];
+
+ return {
+ title: `${site}: ${details.length} problem${details.length === 1 ? '' : 's'} found by the browser check`,
+ message,
+ facts,
+ actions,
+ next: 'The browser check runs again after every release and once a day. This alert closes itself when the check passes.',
+ };
+}
+
/**
/**
* The parts of an alert a person reads, kept apart from the shell command its rule may carry (owner 10-28: raw
* The parts of an alert a person reads, kept apart from the shell command its rule may carry (owner 10-28: raw
* "run: sudo ovhost …" text in the notification body). `rich_content` gives Shared's notification UI the name,
* "run: sudo ovhost …" text in the notification body). `rich_content` gives Shared's notification UI the name,
@@ -67,8 +215,10 @@ function readable(a) {
@@ -67,8 +215,10 @@ function readable(a) {
}
}
function opsContent(a, state, r = readable(a)) {
function opsContent(a, state, r = readable(a)) {
+ const browser = browserCheck(a);
return { kind: 'ops_alert', name: a.name, state, severity: a.severity, sites: r.sites, release: r.release, summary: r.summary,
return { kind: 'ops_alert', name: a.name, state, severity: a.severity, sites: r.sites, release: r.release, summary: r.summary,
- command: r.command, service: a.service || null, since: a.started_at || null, runbook_url: STATUS_URL };
+ command: r.command, service: a.service || null, since: a.started_at || null, runbook_url: STATUS_URL,
+ ...(browser ? { facts: browser.facts, actions: browser.actions, next: browser.next } : {}) };
}
}
/**
/**
@@ -128,11 +278,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
@@ -128,11 +278,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
if (kind === 'opened' || kind === 'reminded') {
if (kind === 'opened' || kind === 'reminded') {
if (typeof notify !== 'function') continue;
if (typeof notify !== 'function') continue;
const r = readable(a);
const r = readable(a);
+ const browser = browserCheck(a);
const n = {
const n = {
type: 'OPERATOR_ALERT', category: 'admin', service: 'host', url: STATUS_URL,
type: 'OPERATOR_ALERT', category: 'admin', service: 'host', url: STATUS_URL,
priority: critical ? 'critical' : 'high', icon: critical ? '🚨' : '⚠️', silent: !critical,
priority: critical ? 'critical' : 'high', icon: critical ? '🚨' : '⚠️', silent: !critical,
- title: `${kind === 'reminded' ? 'Still failing: ' : ''}${humanize(a.name)}`.slice(0, 120),
- message: r.message || `${a.summary}. ${message(a)}`.slice(0, 500),
+ title: `${kind === 'reminded' ? 'Still failing: ' : ''}${browser ? browser.title : humanize(a.name)}`.slice(0, 120),
+ message: (browser ? browser.message : (r.message || `${a.summary}. ${message(a)}`)).slice(0, 500),
rich_content: opsContent(a, kind === 'reminded' ? 'reminded' : 'firing', r),
rich_content: opsContent(a, kind === 'reminded' ? 'reminded' : 'firing', r),
};
};
const kept = kind === 'reminded' ? notices(prev) : [];
const kept = kind === 'reminded' ? notices(prev) : [];
@@ -148,11 +299,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
@@ -148,11 +299,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
}
}
if (typeof revise !== 'function') continue;
if (typeof revise !== 'function') continue;
const r = readable(a);
const r = readable(a);
+ const browser = browserCheck(a);
const fields = kind === 'resolved'
const fields = kind === 'resolved'
? { title: `Resolved: ${humanize(a.name)}`.slice(0, 120), icon: '✅', is_read: 1, rich_content: opsContent(a, 'resolved', r),
? { title: `Resolved: ${humanize(a.name)}`.slice(0, 120), icon: '✅', is_read: 1, rich_content: opsContent(a, 'resolved', r),
message: `${r.message || a.summary} (resolved after ${ago(now - (prev.opened_at || now))}${prev.reopened ? `; it fired ${prev.reopened + 1} times` : ''}).`.slice(0, 500) }
message: `${r.message || a.summary} (resolved after ${ago(now - (prev.opened_at || now))}${prev.reopened ? `; it fired ${prev.reopened + 1} times` : ''}).`.slice(0, 500) }
- : { title: humanize(a.name).slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, rich_content: opsContent(a, 'firing', r),
- message: `${r.message || a.summary}. Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };
+ : { title: (browser ? browser.title : humanize(a.name)).slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, rich_content: opsContent(a, 'firing', r),
+ message: `${browser ? browser.message : (r.message || a.summary)}. Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };
for (const [uid, id] of notices(prev)) {
for (const [uid, id] of notices(prev)) {
try { await revise(uid, id, fields); } catch (e) { console.warn('[operator-alerts] revise failed:', e.message); }
try { await revise(uid, id, fields); } catch (e) { console.warn('[operator-alerts] revise failed:', e.message); }
}
}
@@ -166,4 +318,4 @@ async function list(db, { limit = 100 } = {}) {
@@ -166,4 +318,4 @@ async function list(db, { limit = 100 } = {}) {
return await 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);
return await 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, humanize, readable, REMIND_MS, FLAP_MS };
+module.exports = { ensureTables, receive, list, operatorUserIds, humanize, readable, browserCheck, REMIND_MS, FLAP_MS };
@@ -13,6 +13,7 @@ const express = require('express');
@@ -13,6 +13,7 @@ const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const alerts = require('../server/operator/alerts');
const alerts = require('../server/operator/alerts');
+const { EmailService } = require('../server/notifications/email-service');
(async () => {
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));
@@ -42,6 +43,18 @@ app.use('/internal', require('../server/internal/routes'));
@@ -42,6 +43,18 @@ app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
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 });
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 });
+const browserAlert = {
+ fingerprint: 'f'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical',
+ summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s)); run: sudo ovhost browser-watch --sites domains --force',
+ description: 'The automatic check of openvibe.domains found a problem after release 74bdff4.',
+ service: 'domains', started_at: '2026-11-10T09:12:30Z', failing_since: '2026-11-10T09:12:30Z',
+ release: '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b',
+ release_url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Domains/commit/74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b',
+ checked_at: '2026-11-10T09:12:00Z',
+ details: [{ route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold', '#faq-fold'],
+ help: 'Ensures the summary element has a non-empty accessible name',
+ help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name', page_url: 'https://openvibe.domains/' }],
+};
(async () => {
(async () => {
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
@@ -71,6 +84,9 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
@@ -71,6 +84,9 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.strictEqual(r.status, 400);
assert.strictEqual(r.status, 400);
r = await post({ source: 'grafana', alerts: [] }, auth);
r = await post({ source: 'grafana', alerts: [] }, auth);
assert.strictEqual(r.status, 400);
assert.strictEqual(r.status, 400);
+ assert.ok(validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [browserAlert] }).valid, 'the browser-check details are part of the contract now');
+ assert.ok(!validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [{ ...browserAlert, details: [{ route: '/' }] }] }).valid, 'a detail must say which check it is');
+ assert.ok(!validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [{ ...browserAlert, details: [{ check: 'axe', page_url: 'not a url' }] }] }).valid, 'a link is a URI');
// Opens: both operators (owner by OWNER_USERNAME, case-insensitive; the extra username) are paged.
// 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);
r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed', { service: 'host', description: 'No successful backup in 26 hours.' }), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);
@@ -155,6 +171,66 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
@@ -155,6 +171,66 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
process.env.OWNER_USERNAME = 'ghost'; process.env.OPERATOR_ALERT_USERNAMES = '';
process.env.OWNER_USERNAME = 'ghost'; process.env.OPERATOR_ALERT_USERNAMES = '';
r = await post({ source: 'prometheus', alerts: [alert('Lonely')] }, auth);
r = await post({ source: 'prometheus', alerts: [alert('Lonely')] }, auth);
assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);
assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);
+
+ // ── A browser-check alert says it in words (network.operator-alerts-request@1 details) ─────────
+ process.env.OWNER_USERNAME = 'Boss'; process.env.OPERATOR_ALERT_USERNAMES = '';
+ const bc = alerts.browserCheck(browserAlert);
+ assert.strictEqual(bc.title, 'openvibe.domains: 1 problem found by the browser check');
+ assert.strictEqual(bc.message, 'After release 74bdff4, the automatic check of openvibe.domains found a problem on the page /. Two fold headings have no name a screen reader can say (#dev-fold, #faq-fold).');
+ assert.deepStrictEqual(bc.facts.map((f) => f.label), ['Site', 'Page', 'What', 'Where', 'Release', 'Checked', 'Failing since']);
+ assert.deepStrictEqual(bc.facts.find((f) => f.label === 'Page'), { label: 'Page', value: '/', href: 'https://openvibe.domains/' });
+ assert.deepStrictEqual(bc.facts.find((f) => f.label === 'Release'), { label: 'Release', value: '74bdff4', href: browserAlert.release_url });
+ assert.deepStrictEqual(bc.actions.map((a) => a.label), ['Open the page', 'See the release', 'What this rule means']);
+ assert.match(bc.next, /runs again after every release and once a day.*closes itself when the check passes/);
+
+ // The notification itself carries the words and the stored facts/actions/next.
+ sent.length = 0;
+ out = await alerts.receive(db, { source: 'prometheus', alerts: [browserAlert] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: 'browser-1' }; } });
+ assert.deepStrictEqual([out.opened, out.notified], [1, 1]);
+ assert.strictEqual(sent[0].title, bc.title);
+ assert.strictEqual(sent[0].message, bc.message);
+ assert.deepStrictEqual(sent[0].rich_content.facts, bc.facts);
+ assert.deepStrictEqual(sent[0].rich_content.actions, bc.actions);
+ assert.strictEqual(sent[0].rich_content.next, bc.next);
+
+ // A reminder says "Still failing" in front, and keeps the details.
+ sent.length = 0;
+ out = await alerts.receive(db, { source: 'prometheus', alerts: [browserAlert] }, { notify: (u, n) => { sent.push(n); return { id: 'browser-2' }; }, now: Date.now() + alerts.REMIND_MS + 1000 });
+ assert.strictEqual(out.reminded, 1);
+ assert.strictEqual(sent[0].title, 'Still failing: openvibe.domains: 1 problem found by the browser check');
+
+ // An unknown axe rule falls back to its id and axe's help text.
+ const unknown = alerts.browserCheck({ ...browserAlert, details: [{ route: '/x', check: 'axe', rule: 'mystery-rule', help: 'Makes sure the widget has a name', targets: ['#widget'], page_url: 'https://openvibe.domains/x' }] });
+ assert.match(unknown.message, /The page breaks the accessibility rule mystery-rule \(Makes sure the widget has a name\) \(#widget\)\./);
+ const consoleError = alerts.browserCheck({ ...browserAlert, details: [{ route: '/', check: 'errors', message: 'WebSocket closed', width: 390, page_url: 'https://openvibe.domains/' }, { route: '/', check: 'overflow', width: 390, offenders: ['.wide'], page_url: 'https://openvibe.domains/' }] });
+ assert.match(consoleError.message, /found 2 problems on the page \/.*console error: WebSocket closed.*At 390 px, the page scrolls sideways \(\.wide\)/);
+
+ // Links that are not https or not on an allowed host are dropped; text is kept as text.
+ const unsafe = alerts.browserCheck({ ...browserAlert,
+ release_url: 'http://github.com/OpenVibers/OpenVibe.Domains/commit/74bdff4',
+ details: [{ route: '/', check: 'axe', rule: 'summary-name', targets: ['<img onerror=alert(1)>'], help: 'x', help_url: 'javascript:alert(1)', page_url: 'https://evil.example/' }] });
+ assert.deepStrictEqual(unsafe.actions, [], 'no http, javascript: or off-network link becomes an action');
+ assert.strictEqual(unsafe.facts.find((f) => f.label === 'Page').href, undefined);
+ assert.strictEqual(unsafe.facts.find((f) => f.label === 'Site').value, 'domains', 'an off-network page does not rename the site');
+ assert.ok(unsafe.message.includes('<img onerror=alert(1)>'), 'the target stays text (the UI escapes it)');
+
+ // A plain Prometheus alert with no details still reads as before.
+ sent.length = 0;
+ out = await alerts.receive(db, { source: 'prometheus', alerts: [{ fingerprint: '0'.repeat(16), name: 'OpenVibeDiskFull', severity: 'warning', summary: 'The host disk is 95% full', description: 'over 90% for an hour', service: 'host', started_at: '2026-11-10T09:00:00Z' }] }, { notify: (u, n) => { sent.push(n); return { id: 'plain-1' }; } });
+ assert.deepStrictEqual([out.opened, out.notified], [1, 1]);
+ assert.strictEqual(sent[0].title, 'Disk full');
+ assert.strictEqual(sent[0].message, 'host · The host disk is 95% full · over 90% for an hour');
+ assert.strictEqual(sent[0].rich_content.facts, undefined);
+ assert.strictEqual(sent[0].rich_content.actions, undefined);
+ assert.strictEqual(sent[0].rich_content.next, undefined);
+
+ // The email's plain-text part shows the facts, the actions' links and the next step.
+ const text = EmailService.prototype._buildEmailText({ username: 'Ada', notification: { title: bc.title, message: bc.message, rich_content: bc } });
+ assert.match(text, /Site: openvibe\.domains/);
+ assert.match(text, /Open the page: https:\/\/openvibe\.domains\//);
+ assert.match(text, /See the release: https:\/\/git\.openvibe\.codes\//);
+ assert.match(text, /What this rule means: https:\/\/dequeuniversity\.com\//);
+ assert.match(text, /The browser check runs again after every release/);
} finally {
} finally {
server.close();
server.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });