openvibers/openvibe.host Commits

agent: ovhost shots, the screenshot pass (plan L9) (#293)

OP OpenVibers committed 7 days ago · b7e61d0e87 · 9 files, +557 −1

Changes

ddiff --git a/deploy/systemd/openvibe-shots.service b/deploy/systemd/openvibe-shots.service
nnew file mode 100644
iindex 0000000..c1524f5
---- /dev/null
++++ b/deploy/systemd/openvibe-shots.service
@@@ -0,0 +1,19 @@
++[Unit]
++Description=OpenVibe screenshot pass: each public page at two viewports, stored in OpenVibe.Media (plan L9)
++Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/operator-alerts.md
++After=network-online.target
++Wants=network-online.target
++
++[Service]
++Type=oneshot
++# Root reads the inventory and uploads the PNGs; Chrome runs as the unprivileged ovcheck account
++# (runuser), never as root. The pages are the ones the inventory lists for each service ("/" when it
++# lists none). The Media credentials are in /etc/openvibe/host.env (OV_OAUTH_CLIENT_SECRET).
++User=root
++ExecStart=/usr/local/bin/ovhost shots
++Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
++Environment=OVHOST_INVENTORY=/etc/openvibe/host.json
++Nice=10
++TimeoutStartSec=30min
++PrivateTmp=true
++ProtectSystem=full
ddiff --git a/deploy/systemd/openvibe-shots.timer b/deploy/systemd/openvibe-shots.timer
nnew file mode 100644
iindex 0000000..f8e3b05
---- /dev/null
++++ b/deploy/systemd/openvibe-shots.timer
@@@ -0,0 +1,10 @@
++[Unit]
++Description=Capture the public pages of every service every six hours (plan L9, devblog §3.3)
++
++[Timer]
++OnBootSec=10min
++OnUnitActiveSec=6h
++AccuracySec=1min
++
++[Install]
++WantedBy=timers.target
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 2df74c0..6e111fa 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -102,6 +102,11 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
check each site in a real browser after every release (and
daily) as ovcheck; metrics for OpenVibeBrowserCheckFailed
(openvibe-browsercheck.timer, every 5 minutes)
++ shots [--sites live,blog] [--pages /,/pricing] [--dry-run]
++ capture each public page at 390x844 and 1440x900 (dark, no
++ animations, private parts masked) and store the PNG in
++ OpenVibe.Media, in Host's own tenant; 14 days are kept
++ (openvibe-shots.timer, every 6 hours)
alerts relay [--prometheus <url>] [--dry-run]
the alerts firing in Prometheus, sent to Network, which pages
the operator (openvibe-alerts.timer, every 2 minutes)
@@@ -128,7 +133,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
`;
--const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);
++const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'pages', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);
function parseArgs(argv) {
const pos = [];
@@@ -617,6 +622,21 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
: x.action === 'skipped' ? `skip ${x.site} ${x.release || '?'} (${x.reason}; last ${x.ok ? 'passed' : 'failed'})` : `ERR ${x.site}: ${x.reason}`);
return r.sites.some((x) => x.action === 'error') ? 2 : r.sites.some((x) => x.action === 'checked' && !x.ok) ? 1 : 0;
}
++ case 'shots': {
++ // Plan L9: the screenshot pass (devblog §3.3). Chrome runs as ovcheck; the PNGs go to Media.
++ const services = opts.sites ? String(opts.sites).split(',').map((x) => x.trim()).filter(Boolean) : null;
++ const pages = opts.pages ? String(opts.pages).split(',').map((x) => x.trim()).filter(Boolean) : null;
++ const { media, error } = opts['dry-run'] ? { media: null, error: null } : await lib.shots.mediaFor(ctx, { env });
++ if (error && !opts['dry-run']) { out(`[ovhost] the shots cannot be stored: ${error}`); return 1; }
++ const r = await lib.shots.shots(ctx, { services, pages, media, dryRun: !!opts['dry-run'], log: (m) => log(m) });
++ if (json) emit(r);
++ else {
++ out(`${r.captured} shot(s) taken, ${r.uploaded} stored${r.pruned ? `, ${r.pruned} pruned` : ''}`);
++ for (const s of r.shots) out(` ${s.service} ${s.path} ${s.viewport} ${s.release} ${s.id}`);
++ if (r.error) out(`[ovhost] ${r.error}`);
++ }
++ return r.error ? 2 : 0;
++ }
case 'alerts': {
// WS-H task 11: deliver the firing Prometheus alerts to the operator through Network.
if (pos[1] !== 'relay') throw new UsageError('usage: ovhost alerts relay [--prometheus <url>] [--dry-run] [--events-env <file>]');
ddiff --git a/lib/index.js b/lib/index.js
iindex 572b9b8..78810de 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -42,6 +42,7 @@ module.exports = {
alerts: require('./alerts'),
nodes: require('./nodes'),
browserWatch: require('./browser-watch'),
++ shots: require('./shots'),
incidents: require('./incidents'),
dns: require('./dns'),
snapshot,
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex c9dad99..2e3d687 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -250,6 +250,17 @@ function normaliseService(id, raw, defaults) {
nginx: s.nginx || null,
// The public origin release notifications name (lib/announce.js); default: the manifest's publicOrigin.
origin: s.origin == null ? null : httpsOrigin(s.origin, `${where}.origin`),
++ // The pages the screenshot pass captures for this service (plan L9): its own list, or "/" when it names
++ // none. Public pages only: the origins come from the inventory.
++ shots: (() => {
++ const sh = s.shots || {};
++ const pages = sh.pages == null ? [] : sh.pages;
++ if (!Array.isArray(pages)) throw new InventoryError(`${where}.shots.pages must be a list of paths`);
++ for (const p of pages) {
++ if (typeof p !== 'string' || !p.startsWith('/') || p.length > 200 || /\s/.test(p)) throw new InventoryError(`${where}.shots.pages must be paths such as "/" or "/pricing"`);
++ }
++ return { pages: pages.slice() };
++ })(),
// Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory
// `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).
lifecycle: s.lifecycle == null ? null : s.lifecycle,
ddiff --git a/lib/media.js b/lib/media.js
nnew file mode 100644
iindex 0000000..f6692d9
---- /dev/null
++++ b/lib/media.js
@@@ -0,0 +1,128 @@
++'use strict';
++/**
++ * OpenVibe.Media, from ovhost: the screenshot pass (plan L9, devblog §3.3).
++ *
++ * The shots ovhost captures are Media objects in Host's own tenant ("host"), uploaded through the
++ * Object API v2 the way OpenVibe.Community and Codes do: init, PUT the bytes, complete. Host reaches
++ * Media with its own service token (the client id it already has, in /etc/openvibe/host.env); the
++ * grant is media.object.* on the host namespace (Network's default grants).
++ *
++ * The secret is never printed and never leaves this module: it is a non-enumerable property of the
++ * configuration, so JSON.stringify and util.inspect never show it.
++ */
++const path = require('path');
++
++const DEFAULT_ENV_FILE = '/etc/openvibe/host.env';
++const APP = 'host';
++const SCOPE = 'media.object.upload media.object.read media.object.list media.object.delete';
++
++function parseEnv(text) {
++ const out = {};
++ for (const line of String(text || '').split('\n')) {
++ const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
++ if (!m) continue;
++ let v = m[2].trim();
++ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);
++ else v = v.replace(/\s+#.*$/, '');
++ out[m[1]] = v;
++ }
++ return out;
++}
++
++/**
++ * -> { file, clientId, networkUrl, mediaUrl } with the secret as a non-enumerable property, or
++ * { file, error, notConfigured }.
++ */
++async function loadCredentials(exec, inv, { file = null, env = {} } = {}) {
++ const f = file || env.OVHOST_MEDIA_ENV || (inv.shots && inv.shots.envFile) || DEFAULT_ENV_FILE;
++ if (!path.isAbsolute(f)) return { file: f, error: 'the media env file must be an absolute path', notConfigured: true };
++ let text;
++ try { text = await exec.readFile(f, { privileged: true }); } catch (err) { return { file: f, error: `${f} is not readable (${err.code || 'error'})`, notConfigured: true }; }
++ if (text == null) return { file: f, error: `${f} not found`, notConfigured: true };
++ const v = parseEnv(text);
++ const missing = ['OV_OAUTH_CLIENT_SECRET'].filter((n) => !v[n]);
++ if (missing.length) return { file: f, error: `${f} sets no ${missing.join(' and no ')}`, notConfigured: true };
++ const network = v.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000';
++ const media = v.MEDIA_INTERNAL_URL || v.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100';
++ return Object.defineProperty(
++ { file: f, clientId: v.OV_OAUTH_CLIENT_ID || 'host', networkUrl: network.replace(/\/+$/, ''), mediaUrl: media.replace(/\/+$/, '') },
++ 'secret', { value: v.OV_OAUTH_CLIENT_SECRET, enumerable: false },
++ );
++}
++
++/**
++ * The Media client: a token from Network, then the Object API v2 of the "host" tenant.
++ * uploadPng() stores one screenshot; list() reads the index; remove() prunes.
++ */
++function createMedia({ clientId, secret, networkUrl, mediaUrl, app = APP, fetchImpl = globalThis.fetch }) {
++ let token = null;
++ let tokenAt = 0;
++
++ async function authHeaders() {
++ if (token && Date.now() - tokenAt < 240000) return { Authorization: `Bearer ${token}` };
++ const body = new URLSearchParams({
++ grant_type: 'client_credentials', client_id: clientId, client_secret: secret,
++ audience: 'openvibe.media', scope: SCOPE,
++ });
++ const res = await fetchImpl(`${networkUrl}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
++ });
++ if (!res.ok) throw new Error(`Network answered ${res.status} for a Media token`);
++ const j = await res.json();
++ if (!j || !j.access_token) throw new Error('Network answered no access token');
++ token = j.access_token;
++ tokenAt = Date.now();
++ return { Authorization: `Bearer ${token}` };
++ }
++
++ async function request(pathname, { method = 'GET', json, body, headers = {} } = {}) {
++ const res = await fetchImpl(`${mediaUrl}${pathname}`, {
++ method,
++ headers: { Accept: 'application/json', ...(await authHeaders()), ...(json ? { 'Content-Type': 'application/json' } : {}), ...headers },
++ body: json ? JSON.stringify(json) : body,
++ });
++ const text = await res.text();
++ if (!res.ok) throw new Error(`Media answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
++ if (!text) return null;
++ try { return JSON.parse(text); } catch { return { raw: text }; }
++ }
++
++ const base = () => `/api/v2/${encodeURIComponent(app)}/objects`;
++
++ return {
++ app,
++ /** One PNG: init, PUT the bytes, complete. Answers { id, object }. */
++ async uploadPng({ data, mime = 'image/png', kind = 'screenshot', visibility = 'unlisted', metadata = {} }) {
++ const init = await request(base(), { method: 'POST', json: { kind, visibility, metadata } });
++ const id = init && (init.id || (init.object && init.object.id));
++ if (!id) throw new Error('Media answered no object id');
++ await request(`${base()}/${encodeURIComponent(id)}/content`, { method: 'PUT', body: data, headers: { 'Content-Type': mime } });
++ const done = await request(`${base()}/${encodeURIComponent(id)}/complete`, { method: 'POST', json: {} });
++ return { id, object: (done && done.object) || done };
++ },
++ /** The shots of this tenant, newest first. */
++ async list({ kind = null, limit = 100, cursor = null, namespace = null } = {}) {
++ const q = new URLSearchParams({ limit: String(limit) });
++ if (kind) q.set('kind', kind);
++ if (cursor) q.set('cursor', cursor);
++ if (namespace) q.set('namespace', namespace);
++ const r = await request(`${base()}?${q.toString()}`);
++ return { objects: (r && r.objects) || [], next_cursor: (r && r.next_cursor) || null };
++ },
++ /** Every shot, page by page. */
++ async listAll({ kind = 'screenshot', limit = 100 } = {}) {
++ const out = [];
++ let cursor = null;
++ for (;;) {
++ const page = await this.list({ kind, limit, cursor });
++ out.push(...page.objects);
++ if (!page.next_cursor) break;
++ cursor = page.next_cursor;
++ }
++ return out;
++ },
++ async remove(id) { return await request(`${base()}/${encodeURIComponent(id)}`, { method: 'DELETE' }); },
++ };
++}
++
++module.exports = { createMedia, loadCredentials, parseEnv, APP, SCOPE, DEFAULT_ENV_FILE };
ddiff --git a/lib/shots.js b/lib/shots.js
nnew file mode 100644
iindex 0000000..cae9f51
---- /dev/null
++++ b/lib/shots.js
@@@ -0,0 +1,190 @@
++'use strict';
++/**
++ * `ovhost shots` (plan L9, devblog §3.3): the screenshot pass.
++ *
++ * ovhost shots [--sites live,blog] [--pages /,/pricing] [--dry-run]
++ * (openvibe-shots.timer: every 6 hours)
++ *
++ * For every service the inventory names, the pages it lists (its "shots.pages" list, or "/" when it
++ * names none), at two viewports — 390x844 (a phone: isMobile, hasTouch) and 1440x900 — the page is
++ * captured in headless Chrome: dark theme, no animations, and the private parts masked ([data-private],
++ * the inputs, and any email address on the page). Chrome runs as the unprivileged ovcheck account,
++ * never as root, exactly like the browser check.
++ *
++ * Every PNG is stored in OpenVibe.Media, in Host's own tenant, with the service, the path, the
++ * viewport, the release it was taken on and the sha256 of the bytes. The shots of a page and a
++ * viewport are kept for 14 days; the same command prunes the older ones.
++ *
++ * Only public pages are captured: the origins come from the inventory, and a page that is not a
++ * path of a public site is refused before Chrome starts.
++ */
++const { spawn } = require('child_process');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const { originOf, currentRelease } = require('./browser-watch');
++
++/** The two viewports of §3.3: a phone and a desktop. */
++const VIEWPORTS = Object.freeze([
++ { name: 'phone', width: 390, height: 844 },
++ { name: 'desktop', width: 1440, height: 900 },
++]);
++/** How long a shot of a page and a viewport is kept. */
++const RETENTION_DAYS = 14;
++const RETENTION_MS = RETENTION_DAYS * 24 * 3600 * 1000;
++const SCRIPT = path.join(__dirname, '..', 'scripts', 'shots-capture.js');
++const SITE_RE = /^[a-z][a-z0-9-]{1,39}$/;
++const PAGE_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]*$/;
++
++/** The pages of a service: the inventory's own list, or the home page. */
++function pagesOf(inv, id) {
++ const entry = inv && inv.services && inv.services[id];
++ const list = entry && entry.shots && Array.isArray(entry.shots.pages) ? entry.shots.pages : [];
++ return list.length ? list.slice() : ['/'];
++}
++
++/** The capture plan: one target per page and viewport. */
++function plan(inv, { services = null, pages = null } = {}) {
++ const ids = services && services.length ? services : Object.keys((inv && inv.services) || {});
++ const out = [];
++ for (const id of ids) {
++ if (!SITE_RE.test(id)) continue;
++ const origin = originOf(inv, id, null);
++ if (!origin) continue;
++ let list = pages && pages.length ? pages : pagesOf(inv, id);
++ list = list.filter((p) => PAGE_RE.test(String(p)));
++ if (!list.length) continue;
++ for (const p of list) {
++ for (const v of VIEWPORTS) out.push({ service: id, origin, path: p, viewport: v.name, width: v.width, height: v.height });
++ }
++ }
++ return out;
++}
++
++/**
++ * Chrome, as the unprivileged account (Chrome never runs as root). The capture script writes the PNGs
++ * into a temp directory and prints what it took; the parent hashes and uploads them.
++ */
++function captureWithChrome(targets, { asUser = 'ovcheck', home = '/var/lib/ovcheck', timeoutMs = 15 * 60 * 1000, spawnFn = spawn } = {}) {
++ return new Promise((resolve) => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shots-'));
++ const targetsFile = path.join(dir, 'targets.json');
++ const outDir = path.join(dir, 'png');
++ fs.mkdirSync(outDir, { recursive: true });
++ fs.writeFileSync(targetsFile, JSON.stringify(targets));
++ const argv = [SCRIPT, '--targets', targetsFile, '--out', outDir];
++ const child = asUser
++ ? spawnFn('runuser', ['-u', asUser, '--', 'env', `HOME=${home || '/tmp'}`, process.execPath, ...argv], { stdio: ['ignore', 'pipe', 'pipe'] })
++ : spawnFn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
++ let out = '', err = '';
++ child.stdout.on('data', (c) => { out += c; });
++ child.stderr.on('data', (c) => { err += c; });
++ const timer = setTimeout(() => { err += '\ntimed out'; child.kill('SIGKILL'); }, timeoutMs);
++ child.on('error', (e) => { clearTimeout(timer); resolve({ shots: [], error: e.message }); });
++ child.on('close', (code) => {
++ clearTimeout(timer);
++ let parsed = null;
++ try { parsed = JSON.parse(out.split('\n').filter((l) => l.trim().startsWith('{')).pop() || 'null'); } catch { parsed = null; }
++ const shots = [];
++ for (const s of (parsed && parsed.shots) || []) {
++ try {
++ const data = fs.readFileSync(s.file);
++ shots.push({ ...s, data, hash: crypto.createHash('sha256').update(data).digest('hex') });
++ } catch { /* the file the capture wrote is not there: the shot is lost */ }
++ }
++ fs.rmSync(dir, { recursive: true, force: true });
++ resolve({ shots, error: code === 0 || shots.length ? null : (err.split('\n').filter(Boolean).pop() || `the capture exited ${code}`) });
++ });
++ });
++}
++
++/** The shots to prune: older than the retention, per page and viewport. */
++function expired(shots, now = Date.now()) {
++ return shots.filter((s) => {
++ const at = Date.parse((s.metadata && s.metadata.captured_at) || s.created_at || 0);
++ if (!Number.isFinite(at)) return false;
++ return now - at > RETENTION_MS;
++ });
++}
++
++/**
++ * One pass: capture, upload, prune.
++ * opts: services, pages, now, capture (the capture function; tests pass a fake one), media (the Media
++ * client), asUser, home, dryRun.
++ */
++async function shots(ctx, {
++ services = null, pages = null, now = Date.now(), capture = captureWithChrome,
++ media = null, asUser = 'ovcheck', home = '/var/lib/ovcheck', dryRun = false, log = () => {},
++} = {}) {
++ const { exec, inv } = ctx;
++ const targets = plan(inv, { services, pages });
++ if (!targets.length) return { captured: 0, uploaded: 0, pruned: 0, targets: [], reason: 'no service to capture' };
++
++ // The release each service is on: the key of the shot (the same key the browser check uses).
++ const releases = {};
++ for (const t of targets) if (releases[t.service] === undefined) {
++ const r = await currentRelease(exec, t.origin);
++ releases[t.service] = r || 'unknown';
++ }
++
++ const captured = await capture(targets, { asUser, home });
++ const taken = (captured.shots || []).map((s) => ({
++ ...s,
++ release: releases[s.service] || 'unknown',
++ captured_at: new Date(now).toISOString(),
++ }));
++
++ const uploaded = [];
++ if (!dryRun && media) {
++ for (const s of taken) {
++ if (!s.data) continue;
++ const metadata = {
++ service: s.service, path: s.path, viewport: s.viewport,
++ width: s.width, height: s.height, release: s.release,
++ hash: s.hash, captured_at: s.captured_at, source: 'ovhost shots',
++ };
++ try {
++ const r = await media.uploadPng({ data: s.data, metadata });
++ uploaded.push({ ...s, id: r.id });
++ } catch (err) {
++ log(`the shot of ${s.service}${s.path} at ${s.viewport} was not stored: ${err.message}`);
++ }
++ }
++ }
++
++ let pruned = [];
++ if (!dryRun && media) {
++ try {
++ const all = await media.listAll({ kind: 'screenshot' });
++ for (const o of expired(all, now)) {
++ try { await media.remove(o.id); pruned.push(o.id); } catch { /* the object stays */ }
++ }
++ } catch (err) {
++ log(`the shots were not pruned: ${err.message}`);
++ }
++ }
++
++ return {
++ captured: taken.length,
++ uploaded: uploaded.length,
++ pruned: pruned.length,
++ releases,
++ shots: uploaded.map((s) => ({ service: s.service, path: s.path, viewport: s.viewport, release: s.release, id: s.id, hash: s.hash })),
++ error: captured.error || null,
++ };
++}
++
++/** The Media client of this host, from the credentials in the env file (the secret never leaves it). */
++async function mediaFor(ctx, { env = {} } = {}) {
++ const { loadCredentials, createMedia } = require('./media');
++ const creds = await loadCredentials(ctx.exec, ctx.inv, { env });
++ if (creds.error) return { media: null, error: creds.error };
++ return {
++ media: createMedia({ clientId: creds.clientId, secret: creds.secret, networkUrl: creds.networkUrl, mediaUrl: creds.mediaUrl }),
++ error: null,
++ };
++}
++
++module.exports = { shots, plan, pagesOf, expired, captureWithChrome, mediaFor, VIEWPORTS, RETENTION_DAYS, RETENTION_MS, SCRIPT };
ddiff --git a/scripts/shots-capture.js b/scripts/shots-capture.js
nnew file mode 100644
iindex 0000000..3b20120
---- /dev/null
++++ b/scripts/shots-capture.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * The capture itself (plan L9, devblog §3.3), run by ovhost shots as the unprivileged ovcheck account:
++ * Chrome opens each page at each viewport, dark and without animations, and writes a PNG.
++ *
++ * node scripts/shots-capture.js --targets <file.json> --out <dir>
++ *
++ * The target file holds [{ service, origin, path, viewport, width, height }]; the script prints
++ * { shots: [{ service, path, viewport, width, height, file }] } on stdout. It never prints a page's
++ * content, only the file it wrote.
++ */
++const fs = require('fs');
++const path = require('path');
++const harness = require('openvibe-shared/browser-harness');
++
++/** The private parts of a page: what a shot must never show (§3.3). */
++const MASK_SELECTOR = '[data-private], input, textarea, select, [data-ov-mask]';
++
++/** Dark theme, no animations, no caret, and every email address hidden. */
++const PREPARE = `(() => {
++ const st = document.createElement('style');
++ st.setAttribute('data-ov-shots', '1');
++ st.textContent = '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}';
++ (document.documentElement || document.body).appendChild(st);
++ try { if (document.getAnimations) document.getAnimations().forEach((a) => { try { a.finish(); } catch (e) {} }); } catch (e) {}
++ const re = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g;
++ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
++ const nodes = [];
++ while (walker.nextNode()) nodes.push(walker.currentNode);
++ for (const node of nodes) {
++ const text = node.nodeValue || '';
++ re.lastIndex = 0;
++ if (!re.test(text)) continue;
++ re.lastIndex = 0;
++ node.nodeValue = text.replace(re, '••••');
++ const el = node.parentElement;
++ if (el && !el.closest('[data-private]')) el.setAttribute('data-ov-mask', '1');
++ }
++ return true;
++})()`;
++
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60) || 'page';
++
++function arg(name) {
++ const i = process.argv.indexOf(`--${name}`);
++ return i > 0 ? process.argv[i + 1] : null;
++}
++
++(async () => {
++ const targetsFile = arg('targets');
++ const out = arg('out');
++ if (!targetsFile || !out) { console.error('shots-capture: --targets and --out are needed'); process.exit(1); }
++ const targets = JSON.parse(fs.readFileSync(targetsFile, 'utf8'));
++ fs.mkdirSync(out, { recursive: true });
++ const shots = [];
++ const browser = await harness.launch({});
++ const context = await browser.newContext();
++ try {
++ for (const t of targets) {
++ const page = await harness.openPage(browser, context, { width: t.width, height: t.height });
++ try {
++ // Dark theme before the page loads, so the first paint is the dark one.
++ await page.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'dark' }] });
++ await page.goto(`${t.origin}${t.path}`, { quietMs: 1200, maxMs: 20000 });
++ await page.evaluate(PREPARE);
++ await sleep(1200);
++ const shot = await page.send('Page.captureScreenshot', { format: 'png', mask: [{ selector: MASK_SELECTOR }] });
++ const file = path.join(out, `${safe(t.service)}__${safe(t.path)}__${safe(t.viewport)}.png`);
++ fs.writeFileSync(file, Buffer.from(shot.data, 'base64'));
++ shots.push({ service: t.service, path: t.path, viewport: t.viewport, width: t.width, height: t.height, file });
++ } catch (err) {
++ console.error(`shots-capture: ${t.service}${t.path} at ${t.viewport} failed: ${err.message}`);
++ } finally {
++ await page.close().catch(() => {});
++ }
++ }
++ } finally {
++ await browser.close().catch(() => {});
++ }
++ process.stdout.write(`${JSON.stringify({ shots })}\n`);
++ process.exit(0);
++})().catch((err) => { console.error(`shots-capture: ${err.message}`); process.exit(2); });
ddiff --git a/test/shots.test.js b/test/shots.test.js
nnew file mode 100644
iindex 0000000..7c4edd5
---- /dev/null
++++ b/test/shots.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++/**
++ * The screenshot pass (lib/shots.js, plan L9, devblog §3.3): a capture is stored in OpenVibe.Media with
++ * the service, the path, the viewport, the release and the hash of the bytes; the shots of a page and a
++ * viewport are kept for 14 days, and the same command prunes the older ones.
++ */
++const assert = require('assert');
++const { scenario, test, runTests } = require('./helpers');
++const { shots, plan, expired, VIEWPORTS, RETENTION_MS } = require('../lib/shots');
++
++/** A Media that remembers what it was given. */
++function fakeMedia(objects = []) {
++ const uploads = [];
++ const removed = [];
++ return {
++ uploads, removed, objects,
++ async uploadPng({ data, metadata }) {
++ const id = `med_${String(uploads.length + 1).padStart(26, '0')}`;
++ uploads.push({ id, data, metadata });
++ objects.push({ id, metadata, created_at: metadata.captured_at });
++ return { id };
++ },
++ async listAll() { return objects.slice(); },
++ async remove(id) { removed.push(id); const i = objects.findIndex((o) => o.id === id); if (i >= 0) objects.splice(i, 1); },
++ };
++}
++
++/** A capture that returns the targets it was given, as Chrome would. */
++function fakeCapture(data = 'png-bytes') {
++ const calls = [];
++ const fn = async (targets) => {
++ calls.push(targets);
++ return { shots: targets.map((t) => ({ ...t, data: Buffer.from(`${data}:${t.path}:${t.viewport}`), hash: `hash-${t.service}-${t.path}-${t.viewport}` })) };
++ };
++ fn.calls = calls;
++ return fn;
++}
++
++function site({ pages = null, release = 'abc1234def56' } = {}) {
++ const host = scenario();
++ host.inv.services.live.origin = 'https://openvibe.live';
++ if (pages) host.inv.services.live.shots = { pages };
++ host.http.set('https://openvibe.live/release.json', () => ({ status: 200, body: { service: 'live', release, booted_at: '2026-11-01T00:00:00Z' } }));
++ return host;
++}
++
++runTests([
++ test('the plan holds both viewports of §3.3 and the pages of the inventory', async () => {
++ const host = site({ pages: ['/', '/pricing'] });
++ const p = plan(host.inv, { services: ['live'] });
++ assert.deepStrictEqual(p.map((t) => [t.path, t.viewport, t.width, t.height]), [
++ ['/', 'phone', 390, 844], ['/', 'desktop', 1440, 900],
++ ['/pricing', 'phone', 390, 844], ['/pricing', 'desktop', 1440, 900],
++ ]);
++ assert.deepStrictEqual(VIEWPORTS.map((v) => v.name), ['phone', 'desktop']);
++ }),
++
++ test('without a list of pages the home page is captured', async () => {
++ const host = site();
++ assert.deepStrictEqual(plan(host.inv, { services: ['live'] }).map((t) => t.path), ['/', '/']);
++ }),
++
++ test('a shot is stored in Media with the service, path, viewport, release and hash', async () => {
++ const host = site({ pages: ['/'] });
++ const media = fakeMedia();
++ const capture = fakeCapture();
++ const now = Date.parse('2026-11-06T12:00:00Z');
++ const r = await shots(host.ctx(), { services: ['live'], media, capture, now });
++ assert.strictEqual(r.captured, 2, 'one shot per viewport');
++ assert.strictEqual(r.uploaded, 2);
++ assert.strictEqual(media.uploads.length, 2);
++ const m = media.uploads[0].metadata;
++ assert.strictEqual(m.service, 'live');
++ assert.strictEqual(m.path, '/');
++ assert.strictEqual(m.viewport, 'phone');
++ assert.strictEqual(m.release, 'abc1234def56', 'the release the service was on is the key of the shot');
++ assert.strictEqual(m.hash, 'hash-live-/-phone');
++ assert.strictEqual(m.captured_at, new Date(now).toISOString());
++ }),
++
++ test('a shot older than 14 days is pruned, a younger one is kept', async () => {
++ const now = Date.parse('2026-11-06T12:00:00Z');
++ const old = { id: 'med_old', metadata: { captured_at: new Date(now - RETENTION_MS - 60000).toISOString() } };
++ const fresh = { id: 'med_fresh', metadata: { captured_at: new Date(now - RETENTION_MS + 60000).toISOString() } };
++ assert.deepStrictEqual(expired([old, fresh], now).map((o) => o.id), ['med_old']);
++
++ const host = site({ pages: ['/'] });
++ const media = fakeMedia([old, fresh]);
++ const r = await shots(host.ctx(), { services: ['live'], media, capture: fakeCapture(), now });
++ assert.deepStrictEqual(media.removed, ['med_old']);
++ assert.strictEqual(r.pruned, 1);
++ assert.ok(media.objects.some((o) => o.id === 'med_fresh'), 'the young shot stays');
++ })
++]);