openvibers/openvibe.host Commits
Alerts: a browser-check alert carries what failed, the full release and its links (#365)
Changes
@@@ -392,7 +392,7 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openviPart of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.384.0++- openvibe-contracts: v0.399.0- openvibe-sdk: v0.125.0- openvibe-shared: v4.199.0- openvibe-publishing: v1.7.0@@@ -13,11 +13,11 @@"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey","port": 4910,"domain": "openvibe.host",-- "contracts": "openvibe-contracts v0.384.0",++ "contracts": "openvibe-contracts v0.399.0","sdk": "openvibe-sdk v0.125.0","shared": "openvibe-shared v4.199.0","packages": [-- "openvibe-contracts v0.384.0",++ "openvibe-contracts v0.399.0","openvibe-sdk v0.125.0","openvibe-shared v4.199.0"],@@@ -26,8 +26,19 @@ Prometheus (127.0.0.1:9090) ──GET /api/v1/alerts──▶ ovhost alerts re- **Reminded**: the alert pages again once a day while it stays open.- **Resolved**: a notice goes out when the alert drops out of the firing set.- **Recipients**: `OWNER_USERNAME` in Network's environment, plus the comma-separated `OPERATOR_ALERT_USERNAMES`.--- **What leaves the host**: only the alert name, severity, summary, description, `service` label and start time.++- **What leaves the host**: the alert name, severity, summary, description, `service` label and start time.Instance and job labels stay on the host; the fingerprint is a hash of the labels.++- **A browser-check alert says what failed**: `OpenVibeBrowserCheckFailed` also carries up to five `details`++ from `/var/lib/openvibe-host/browser-watch/<site>.json` (the route, the check, the axe rule and impact, up to++ five target selectors, the rule's help text and Deque University page, the failing page's own URL, a console++ error, a viewport width, a status, overflow offenders), the release sha resolved to the full commit, the++ commit's page (`git.openvibe.codes` when the repository is mirrored there, else `github.com`), when the check++ ran and since when the alert has been failing. Network turns them into the notification's sentence, a facts++ table, action links and a next step.++- **Links are built on the host and allowlisted**: https only, and the host must be one of the network's own++ domains (from the contracts service manifests), `git.openvibe.codes`, `github.com` or++ `dequeuniversity.com`; anything else is dropped. Every string is text and is escaped where a notification++ shows it.- **Pending alerts** (inside their `for:` window) are not sent.- **Prometheus unreachable**: the relay sends nothing, because an empty set would resolve every open alert.- **Relay failures**: `openvibe_alert_relay_last_run_ok` and `openvibe_alert_relay_last_success_timestamp_seconds`@@@ -63,7 +74,9 @@ The release is its own series (`openvibe_browser_check_release_info`), so a fail- **A settle window:** a release is judged once its process has run 3 minutes (`/release.json` `booted_at`). This stops a restart's reconnecting sockets from paging.- **Confirmation:** the confirming second run of a failure waits a minute.--- **What failed:** the axe rules and targets, console errors, overflow and statuses are in `/var/lib/openvibe-host/browser-watch/<site>.json` under `details`.++- **What failed:** the axe rules (with their help text and Deque help page), targets, console errors, overflow++ offenders, duplicate scripts, JSON-LD the page does not show and statuses are in++ `/var/lib/openvibe-host/browser-watch/<site>.json` under `details`; the relay sends the first five to Network.## Commands@@@ -14,6 +14,14 @@* = the rule's annotations, service = the service label when it is a service id, started_at = activeAt.* Free-form labels never leave the host.*++ * A browser-check alert (`OpenVibeBrowserCheckFailed`) carries what its result file knows, so the notification can++ * explain it in words instead of repeating the rule: up to five `details` (route, check, axe rule and impact, at++ * most five target selectors, the rule's help text and Deque help page, the failing page's own URL, a console++ * error, a viewport width, a status, overflow offenders), the release sha (resolved to the full commit when the++ * service's checkout has it) and `release_url`, `checked_at` and `failing_since`. Links are built on the host from++ * facts it has and are accepted only when https on one of the network's own domains, git.openvibe.codes, github.com++ * or dequeuniversity.com. A plain Prometheus alert carries only its annotations, as before.++ ** A Prometheus that does not answer sends nothing: an empty set would resolve every open alert. Each run* writes openvibe_alert_relay.prom to the textfile collector (last run, last success, alerts firing).* Credentials: Host's principal from /etc/openvibe/host.env (announce.js loadCredentials; the secret and the@@@ -22,6 +30,7 @@const crypto = require('crypto');const path = require('path');const { loadCredentials } = require('./announce');++const { axeHelpUrl } = require('./links');const DEFAULT_PROMETHEUS = 'http://127.0.0.1:9090';// The rules' severity label: page (and critical) pages now; ticket (and warning) pages at high; info is quiet.@@@ -30,6 +39,12 @@ const NAME_RE = /^[A-Za-z][A-Za-z0-9_:]{0,99}$/;const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;const TEXTFILE_DIR = '/var/lib/prometheus/node-exporter';const MAX = 200;++const MAX_DETAILS = 5;++const MAX_TARGETS = 5;++const COMMIT_SHA_RE = /^[0-9a-f]{7,40}$/i;++const REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;++// The only hosts a link may point at, besides the network's own public domains (contracts' service manifests).++const LINK_HOSTS = new Set(['git.openvibe.codes', 'github.com', 'dequeuniversity.com']);class AlertsError extends Error {constructor(message, exitCode = 2) { super(message); this.name = 'AlertsError'; this.exitCode = exitCode; }@@@ -67,6 +82,139 @@ function toReport(promAlerts) {return [...out.values()].slice(0, MAX);}++// ── What a browser-check alert carries (the result file's facts, in text and https links) ──────────++++let domainsCache = null;++++/** The network's own public domains, from the service manifests; the only site hosts a link may use. */++function networkDomains(lib = null) {++ if (domainsCache) return domainsCache;++ let c = lib;++ if (!c) { try { c = require('openvibe-contracts'); } catch { c = null; } }++ const out = new Set();++ for (const m of (c && c.services && c.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. Every URL a notification shows passes through here. */++function safeLink(value, domains) {++ try {++ const u = new URL(String(value));++ if (u.protocol !== 'https:') return null;++ const host = u.hostname.toLowerCase();++ if (!LINK_HOSTS.has(host) && !domains.has(host)) return null;++ return u.href;++ } catch { return null; }++}++++/** The failing page on the site's own origin: https, a network domain, and never off that origin. */++function pageLink(origin, route, domains) {++ try {++ const base = new URL(String(origin));++ if (base.protocol !== 'https:' || !domains.has(base.hostname.toLowerCase())) return null;++ const u = new URL(String(route || '/'), base);++ return u.origin === base.origin ? u.href : null;++ } catch { return null; }++}++++/** The repository slug (OpenVibers/OpenVibe.Domains) from the service manifest, when it declares one. */++function serviceRepo(service, lib) {++ try {++ const m = lib && lib.services && typeof lib.services.get === 'function' ? lib.services.get(service) : null;++ return m && REPO_RE.test(String(m.repository || '')) ? String(m.repository) : null;++ } catch { return null; }++}++++/** The deployed commit's full sha: git in the service's checkout when it knows the short one from /release.json. */++async function fullCommit(exec, inv, service, short) {++ const s = String(short || '').toLowerCase();++ if (!COMMIT_SHA_RE.test(s)) return null;++ if (s.length === 40) return s;++ const svc = inv.services && inv.services[service];++ const dir = svc && svc.repo;++ if (!dir) return s;++ try {++ const r = await exec.run('git', ['-C', dir, '-c', `safe.directory=${dir}`, 'rev-parse', '--verify', `${s}^{commit}`], { as: svc.owner || 'root' });++ const out = String(r.stdout || '').trim().toLowerCase();++ return r.code === 0 && /^[0-9a-f]{40}$/.test(out) ? out : s;++ } catch { return s; }++}++++/** The commit page: git.openvibe.codes when the repository is mirrored there, else github.com. */++async function releaseLink(exec, repo, sha, cache = new Map()) {++ if (!repo || !COMMIT_SHA_RE.test(String(sha || ''))) return null;++ let forge = cache.get(repo);++ if (forge === undefined) {++ forge = false;++ try { forge = (await exec.request(`https://git.openvibe.codes/${repo}`, { timeoutMs: 3000 })).status === 200; } catch { forge = false; }++ cache.set(repo, forge);++ }++ return forge ? `https://git.openvibe.codes/${repo}/commit/${sha}` : `https://github.com/${repo}/commit/${sha}`;++}++++/** One result-file detail → the contract's optional fields, with only allowed https links. */++function browserDetail(d, origin, domains, axeVersion = null) {++ if (!d || typeof d !== 'object') return null;++ const check = clip(d.check, 40);++ if (!check) return null;++ const out = { check };++ const route = typeof d.route === 'string' ? clip(d.route, 200) : '';++ if (route) out.route = route;++ if (d.rule) out.rule = clip(d.rule, 100);++ if (['minor', 'moderate', 'serious', 'critical'].includes(d.impact)) out.impact = d.impact;++ if (Array.isArray(d.targets)) { const t = d.targets.slice(0, MAX_TARGETS).map((x) => clip(x, 160)).filter(Boolean); if (t.length) out.targets = t; }++ if (d.help) out.help = clip(d.help, 300);++ if (check === 'axe') {++ const help = safeLink(d.help_url, domains) || axeHelpUrl(d.rule, axeVersion);++ if (help) out.help_url = help;++ }++ const page = pageLink(origin, route, domains);++ if (page) out.page_url = page;++ if (d.message) out.message = clip(d.message, 300);++ if (Number.isInteger(d.width) && d.width >= 0 && d.width <= 10000) out.width = d.width;++ if (Number.isInteger(d.status) && d.status >= 100 && d.status <= 599) out.status = d.status;++ if (Array.isArray(d.offenders)) { const o = d.offenders.slice(0, MAX_TARGETS).map((x) => clip(x, 160)).filter(Boolean); if (o.length) out.offenders = o; }++ return out;++}++++/**++ * Add the browser-check result file's facts to the alerts that have one. A missing or stale file, an unreachable++ * Forgejo or a checkout without the commit never fails the relay: the alert stays what Prometheus said.++ */++async function enrichBrowser(ctx, alerts, { contracts: lib = null, domains = null, forgejo = new Map() } = {}) {++ const { exec, inv } = ctx;++ const libc = lib || (() => { try { return require('openvibe-contracts'); } catch { return null; } })();++ const allowed = domains || networkDomains(libc);++ const repos = new Map();++ for (const a of alerts) {++ if (a.name !== 'OpenVibeBrowserCheckFailed' || !a.service) continue;++ a.failing_since = a.started_at;++ try {++ let state = null;++ try { state = JSON.parse((await exec.readFile(path.join(inv.stateDir, 'browser-watch', `${a.service}.json`))) || 'null'); } catch { state = null; }++ if (!state || state.ok !== false) continue;++ const details = [];++ for (const d of (state.details || []).slice(0, MAX_DETAILS)) { const x = browserDetail(d, state.origin, allowed, state.axeVersion); if (x) details.push(x); }++ const sha = await fullCommit(exec, inv, a.service, state.release);++ if (sha) {++ a.release = sha;++ let repo = repos.get(a.service);++ if (repo === undefined) { repo = serviceRepo(a.service, libc); repos.set(a.service, repo); }++ const url = await releaseLink(exec, repo, sha, forgejo);++ if (url) a.release_url = url;++ }++ if (details.length) a.details = details;++ const checked = Date.parse(state.checkedAt);++ if (Number.isFinite(checked)) a.checked_at = new Date(checked).toISOString();++ } catch { /* the alert keeps what Prometheus said */ }++ }++ return alerts;++}++async function writeMetrics(exec, dir, r) {if (!(await exec.readdir(dir))) return false;const now = Math.floor(Date.now() / 1000);@@@ -109,7 +257,7 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRunif (!data || data.status !== 'success' || !data.data || !Array.isArray(data.data.alerts)) {return done({ ok: false, stage: 'prometheus', firing: null, error: p.status ? `Prometheus answered ${p.status}` : `Prometheus unreachable (${p.error || 'no answer'})` });}-- const alerts = toReport(data.data.alerts);++ const alerts = await enrichBrowser(ctx, toReport(data.data.alerts));const body = { source: 'prometheus', sent_at: new Date().toISOString(), alerts };if (dryRun) return { ok: true, dryRun: true, firing: alerts.length, alerts };@@@ -131,4 +279,4 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRunreturn done({ ok: true, firing: alerts.length, alerts, result });}--module.exports = { relay, toReport, fingerprint, writeMetrics, AlertsError, DEFAULT_PROMETHEUS };++module.exports = { relay, toReport, enrichBrowser, browserDetail, networkDomains, safeLink, fullCommit, releaseLink, fingerprint, writeMetrics, AlertsError, DEFAULT_PROMETHEUS };@@@ -24,6 +24,7 @@*/const path = require('path');const { runBrowserCheck } = require('./browser-check-hook');++const { axeHelpUrl } = require('./links');const RECHECK_FAILED_MS = 30 * 60 * 1000;const DAY_MS = 24 * 3600 * 1000;@@@ -56,21 +57,26 @@ async function currentRelease(exec, origin, { withAge = false } = {}) {/*** What failed, for the operator (kept in the state file, never in metrics): per route, the axe rule ids-- * with up to 3 targets, the first console errors, overflow and status. At most 20 entries, text clipped.++ * with up to 5 targets and the rule's help text and help page, the first console errors, overflow, duplicate++ * scripts, JSON-LD the page does not show and statuses. At most 20 entries, text clipped; the relay sends 5.*/function failureDetails(reports) {const out = [];const clip = (v, n = 200) => String(v == null ? '' : v).slice(0, n);++ const axeVersion = ((reports || []).find((r) => r && r.axe_version) || {}).axe_version || null;for (const r of reports || []) {for (const route of r.routes || []) {if (route.ok !== false) continue;-- for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 3).map((t) => clip(t, 160)) });++ for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 5).map((t) => clip(t, 160)), help: clip(v.help, 300), help_url: axeHelpUrl(v.id, axeVersion) });for (const e of route.errors || []) out.push({ route: route.path, check: 'errors', message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });for (const w of route.widths || []) {for (const e of (w.errors || []).slice(0, 3)) out.push({ route: route.path, check: 'errors', width: w.width, message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });-- if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 3).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });++ if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 5).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });++ for (const d of (w.duplicateScripts || []).slice(0, 3)) out.push({ route: route.path, check: 'scripts', width: w.width, message: clip(typeof d === 'string' ? d : d && d.url ? d.url : JSON.stringify(d), 300) });if (w.status && w.status >= 400 && !route.expectStatus) out.push({ route: route.path, check: 'status', width: w.width, status: w.status });}++ for (const e of (route.jsonld && route.jsonld.parseErrors) || []) out.push({ route: route.path, check: 'jsonld', message: clip(`does not parse: ${e}`, 300) });++ for (const e of (route.jsonld && route.jsonld.entities) || []) if (!e.found) out.push({ route: route.path, check: 'jsonld', message: clip(`${e.type || '?'}: ${e.value || e.field || ''}`, 300) });}}return out.slice(0, 20);@@@ -151,7 +157,8 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',}if (r.code >= 2 || !Array.isArray(reports)) { out.push({ site, release, action: 'error', reason: r.error || `the check did not run (exit ${r.code})` }); continue; }const failing = failingChecks(reports);-- const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), checkedAt: new Date(now).toISOString() };++ const axeVersion = (reports.find((x) => x && x.axe_version) || {}).axe_version || null;++ const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), ...(axeVersion ? { axeVersion } : {}), checkedAt: new Date(now).toISOString() };await exec.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o640 });out.push({ site, release, action: 'checked', ok: state.ok, failing });}nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++/**++ * Links built on the host for an operator alert (lib/alerts.js, lib/browser-watch.js).++ * The browser check keeps the axe rule's help text but not axe's helpUrl, so the page is built from the rule id++ * and the axe version the check ran (the same URL axe's helpUrl names), with a current-version fallback for a++ * result file written before the version was kept.++ */++const AXE_FALLBACK_VERSION = '4.13';++const RULE_RE = /^[A-Za-z0-9_-]{1,100}$/;++++/** The rule's Deque University page, or null when the rule id is not one. */++function axeHelpUrl(rule, version = null) {++ if (!RULE_RE.test(String(rule || ''))) return null;++ const v = (/^(\d+\.\d+)/.exec(String(version || '')) || [])[1] || AXE_FALLBACK_VERSION;++ return `https://dequeuniversity.com/rules/axe/${v}/${rule}?application=axeAPI`;++}++++module.exports = { axeHelpUrl, AXE_FALLBACK_VERSION };@@@ -15,7 +15,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.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",@@@ -1615,9 +1615,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.384.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",-- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",++ "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",@@@ -31,7 +31,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.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",@@@ -319,6 +319,8 @@ async function networkDown(h) {ignoreErrors: has('strict-errors') ? [] : KNOWN_ERRORS,});rep.id = site.id;++ // The result file keeps it, so a failure detail can build the rule's Deque help page.++ rep.axe_version = has('no-axe') ? null : h.AXE.version;reports.push(rep);}} finally { if (browser) await browser.close(); }@@@ -8,7 +8,7 @@const assert = require('assert');const contracts = require('openvibe-contracts');const { scenario, test, runTests, SECRET } = require('./helpers');--const { toReport, fingerprint } = require('../lib/alerts');++const { toReport, enrichBrowser, networkDomains, fingerprint } = require('../lib/alerts');const TOKEN = 'tok_ALERTS_TOKEN_MUST_NEVER_APPEAR_5d1e';const HOST_SECRET = `${SECRET}-host`;@@@ -125,6 +125,93 @@ runTests([assert.strictEqual(r.code, 2);assert.match(r.out, /ALERTS not delivered \(credentials\)/);}),++ // ── What a browser-check alert carries (WS-H task 11, the operator's explanation) ─────────────++ test('browser-check alert: the result file\'s details, the full release, page and help links, allowed hosts only', async () => {++ const host = scenario();++ const sha = '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b';++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({++ site: 'domains', origin: 'https://openvibe.domains', release: sha, ok: false, failing: [{ check: 'axe', fail: 1 }], axeVersion: '4.13.0',++ checkedAt: '2026-11-10T09:12:00Z',++ details: [++ { route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'], help: 'Ensures the summary element has a non-empty accessible name', help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI' },++ { route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },++ ],++ }));++ const a = { fingerprint: 'a'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s))', service: 'domains', started_at: '2026-11-10T09:12:30Z' };++ const [out] = await enrichBrowser(host.ctx(), [a], { domains: networkDomains() });++ assert.strictEqual(out.release, sha, 'the full release sha');++ assert.strictEqual(out.release_url, `https://github.com/OpenVibers/OpenVibe.Domains/commit/${sha}`, 'GitHub when Forgejo does not answer');++ assert.strictEqual(out.checked_at, '2026-11-10T09:12:00.000Z');++ assert.strictEqual(out.failing_since, '2026-11-10T09:12:30Z');++ assert.deepStrictEqual(out.details[0], { check: 'axe', route: '/', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'],++ help: 'Ensures the summary element has a non-empty accessible name', help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI', page_url: 'https://openvibe.domains/' });++ assert.deepStrictEqual(out.details[1], { check: 'errors', route: '/', width: 390, message: 'WebSocket closed', page_url: 'https://openvibe.domains/' });++ const v = contracts.validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [out] });++ assert.ok(v.valid, JSON.stringify(v.errors));++ }),++ test('browser-check alert: Forgejo when the repository is mirrored there; an off-host page or http help link is dropped', async () => {++ const host = scenario();++ host.http.set('https://git.openvibe.codes/OpenVibers/OpenVibe.Domains', () => ({ status: 200, body: 'ok' }));++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({++ site: 'domains', origin: 'https://openvibe.domains', release: '74bdff4', ok: false, axeVersion: '4.13.0', checkedAt: '2026-11-10T09:12:00Z',++ details: [++ { route: '//evil.example/x', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#a'], help: 'x', help_url: 'http://dequeuniversity.com/rules/axe/4.13/summary-name' },++ { route: '/two', check: 'axe', rule: 'not a rule!', targets: ['#b'] },++ ],++ }));++ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'b'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains failed', service: 'domains', started_at: '2026-11-10T09:12:30Z' }], { domains: networkDomains() });++ assert.strictEqual(out.release_url, 'https://git.openvibe.codes/OpenVibers/OpenVibe.Domains/commit/74bdff4', 'the network own Forgejo when the mirror answers');++ assert.strictEqual(out.details[0].page_url, undefined, 'a route that escapes the origin gets no link');++ assert.strictEqual(out.details[0].help_url, 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI', 'an http helpUrl is replaced by the https Deque page');++ assert.strictEqual(out.details[1].help_url, undefined, 'an unknown rule id gets no made-up URL');++ }),++ test('browser-check alert: the short release in /release.json becomes the full commit from the checkout; 5 details, 5 targets', async () => {++ const host = scenario();++ const repo = host.createRepo('/opt/openvibe.domains', { owner: 'ubuntu' });++ const sha = repo.commit({ 'package.json': '{}' });++ repo.publish(sha); repo.checkout(sha);++ host.inv.services.domains = { repo: '/opt/openvibe.domains', owner: 'ubuntu' };++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({++ site: 'domains', origin: 'https://openvibe.domains', release: sha.slice(0, 12), ok: false, checkedAt: '2026-11-10T09:12:00Z',++ details: Array.from({ length: 7 }, (_, i) => ({ route: `/${i}`, check: 'axe', rule: 'summary-name', targets: Array.from({ length: 7 }, (_, j) => `#t${i}-${j}`) })),++ }));++ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'c'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains failed', service: 'domains', started_at: '2026-11-10T09:12:30Z' }], { domains: networkDomains() });++ assert.strictEqual(out.release, sha);++ assert.strictEqual(out.release_url, `https://github.com/OpenVibers/OpenVibe.Domains/commit/${sha}`);++ assert.strictEqual(out.details.length, 5, 'at most 5 details');++ assert.strictEqual(out.details[0].targets.length, 5, 'at most 5 targets');++ }),++ test('a Prometheus alert without a result file is sent unchanged (no details, no release)', async () => {++ const host = scenario();++ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'd'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z' }], { domains: networkDomains() });++ assert.deepStrictEqual(out, { fingerprint: 'd'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z', failing_since: '2026-09-26T05:00:00Z' });++ assert.ok(contracts.validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [out] }).valid);++ const host2 = scenario();++ host2.put('/var/lib/openvibe-host/browser-watch/live.json', JSON.stringify({ site: 'live', ok: true, release: 'abc1234', checkedAt: '2026-09-26T04:00:00Z' }));++ const [pass] = await enrichBrowser(host2.ctx(), [{ fingerprint: 'e'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z' }], { domains: networkDomains() });++ assert.strictEqual(pass.details, undefined, 'a stale pass is not used for a firing alert');++ }),++ test('alerts relay delivers a browser-check alert with what the result file knows', async () => {++ const sha = '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b';++ const host = withNetwork({ prom: { status: 200, body: { status: 'success', data: { alerts: [{++ labels: { alertname: 'OpenVibeBrowserCheckFailed', severity: 'page', service: 'domains' }, state: 'firing', activeAt: '2026-11-10T09:12:30Z',++ annotations: { summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s))' },++ }] } } } });++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({++ site: 'domains', origin: 'https://openvibe.domains', release: sha, ok: false, checkedAt: '2026-11-10T09:12:00Z',++ details: [{ route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'], help: 'Ensures the summary element has a non-empty accessible name' }],++ }));++ const r = await host.cli('alerts', 'relay');++ assert.strictEqual(r.code, 0, r.out);++ const doc = host.delivered[0];++ assert.ok(contracts.validate('network.operator-alerts-request@1', doc).valid);++ const a = doc.alerts[0];++ assert.deepStrictEqual([a.service, a.release, a.checked_at, a.failing_since], ['domains', sha, '2026-11-10T09:12:00.000Z', '2026-11-10T09:12:30.000Z']);++ assert.strictEqual(a.details.length, 1);++ assert.strictEqual(a.details[0].page_url, 'https://openvibe.domains/');++ assert.strictEqual(a.details[0].help_url, 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI');++ noSecrets(host, r.out);++ }),test('alerts: usage', async () => {const r = await scenario().cli('alerts');assert.notStrictEqual(r.code, 0);@@@ -12,8 +12,8 @@ const { runBrowserCheck } = require('../lib/browser-check-hook');const PROM = '/var/lib/prometheus/node-exporter/openvibe_browsercheck.prom';const pass = [{ base: 'https://openvibe.live', ok: true, summary: { checks: { status: { fail: 0 }, console: { fail: 0 } } } }];--const fail = [{ base: 'https://openvibe.live', ok: false, summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },-- routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'] }] }, { path: '/ok', ok: true }] }];++const fail = [{ base: 'https://openvibe.live', ok: false, axe_version: '4.13.0', summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },++ routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', help: 'Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'], duplicateScripts: [{ url: 'https://openvibe.live/js/app.js?v=1', count: 2 }] }] }, { path: '/ok', ok: true }] }];function site({ release = 'abc1234def56' } = {}) {const host = scenario();@@@ -58,10 +58,12 @@ runTests([assert.match(host.files.get(PROM).content, /^openvibe_browser_check_failing_checks\{service="live"\} 2$/m);const st = JSON.parse(host.files.get('/var/lib/openvibe-host/browser-watch/live.json').content);assert.deepStrictEqual(st.details, [-- { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c'] },++ { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'], help: 'Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds', help_url: 'https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=axeAPI' },{ route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },{ route: '/', check: 'overflow', width: 390, offenders: ['.wide'] },++ { route: '/', check: 'scripts', width: 390, message: 'https://openvibe.live/js/app.js?v=1' },], 'what failed is kept for the operator');++ assert.strictEqual(st.axeVersion, '4.13.0', 'the relay can build a help page for a detail written before the version was kept');// The same failed release: left alone for 30 minutes, then checked again (a transient failure clears itself).const t0 = Date.parse(st.checkedAt);r = await watch(host.ctx(), { retryDelayMs: 0, check: host.check, now: t0 + 10 * 60000 });
@@ -392,7 +392,7 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvi
@@ -392,7 +392,7 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvi
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.384.0
+- openvibe-contracts: v0.399.0
- openvibe-sdk: v0.125.0
- openvibe-sdk: v0.125.0
- openvibe-shared: v4.199.0
- openvibe-shared: v4.199.0
- openvibe-publishing: v1.7.0
- openvibe-publishing: v1.7.0
@@ -13,11 +13,11 @@
@@ -13,11 +13,11 @@
"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"port": 4910,
"port": 4910,
"domain": "openvibe.host",
"domain": "openvibe.host",
- "contracts": "openvibe-contracts v0.384.0",
+ "contracts": "openvibe-contracts v0.399.0",
"sdk": "openvibe-sdk v0.125.0",
"sdk": "openvibe-sdk v0.125.0",
"shared": "openvibe-shared v4.199.0",
"shared": "openvibe-shared v4.199.0",
"packages": [
"packages": [
- "openvibe-contracts v0.384.0",
+ "openvibe-contracts v0.399.0",
"openvibe-sdk v0.125.0",
"openvibe-sdk v0.125.0",
"openvibe-shared v4.199.0"
"openvibe-shared v4.199.0"
],
],
@@ -26,8 +26,19 @@ Prometheus (127.0.0.1:9090) ──GET /api/v1/alerts──▶ ovhost alerts re
@@ -26,8 +26,19 @@ Prometheus (127.0.0.1:9090) ──GET /api/v1/alerts──▶ ovhost alerts re
- **Reminded**: the alert pages again once a day while it stays open.
- **Reminded**: the alert pages again once a day while it stays open.
- **Resolved**: a notice goes out when the alert drops out of the firing set.
- **Resolved**: a notice goes out when the alert drops out of the firing set.
- **Recipients**: `OWNER_USERNAME` in Network's environment, plus the comma-separated `OPERATOR_ALERT_USERNAMES`.
- **Recipients**: `OWNER_USERNAME` in Network's environment, plus the comma-separated `OPERATOR_ALERT_USERNAMES`.
-- **What leaves the host**: only the alert name, severity, summary, description, `service` label and start time.
+- **What leaves the host**: the alert name, severity, summary, description, `service` label and start time.
Instance and job labels stay on the host; the fingerprint is a hash of the labels.
Instance and job labels stay on the host; the fingerprint is a hash of the labels.
+- **A browser-check alert says what failed**: `OpenVibeBrowserCheckFailed` also carries up to five `details`
+ from `/var/lib/openvibe-host/browser-watch/<site>.json` (the route, the check, the axe rule and impact, up to
+ five target selectors, the rule's help text and Deque University page, the failing page's own URL, a console
+ error, a viewport width, a status, overflow offenders), the release sha resolved to the full commit, the
+ commit's page (`git.openvibe.codes` when the repository is mirrored there, else `github.com`), when the check
+ ran and since when the alert has been failing. Network turns them into the notification's sentence, a facts
+ table, action links and a next step.
+- **Links are built on the host and allowlisted**: https only, and the host must be one of the network's own
+ domains (from the contracts service manifests), `git.openvibe.codes`, `github.com` or
+ `dequeuniversity.com`; anything else is dropped. Every string is text and is escaped where a notification
+ shows it.
- **Pending alerts** (inside their `for:` window) are not sent.
- **Pending alerts** (inside their `for:` window) are not sent.
- **Prometheus unreachable**: the relay sends nothing, because an empty set would resolve every open alert.
- **Prometheus unreachable**: the relay sends nothing, because an empty set would resolve every open alert.
- **Relay failures**: `openvibe_alert_relay_last_run_ok` and `openvibe_alert_relay_last_success_timestamp_seconds`
- **Relay failures**: `openvibe_alert_relay_last_run_ok` and `openvibe_alert_relay_last_success_timestamp_seconds`
@@ -63,7 +74,9 @@ The release is its own series (`openvibe_browser_check_release_info`), so a fail
@@ -63,7 +74,9 @@ The release is its own series (`openvibe_browser_check_release_info`), so a fail
- **A settle window:** a release is judged once its process has run 3 minutes (`/release.json` `booted_at`). This stops a restart's reconnecting sockets from paging.
- **A settle window:** a release is judged once its process has run 3 minutes (`/release.json` `booted_at`). This stops a restart's reconnecting sockets from paging.
- **Confirmation:** the confirming second run of a failure waits a minute.
- **Confirmation:** the confirming second run of a failure waits a minute.
-- **What failed:** the axe rules and targets, console errors, overflow and statuses are in `/var/lib/openvibe-host/browser-watch/<site>.json` under `details`.
+- **What failed:** the axe rules (with their help text and Deque help page), targets, console errors, overflow
+ offenders, duplicate scripts, JSON-LD the page does not show and statuses are in
+ `/var/lib/openvibe-host/browser-watch/<site>.json` under `details`; the relay sends the first five to Network.
## Commands
## Commands
@@ -14,6 +14,14 @@
@@ -14,6 +14,14 @@
* = the rule's annotations, service = the service label when it is a service id, started_at = activeAt.
* = the rule's annotations, service = the service label when it is a service id, started_at = activeAt.
* Free-form labels never leave the host.
* Free-form labels never leave the host.
*
*
+ * A browser-check alert (`OpenVibeBrowserCheckFailed`) carries what its result file knows, so the notification can
+ * explain it in words instead of repeating the rule: up to five `details` (route, check, axe rule and impact, at
+ * most five target selectors, the rule's help text and Deque help page, the failing page's own URL, a console
+ * error, a viewport width, a status, overflow offenders), the release sha (resolved to the full commit when the
+ * service's checkout has it) and `release_url`, `checked_at` and `failing_since`. Links are built on the host from
+ * facts it has and are accepted only when https on one of the network's own domains, git.openvibe.codes, github.com
+ * or dequeuniversity.com. A plain Prometheus alert carries only its annotations, as before.
+ *
* A Prometheus that does not answer sends nothing: an empty set would resolve every open alert. Each run
* A Prometheus that does not answer sends nothing: an empty set would resolve every open alert. Each run
* writes openvibe_alert_relay.prom to the textfile collector (last run, last success, alerts firing).
* writes openvibe_alert_relay.prom to the textfile collector (last run, last success, alerts firing).
* Credentials: Host's principal from /etc/openvibe/host.env (announce.js loadCredentials; the secret and the
* Credentials: Host's principal from /etc/openvibe/host.env (announce.js loadCredentials; the secret and the
@@ -22,6 +30,7 @@
@@ -22,6 +30,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const path = require('path');
const path = require('path');
const { loadCredentials } = require('./announce');
const { loadCredentials } = require('./announce');
+const { axeHelpUrl } = require('./links');
const DEFAULT_PROMETHEUS = 'http://127.0.0.1:9090';
const DEFAULT_PROMETHEUS = 'http://127.0.0.1:9090';
// The rules' severity label: page (and critical) pages now; ticket (and warning) pages at high; info is quiet.
// The rules' severity label: page (and critical) pages now; ticket (and warning) pages at high; info is quiet.
@@ -30,6 +39,12 @@ const NAME_RE = /^[A-Za-z][A-Za-z0-9_:]{0,99}$/;
@@ -30,6 +39,12 @@ const NAME_RE = /^[A-Za-z][A-Za-z0-9_:]{0,99}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const TEXTFILE_DIR = '/var/lib/prometheus/node-exporter';
const TEXTFILE_DIR = '/var/lib/prometheus/node-exporter';
const MAX = 200;
const MAX = 200;
+const MAX_DETAILS = 5;
+const MAX_TARGETS = 5;
+const COMMIT_SHA_RE = /^[0-9a-f]{7,40}$/i;
+const REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
+// The only hosts a link may point at, besides the network's own public domains (contracts' service manifests).
+const LINK_HOSTS = new Set(['git.openvibe.codes', 'github.com', 'dequeuniversity.com']);
class AlertsError extends Error {
class AlertsError extends Error {
constructor(message, exitCode = 2) { super(message); this.name = 'AlertsError'; this.exitCode = exitCode; }
constructor(message, exitCode = 2) { super(message); this.name = 'AlertsError'; this.exitCode = exitCode; }
@@ -67,6 +82,139 @@ function toReport(promAlerts) {
@@ -67,6 +82,139 @@ function toReport(promAlerts) {
return [...out.values()].slice(0, MAX);
return [...out.values()].slice(0, MAX);
}
}
+// ── What a browser-check alert carries (the result file's facts, in text and https links) ──────────
+
+let domainsCache = null;
+
+/** The network's own public domains, from the service manifests; the only site hosts a link may use. */
+function networkDomains(lib = null) {
+ if (domainsCache) return domainsCache;
+ let c = lib;
+ if (!c) { try { c = require('openvibe-contracts'); } catch { c = null; } }
+ const out = new Set();
+ for (const m of (c && c.services && c.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. Every URL a notification shows passes through here. */
+function safeLink(value, domains) {
+ try {
+ const u = new URL(String(value));
+ if (u.protocol !== 'https:') return null;
+ const host = u.hostname.toLowerCase();
+ if (!LINK_HOSTS.has(host) && !domains.has(host)) return null;
+ return u.href;
+ } catch { return null; }
+}
+
+/** The failing page on the site's own origin: https, a network domain, and never off that origin. */
+function pageLink(origin, route, domains) {
+ try {
+ const base = new URL(String(origin));
+ if (base.protocol !== 'https:' || !domains.has(base.hostname.toLowerCase())) return null;
+ const u = new URL(String(route || '/'), base);
+ return u.origin === base.origin ? u.href : null;
+ } catch { return null; }
+}
+
+/** The repository slug (OpenVibers/OpenVibe.Domains) from the service manifest, when it declares one. */
+function serviceRepo(service, lib) {
+ try {
+ const m = lib && lib.services && typeof lib.services.get === 'function' ? lib.services.get(service) : null;
+ return m && REPO_RE.test(String(m.repository || '')) ? String(m.repository) : null;
+ } catch { return null; }
+}
+
+/** The deployed commit's full sha: git in the service's checkout when it knows the short one from /release.json. */
+async function fullCommit(exec, inv, service, short) {
+ const s = String(short || '').toLowerCase();
+ if (!COMMIT_SHA_RE.test(s)) return null;
+ if (s.length === 40) return s;
+ const svc = inv.services && inv.services[service];
+ const dir = svc && svc.repo;
+ if (!dir) return s;
+ try {
+ const r = await exec.run('git', ['-C', dir, '-c', `safe.directory=${dir}`, 'rev-parse', '--verify', `${s}^{commit}`], { as: svc.owner || 'root' });
+ const out = String(r.stdout || '').trim().toLowerCase();
+ return r.code === 0 && /^[0-9a-f]{40}$/.test(out) ? out : s;
+ } catch { return s; }
+}
+
+/** The commit page: git.openvibe.codes when the repository is mirrored there, else github.com. */
+async function releaseLink(exec, repo, sha, cache = new Map()) {
+ if (!repo || !COMMIT_SHA_RE.test(String(sha || ''))) return null;
+ let forge = cache.get(repo);
+ if (forge === undefined) {
+ forge = false;
+ try { forge = (await exec.request(`https://git.openvibe.codes/${repo}`, { timeoutMs: 3000 })).status === 200; } catch { forge = false; }
+ cache.set(repo, forge);
+ }
+ return forge ? `https://git.openvibe.codes/${repo}/commit/${sha}` : `https://github.com/${repo}/commit/${sha}`;
+}
+
+/** One result-file detail → the contract's optional fields, with only allowed https links. */
+function browserDetail(d, origin, domains, axeVersion = null) {
+ if (!d || typeof d !== 'object') return null;
+ const check = clip(d.check, 40);
+ if (!check) return null;
+ const out = { check };
+ const route = typeof d.route === 'string' ? clip(d.route, 200) : '';
+ if (route) out.route = route;
+ if (d.rule) out.rule = clip(d.rule, 100);
+ if (['minor', 'moderate', 'serious', 'critical'].includes(d.impact)) out.impact = d.impact;
+ if (Array.isArray(d.targets)) { const t = d.targets.slice(0, MAX_TARGETS).map((x) => clip(x, 160)).filter(Boolean); if (t.length) out.targets = t; }
+ if (d.help) out.help = clip(d.help, 300);
+ if (check === 'axe') {
+ const help = safeLink(d.help_url, domains) || axeHelpUrl(d.rule, axeVersion);
+ if (help) out.help_url = help;
+ }
+ const page = pageLink(origin, route, domains);
+ if (page) out.page_url = page;
+ if (d.message) out.message = clip(d.message, 300);
+ if (Number.isInteger(d.width) && d.width >= 0 && d.width <= 10000) out.width = d.width;
+ if (Number.isInteger(d.status) && d.status >= 100 && d.status <= 599) out.status = d.status;
+ if (Array.isArray(d.offenders)) { const o = d.offenders.slice(0, MAX_TARGETS).map((x) => clip(x, 160)).filter(Boolean); if (o.length) out.offenders = o; }
+ return out;
+}
+
+/**
+ * Add the browser-check result file's facts to the alerts that have one. A missing or stale file, an unreachable
+ * Forgejo or a checkout without the commit never fails the relay: the alert stays what Prometheus said.
+ */
+async function enrichBrowser(ctx, alerts, { contracts: lib = null, domains = null, forgejo = new Map() } = {}) {
+ const { exec, inv } = ctx;
+ const libc = lib || (() => { try { return require('openvibe-contracts'); } catch { return null; } })();
+ const allowed = domains || networkDomains(libc);
+ const repos = new Map();
+ for (const a of alerts) {
+ if (a.name !== 'OpenVibeBrowserCheckFailed' || !a.service) continue;
+ a.failing_since = a.started_at;
+ try {
+ let state = null;
+ try { state = JSON.parse((await exec.readFile(path.join(inv.stateDir, 'browser-watch', `${a.service}.json`))) || 'null'); } catch { state = null; }
+ if (!state || state.ok !== false) continue;
+ const details = [];
+ for (const d of (state.details || []).slice(0, MAX_DETAILS)) { const x = browserDetail(d, state.origin, allowed, state.axeVersion); if (x) details.push(x); }
+ const sha = await fullCommit(exec, inv, a.service, state.release);
+ if (sha) {
+ a.release = sha;
+ let repo = repos.get(a.service);
+ if (repo === undefined) { repo = serviceRepo(a.service, libc); repos.set(a.service, repo); }
+ const url = await releaseLink(exec, repo, sha, forgejo);
+ if (url) a.release_url = url;
+ }
+ if (details.length) a.details = details;
+ const checked = Date.parse(state.checkedAt);
+ if (Number.isFinite(checked)) a.checked_at = new Date(checked).toISOString();
+ } catch { /* the alert keeps what Prometheus said */ }
+ }
+ return alerts;
+}
+
async function writeMetrics(exec, dir, r) {
async function writeMetrics(exec, dir, r) {
if (!(await exec.readdir(dir))) return false;
if (!(await exec.readdir(dir))) return false;
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
@@ -109,7 +257,7 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRun
@@ -109,7 +257,7 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRun
if (!data || data.status !== 'success' || !data.data || !Array.isArray(data.data.alerts)) {
if (!data || data.status !== 'success' || !data.data || !Array.isArray(data.data.alerts)) {
return done({ ok: false, stage: 'prometheus', firing: null, error: p.status ? `Prometheus answered ${p.status}` : `Prometheus unreachable (${p.error || 'no answer'})` });
return done({ ok: false, stage: 'prometheus', firing: null, error: p.status ? `Prometheus answered ${p.status}` : `Prometheus unreachable (${p.error || 'no answer'})` });
}
}
- const alerts = toReport(data.data.alerts);
+ const alerts = await enrichBrowser(ctx, toReport(data.data.alerts));
const body = { source: 'prometheus', sent_at: new Date().toISOString(), alerts };
const body = { source: 'prometheus', sent_at: new Date().toISOString(), alerts };
if (dryRun) return { ok: true, dryRun: true, firing: alerts.length, alerts };
if (dryRun) return { ok: true, dryRun: true, firing: alerts.length, alerts };
@@ -131,4 +279,4 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRun
@@ -131,4 +279,4 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRun
return done({ ok: true, firing: alerts.length, alerts, result });
return done({ ok: true, firing: alerts.length, alerts, result });
}
}
-module.exports = { relay, toReport, fingerprint, writeMetrics, AlertsError, DEFAULT_PROMETHEUS };
+module.exports = { relay, toReport, enrichBrowser, browserDetail, networkDomains, safeLink, fullCommit, releaseLink, fingerprint, writeMetrics, AlertsError, DEFAULT_PROMETHEUS };
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
*/
*/
const path = require('path');
const path = require('path');
const { runBrowserCheck } = require('./browser-check-hook');
const { runBrowserCheck } = require('./browser-check-hook');
+const { axeHelpUrl } = require('./links');
const RECHECK_FAILED_MS = 30 * 60 * 1000;
const RECHECK_FAILED_MS = 30 * 60 * 1000;
const DAY_MS = 24 * 3600 * 1000;
const DAY_MS = 24 * 3600 * 1000;
@@ -56,21 +57,26 @@ async function currentRelease(exec, origin, { withAge = false } = {}) {
@@ -56,21 +57,26 @@ async function currentRelease(exec, origin, { withAge = false } = {}) {
/**
/**
* What failed, for the operator (kept in the state file, never in metrics): per route, the axe rule ids
* What failed, for the operator (kept in the state file, never in metrics): per route, the axe rule ids
- * with up to 3 targets, the first console errors, overflow and status. At most 20 entries, text clipped.
+ * with up to 5 targets and the rule's help text and help page, the first console errors, overflow, duplicate
+ * scripts, JSON-LD the page does not show and statuses. At most 20 entries, text clipped; the relay sends 5.
*/
*/
function failureDetails(reports) {
function failureDetails(reports) {
const out = [];
const out = [];
const clip = (v, n = 200) => String(v == null ? '' : v).slice(0, n);
const clip = (v, n = 200) => String(v == null ? '' : v).slice(0, n);
+ const axeVersion = ((reports || []).find((r) => r && r.axe_version) || {}).axe_version || null;
for (const r of reports || []) {
for (const r of reports || []) {
for (const route of r.routes || []) {
for (const route of r.routes || []) {
if (route.ok !== false) continue;
if (route.ok !== false) continue;
- for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 3).map((t) => clip(t, 160)) });
+ for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 5).map((t) => clip(t, 160)), help: clip(v.help, 300), help_url: axeHelpUrl(v.id, axeVersion) });
for (const e of route.errors || []) out.push({ route: route.path, check: 'errors', message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
for (const e of route.errors || []) out.push({ route: route.path, check: 'errors', message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
for (const w of route.widths || []) {
for (const w of route.widths || []) {
for (const e of (w.errors || []).slice(0, 3)) out.push({ route: route.path, check: 'errors', width: w.width, message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
for (const e of (w.errors || []).slice(0, 3)) out.push({ route: route.path, check: 'errors', width: w.width, message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
- if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 3).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });
+ if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 5).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });
+ for (const d of (w.duplicateScripts || []).slice(0, 3)) out.push({ route: route.path, check: 'scripts', width: w.width, message: clip(typeof d === 'string' ? d : d && d.url ? d.url : JSON.stringify(d), 300) });
if (w.status && w.status >= 400 && !route.expectStatus) out.push({ route: route.path, check: 'status', width: w.width, status: w.status });
if (w.status && w.status >= 400 && !route.expectStatus) out.push({ route: route.path, check: 'status', width: w.width, status: w.status });
}
}
+ for (const e of (route.jsonld && route.jsonld.parseErrors) || []) out.push({ route: route.path, check: 'jsonld', message: clip(`does not parse: ${e}`, 300) });
+ for (const e of (route.jsonld && route.jsonld.entities) || []) if (!e.found) out.push({ route: route.path, check: 'jsonld', message: clip(`${e.type || '?'}: ${e.value || e.field || ''}`, 300) });
}
}
}
}
return out.slice(0, 20);
return out.slice(0, 20);
@@ -151,7 +157,8 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',
@@ -151,7 +157,8 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',
}
}
if (r.code >= 2 || !Array.isArray(reports)) { out.push({ site, release, action: 'error', reason: r.error || `the check did not run (exit ${r.code})` }); continue; }
if (r.code >= 2 || !Array.isArray(reports)) { out.push({ site, release, action: 'error', reason: r.error || `the check did not run (exit ${r.code})` }); continue; }
const failing = failingChecks(reports);
const failing = failingChecks(reports);
- const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), checkedAt: new Date(now).toISOString() };
+ const axeVersion = (reports.find((x) => x && x.axe_version) || {}).axe_version || null;
+ const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), ...(axeVersion ? { axeVersion } : {}), checkedAt: new Date(now).toISOString() };
await exec.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o640 });
await exec.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o640 });
out.push({ site, release, action: 'checked', ok: state.ok, failing });
out.push({ site, release, action: 'checked', ok: state.ok, failing });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+/**
+ * Links built on the host for an operator alert (lib/alerts.js, lib/browser-watch.js).
+ * The browser check keeps the axe rule's help text but not axe's helpUrl, so the page is built from the rule id
+ * and the axe version the check ran (the same URL axe's helpUrl names), with a current-version fallback for a
+ * result file written before the version was kept.
+ */
+const AXE_FALLBACK_VERSION = '4.13';
+const RULE_RE = /^[A-Za-z0-9_-]{1,100}$/;
+
+/** The rule's Deque University page, or null when the rule id is not one. */
+function axeHelpUrl(rule, version = null) {
+ if (!RULE_RE.test(String(rule || ''))) return null;
+ const v = (/^(\d+\.\d+)/.exec(String(version || '')) || [])[1] || AXE_FALLBACK_VERSION;
+ return `https://dequeuniversity.com/rules/axe/${v}/${rule}?application=axeAPI`;
+}
+
+module.exports = { axeHelpUrl, AXE_FALLBACK_VERSION };
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.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",
@@ -1615,9 +1615,9 @@
@@ -1615,9 +1615,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.384.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",
+ "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",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.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",
@@ -319,6 +319,8 @@ async function networkDown(h) {
@@ -319,6 +319,8 @@ async function networkDown(h) {
ignoreErrors: has('strict-errors') ? [] : KNOWN_ERRORS,
ignoreErrors: has('strict-errors') ? [] : KNOWN_ERRORS,
});
});
rep.id = site.id;
rep.id = site.id;
+ // The result file keeps it, so a failure detail can build the rule's Deque help page.
+ rep.axe_version = has('no-axe') ? null : h.AXE.version;
reports.push(rep);
reports.push(rep);
}
}
} finally { if (browser) await browser.close(); }
} finally { if (browser) await browser.close(); }
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
const assert = require('assert');
const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { scenario, test, runTests, SECRET } = require('./helpers');
const { scenario, test, runTests, SECRET } = require('./helpers');
-const { toReport, fingerprint } = require('../lib/alerts');
+const { toReport, enrichBrowser, networkDomains, fingerprint } = require('../lib/alerts');
const TOKEN = 'tok_ALERTS_TOKEN_MUST_NEVER_APPEAR_5d1e';
const TOKEN = 'tok_ALERTS_TOKEN_MUST_NEVER_APPEAR_5d1e';
const HOST_SECRET = `${SECRET}-host`;
const HOST_SECRET = `${SECRET}-host`;
@@ -125,6 +125,93 @@ runTests([
@@ -125,6 +125,93 @@ runTests([
assert.strictEqual(r.code, 2);
assert.strictEqual(r.code, 2);
assert.match(r.out, /ALERTS not delivered \(credentials\)/);
assert.match(r.out, /ALERTS not delivered \(credentials\)/);
}),
}),
+ // ── What a browser-check alert carries (WS-H task 11, the operator's explanation) ─────────────
+ test('browser-check alert: the result file\'s details, the full release, page and help links, allowed hosts only', async () => {
+ const host = scenario();
+ const sha = '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b';
+ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
+ site: 'domains', origin: 'https://openvibe.domains', release: sha, ok: false, failing: [{ check: 'axe', fail: 1 }], axeVersion: '4.13.0',
+ checkedAt: '2026-11-10T09:12:00Z',
+ details: [
+ { route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'], help: 'Ensures the summary element has a non-empty accessible name', help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI' },
+ { route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },
+ ],
+ }));
+ const a = { fingerprint: 'a'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s))', service: 'domains', started_at: '2026-11-10T09:12:30Z' };
+ const [out] = await enrichBrowser(host.ctx(), [a], { domains: networkDomains() });
+ assert.strictEqual(out.release, sha, 'the full release sha');
+ assert.strictEqual(out.release_url, `https://github.com/OpenVibers/OpenVibe.Domains/commit/${sha}`, 'GitHub when Forgejo does not answer');
+ assert.strictEqual(out.checked_at, '2026-11-10T09:12:00.000Z');
+ assert.strictEqual(out.failing_since, '2026-11-10T09:12:30Z');
+ assert.deepStrictEqual(out.details[0], { check: 'axe', route: '/', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'],
+ help: 'Ensures the summary element has a non-empty accessible name', help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI', page_url: 'https://openvibe.domains/' });
+ assert.deepStrictEqual(out.details[1], { check: 'errors', route: '/', width: 390, message: 'WebSocket closed', page_url: 'https://openvibe.domains/' });
+ const v = contracts.validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [out] });
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ }),
+ test('browser-check alert: Forgejo when the repository is mirrored there; an off-host page or http help link is dropped', async () => {
+ const host = scenario();
+ host.http.set('https://git.openvibe.codes/OpenVibers/OpenVibe.Domains', () => ({ status: 200, body: 'ok' }));
+ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
+ site: 'domains', origin: 'https://openvibe.domains', release: '74bdff4', ok: false, axeVersion: '4.13.0', checkedAt: '2026-11-10T09:12:00Z',
+ details: [
+ { route: '//evil.example/x', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#a'], help: 'x', help_url: 'http://dequeuniversity.com/rules/axe/4.13/summary-name' },
+ { route: '/two', check: 'axe', rule: 'not a rule!', targets: ['#b'] },
+ ],
+ }));
+ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'b'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains failed', service: 'domains', started_at: '2026-11-10T09:12:30Z' }], { domains: networkDomains() });
+ assert.strictEqual(out.release_url, 'https://git.openvibe.codes/OpenVibers/OpenVibe.Domains/commit/74bdff4', 'the network own Forgejo when the mirror answers');
+ assert.strictEqual(out.details[0].page_url, undefined, 'a route that escapes the origin gets no link');
+ assert.strictEqual(out.details[0].help_url, 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI', 'an http helpUrl is replaced by the https Deque page');
+ assert.strictEqual(out.details[1].help_url, undefined, 'an unknown rule id gets no made-up URL');
+ }),
+ test('browser-check alert: the short release in /release.json becomes the full commit from the checkout; 5 details, 5 targets', async () => {
+ const host = scenario();
+ const repo = host.createRepo('/opt/openvibe.domains', { owner: 'ubuntu' });
+ const sha = repo.commit({ 'package.json': '{}' });
+ repo.publish(sha); repo.checkout(sha);
+ host.inv.services.domains = { repo: '/opt/openvibe.domains', owner: 'ubuntu' };
+ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
+ site: 'domains', origin: 'https://openvibe.domains', release: sha.slice(0, 12), ok: false, checkedAt: '2026-11-10T09:12:00Z',
+ details: Array.from({ length: 7 }, (_, i) => ({ route: `/${i}`, check: 'axe', rule: 'summary-name', targets: Array.from({ length: 7 }, (_, j) => `#t${i}-${j}`) })),
+ }));
+ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'c'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains failed', service: 'domains', started_at: '2026-11-10T09:12:30Z' }], { domains: networkDomains() });
+ assert.strictEqual(out.release, sha);
+ assert.strictEqual(out.release_url, `https://github.com/OpenVibers/OpenVibe.Domains/commit/${sha}`);
+ assert.strictEqual(out.details.length, 5, 'at most 5 details');
+ assert.strictEqual(out.details[0].targets.length, 5, 'at most 5 targets');
+ }),
+ test('a Prometheus alert without a result file is sent unchanged (no details, no release)', async () => {
+ const host = scenario();
+ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'd'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z' }], { domains: networkDomains() });
+ assert.deepStrictEqual(out, { fingerprint: 'd'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z', failing_since: '2026-09-26T05:00:00Z' });
+ assert.ok(contracts.validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [out] }).valid);
+ const host2 = scenario();
+ host2.put('/var/lib/openvibe-host/browser-watch/live.json', JSON.stringify({ site: 'live', ok: true, release: 'abc1234', checkedAt: '2026-09-26T04:00:00Z' }));
+ const [pass] = await enrichBrowser(host2.ctx(), [{ fingerprint: 'e'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z' }], { domains: networkDomains() });
+ assert.strictEqual(pass.details, undefined, 'a stale pass is not used for a firing alert');
+ }),
+ test('alerts relay delivers a browser-check alert with what the result file knows', async () => {
+ const sha = '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b';
+ const host = withNetwork({ prom: { status: 200, body: { status: 'success', data: { alerts: [{
+ labels: { alertname: 'OpenVibeBrowserCheckFailed', severity: 'page', service: 'domains' }, state: 'firing', activeAt: '2026-11-10T09:12:30Z',
+ annotations: { summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s))' },
+ }] } } } });
+ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
+ site: 'domains', origin: 'https://openvibe.domains', release: sha, ok: false, checkedAt: '2026-11-10T09:12:00Z',
+ details: [{ route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'], help: 'Ensures the summary element has a non-empty accessible name' }],
+ }));
+ const r = await host.cli('alerts', 'relay');
+ assert.strictEqual(r.code, 0, r.out);
+ const doc = host.delivered[0];
+ assert.ok(contracts.validate('network.operator-alerts-request@1', doc).valid);
+ const a = doc.alerts[0];
+ assert.deepStrictEqual([a.service, a.release, a.checked_at, a.failing_since], ['domains', sha, '2026-11-10T09:12:00.000Z', '2026-11-10T09:12:30.000Z']);
+ assert.strictEqual(a.details.length, 1);
+ assert.strictEqual(a.details[0].page_url, 'https://openvibe.domains/');
+ assert.strictEqual(a.details[0].help_url, 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI');
+ noSecrets(host, r.out);
+ }),
test('alerts: usage', async () => {
test('alerts: usage', async () => {
const r = await scenario().cli('alerts');
const r = await scenario().cli('alerts');
assert.notStrictEqual(r.code, 0);
assert.notStrictEqual(r.code, 0);
@@ -12,8 +12,8 @@ const { runBrowserCheck } = require('../lib/browser-check-hook');
@@ -12,8 +12,8 @@ const { runBrowserCheck } = require('../lib/browser-check-hook');
const PROM = '/var/lib/prometheus/node-exporter/openvibe_browsercheck.prom';
const PROM = '/var/lib/prometheus/node-exporter/openvibe_browsercheck.prom';
const pass = [{ base: 'https://openvibe.live', ok: true, summary: { checks: { status: { fail: 0 }, console: { fail: 0 } } } }];
const pass = [{ base: 'https://openvibe.live', ok: true, summary: { checks: { status: { fail: 0 }, console: { fail: 0 } } } }];
-const fail = [{ base: 'https://openvibe.live', ok: false, summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },
- routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'] }] }, { path: '/ok', ok: true }] }];
+const fail = [{ base: 'https://openvibe.live', ok: false, axe_version: '4.13.0', summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },
+ routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', help: 'Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'], duplicateScripts: [{ url: 'https://openvibe.live/js/app.js?v=1', count: 2 }] }] }, { path: '/ok', ok: true }] }];
function site({ release = 'abc1234def56' } = {}) {
function site({ release = 'abc1234def56' } = {}) {
const host = scenario();
const host = scenario();
@@ -58,10 +58,12 @@ runTests([
@@ -58,10 +58,12 @@ runTests([
assert.match(host.files.get(PROM).content, /^openvibe_browser_check_failing_checks\{service="live"\} 2$/m);
assert.match(host.files.get(PROM).content, /^openvibe_browser_check_failing_checks\{service="live"\} 2$/m);
const st = JSON.parse(host.files.get('/var/lib/openvibe-host/browser-watch/live.json').content);
const st = JSON.parse(host.files.get('/var/lib/openvibe-host/browser-watch/live.json').content);
assert.deepStrictEqual(st.details, [
assert.deepStrictEqual(st.details, [
- { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c'] },
+ { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'], help: 'Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds', help_url: 'https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=axeAPI' },
{ route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },
{ route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },
{ route: '/', check: 'overflow', width: 390, offenders: ['.wide'] },
{ route: '/', check: 'overflow', width: 390, offenders: ['.wide'] },
+ { route: '/', check: 'scripts', width: 390, message: 'https://openvibe.live/js/app.js?v=1' },
], 'what failed is kept for the operator');
], 'what failed is kept for the operator');
+ assert.strictEqual(st.axeVersion, '4.13.0', 'the relay can build a help page for a detail written before the version was kept');
// The same failed release: left alone for 30 minutes, then checked again (a transient failure clears itself).
// The same failed release: left alone for 30 minutes, then checked again (a transient failure clears itself).
const t0 = Date.parse(st.checkedAt);
const t0 = Date.parse(st.checkedAt);
r = await watch(host.ctx(), { retryDelayMs: 0, check: host.check, now: t0 + 10 * 60000 });
r = await watch(host.ctx(), { retryDelayMs: 0, check: host.check, now: t0 + 10 * 60000 });