openvibers/openvibe.shared Commits
openvibe-shared 3.0.0: delete the SQLite config store and SQLite analytics (#54)
Changes
@@@ -30,7 +30,7 @@ jobs:git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEADmkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/nullnpm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"-- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); require('openvibe-shared/release'); require('openvibe-shared/release-compat'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/prune-cli'); require('openvibe-shared/analytics/event.v1.json'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"++ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); require('openvibe-shared/release'); require('openvibe-shared/release-compat'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/pg'); require('openvibe-shared/analytics/event.v1.json'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"# Secret scan (gitleaks) and dependency audit, the workflow every OpenVibers repo calls (Track Q).security:uses: ./.github/workflows/security.yml@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 3.0.0 — 2026-10-10++++- Removed the SQLite configuration store. `createConfigStore` now requires an openvibe-sdk/db handle and returns a promise of the ready PostgreSQL store. `fromRows`, `adminRoutes`, `configSchema`, and other non-SQLite exports remain.++- Removed SQLite analytics: `AnalyticsTracker`, `analytics/tracker`, `analytics/schema`, `analytics/prune-cli`, the SQLite retention and scrub functions, and the SQLite `ANALYTICS_SCHEMA` and `ensureSchema` exports. Use `analytics/pg` (`AnalyticsTrackerPg`, `analyticsSchema`, `pruneRawEventsPg`). `analytics/retention` still exports `MAX_DAYS`.++- Consumers already passing an openvibe-sdk/db handle to `createConfigStore` and using `analytics/pg` need no code changes. Live's `test/shared-pins.test.js` still lists `openvibe-shared/analytics/prune-cli` in `RESOLVE_ONLY`; remove that entry when updating its pin.++- `better-sqlite3` is gone from devDependencies (and the lockfile).++## 2.21.2 — 2026-10-10- `release-watch.js` reopens its release-notification stream with `last_event_id` set to the last SSE id Events sent@@@ -20,7 +20,7 @@ were reconciled first.| Kind | Files ||---|---|| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) || Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` || Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |@@@ -73,7 +73,7 @@ hand-copy files into `node_modules/` or a `vendor/` directory.```jsconst legal = require('openvibe-shared/legal');const seo = require('openvibe-shared/seo');--const { AnalyticsTracker } = require('openvibe-shared/analytics');++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg');```### SEO kit@@@ -246,35 +246,34 @@ never `true`: it reports status `skipped` with the reason and is listed in `skip### Configuration (server, WS-C task 7)`openvibe-shared/config` keeps one namespace of a service's configuration as immutable revisions--(`common.config-snapshot@1`) in the service's own SQLite database. No new dependency: pass the--better-sqlite3 handle. Several namespaces share one database (tables `config_snapshots` and--`config_keys`).----On PostgreSQL (an openvibe-sdk/db handle, ADR-035) `createConfigStore` returns a promise of the store (`config-pg.js`): `const tiering = await config.createConfigStore({ db, … })`. Its reading and writing methods are async, while `get()` and `revision()` stay in memory. The service's migrations create the tables from `config.configSchema()`.++(`common.config-snapshot@1`) in the service's PostgreSQL database. Pass an openvibe-sdk/db handle.++Several namespaces share the `config_snapshots` and `config_keys` tables, created by the service's++migrations from `config.configSchema()`. `createConfigStore` returns a promise of the ready store.++Its database methods are async; `get()` and `revision()` stay in memory.```jsconst config = require('openvibe-shared/config');--const settings = config.createConfigStore({++const settings = await config.createConfigStore({db, service: 'live', namespace: 'live.site_settings',schema, // JSON Schema subset: type, enum, const, required, properties,// additionalProperties, min/max*, pattern, items, uniqueItems, multipleOfvalidate: (values) => true, // or false, a message, a list, { valid, errors }; synchronousclassify: (key) => (/api_key|secret|token/.test(key) ? 'secret' : undefined), // or { key: class }; unknown = internaldefaults, // revision 1 of a new namespace, and under every revision-- legacy: () => config.fromRows(db.prepare('SELECT * FROM site_settings').all()), // revision 1 instead++ legacy: async () => config.fromRows(await db.all('SELECT * FROM site_settings')), // revision 1 insteadonActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },keep: 50, log: console, now: () => new Date(),});settings.get(); // defaults overlaid with the active values: frozen, in memorysettings.get('max_bitrate_kbps'); // one value; never a database readsettings.revision(); // the active revision, or null--settings.propose(values, { actor, reason, merge, unset }); // validated, stored as proposed (redacted snapshot)++await settings.propose(values, { actor, reason, merge, unset }); // validated, stored as proposed (redacted snapshot)await settings.activate(revision, { actor });await settings.apply(values, { actor, reason, merge, unset }); // propose + activateawait settings.rollback({ actor, reason, to }); // a new revision copying the previous good one (or `to`)--settings.lastKnownGood(); settings.history({ limit, before }); settings.snapshot(revision); settings.summary();--settings.import(legacy); // revision 1 from a legacy source when the namespace has none--settings.reload(); // another process changed it: re-read the active revision++await settings.lastKnownGood(); await settings.history({ limit, before }); await settings.snapshot(revision); await settings.summary();++await settings.import(legacy); // revision 1 from a legacy source when the namespace has none++await settings.reload(); // another process changed it: re-read the active revision```- **Values** are a complete snapshot; `merge: true` starts from the active values and `unset` removes@@@ -435,25 +434,22 @@ page's timers and events.### Analytics (server, ADR-021)```js--const Database = require('better-sqlite3'); // the service's own; Shared has no native dependency--const { AnalyticsTracker } = require('openvibe-shared/analytics');--const db = new Database('data/analytics.db');--db.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(db, 'live', {-- retention: { days: 30 }, // nightly prune (the default); false if you schedule it yourself-- paramPrefixes: ['avatar'], // extra words whose next segment is a parameter-- pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']], // parameters the segment rules can't see++const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');++// Put analyticsSchema() in the service's database migrations.++const analytics = new AnalyticsTrackerPg(db, 'live', {++ retention: { days: 30 },++ paramPrefixes: ['avatar'],++ pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']],});app.use(analytics.middleware());++// A service may call pruneRawEventsPg(db, { days: 30 }) in its own job runner.```A raw event has no IP, user or subject id, or city. It has a route template (never a raw URL), areferer origin, a user-agent class, a rotating session id and a country. Raw events are kept for30 days, and the rollups for longer. A request with `Sec-GPC: 1` or `DNT: 1` is not recorded atall. The row shape is `analytics/event.v1` (`docs/schemas/analytics-event.v1.json`), and--`require('openvibe-shared/analytics/event').checkRow(row)` checks a stored row against it. The--prune and one-time scrub command is `openvibe-shared/analytics/prune-cli`: a service wraps it in--its own `scripts/analytics-prune.js` and passes in its `Database` and default paths.++`require('openvibe-shared/analytics/event').checkRow(row)` checks a stored row against it. PostgreSQL retention uses `pruneRawEventsPg(db, { days })`; `retention.MAX_DAYS` is 30.### Browser side: two options@@@ -697,12 +693,10 @@ Tests are plain Node with stubbed browser globals. Some tests guard the release:- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.- **Browser bundles**: browser files must be free of `require()` and Node globals(`test/browser-bundles.test.js`).--- **Exports**: every export must resolve, and every module file (including `analytics/*.js`) must-- have one.--- **Analytics (ADR-021)**: no personal data in any table after real requests, opt-out requests-- leave nothing, every row is a valid `analytics/event.v1`, prune/scrub keep rollups intact, and-- the prune CLI's dry run changes nothing (`test/analytics-*.test.js`, with `better-sqlite3` as a-- dev dependency).++- **Exports**: every export must resolve, and every public module file must++ have one. Internal `config-core.js` and `analytics/core.js` are private.++- **Analytics (ADR-021)**: PostgreSQL tests cover request privacy, opt-out, rollups, and raw-event retention;++ pure privacy and event helpers have their own tests.CI runs on Node 22.22.1 and installs a tag-style tarball into an empty project.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "2.11.0",++ "version": "3.0.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,201 @@++'use strict';++// Request reduction shared by the PostgreSQL tracker.++const crypto = require('crypto');++const privacy = require('./privacy');++const { allowed } = require('./event');++++const SESSION_IDLE_MS = 30 * 60 * 1000;++const MAX_TRACKED_CLIENTS = 200000;++const MAX_BUFFER = 5000;++const SESSION_ID_RE = /^[0-9a-f]{16}$/;++const METHOD_RE = /^[A-Z][A-Z-]{0,15}$/;++const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;++const ALLOWED = { device: allowed('device'), browser: allowed('browser'), os: allowed('os') };++++const pad = (n) => String(n).padStart(2, '0');++/** 'YYYY-MM-DD HH:MM:SS' in UTC, the format of CURRENT_TIMESTAMP. */++function sqlTime(ms) {++ const d = new Date(ms);++ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;++}++const dayOf = (ms) => sqlTime(ms).slice(0, 10);++const hourOf = (ms) => sqlTime(ms).slice(0, 13) + ':00:00';++++function isStaticOrHealth(path) {++ return path === '/api/health' || path === '/health' ||++ /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path);++}++++class AnalyticsTrackerCore {++ constructor(db, service, opts = {}) {++ this.db = db;++ this.service = service;++ this._now = opts.now || Date.now;++ this._pathOpts = privacy.pathOptions({ paramPrefixes: opts.paramPrefixes, pathRules: opts.pathRules });++ this.pruneJob = null;++ this._buffer = [];++ this._visitors = new Map();++ this._sessions = new Map();++ this._rates = new Map();++ this._saltDay = null;++ this._salt = null;++ this._timers = [];++ }++++ _sessionId(vhash, nowMs) {++ let s = this._sessions.get(vhash);++ if (!s || nowMs - s.last > SESSION_IDLE_MS) {++ s = { sid: crypto.randomBytes(8).toString('hex'), last: nowMs };++ this._sessions.set(vhash, s);++ }++ s.last = nowMs;++ return s.sid;++ }++++ /** Hits from this client in the current and previous minute (memory only). */++ _rateHit(ip, nowMs) {++ const minute = Math.floor(nowMs / 60000);++ let r = this._rates.get(ip);++ if (!r) { r = { minute, count: 0, prev: 0 }; this._rates.set(ip, r); }++ if (r.minute !== minute) {++ r.prev = r.minute === minute - 1 ? r.count : 0;++ r.count = 0;++ r.minute = minute;++ }++ r.count++;++ return r.count + r.prev;++ }++++ _sweep() {++ const nowMs = this._now();++ const minute = Math.floor(nowMs / 60000);++ for (const [ip, r] of this._rates) if (r.minute < minute - 1) this._rates.delete(ip);++ for (const [h, s] of this._sessions) if (nowMs - s.last > SESSION_IDLE_MS) this._sessions.delete(h);++ if (this._rates.size > MAX_TRACKED_CLIENTS) this._rates.clear();++ if (this._sessions.size > MAX_TRACKED_CLIENTS) this._sessions.clear();++ }++++ _push(event, visitor) {++ if (this._buffer.length >= MAX_BUFFER) this._buffer.shift();++ this._buffer.push(event);++ if (visitor) {++ const key = `${visitor.hour}|${visitor.vhash}`;++ const prev = this._visitors.get(key);++ if (!prev) this._visitors.set(key, visitor);++ else if (visitor.authenticated) prev.authenticated = true;++ }++ if (this._buffer.length >= 100) this.flush();++ }++++ /**++ * Record one finished request. `entryPath` is the path as it arrived (req.path inside a mounted++ * router is relative by the time the response finishes). Returns false, recording nothing, for an++ * opted-out request (Sec-GPC: 1 / DNT: 1); true otherwise.++ */++ record(req, res, entryPath, responseTimeMs) {++ const headers = req.headers || {};++ if (privacy.optedOut(headers)) return false;++ entryPath = String(entryPath || '');++ const nowMs = this._now();++ const ua = String(headers['user-agent'] || '');++ const ip = String(req.ip || (req.socket && req.socket.remoteAddress) || '');++ const bot = privacy.classifyRequest({ headers, path: entryPath });++ if (this._rateHit(ip, nowMs) > privacy.HIGH_REQUEST_RATE && !bot.isBot) {++ bot.isBot = true;++ bot.botType = 'rate_limit';++ bot.confidence = 0.80;++ }++ const parsed = privacy.parseUserAgent(ua);++ const day = dayOf(nowMs);++ const vhash = this._visitorHash(ip, ua, day);++ const authenticated = !!(req.user && (req.user.id != null || req.user.sub != null));++ const isApi = entryPath.startsWith('/api/') || entryPath.startsWith('/internal/') || entryPath.startsWith('/oauth/');++ this._push({++ service: this.service,++ event_type: isApi ? 'api_call' : 'pageview',++ path: privacy.routeTemplate(req, entryPath, this._pathOpts),++ method: METHOD_RE.test(String(req.method || '')) ? req.method : null,++ status_code: res.statusCode,++ response_time_ms: responseTimeMs,++ session_id: this._sessionId(vhash, nowMs),++ country: privacy.countryCode(headers['cf-ipcountry']),++ user_agent: privacy.uaClass(ua),++ referer: privacy.refererOrigin(headers.referer || headers.referrer),++ is_bot: bot.isBot,++ bot_type: bot.botType,++ device_type: parsed.device,++ browser: parsed.browser,++ os: parsed.os,++ authenticated,++ created_at: sqlTime(nowMs),++ }, { day, hour: hourOf(nowMs), vhash, authenticated });++ return true;++ }++++ /**++ * Express middleware: one row per finished request (health checks, static assets and opted-out++ * requests skipped).++ */++ middleware() {++ return (req, res, next) => {++ const entryPath = req.path || '';++ if (isStaticOrHealth(entryPath) || privacy.optedOut(req.headers)) return next();++ const started = Date.now();++ let done = false;++ const finish = () => {++ if (done) return;++ done = true;++ try { this.record(req, res, entryPath, Date.now() - started); }++ catch (err) { console.error(`[Analytics:${this.service}] record error:`, err.message); }++ };++ res.once('finish', finish);++ res.once('close', finish);++ next();++ };++ }++++ /**++ * A custom (non-HTTP) event. Personal fields in `data` (ip, user_id, city) are ignored; path,++ * referer and user_agent are reduced like a request's; session_id is kept only if it is one of++ * this tracker's rotating ids; any other value outside analytics/event.v1 is stored as NULL, and++ * an event name outside it throws a TypeError. Pass the triggering request's headers as++ * `data.headers` when there is one: an opted-out request (Sec-GPC / DNT) records nothing.++ * Returns whether it recorded.++ */++ trackEvent(eventType, data = {}) {++ const name = String(eventType == null ? 'event' : eventType);++ if (!EVENT_NAME_RE.test(name)) throw new TypeError(`analytics event name must match ${EVENT_NAME_RE} (analytics/event.v1)`);++ if (privacy.optedOut(data.headers)) return false;++ const nowMs = this._now();++ const ua = data.user_agent ? String(data.user_agent) : '';++ const parsed = ua ? privacy.parseUserAgent(ua) : {};++ const method = data.method ? String(data.method).toUpperCase() : null;++ const status = Number(data.status_code);++ const ms = Number(data.response_time_ms);++ const botType = data.bot_type ? String(data.bot_type) : null;++ const pick = (field, given, fallback) => (ALLOWED[field].includes(given) ? given : (fallback || null));++ this._push({++ service: this.service,++ event_type: name,++ path: data.path ? privacy.normalisePath(data.path, this._pathOpts) : null,++ method: method && METHOD_RE.test(method) ? method : null,++ status_code: Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,++ response_time_ms: Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : null,++ session_id: SESSION_ID_RE.test(String(data.session_id || '')) ? data.session_id : null,++ country: privacy.countryCode(data.country),++ user_agent: ua ? privacy.uaClass(ua) : null,++ referer: privacy.refererOrigin(data.referer),++ is_bot: !!data.is_bot,++ bot_type: botType && /^[a-z0-9_]{1,32}$/.test(botType) ? botType : null,++ device_type: pick('device', data.device_type, parsed.device),++ browser: pick('browser', data.browser, parsed.browser),++ os: pick('os', data.os, parsed.os),++ authenticated: !!data.authenticated,++ created_at: sqlTime(nowMs),++ }, null);++ return true;++ }++++}++++module.exports = { AnalyticsTrackerCore, sqlTime, dayOf, hourOf };@@@ -85,7 +85,7 @@ function validateEvent(ev) {const nullable = (v) => (v === undefined || v === '' ? null : v);const bool = (v) => v === true || v === 1 || v === '1';--/** 'YYYY-MM-DD HH:MM:SS' (SQLite CURRENT_TIMESTAMP, UTC) -> 'YYYY-MM-DDTHH:MM:SSZ'; ISO input passes. */++/** 'YYYY-MM-DD HH:MM:SS' (UTC database timestamp) -> 'YYYY-MM-DDTHH:MM:SSZ'; ISO input passes. */function isoTime(v) {if (v == null) return v;const s = String(v);@@@ -1,49 +1,21 @@'use strict';--/**-- * openvibe-shared/analytics: request analytics within ADR-021 (OpenVibe.Contracts-- * docs/adr/ADR-021-analytics.md). A raw event has no IP, user or subject id, or city. Paths are route-- * templates and referers are reduced to origins. A user agent is stored as a class, sessions are-- * rotating ids, and raw events are pruned after 30 days. A request with `Sec-GPC: 1` or `DNT: 1` is-- * not recorded. The event shape is analytics/event.v1 (docs/schemas/analytics-event.v1.json).-- *-- * const { AnalyticsTracker } = require('openvibe-shared/analytics');-- * const tracker = new AnalyticsTracker(db, 'live', { retention: { days: 30 } }); // db: better-sqlite3-- * app.use(tracker.middleware());-- *-- * Parts (also available as subpaths): tracker, privacy, retention, schema, event, prune-cli.-- * No dependencies: the caller passes its own better-sqlite3 handle (and Database, for the CLI).-- */----const { AnalyticsTracker } = require('./tracker');++/** PostgreSQL analytics and the shared privacy and event helpers. */const privacy = require('./privacy');const retention = require('./retention');--const schema = require('./schema');const event = require('./event');----/**-- * @deprecated The pre-ADR-021 module's tuning object, kept so the 1.x export list stays complete-- * (register item C-80). It is frozen and read-only, and the tracker does not read it. It will be-- * removed in 2.0.0.-- */++const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('./pg');const SUSPICIOUS_PATTERNS = Object.freeze({highRequestRate: privacy.HIGH_REQUEST_RATE,rapidPageViews: 20,noJsExecution: true,honeypotPaths: Object.freeze([...privacy.HONEYPOT_PATHS]),});--module.exports = {-- AnalyticsTracker,-- privacy,-- retention,-- schema,-- event,-- ensureSchema: schema.ensureSchema,++ AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg,++ privacy, retention, event,optedOut: privacy.optedOut,-- // Names the pre-ADR module exported. They are kept for 1.x and now use the ADR-021 behaviour.classifyRequest: privacy.classifyRequest,parseUserAgent: privacy.parseUserAgent,-- ANALYTICS_SCHEMA: schema.ANALYTICS_SCHEMA,BOT_USER_AGENTS: privacy.BOT_USER_AGENTS,SUSPICIOUS_PATTERNS,};@@@ -1,7 +1,7 @@'use strict';/*** Request analytics on PostgreSQL (ADR-035; ADR-021's privacy bounds unchanged). The same tracker, tables and-- * getStats/getOverview/getBotAnalysis shapes as ./tracker.js, on an openvibe-sdk/db handle:++ * getStats/getOverview/getBotAnalysis shapes on an openvibe-sdk/db handle:** const { AnalyticsTrackerPg, analyticsSchema } = require('openvibe-shared/analytics/pg');* // migrations/NNNN_analytics.sql holds analyticsSchema()@@@ -9,7 +9,7 @@* app.use(tracker.middleware());* await tracker.flush(); await tracker.aggregate(); await tracker.getStats({ days: 7 });*-- * What differs from the SQLite tracker:++ * PostgreSQL request handling:* - the request path never waits for the database: record() buffers the row in memory, and flush() (every* 5 s, and at 100 rows) writes the buffer in one transaction;* - uniques across processes: the visitor hash needs the day's salt that every process of the service shares@@@ -21,7 +21,7 @@* - reads and rollups are async; SUM() over bigint comes back as numeric (exact), so every sum is cast.*/const crypto = require('crypto');--const { AnalyticsTracker, sqlTime, dayOf, hourOf } = require('./tracker');++const { AnalyticsTrackerCore, sqlTime, dayOf, hourOf } = require('./core');const privacy = require('./privacy');const MAX_BUFFER = 5000;@@@ -86,15 +86,15 @@ CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_d`.trim();}--class AnalyticsTrackerPg extends AnalyticsTracker {++class AnalyticsTrackerPg extends AnalyticsTrackerCore {/*** @param {object} db openvibe-sdk/db handle (the service's own database, analyticsSchema() migrated)* @param {string} service-- * @param {object} [opts] as AnalyticsTracker: paramPrefixes, pathRules, retention ({ days = 30, intervalMs,++ * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, intervalMs,* initialDelayMs } or false), timers, now*/constructor(db, service, opts = {}) {-- super(db, service, { ...opts, timers: false, __pg: true });++ super(db, service, opts);this._pending = []; // visitors waiting for the shared salt: { day, hour, ip, ua, authenticated }this._localKeys = new Map(); // day → process-local session keythis._salts = new Map(); // day → shared salt (cached after the first read)@@@ -281,7 +281,7 @@ class AnalyticsTrackerPg extends AnalyticsTracker {JSON.stringify(topCountries), JSON.stringify(devices), JSON.stringify(browsers)]);}-- // ── Reads (admin dashboards): the SQLite tracker's shapes ──++ // ── Reads (admin dashboards): dashboard shapes ──async getStats(options = {}) {const { days = 30, hours, service } = options;ddeleted file mode 100644@@@ -1,235 +0,0 @@--'use strict';--/**-- * The analytics-prune command (ADR-021): raw-event retention and the one-time scrub of pre-ADR rows,-- * for one or more analytics databases. A service keeps a thin scripts/analytics-prune.js that passes-- * its better-sqlite3 and its defaults:-- *-- * const Database = require('better-sqlite3');-- * const cli = require('openvibe-shared/analytics/prune-cli');-- * const main = (argv, log) => cli.main(argv, { Database, log, defaultDb: process.env.ANALYTICS_DB || 'data/analytics.db' });-- * if (require.main === module) cli.run(main);-- * module.exports = { main };-- *-- * Options given by the service (second argument of main):-- * Database better-sqlite3's constructor (required; this package has no native dependency)-- * log line logger (default console.log)-- * defaultDb the database when no --db is given: a path, or (args, env) => path-- * targets (args) => [{ name, file }]: the databases when no --db is given (several apps)-- * options extra repeatable value flags, { '--app': 'apps' } -> args.apps = [...]-- * paramPrefixes the service's extra parameter words } as for the tracker, so legacy rows are-- * pathRules the service's [RegExp, replacement] } templated like new ones-- * backupMode file mode of a backup (default 0o600: a pre-scrub backup holds the old IPs)-- * usage the help text (default: USAGE below)-- * root base for relative --db / --backup paths (default process.cwd())-- * env environment (default process.env)-- *-- * Exit codes: 0 done (or dry run), 1 a check failed (backup unreadable, rollup totals changed),-- * 2 refused (bad arguments, no backup choice, existing backup target, missing database).-- */----const fs = require('fs');--const path = require('path');--const retention = require('./retention');----const USAGE = `Raw analytics retention and one-time scrub (ADR-021).---- analytics-prune dry run: counts only, changes nothing-- analytics-prune --scrub dry run including what the scrub would rewrite-- analytics-prune --apply --backup <path> online backup, then prune-- analytics-prune --apply --scrub --backup <path>-- analytics-prune --apply --no-backup prune without a backup (explicit)----Options:-- --db <file> an analytics database (repeatable; default: the service's own)-- --days <n> keep raw events newer than n days, 1..30 (default 30)-- --scrub after pruning, rewrite the remaining rows: ip/user_id/city -> NULL, path -> route-- template, referer -> origin, user_agent -> class, legacy session ids -> NULL; same-- path/referer reduction in the rollups' top lists (their counts are untouched)-- --backup <path> one database: a new file; several: a directory, each backup <name>.analytics.db-- in it (sqlite online backup, owner-only, verified with quick_check and a row count)-- --no-backup explicitly skip the backup-- --no-vacuum skip the VACUUM that follows an --apply (VACUUM rewrites the file so pruned and-- scrubbed values do not survive in free pages; it needs ~2x the database size free-- and holds the database's write lock while it runs)-- --batch <n> rows per write batch (default 5000)----every database; any difference exits 1. Prune and scrub are safe while the service is up (short--batches; the tracker retries a busy flush).`;----function parseArgs(argv, extra = {}) {-- const a = {-- apply: false, scrub: false, vacuum: true, backup: null, noBackup: false,-- days: retention.MAX_DAYS, batch: retention.DEFAULT_BATCH, dbs: [], db: null,-- };-- for (const key of Object.values(extra)) a[key] = [];-- for (let i = 0; i < argv.length; i++) {-- const k = argv[i];-- const val = () => { if (i + 1 >= argv.length) throw new Error(`${k} needs a value`); return argv[++i]; };-- if (k === '--apply') a.apply = true;-- else if (k === '--scrub') a.scrub = true;-- else if (k === '--no-vacuum') a.vacuum = false;-- else if (k === '--no-backup') a.noBackup = true;-- else if (k === '--backup') a.backup = val();-- else if (k === '--days') a.days = Number(val());-- else if (k === '--batch') a.batch = Number(val());-- else if (k === '--db') a.dbs.push(val());-- else if (Object.prototype.hasOwnProperty.call(extra, k)) a[extra[k]].push(val());-- else if (k === '-h' || k === '--help') a.help = true;-- else throw new Error(`unknown option ${k}`);-- }-- a.db = a.dbs[0] || null;-- retention.checkDays(a.days);-- if (!Number.isInteger(a.batch) || a.batch < 1 || a.batch > 100000) throw new Error('--batch must be 1..100000');-- if (a.backup && a.noBackup) throw new Error('--backup and --no-backup are exclusive');-- return a;--}----/** [{ name, file }] for explicit --db paths: apps/<app>/data/analytics.db -> "<app>", else the file's base name. */--function namedTargets(files, root = process.cwd()) {-- const seen = new Map();-- return files.map((f) => {-- const file = path.resolve(root, f);-- let name = path.basename(path.dirname(file)) === 'data'-- ? path.basename(path.dirname(path.dirname(file)))-- : path.basename(file, path.extname(file));-- const n = (seen.get(name) || 0) + 1;-- seen.set(name, n);-- if (n > 1) name = `${name}-${n}`;-- return { name, file };-- });--}----function resolveTargets(args, opts) {-- const root = opts.root || process.cwd();-- if (args.dbs.length) return namedTargets(args.dbs, root);-- if (opts.targets) return opts.targets(args);-- const d = typeof opts.defaultDb === 'function' ? opts.defaultDb(args, opts.env || process.env) : opts.defaultDb;-- if (!d) return [];-- return namedTargets([d], root);--}----const mb = (n) => `${(n / 1048576).toFixed(1)} MB`;--function fileBytes(f) { try { return fs.statSync(f).size; } catch { return 0; } }--function freeBytes(dir) { try { const s = fs.statfsSync(dir); return s.bavail * s.bsize; } catch { return null; } }--const sameTotals = (a, b) => JSON.stringify(a) === JSON.stringify(b);----async function backupTo(Database, db, target, need, mode, expectRows, log) {-- if (fs.existsSync(target)) { log(`error: backup target ${target} already exists; choose a new path`); return 2; }-- const free = freeBytes(path.dirname(target));-- if (free != null && free < need * 1.1) { log(`error: ${mb(free)} free at ${path.dirname(target)}, the backup needs about ${mb(need)}`); return 2; }-- // The backup may hold pre-ADR rows (IPs, user ids): owner-only from the first byte.-- const umask = process.umask(0o077);-- try { await db.backup(target); } finally { process.umask(umask); }-- fs.chmodSync(target, mode);-- const b = new Database(target, { readonly: true });-- try {-- const ok = b.pragma('quick_check', { simple: true });-- const n = b.prepare("SELECT COUNT(*) FROM sqlite_master WHERE name = 'analytics_events'").pluck().get()-- ? b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get() : 0;-- if (ok !== 'ok' || n < expectRows) { log(`error: backup check failed (quick_check=${ok}, rows=${n})`); return 1; }-- } finally { b.close(); }-- log(`backup ${target} (${mb(fileBytes(target))}, mode ${(mode & 0o777).toString(8).padStart(4, '0')}, quick_check ok)`);-- return 0;--}----async function runOne(t, args, backupTarget, opts, log) {-- const { Database } = opts;-- const pathOpts = { paramPrefixes: opts.paramPrefixes, pathRules: opts.pathRules };-- if (!fs.existsSync(t.file)) { log(`error: ${t.file} does not exist`); return 2; }-- const db = new Database(t.file, args.apply ? { fileMustExist: true } : { readonly: true, fileMustExist: true });-- try {-- db.pragma('busy_timeout = 5000');-- const walBytes = fileBytes(t.file + '-wal');-- const before = retention.inspect(db, { days: args.days, ...pathOpts });-- log(`\n[${t.name}] ${t.file}`);-- log(`size ${mb(before.bytes)} (+ ${mb(walBytes)} WAL); a backup needs about ${mb(before.bytes + walBytes)}; VACUUM about ${mb(2 * before.bytes)} more`);-- log(`raw events ${before.events} (oldest ${before.oldest || '-'}, newest ${before.newest || '-'})`);-- log(`prune ${before.older} rows created before ${before.cutoff} UTC (${args.days} days); ${before.remaining} stay`);-- if (before.toScrub) {-- const s = before.toScrub;-- log(`scrub of the rows that stay: ${s.personal} with ip/user_id/city, ${s.paths} paths to template, ${s.referers} referers to origin, ${s.user_agents} user agents to class, ${s.sessions} legacy session ids${args.scrub ? '' : ' (needs --scrub)'}`);-- }-- log(`rate rows ${before.rateRows || 0} (IP counters; the tracker keeps them in memory now)`);-- for (const [k, v] of Object.entries(before.rollups)) log(`rollups ${k}: ${v.rows} rows, ${v.pageviews} pageviews, ${v.api_calls} api calls (kept)`);-- if (!args.apply) return 0;---- if (backupTarget) {-- const code = await backupTo(Database, db, backupTarget, before.bytes + walBytes, opts.backupMode, before.events, log);-- if (code) return code;-- }---- db.pragma('secure_delete = ON');-- const totalsBefore = retention.rollupTotals(db);-- const pruned = await retention.pruneRawEvents(db, { days: args.days, batchSize: args.batch });-- log(`pruned ${pruned.deleted} rows in ${pruned.batches} batches`);-- if (args.scrub) {-- const s = await retention.scrubEvents(db, { batchSize: args.batch, ...pathOpts });-- const r = retention.scrubRollups(db, pathOpts);-- log(`scrubbed ${s.rows} raw rows in ${s.batches} batches; rollup top lists rewritten in ${r.hourly} hourly and ${r.daily} daily rows`);-- }-- const totalsAfter = retention.rollupTotals(db);-- if (!sameTotals(totalsBefore, totalsAfter)) {-- log(`error: rollup totals changed!\n before ${JSON.stringify(totalsBefore)}\n after ${JSON.stringify(totalsAfter)}`);-- return 1;-- }-- log('rollups totals unchanged');-- if (args.vacuum && (pruned.deleted || args.scrub)) {-- db.pragma('wal_checkpoint(TRUNCATE)');-- db.exec('VACUUM');-- db.pragma('wal_checkpoint(TRUNCATE)');-- log(`vacuumed ${mb(fileBytes(t.file))}`);-- }-- const after = retention.inspect(db, { days: args.days, ...pathOpts });-- log(`now ${after.events} raw events, ${after.older} older than ${args.days} days${after.toScrub ? `, ${after.toScrub.personal} with ip/user_id/city` : ''}`);-- return 0;-- } finally {-- db.close();-- }--}----/** Run the command. Resolves the exit code; never calls process.exit. */--async function main(argv, opts = {}) {-- const log = opts.log || console.log;-- if (typeof opts.Database !== 'function') throw new TypeError('prune-cli: pass { Database } (better-sqlite3)');-- const o = { backupMode: 0o600, ...opts };-- let args;-- try { args = parseArgs(argv, o.options || {}); } catch (e) { log(`error: ${e.message}`); return 2; }-- if (args.help) { log(o.usage || USAGE); return 0; }-- const list = resolveTargets(args, o);-- if (!list.length) { log('no analytics databases found (use --db)'); return 2; }-- if (args.apply && !args.backup && !args.noBackup) {-- log('refusing --apply without a backup: pass --backup <new file or directory> (sqlite online backup) or --no-backup');-- return 2;-- }-- let backupFor = () => null;-- if (args.apply && args.backup) {-- const target = path.resolve(o.root || process.cwd(), args.backup);-- if (list.length === 1) {-- backupFor = () => target;-- } else {-- if (fs.existsSync(target) && !fs.statSync(target).isDirectory()) { log(`error: ${target} is a file; several databases need a directory`); return 2; }-- const clash = list.map((t) => path.join(target, `${t.name}.analytics.db`)).filter((f) => fs.existsSync(f));-- if (clash.length) { log(`error: backup targets already exist: ${clash.join(', ')}`); return 2; }-- fs.mkdirSync(target, { recursive: true, mode: 0o700 });-- backupFor = (t) => path.join(target, `${t.name}.analytics.db`);-- }-- }-- let worst = 0;-- for (const t of list) {-- const code = await runOne(t, args, backupFor(t), o, log);-- worst = Math.max(worst, code);-- if (code) { if (list.length > 1) log(`[${t.name}] stopped (exit ${code}); later databases not touched`); break; }-- }-- if (!args.apply && worst === 0) log('\ndry run: nothing changed. Re-run with --apply --backup <file|dir> (or --no-backup).');-- return worst;--}----/** For a service's script: `if (require.main === module) run(main)` — exits with main's code. */--function run(mainFn, argv = process.argv.slice(2)) {-- return Promise.resolve()-- .then(() => mainFn(argv))-- .then((code) => process.exit(code), (err) => { console.error(err); process.exit(1); });--}----module.exports = { USAGE, main, run, parseArgs, namedTargets, resolveTargets };@@@ -1,41 +1,9 @@'use strict';--/**-- * Raw analytics retention and the one-time scrub (ADR-021).-- *-- * pruneRawEvents(db, { days }) delete analytics_events rows older than `days` (≤ 30) in bounded-- * batches, yielding to the event loop between batches. Rollups-- * (analytics_hourly / analytics_daily) are never touched. Also clears-- * leftovers: visitor hashes older than yesterday, salts of past days,-- * and analytics_rate_tracking (IP counters live in memory now).-- * scrubEvents(db) rewrite the rows written before ADR-021: ip / user_id / city →-- * NULL (authenticated = 1 where a user id was present), path → route-- * template, referer → origin, user_agent → class, session ids that are-- * not rotating ids → NULL.-- * scrubRollups(db) the same path/referer reduction inside the rollups' top_paths /-- * top_referers JSON (counts merged, never changed).-- * inspect(db, { days }) read-only counts for a dry run.-- * rollupTotals(db) sums of every rollup counter, to prove a run left them alone.-- * schedulePrune(db, opts) the nightly prune on unref'd timers (first run 5 min after boot,-- * then every 24 h); returns { run, stop }.-- *-- * scrubEvents / scrubRollups / inspect take the service's path options ({ paramPrefixes, pathRules },-- * see privacy.normalisePath) so legacy rows are templated with the same rules as new ones.-- *-- * Used by the tracker (opts.retention), a service's own job runner, and ./prune-cli.js.-- */----const { ensureSchema } = require('./schema');--const privacy = require('./privacy');++/** Backend-independent analytics retention bounds. */++const { sqlTime } = require('./core');const MAX_DAYS = 30;const DEFAULT_BATCH = 5000;--const ROTATING_SESSION_RE = /^[0-9a-f]{16}$/;----const pad = (n) => String(n).padStart(2, '0');--function sqlTime(ms) {-- const d = new Date(ms);-- return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;--}function checkDays(days) {const n = Number(days);@@@ -50,199 +18,4 @@ function cutoffFor(days, nowMs = Date.now()) {return sqlTime(nowMs - checkDays(days) * 86400000);}--const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);--const columnExists = (db, table, col) => db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);--const tick = () => new Promise((r) => setImmediate(r));----/**-- * Delete raw events older than `days` in batches of `batchSize` (each batch its own short write-- * transaction). Returns { cutoff, deleted, batches, complete }.-- */--async function pruneRawEvents(db, { days = MAX_DAYS, batchSize = DEFAULT_BATCH, maxBatches = Infinity, now = Date.now } = {}) {-- const cutoff = cutoffFor(days, now());-- const out = { cutoff, deleted: 0, batches: 0, complete: true };-- if (!tableExists(db, 'analytics_events')) return out;-- const del = db.prepare('DELETE FROM analytics_events WHERE id IN (SELECT id FROM analytics_events WHERE created_at < ? LIMIT ?)');-- for (;;) {-- if (out.batches >= maxBatches) { out.complete = false; break; }-- const n = del.run(cutoff, batchSize).changes;-- out.batches++;-- out.deleted += n;-- if (n < batchSize) break;-- await tick();-- }-- const today = sqlTime(now()).slice(0, 10);-- const yesterday = sqlTime(now() - 86400000).slice(0, 10);-- if (tableExists(db, 'analytics_visitor_days')) db.prepare('DELETE FROM analytics_visitor_days WHERE day < ?').run(yesterday);-- if (tableExists(db, 'analytics_day_salts')) db.prepare('DELETE FROM analytics_day_salts WHERE day < ?').run(today);-- if (tableExists(db, 'analytics_rate_tracking')) db.prepare('DELETE FROM analytics_rate_tracking').run();-- return out;--}----/** SQL functions for the scrub and the dry-run counts (connection-scoped, safe on a read-only handle). */--function registerFunctions(db, { paramPrefixes, pathRules } = {}) {-- const opts = privacy.pathOptions({ paramPrefixes, pathRules });-- db.function('ov_route_template', { deterministic: true }, (p) => (p == null ? null : privacy.normalisePath(p, opts)));-- db.function('ov_referer_origin', { deterministic: true }, (r) => (r == null || r === '' ? r : privacy.refererOrigin(r)));-- db.function('ov_ua_class', { deterministic: true }, (u) => privacy.uaClass(u));-- db.function('ov_rotating_session', { deterministic: true }, (s) => (s != null && ROTATING_SESSION_RE.test(String(s)) ? s : null));--}----/** Rewrite every raw row to the ADR-021 fields, in id-range batches. Returns { rows, batches }. */--async function scrubEvents(db, { batchSize = DEFAULT_BATCH, paramPrefixes, pathRules } = {}) {-- ensureSchema(db);-- registerFunctions(db, { paramPrefixes, pathRules });-- const { lo, hi } = db.prepare('SELECT MIN(id) AS lo, MAX(id) AS hi FROM analytics_events').get();-- const out = { rows: 0, batches: 0 };-- if (lo == null) return out;-- const upd = db.prepare(`-- UPDATE analytics_events SET-- authenticated = CASE WHEN user_id IS NOT NULL THEN 1 ELSE authenticated END,-- ip = NULL, user_id = NULL, city = NULL,-- session_id = ov_rotating_session(session_id),-- path = ov_route_template(path),-- referer = ov_referer_origin(referer),-- user_agent = ov_ua_class(user_agent)-- WHERE id >= ? AND id < ?-- `);-- for (let from = lo; from <= hi; from += batchSize) {-- out.rows += upd.run(from, from + batchSize).changes;-- out.batches++;-- await tick();-- }-- return out;--}----/** Re-template a rollup's top list ([{ path|referer, cnt }]) — counts are summed, never altered. */--function reduceTopList(json, key, fn) {-- let list;-- try { list = JSON.parse(json); } catch { return null; }-- if (!Array.isArray(list)) return null;-- const merged = new Map();-- for (const item of list) {-- if (!item || typeof item !== 'object') continue;-- const k = fn(item[key]);-- if (k == null) continue;-- merged.set(k, (merged.get(k) || 0) + (Number(item.cnt) || 0));-- }-- const next = [...merged].map(([k, cnt]) => ({ [key]: k, cnt })).sort((a, b) => b.cnt - a.cnt);-- const text = JSON.stringify(next);-- return text === json ? null : text;--}----/** Scrub top_paths / top_referers in both rollup tables. Returns { hourly, daily } rows changed. */--function scrubRollups(db, { paramPrefixes, pathRules } = {}) {-- const opts = privacy.pathOptions({ paramPrefixes, pathRules });-- const tpl = (p) => (p == null ? null : privacy.normalisePath(p, opts));-- const out = { hourly: 0, daily: 0 };-- for (const table of ['analytics_hourly', 'analytics_daily']) {-- if (!tableExists(db, table)) continue;-- const rows = db.prepare(`SELECT id, top_paths, top_referers FROM ${table}`).all();-- const upd = db.prepare(`UPDATE ${table} SET top_paths = ?, top_referers = ? WHERE id = ?`);-- db.transaction(() => {-- for (const r of rows) {-- const paths = r.top_paths ? reduceTopList(r.top_paths, 'path', tpl) : null;-- const refs = r.top_referers ? reduceTopList(r.top_referers, 'referer', privacy.refererOrigin) : null;-- if (paths == null && refs == null) continue;-- upd.run(paths != null ? paths : r.top_paths, refs != null ? refs : r.top_referers, r.id);-- out[table === 'analytics_hourly' ? 'hourly' : 'daily']++;-- }-- })();-- }-- return out;--}----/** Sums of every rollup counter; equal before and after a prune or scrub. */--function rollupTotals(db) {-- const out = {};-- if (tableExists(db, 'analytics_hourly')) {-- out.hourly = db.prepare(`SELECT COUNT(*) AS rows, TOTAL(pageviews) AS pageviews, TOTAL(api_calls) AS api_calls,-- TOTAL(unique_visitors) AS unique_visitors, TOTAL(unique_users) AS unique_users, TOTAL(bot_hits) AS bot_hits,-- TOTAL(error_count) AS error_count FROM analytics_hourly`).get();-- }-- if (tableExists(db, 'analytics_daily')) {-- out.daily = db.prepare(`SELECT COUNT(*) AS rows, TOTAL(pageviews) AS pageviews, TOTAL(api_calls) AS api_calls,-- TOTAL(unique_visitors) AS unique_visitors, TOTAL(unique_users) AS unique_users, TOTAL(new_users) AS new_users,-- TOTAL(bot_hits) AS bot_hits, TOTAL(error_count) AS error_count FROM analytics_daily`).get();-- }-- return out;--}----/** Read-only counts for a dry run. */--function inspect(db, { days = MAX_DAYS, now = Date.now, paramPrefixes, pathRules } = {}) {-- const cutoff = cutoffFor(days, now());-- const out = { cutoff, events: 0, older: 0, remaining: 0, oldest: null, newest: null };-- const pageSize = db.pragma('page_size', { simple: true });-- out.bytes = db.pragma('page_count', { simple: true }) * pageSize;-- out.freeBytes = db.pragma('freelist_count', { simple: true }) * pageSize;-- out.rollups = rollupTotals(db);-- if (!tableExists(db, 'analytics_events')) return out;-- registerFunctions(db, { paramPrefixes, pathRules });-- const e = db.prepare('SELECT COUNT(*) AS n, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM analytics_events').get();-- out.events = e.n;-- out.oldest = e.oldest;-- out.newest = e.newest;-- out.older = db.prepare('SELECT COUNT(*) FROM analytics_events WHERE created_at < ?').pluck().get(cutoff);-- out.remaining = out.events - out.older;-- const personal = ['ip', 'user_id', 'city'].filter((c) => columnExists(db, 'analytics_events', c))-- .map((c) => `${c} IS NOT NULL`).join(' OR ') || '0';-- const r = db.prepare(`-- SELECT-- COUNT(*) FILTER (WHERE ${personal}) AS personal,-- COUNT(*) FILTER (WHERE path IS NOT ov_route_template(path)) AS paths,-- COUNT(*) FILTER (WHERE referer IS NOT ov_referer_origin(referer)) AS referers,-- COUNT(*) FILTER (WHERE user_agent IS NOT ov_ua_class(user_agent)) AS user_agents,-- COUNT(*) FILTER (WHERE session_id IS NOT ov_rotating_session(session_id)) AS sessions-- FROM analytics_events WHERE created_at >= ?-- `).get(cutoff);-- out.toScrub = r;-- out.rateRows = tableExists(db, 'analytics_rate_tracking') ? db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get() : 0;-- return out;--}----/**-- * Run pruneRawEvents(db, { days }) on timers: first after `initialDelayMs` (default 5 min), then every-- * `intervalMs` (default 24 h). Timers are unref'd, so they never hold the process open. An overlapping-- * run is skipped (resolves null). `label` names the service in log lines.-- * Returns { run, stop }: run() prunes now (resolves the prune result, or null on error/overlap).-- */--function schedulePrune(db, {-- days = MAX_DAYS, batchSize = DEFAULT_BATCH, initialDelayMs = 5 * 60 * 1000, intervalMs = 24 * 60 * 60 * 1000,-- label = 'Analytics', log = console.log, logError = console.error, now = Date.now,--} = {}) {-- checkDays(days);-- let running = false;-- const run = async () => {-- if (running) return null;-- running = true;-- try {-- const out = await pruneRawEvents(db, { days, batchSize, now });-- if (out.deleted) log(`[${label}] pruned ${out.deleted} raw events older than ${out.cutoff}`);-- return out;-- } catch (err) {-- logError(`[${label}] analytics-prune error:`, err.message);-- return null;-- } finally {-- running = false;-- }-- };-- const first = setTimeout(run, initialDelayMs);-- const every = setInterval(run, intervalMs);-- if (first.unref) first.unref();-- if (every.unref) every.unref();-- return { run, stop() { clearTimeout(first); clearInterval(every); } };--}----module.exports = {-- MAX_DAYS,-- DEFAULT_BATCH,-- checkDays,-- cutoffFor,-- pruneRawEvents,-- scrubEvents,-- scrubRollups,-- rollupTotals,-- inspect,-- reduceTopList,-- schedulePrune,--};++module.exports = { MAX_DAYS, DEFAULT_BATCH, checkDays, cutoffFor };ddeleted file mode 100644@@@ -1,123 +0,0 @@--'use strict';--/**-- * Analytics database schema (ADR-021). Same tables as the openvibe-shared tracker it replaces, so the-- * existing analytics.db files and the admin dashboards keep working; the columns that used to hold-- * personal data (ip, user_id, city) stay in the table for compatibility but are always NULL now, and-- * user_agent / referer / path hold the reduced forms (class, origin, route template).-- *-- * Added:-- * analytics_events.authenticated 0/1 — whether the request was signed in (a flag, never who)-- * analytics_visitor_days the day's salted visitor hashes, for the unique-visitor rollups;-- * rows are deleted once the day's rollup is final (≤ ~25 h)-- * analytics_day_salts the random salt of the current UTC day (so a restart does not-- * double-count); deleted together with that day's hashes-- * Emptied: analytics_rate_tracking (the rate check keeps per-IP counters in memory only now)-- */----const ANALYTICS_SCHEMA = `-- CREATE TABLE IF NOT EXISTS analytics_events (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- service TEXT NOT NULL,-- event_type TEXT NOT NULL DEFAULT 'pageview',-- path TEXT,-- method TEXT DEFAULT 'GET',-- status_code INTEGER,-- response_time_ms INTEGER,-- user_id INTEGER,-- session_id TEXT,-- ip TEXT,-- country TEXT,-- city TEXT,-- user_agent TEXT,-- referer TEXT,-- is_bot INTEGER DEFAULT 0,-- bot_type TEXT,-- device_type TEXT,-- browser TEXT,-- os TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );---- CREATE TABLE IF NOT EXISTS analytics_hourly (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- service TEXT NOT NULL,-- hour TEXT NOT NULL,-- pageviews INTEGER DEFAULT 0,-- api_calls INTEGER DEFAULT 0,-- unique_visitors INTEGER DEFAULT 0,-- unique_users INTEGER DEFAULT 0,-- bot_hits INTEGER DEFAULT 0,-- avg_response_ms INTEGER DEFAULT 0,-- error_count INTEGER DEFAULT 0,-- bandwidth_bytes INTEGER DEFAULT 0,-- top_paths TEXT,-- top_referers TEXT,-- UNIQUE(service, hour)-- );---- CREATE TABLE IF NOT EXISTS analytics_daily (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- service TEXT NOT NULL,-- date TEXT NOT NULL,-- pageviews INTEGER DEFAULT 0,-- api_calls INTEGER DEFAULT 0,-- unique_visitors INTEGER DEFAULT 0,-- unique_users INTEGER DEFAULT 0,-- new_users INTEGER DEFAULT 0,-- bot_hits INTEGER DEFAULT 0,-- avg_response_ms INTEGER DEFAULT 0,-- error_count INTEGER DEFAULT 0,-- top_paths TEXT,-- top_referers TEXT,-- top_countries TEXT,-- device_breakdown TEXT,-- browser_breakdown TEXT,-- UNIQUE(service, date)-- );---- CREATE TABLE IF NOT EXISTS analytics_rate_tracking (-- ip TEXT NOT NULL,-- window_start INTEGER NOT NULL,-- hit_count INTEGER DEFAULT 1,-- PRIMARY KEY (ip, window_start)-- );---- CREATE TABLE IF NOT EXISTS analytics_visitor_days (-- service TEXT NOT NULL,-- day TEXT NOT NULL,-- hour TEXT NOT NULL,-- vhash TEXT NOT NULL,-- authenticated INTEGER NOT NULL DEFAULT 0,-- PRIMARY KEY (service, hour, vhash)-- );---- CREATE TABLE IF NOT EXISTS analytics_day_salts (-- service TEXT NOT NULL,-- day TEXT NOT NULL,-- salt TEXT NOT NULL,-- PRIMARY KEY (service, day)-- );---- CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events(created_at);-- CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events(service, created_at);-- CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events(service, path, created_at);-- CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events(is_bot, created_at);-- CREATE INDEX IF NOT EXISTS idx_analytics_hourly_lookup ON analytics_hourly(service, hour);-- CREATE INDEX IF NOT EXISTS idx_analytics_daily_lookup ON analytics_daily(service, date);-- CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_days(service, day);---- -- Indexes over the retired personal columns.-- DROP INDEX IF EXISTS idx_analytics_events_ip;-- DROP INDEX IF EXISTS idx_analytics_events_user;--`;----/** Create or upgrade the analytics tables in place. Idempotent; never touches rows. */--function ensureSchema(db) {-- db.exec(ANALYTICS_SCHEMA);-- const cols = db.prepare('PRAGMA table_info(analytics_events)').all().map((c) => c.name);-- if (!cols.includes('authenticated')) {-- db.exec('ALTER TABLE analytics_events ADD COLUMN authenticated INTEGER NOT NULL DEFAULT 0');-- }--}----module.exports = { ANALYTICS_SCHEMA, ensureSchema };ddeleted file mode 100644@@@ -1,754 +0,0 @@--'use strict';--/**-- * Request analytics within the ADR-021 privacy bounds. Drop-in replacement for the pre-ADR-- * openvibe-shared AnalyticsTracker (same constructor, tables and getStats/getOverview/getBotAnalysis-- * shapes), minus the personal data:-- *-- * raw row service, event_type, route template, method, status, response time, rotating session-- * id, country (CDN header), user-agent class + browser/os/device, referer ORIGIN,-- * bot flags, signed-in flag, timestamp. ip / user_id / city are always NULL.-- * session id random 16 hex chars, held in memory against the day's visitor hash; a new one after-- * 30 minutes idle and at every UTC midnight. Never derived from, or equal to, a user id.-- * rate check per-IP hit counters for the current and previous minute, IN MEMORY only (dropped-- * after 2 minutes idle; analytics_rate_tracking is emptied on boot and no longer written).-- * uniques visitor hash = HMAC-SHA256(day salt, ip + "\n" + user agent), first 16 hex chars.-- * The salt is random per UTC day and service (analytics_day_salts, deleted as soon as-- * the day is over); hashes go to analytics_visitor_days only, never to raw events,-- * and are deleted by the first aggregation after their day ends, right after that day's-- * final hourly/daily rollup — so no hash or salt outlives its day by more than about-- * one aggregation interval (1 h). Rollups keep only the counts.-- *-- * opt-out a request with `Sec-GPC: 1` or `DNT: 1` is not recorded at all (privacy.optedOut):-- * no row, no visitor hash, no session id, no rate counter.-- *-- * Retention of raw events (30 days) is ./retention.js; this tracker writes, rolls up and (with-- * opts.retention) schedules the prune. The row shape is analytics/event.v1 (./event.js).-- */----const crypto = require('crypto');--const { ensureSchema } = require('./schema');--const privacy = require('./privacy');--const retention = require('./retention');--const { allowed } = require('./event');----const SESSION_IDLE_MS = 30 * 60 * 1000;--const MAX_TRACKED_CLIENTS = 200000;--const MAX_BUFFER = 5000;--const SESSION_ID_RE = /^[0-9a-f]{16}$/;--const METHOD_RE = /^[A-Z][A-Z-]{0,15}$/;--const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;--const ALLOWED = { device: allowed('device'), browser: allowed('browser'), os: allowed('os') };----const pad = (n) => String(n).padStart(2, '0');--/** 'YYYY-MM-DD HH:MM:SS' in UTC, the format of CURRENT_TIMESTAMP. */--function sqlTime(ms) {-- const d = new Date(ms);-- return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;--}--const dayOf = (ms) => sqlTime(ms).slice(0, 10);--const hourOf = (ms) => sqlTime(ms).slice(0, 13) + ':00:00';--const parseSqlTime = (s) => Date.parse(s.replace(' ', 'T') + 'Z');----function isStaticOrHealth(path) {-- return path === '/api/health' || path === '/health' ||-- /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path);--}----class AnalyticsTracker {-- /**-- * @param {object} db better-sqlite3 handle (its own analytics database)-- * @param {string} service service name ('live', 'openvibe-yt', …)-- * @param {object} [opts]-- * @param {string[]} [opts.paramPrefixes] extra words whose next path segment is a parameter-- * (added to privacy.DEFAULT_PARAM_PREFIXES), e.g. Network's ['avatar', 'anon', 'projects']-- * @param {Array<[RegExp, string]>} [opts.pathRules] rewrites applied to a raw path before it is-- * templated, for parameters the segment rules cannot see (privacy.normalisePath)-- * @param {false|object} [opts.retention] { days = 30, intervalMs = 24 h, initialDelayMs = 5 min }:-- * the raw-event prune on a timer (retention.schedulePrune; the job is this.pruneJob). On by-- * default (ADR-021: raw events at most 30 days); pass false only when the service schedules-- * retention.pruneRawEvents itself.-- * @param {boolean} [opts.timers=true] false: no flush/aggregate timers (tests call flush/aggregate)-- * @param {function} [opts.now] clock (tests)-- */-- constructor(db, service, opts = {}) {-- this.db = db;-- this.service = service;-- this._now = opts.now || Date.now;-- this._pathOpts = privacy.pathOptions({ paramPrefixes: opts.paramPrefixes, pathRules: opts.pathRules });-- this.pruneJob = null;-- this._buffer = [];-- this._visitors = new Map(); // `${hour}|${vhash}` -> { day, hour, vhash, authenticated }-- this._sessions = new Map(); // vhash -> { sid, last }-- this._rates = new Map(); // client ip -> { minute, count, prev } (memory only)-- this._saltDay = null;-- this._salt = null;-- this._timers = [];---- if (opts.__pg) return; // analytics/pg.js: an openvibe-sdk/db handle, its own writes and timers-- ensureSchema(db);-- try { db.pragma('secure_delete = ON'); } catch { /* not fatal */ }-- try { db.pragma('busy_timeout = 250'); } catch { /* not fatal */ }-- db.exec('DELETE FROM analytics_rate_tracking');---- this._insertEvent = db.prepare(`-- INSERT INTO analytics_events-- (service, event_type, path, method, status_code, response_time_ms,-- user_id, session_id, ip, country, city, user_agent, referer,-- is_bot, bot_type, device_type, browser, os, authenticated, created_at)-- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)-- `);-- this._upsertVisitor = db.prepare(`-- INSERT INTO analytics_visitor_days (service, day, hour, vhash, authenticated) VALUES (?, ?, ?, ?, ?)-- ON CONFLICT(service, hour, vhash) DO UPDATE SET authenticated = MAX(authenticated, excluded.authenticated)-- `);-- this._write = db.transaction((events, visitors) => {-- for (const e of events) {-- this._insertEvent.run(-- e.service, e.event_type, e.path, e.method, e.status_code, e.response_time_ms,-- e.session_id, e.country, e.user_agent, e.referer,-- e.is_bot ? 1 : 0, e.bot_type, e.device_type, e.browser, e.os, e.authenticated ? 1 : 0, e.created_at,-- );-- }-- for (const v of visitors) this._upsertVisitor.run(this.service, v.day, v.hour, v.vhash, v.authenticated ? 1 : 0);-- });---- if (opts.timers !== false) {-- const every = (fn, ms) => { const t = setInterval(fn, ms); if (t.unref) t.unref(); this._timers.push(t); };-- const once = (fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); this._timers.push(t); };-- every(() => { this.flush(); this._sweep(); }, 5000);-- every(() => this.aggregate(), 60 * 60 * 1000);-- once(() => this.aggregate(), 10_000);-- if (opts.retention !== false) {-- const r = opts.retention && typeof opts.retention === 'object' ? opts.retention : {};-- this.pruneJob = retention.schedulePrune(db, { label: `Analytics:${service}`, now: this._now, ...r });-- }-- }-- }---- // ── Per-request reducers ──────────────────────────────────---- _daySalt(day) {-- if (this._saltDay === day) return this._salt;-- let row = this.db.prepare('SELECT salt FROM analytics_day_salts WHERE service = ? AND day = ?').get(this.service, day);-- if (!row) {-- this.db.prepare('INSERT OR IGNORE INTO analytics_day_salts (service, day, salt) VALUES (?, ?, ?)')-- .run(this.service, day, crypto.randomBytes(32).toString('hex'));-- row = this.db.prepare('SELECT salt FROM analytics_day_salts WHERE service = ? AND day = ?').get(this.service, day);-- }-- // Earlier days' salts are never needed again: their hashes are already written.-- this.db.prepare('DELETE FROM analytics_day_salts WHERE service = ? AND day < ?').run(this.service, day);-- this._saltDay = day;-- this._salt = row.salt;-- this._sessions.clear();-- return this._salt;-- }---- _visitorHash(ip, ua, day) {-- return crypto.createHmac('sha256', this._daySalt(day)).update(`${ip}\n${ua}`).digest('hex').slice(0, 16);-- }---- _sessionId(vhash, nowMs) {-- let s = this._sessions.get(vhash);-- if (!s || nowMs - s.last > SESSION_IDLE_MS) {-- s = { sid: crypto.randomBytes(8).toString('hex'), last: nowMs };-- this._sessions.set(vhash, s);-- }-- s.last = nowMs;-- return s.sid;-- }---- /** Hits from this client in the current and previous minute (memory only). */-- _rateHit(ip, nowMs) {-- const minute = Math.floor(nowMs / 60000);-- let r = this._rates.get(ip);-- if (!r) { r = { minute, count: 0, prev: 0 }; this._rates.set(ip, r); }-- if (r.minute !== minute) {-- r.prev = r.minute === minute - 1 ? r.count : 0;-- r.count = 0;-- r.minute = minute;-- }-- r.count++;-- return r.count + r.prev;-- }---- _sweep() {-- const nowMs = this._now();-- const minute = Math.floor(nowMs / 60000);-- for (const [ip, r] of this._rates) if (r.minute < minute - 1) this._rates.delete(ip);-- for (const [h, s] of this._sessions) if (nowMs - s.last > SESSION_IDLE_MS) this._sessions.delete(h);-- if (this._rates.size > MAX_TRACKED_CLIENTS) this._rates.clear();-- if (this._sessions.size > MAX_TRACKED_CLIENTS) this._sessions.clear();-- }---- _push(event, visitor) {-- if (this._buffer.length >= MAX_BUFFER) this._buffer.shift();-- this._buffer.push(event);-- if (visitor) {-- const key = `${visitor.hour}|${visitor.vhash}`;-- const prev = this._visitors.get(key);-- if (!prev) this._visitors.set(key, visitor);-- else if (visitor.authenticated) prev.authenticated = true;-- }-- if (this._buffer.length >= 100) this.flush();-- }---- /**-- * Record one finished request. `entryPath` is the path as it arrived (req.path inside a mounted-- * router is relative by the time the response finishes). Returns false, recording nothing, for an-- * opted-out request (Sec-GPC: 1 / DNT: 1); true otherwise.-- */-- record(req, res, entryPath, responseTimeMs) {-- const headers = req.headers || {};-- if (privacy.optedOut(headers)) return false;-- entryPath = String(entryPath || '');-- const nowMs = this._now();-- const ua = String(headers['user-agent'] || '');-- const ip = String(req.ip || (req.socket && req.socket.remoteAddress) || '');-- const bot = privacy.classifyRequest({ headers, path: entryPath });-- if (this._rateHit(ip, nowMs) > privacy.HIGH_REQUEST_RATE && !bot.isBot) {-- bot.isBot = true;-- bot.botType = 'rate_limit';-- bot.confidence = 0.80;-- }-- const parsed = privacy.parseUserAgent(ua);-- const day = dayOf(nowMs);-- const vhash = this._visitorHash(ip, ua, day);-- const authenticated = !!(req.user && (req.user.id != null || req.user.sub != null));-- const isApi = entryPath.startsWith('/api/') || entryPath.startsWith('/internal/') || entryPath.startsWith('/oauth/');-- this._push({-- service: this.service,-- event_type: isApi ? 'api_call' : 'pageview',-- path: privacy.routeTemplate(req, entryPath, this._pathOpts),-- method: METHOD_RE.test(String(req.method || '')) ? req.method : null,-- status_code: res.statusCode,-- response_time_ms: responseTimeMs,-- session_id: this._sessionId(vhash, nowMs),-- country: privacy.countryCode(headers['cf-ipcountry']),-- user_agent: privacy.uaClass(ua),-- referer: privacy.refererOrigin(headers.referer || headers.referrer),-- is_bot: bot.isBot,-- bot_type: bot.botType,-- device_type: parsed.device,-- browser: parsed.browser,-- os: parsed.os,-- authenticated,-- created_at: sqlTime(nowMs),-- }, { day, hour: hourOf(nowMs), vhash, authenticated });-- return true;-- }---- /**-- * Express middleware: one row per finished request (health checks, static assets and opted-out-- * requests skipped).-- */-- middleware() {-- return (req, res, next) => {-- const entryPath = req.path || '';-- if (isStaticOrHealth(entryPath) || privacy.optedOut(req.headers)) return next();-- const started = Date.now();-- let done = false;-- const finish = () => {-- if (done) return;-- done = true;-- try { this.record(req, res, entryPath, Date.now() - started); }-- catch (err) { console.error(`[Analytics:${this.service}] record error:`, err.message); }-- };-- res.once('finish', finish);-- res.once('close', finish);-- next();-- };-- }---- /**-- * A custom (non-HTTP) event. Personal fields in `data` (ip, user_id, city) are ignored; path,-- * referer and user_agent are reduced like a request's; session_id is kept only if it is one of-- * this tracker's rotating ids; any other value outside analytics/event.v1 is stored as NULL, and-- * an event name outside it throws a TypeError. Pass the triggering request's headers as-- * `data.headers` when there is one: an opted-out request (Sec-GPC / DNT) records nothing.-- * Returns whether it recorded.-- */-- trackEvent(eventType, data = {}) {-- const name = String(eventType == null ? 'event' : eventType);-- if (!EVENT_NAME_RE.test(name)) throw new TypeError(`analytics event name must match ${EVENT_NAME_RE} (analytics/event.v1)`);-- if (privacy.optedOut(data.headers)) return false;-- const nowMs = this._now();-- const ua = data.user_agent ? String(data.user_agent) : '';-- const parsed = ua ? privacy.parseUserAgent(ua) : {};-- const method = data.method ? String(data.method).toUpperCase() : null;-- const status = Number(data.status_code);-- const ms = Number(data.response_time_ms);-- const botType = data.bot_type ? String(data.bot_type) : null;-- const pick = (field, given, fallback) => (ALLOWED[field].includes(given) ? given : (fallback || null));-- this._push({-- service: this.service,-- event_type: name,-- path: data.path ? privacy.normalisePath(data.path, this._pathOpts) : null,-- method: method && METHOD_RE.test(method) ? method : null,-- status_code: Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,-- response_time_ms: Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : null,-- session_id: SESSION_ID_RE.test(String(data.session_id || '')) ? data.session_id : null,-- country: privacy.countryCode(data.country),-- user_agent: ua ? privacy.uaClass(ua) : null,-- referer: privacy.refererOrigin(data.referer),-- is_bot: !!data.is_bot,-- bot_type: botType && /^[a-z0-9_]{1,32}$/.test(botType) ? botType : null,-- device_type: pick('device', data.device_type, parsed.device),-- browser: pick('browser', data.browser, parsed.browser),-- os: pick('os', data.os, parsed.os),-- authenticated: !!data.authenticated,-- created_at: sqlTime(nowMs),-- }, null);-- return true;-- }---- /** Write buffered events and visitor hashes. On a busy database they are kept for the next flush. */-- flush() {-- if (!this._buffer.length && !this._visitors.size) return;-- const events = this._buffer.splice(0);-- const visitors = [...this._visitors.values()];-- this._visitors.clear();-- try {-- this._write(events, visitors);-- } catch (err) {-- console.error(`[Analytics:${this.service}] Flush error:`, err.message);-- this._buffer = events.slice(-MAX_BUFFER);-- for (const v of visitors) this._visitors.set(`${v.hour}|${v.vhash}`, v);-- }-- }---- // ── Rollups ───────────────────────────────────────────────---- /**-- * Hourly + daily rollups for the last 2 hours / 2 days, plus every earlier day that still has-- * visitor hashes (finalised here), after which those hashes are deleted.-- */-- aggregate() {-- try {-- this.flush();-- const nowMs = this._now();-- const today = dayOf(nowMs);-- const hours = new Set([hourOf(nowMs), hourOf(nowMs - 3600000)]);-- const days = new Set([today, dayOf(nowMs - 86400000)]);-- const pending = this.db.prepare('SELECT DISTINCT day, hour FROM analytics_visitor_days WHERE service = ? AND day < ?').all(this.service, today);-- for (const p of pending) { hours.add(p.hour); days.add(p.day); }-- for (const h of hours) this._aggregateHour(h);-- for (const d of days) this._aggregateDay(d);-- this.db.prepare('DELETE FROM analytics_visitor_days WHERE service = ? AND day < ?').run(this.service, today);-- this.db.prepare('DELETE FROM analytics_day_salts WHERE service = ? AND day < ?').run(this.service, today);-- } catch (err) {-- console.error(`[Analytics:${this.service}] Aggregation error:`, err.message);-- }-- }---- _aggregateHour(hourStart) {-- const nextHour = sqlTime(parseSqlTime(hourStart) + 3600000);-- const svc = this.service;-- const stats = this.db.prepare(`-- SELECT-- COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,-- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,-- COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,-- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,-- COUNT(*) FILTER (WHERE status_code >= 400) AS error_count-- FROM analytics_events-- WHERE service = ? AND created_at >= ? AND created_at < ?-- `).get(svc, hourStart, nextHour);-- if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) return;---- // Uniques only grow within a period, so never lower a stored count (restarts, or rows-- // counted before this tracker when the old one still stored IPs).-- const v = this.db.prepare('SELECT COUNT(*) AS visitors, COALESCE(SUM(authenticated), 0) AS users FROM analytics_visitor_days WHERE service = ? AND hour = ?').get(svc, hourStart);-- const prev = this.db.prepare('SELECT unique_visitors, unique_users FROM analytics_hourly WHERE service = ? AND hour = ?').get(svc, hourStart) || {};---- const topPaths = this.db.prepare(`-- SELECT path, COUNT(*) as cnt FROM analytics_events-- WHERE service = ? AND created_at >= ? AND created_at < ? AND is_bot = 0-- GROUP BY path ORDER BY cnt DESC LIMIT 10-- `).all(svc, hourStart, nextHour);-- const topReferers = this.db.prepare(`-- SELECT referer, COUNT(*) as cnt FROM analytics_events-- WHERE service = ? AND created_at >= ? AND created_at < ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0-- GROUP BY referer ORDER BY cnt DESC LIMIT 10-- `).all(svc, hourStart, nextHour);---- this.db.prepare(`-- INSERT INTO analytics_hourly-- (service, hour, pageviews, api_calls, unique_visitors, unique_users, bot_hits, avg_response_ms, error_count, top_paths, top_referers)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)-- ON CONFLICT(service, hour) DO UPDATE SET-- pageviews = excluded.pageviews, api_calls = excluded.api_calls,-- unique_visitors = excluded.unique_visitors, unique_users = excluded.unique_users,-- bot_hits = excluded.bot_hits, avg_response_ms = excluded.avg_response_ms,-- error_count = excluded.error_count, top_paths = excluded.top_paths, top_referers = excluded.top_referers-- `).run(-- svc, hourStart, stats.pageviews, stats.api_calls,-- Math.max(prev.unique_visitors || 0, v.visitors || 0), Math.max(prev.unique_users || 0, v.users || 0),-- stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,-- JSON.stringify(topPaths), JSON.stringify(topReferers),-- );-- }---- _aggregateDay(date) {-- const dayStart = date + ' 00:00:00';-- const dayEnd = date + ' 23:59:59';-- const svc = this.service;-- const q = (sql) => this.db.prepare(sql);-- const stats = q(`-- SELECT-- COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,-- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,-- COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,-- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,-- COUNT(*) FILTER (WHERE status_code >= 400) AS error_count-- FROM analytics_events-- WHERE service = ? AND created_at >= ? AND created_at <= ?-- `).get(svc, dayStart, dayEnd);-- if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) return;---- const v = q(`-- SELECT COUNT(DISTINCT vhash) AS visitors,-- COUNT(DISTINCT CASE WHEN authenticated = 1 THEN vhash END) AS users-- FROM analytics_visitor_days WHERE service = ? AND day = ?-- `).get(svc, date);-- const prev = q('SELECT unique_visitors, unique_users, new_users FROM analytics_daily WHERE service = ? AND date = ?').get(svc, date) || {};---- const range = [svc, dayStart, dayEnd];-- const topPaths = q(`SELECT path, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0 GROUP BY path ORDER BY cnt DESC LIMIT 15`).all(...range);-- const topReferers = q(`SELECT referer, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0 GROUP BY referer ORDER BY cnt DESC LIMIT 10`).all(...range);-- const topCountries = q(`SELECT country, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND country IS NOT NULL AND is_bot = 0 GROUP BY country ORDER BY cnt DESC LIMIT 10`).all(...range);-- const devices = q(`SELECT device_type, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0 GROUP BY device_type ORDER BY cnt DESC`).all(...range);-- const browsers = q(`SELECT browser, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0 GROUP BY browser ORDER BY cnt DESC`).all(...range);---- q(`-- INSERT INTO analytics_daily-- (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, bot_hits,-- avg_response_ms, error_count, top_paths, top_referers, top_countries, device_breakdown, browser_breakdown)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)-- ON CONFLICT(service, date) DO UPDATE SET-- pageviews = excluded.pageviews, api_calls = excluded.api_calls,-- unique_visitors = excluded.unique_visitors, unique_users = excluded.unique_users,-- new_users = excluded.new_users, bot_hits = excluded.bot_hits,-- avg_response_ms = excluded.avg_response_ms, error_count = excluded.error_count,-- top_paths = excluded.top_paths, top_referers = excluded.top_referers,-- top_countries = excluded.top_countries, device_breakdown = excluded.device_breakdown,-- browser_breakdown = excluded.browser_breakdown-- `).run(-- svc, date, stats.pageviews, stats.api_calls,-- Math.max(prev.unique_visitors || 0, v.visitors || 0), Math.max(prev.unique_users || 0, v.users || 0),-- // New users needs a user id history, which analytics no longer has: keep what was-- // recorded before, NULL (not measured) from now on.-- prev.new_users != null ? prev.new_users : null,-- stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,-- JSON.stringify(topPaths), JSON.stringify(topReferers), JSON.stringify(topCountries),-- JSON.stringify(devices), JSON.stringify(browsers),-- );-- }---- // ── Reads (admin dashboards) ─────────────────────────────-- // Same shapes as before. Where the old queries counted distinct IPs or user ids over raw events,-- // these count distinct rotating session ids (all sessions / signed-in sessions); rollup-based-- // numbers (daily/hourly unique_visitors) are true unique visitors per period.---- getStats(options = {}) {-- const { days = 30, hours, service } = options;-- const svc = service || this.service;-- const nowMs = this._now();-- const rangeMs = hours ? hours * 3600000 : days * 86400000;-- const rangeCutoff = sqlTime(nowMs - rangeMs);-- const isSubDay = hours && hours < 24;-- const effectiveDays = hours ? hours / 24 : days;-- const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;-- const bucketExpr = `strftime('%Y-%m-%d %H:', created_at) || CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT)`;-- const q = (sql) => this.db.prepare(sql);-- const safe = (fn, fallback) => { try { return fn() || fallback; } catch { return fallback; } };---- let daily = [];-- if (!isSubDay) {-- daily = q('SELECT * FROM analytics_daily WHERE service = ? AND date >= ? ORDER BY date ASC').all(svc, dayOf(nowMs - rangeMs));-- }---- const hourlyLimit = hours ? Math.min(hours, 168) : 24;-- const hourlyCutoff = sqlTime(nowMs - hourlyLimit * 3600000);-- const hourly = q('SELECT * FROM analytics_hourly WHERE service = ? AND hour >= ? ORDER BY hour ASC').all(svc, hourlyCutoff);---- let timeBuckets = [];-- if (isSubDay) {-- timeBuckets = q(`-- SELECT ${bucketExpr} as bucket,-- COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS pageviews,-- COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS api_calls,-- COUNT(DISTINCT session_id) FILTER (WHERE is_bot = 0) AS unique_visitors,-- COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,-- COUNT(*) FILTER (WHERE status_code >= 400) AS errors,-- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms-- FROM analytics_events-- WHERE service = ? AND created_at >= ?-- GROUP BY bucket ORDER BY bucket ASC-- `).all(svc, rangeCutoff);-- }---- let summary;-- if (hours && hours < 48) {-- summary = q(`-- SELECT-- COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS total_pageviews,-- COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS total_api_calls,-- COUNT(DISTINCT session_id) FILTER (WHERE is_bot = 0) AS total_unique_visitors,-- COUNT(DISTINCT session_id) FILTER (WHERE is_bot = 0 AND authenticated = 1) AS total_unique_users,-- COUNT(*) FILTER (WHERE is_bot = 1) AS total_bot_hits,-- COUNT(*) FILTER (WHERE status_code >= 400) AS total_errors,-- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms-- FROM analytics_events-- WHERE service = ? AND created_at >= ?-- `).get(svc, rangeCutoff);-- } else {-- summary = q(`-- SELECT-- SUM(pageviews) as total_pageviews,-- SUM(api_calls) as total_api_calls,-- SUM(unique_visitors) as total_unique_visitors,-- SUM(unique_users) as total_unique_users,-- SUM(bot_hits) as total_bot_hits,-- SUM(error_count) as total_errors,-- CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms-- FROM analytics_daily-- WHERE service = ? AND date >= ?-- `).get(svc, dayOf(nowMs - rangeMs));-- }---- const realtime = q(`-- SELECT COUNT(*) as requests, COUNT(DISTINCT session_id) as visitors, COUNT(*) FILTER (WHERE is_bot = 1) as bots-- FROM analytics_events WHERE service = ? AND created_at >= ?-- `).get(svc, sqlTime(nowMs - 300000));---- const human = [svc, rangeCutoff];-- const topPages = q(`-- SELECT path, COUNT(*) as hits, COUNT(DISTINCT session_id) as visitors-- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0-- GROUP BY path ORDER BY hits DESC LIMIT 20-- `).all(...human);-- const topReferers = q(`-- SELECT referer, COUNT(*) as hits FROM analytics_events-- WHERE service = ? AND created_at >= ? AND referer IS NOT NULL AND referer != '' AND is_bot = 0-- GROUP BY referer ORDER BY hits DESC LIMIT 15-- `).all(...human);-- const botBreakdown = q(`-- SELECT bot_type, COUNT(*) as hits FROM analytics_events-- WHERE service = ? AND created_at >= ? AND is_bot = 1-- GROUP BY bot_type ORDER BY hits DESC LIMIT 15-- `).all(...human);-- const statusCodes = q(`-- SELECT CASE-- WHEN status_code >= 200 AND status_code < 300 THEN '2xx'-- WHEN status_code >= 300 AND status_code < 400 THEN '3xx'-- WHEN status_code >= 400 AND status_code < 500 THEN '4xx'-- WHEN status_code >= 500 THEN '5xx'-- ELSE 'other' END as group_code,-- COUNT(*) as cnt-- FROM analytics_events WHERE service = ? AND created_at >= ?-- GROUP BY group_code ORDER BY cnt DESC-- `).all(...human);-- const breakdown = (col) => q(`-- SELECT ${col}, COUNT(*) as cnt FROM analytics_events-- WHERE service = ? AND created_at >= ? AND is_bot = 0-- GROUP BY ${col} ORDER BY cnt DESC-- `).all(...human);-- const deviceBreakdown = breakdown('device_type');-- const browserBreakdown = breakdown('browser');-- const osBreakdown = breakdown('os');-- const countryBreakdown = q(`-- SELECT country, COUNT(*) as cnt FROM analytics_events-- WHERE service = ? AND created_at >= ? AND country IS NOT NULL AND is_bot = 0-- GROUP BY country ORDER BY cnt DESC LIMIT 20-- `).all(...human);---- const responsePercentiles = safe(() => {-- const vals = q(`-- SELECT response_time_ms FROM analytics_events-- WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0-- ORDER BY response_time_ms ASC-- `).pluck().all(...human);-- if (!vals.length) return {};-- const pct = (p) => vals[Math.min(Math.floor(vals.length * p), vals.length - 1)];-- return { p50: pct(0.5), p90: pct(0.9), p95: pct(0.95), p99: pct(0.99), max: vals[vals.length - 1] };-- }, {});---- const bandwidth = safe(() => {-- const bw = q(`-- SELECT COUNT(*) as total_requests,-- COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,-- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests,-- SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END) AS estimated_bytes-- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0-- `).get(...human);-- return bw && {-- total_requests: bw.total_requests || 0,-- page_requests: bw.page_requests || 0,-- api_requests: bw.api_requests || 0,-- estimated_bytes: bw.estimated_bytes || 0,-- };-- }, {});---- const authBreakdown = safe(() => q(`-- SELECT-- COUNT(*) FILTER (WHERE authenticated = 1) AS authenticated,-- COUNT(*) FILTER (WHERE authenticated = 0) AS anonymous,-- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS unique_authenticated_users,-- COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions-- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0-- `).get(...human), {});---- const errorTrend = safe(() => (isSubDay-- ? q(`-- SELECT ${bucketExpr} as bucket, COUNT(*) AS total,-- COUNT(*) FILTER (WHERE status_code >= 400) AS errors,-- COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors-- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0-- GROUP BY bucket ORDER BY bucket ASC-- `).all(...human)-- : q(`-- SELECT hour as bucket, (pageviews + api_calls) as total, error_count as errors-- FROM analytics_hourly WHERE service = ? AND hour >= ? ORDER BY hour ASC-- `).all(svc, hourlyCutoff)), []);---- const peakHours = safe(() => q(`-- SELECT CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day, COUNT(*) AS hits,-- COUNT(DISTINCT session_id) AS visitors, CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms-- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0-- GROUP BY hour_of_day ORDER BY hour_of_day ASC-- `).all(...human), []);---- const topApiEndpoints = safe(() => q(`-- SELECT path, method, COUNT(*) as hits, CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,-- COUNT(*) FILTER (WHERE status_code >= 400) AS errors-- FROM analytics_events WHERE service = ? AND created_at >= ? AND event_type = 'api_call' AND is_bot = 0-- GROUP BY path, method ORDER BY hits DESC LIMIT 20-- `).all(...human), []);---- const sessionCount = safe(() => q(`-- SELECT COUNT(DISTINCT session_id) FROM analytics_events-- WHERE service = ? AND created_at >= ? AND session_id IS NOT NULL AND is_bot = 0-- `).pluck().get(...human), 0);---- // New vs returning needs a visitor identity that outlives a day; analytics keeps none.-- const visitorTypes = { new_visitors: null, returning_visitors: null, not_collected: 'ADR-021' };---- const slowestEndpoints = safe(() => q(`-- SELECT path, method, COUNT(*) as hits, CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms, MAX(response_time_ms) AS max_ms-- FROM analytics_events WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0-- GROUP BY path, method HAVING hits >= 3 ORDER BY avg_ms DESC LIMIT 10-- `).all(...human), []);---- const authTrend = safe(() => q(`-- SELECT ${isSubDay ? bucketExpr : "strftime('%Y-%m-%d', created_at)"} AS bucket,-- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS auth_visitors,-- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 0) AS anon_visitors,-- COUNT(*) FILTER (WHERE authenticated = 1) AS auth_hits,-- COUNT(*) FILTER (WHERE authenticated = 0) AS anon_hits-- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0-- GROUP BY bucket ORDER BY bucket ASC-- `).all(...human), []);---- return {-- service: svc,-- period_days: effectiveDays,-- period_hours: hours || null,-- summary: summary || {},-- realtime: realtime || {},-- daily,-- hourly,-- timeBuckets,-- topPages,-- topReferers,-- botBreakdown,-- statusCodes,-- deviceBreakdown,-- browserBreakdown,-- osBreakdown,-- countryBreakdown,-- responsePercentiles,-- bandwidth,-- authBreakdown,-- errorTrend,-- peakHours,-- topApiEndpoints,-- sessionCount,-- visitorTypes,-- slowestEndpoints,-- authTrend,-- };-- }---- getOverview(days = 30) {-- const cutoff = dayOf(this._now() - days * 86400000);-- const services = this.db.prepare(`-- SELECT service, SUM(pageviews) as pageviews, SUM(api_calls) as api_calls,-- SUM(unique_visitors) as unique_visitors, SUM(unique_users) as unique_users,-- SUM(bot_hits) as bot_hits, SUM(error_count) as errors,-- CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms-- FROM analytics_daily WHERE date >= ? GROUP BY service ORDER BY pageviews DESC-- `).all(cutoff);-- const totals = this.db.prepare(`-- SELECT SUM(pageviews) as pageviews, SUM(api_calls) as api_calls, SUM(unique_visitors) as unique_visitors,-- SUM(bot_hits) as bot_hits, SUM(error_count) as errors-- FROM analytics_daily WHERE date >= ?-- `).get(cutoff);-- const dailyTrend = this.db.prepare(`-- SELECT date, SUM(pageviews) as pageviews, SUM(api_calls) as api_calls,-- SUM(unique_visitors) as unique_visitors, SUM(bot_hits) as bot_hits-- FROM analytics_daily WHERE date >= ? GROUP BY date ORDER BY date ASC-- `).all(cutoff);-- const realtime = this.db.prepare(`-- SELECT service, COUNT(*) as requests, COUNT(DISTINCT session_id) as visitors, COUNT(*) FILTER (WHERE is_bot = 1) as bots-- FROM analytics_events WHERE created_at >= ? GROUP BY service-- `).all(sqlTime(this._now() - 300000));-- return { period_days: days, totals: totals || {}, services, dailyTrend, realtime };-- }---- /**-- * Bot analysis. There are no IPs any more: the "top bot" rows are user-agent classes-- * (`bot:<family>`), the "suspicious" rows are high-volume rotating sessions. The `ip` field is-- * kept (null) so existing dashboards render; `ua_class` / `session_id` say what the row is.-- */-- getBotAnalysis(days = 30) {-- const since = sqlTime(this._now() - Math.min(Math.max(1, days), 365) * 86400000);-- const topBotIPs = this.db.prepare(`-- SELECT NULL AS ip, user_agent AS ua_class, bot_type, COUNT(*) as hits,-- COUNT(DISTINCT session_id) AS sessions,-- MIN(created_at) as first_seen, MAX(created_at) as last_seen-- FROM analytics_events WHERE is_bot = 1 AND created_at >= ?-- GROUP BY user_agent, bot_type ORDER BY hits DESC LIMIT 25-- `).all(since);-- const botTrend = this.db.prepare(`-- SELECT date, SUM(pageviews) + SUM(api_calls) as human_hits, SUM(bot_hits) as bot_hits-- FROM analytics_daily WHERE date >= ? GROUP BY date ORDER BY date ASC-- `).all(since.slice(0, 10));-- const botTypes = this.db.prepare(`-- SELECT bot_type, COUNT(*) as hits, NULL AS unique_ips, COUNT(DISTINCT session_id) AS unique_sessions-- FROM analytics_events WHERE is_bot = 1 AND created_at >= ?-- GROUP BY bot_type ORDER BY hits DESC-- `).all(since);-- const suspiciousIPs = this.db.prepare(`-- SELECT NULL AS ip, session_id, COUNT(*) as total_hits,-- COUNT(*) FILTER (WHERE status_code >= 400) as error_hits,-- COUNT(DISTINCT path) as unique_paths,-- MIN(created_at) as first_seen, MAX(created_at) as last_seen-- FROM analytics_events-- WHERE created_at >= ? AND is_bot = 0 AND session_id IS NOT NULL-- GROUP BY session_id HAVING total_hits > 500-- ORDER BY total_hits DESC LIMIT 20-- `).all(sqlTime(this._now() - 7 * 86400000));-- return { topBotIPs, botTrend, botTypes, suspiciousIPs };-- }---- destroy() {-- this.flush();-- for (const t of this._timers) { clearInterval(t); clearTimeout(t); }-- this._timers = [];-- if (this.pruneJob) { this.pruneJob.stop(); this.pruneJob = null; }-- this._rates.clear();-- this._sessions.clear();-- }--}----module.exports = { AnalyticsTracker, sqlTime, dayOf, hourOf, SESSION_IDLE_MS };nnew file mode 100644@@@ -0,0 +1,215 @@++'use strict';++// Shared validation, redaction and row conversion for the PostgreSQL config store.++const crypto = require('crypto');++++const CLASSES = ['public', 'internal', 'secret'];++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/;++const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;++const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);++const validKey = (k) => KEY_RE.test(k) && !RESERVED.has(k);++const SUBJECT_ID = {++ user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/,++ app: /^app_[0-9A-HJKMNP-TV-Z]{26}$/, mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,++ service: SERVICE_RE, system: SERVICE_RE,++};++const PREFIXED = { usr_: 'user', gst_: 'guest', app_: 'app', mod_: 'mod' };++const REASON_MAX = 500;++const ERROR_MAX = 2000;++++class ConfigError extends Error {++ constructor(code, message, { status = 400, errors = null, revision = null, cause } = {}) {++ super(message, cause === undefined ? undefined : { cause });++ this.name = 'ConfigError';++ this.code = code;++ this.status = status;++ if (errors) this.errors = errors;++ if (revision != null) this.revision = revision;++ }++}++++// ── JSON helpers ────────────────────────────────────────────────++++/** Canonical JSON: object keys sorted at every depth, no whitespace. */++function canonical(v) {++ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;++ return JSON.stringify(v);++}++const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');++const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));++const pointer = (k) => `/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`;++const clone = (v) => JSON.parse(JSON.stringify(v));++const same = (a, b) => canonical(a) === canonical(b);++const has = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);++++function deepFreeze(v) {++ if (v && typeof v === 'object' && !Object.isFrozen(v)) { Object.freeze(v); for (const x of Object.values(v)) deepFreeze(x); }++ return v;++}++++/** Only what JSON keeps as it is: no undefined, functions, NaN/Infinity, Dates or class instances. */++function jsonErrors(v, path, errors, depth = 0) {++ if (depth > 32) { errors.push({ path, message: 'is nested too deeply' }); return; }++ if (v === null || typeof v === 'string' || typeof v === 'boolean') return;++ if (typeof v === 'number') { if (!Number.isFinite(v)) errors.push({ path, message: 'must be a finite number' }); return; }++ if (Array.isArray(v)) { v.forEach((x, i) => jsonErrors(x, `${path}/${i}`, errors, depth + 1)); return; }++ if (isPlainObject(v)) { for (const [k, x] of Object.entries(v)) jsonErrors(x, `${path}${pointer(k)}`, errors, depth + 1); return; }++ errors.push({ path, message: 'is not a JSON value' });++}++++const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v).length === 2 && typeof v.fingerprint === 'string';++/** An object with a redacted member is reserved for markers (common.config-snapshot@1). */++const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');++const sameHex = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));++++// ── A small JSON Schema subset ──────────────────────────────────++// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,++// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,++// multipleOf. Annotations (title, description, default, format, x-…) are ignored. Anything else++// throws when the store is created, so a schema is never silently checked less than it says.++++const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'minProperties', 'maxProperties',++ 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum',++ 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);++const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);++const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];++const patterns = new WeakMap();++++function compileSchema(s, at = '#') {++ if (typeof s === 'boolean') return;++ if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);++ for (const k of Object.keys(s)) {++ if (!KEYWORDS.has(k) && !ANNOTATIONS.has(k) && !k.startsWith('x-')) throw new TypeError(`config schema ${at}: unsupported keyword "${k}"`);++ }++ if (s.type !== undefined && ![].concat(s.type).every((t) => TYPES.includes(t))) throw new TypeError(`config schema ${at}: unknown type`);++ if (s.pattern !== undefined) patterns.set(s, new RegExp(s.pattern, 'u'));++ if (s.properties) for (const [k, sub] of Object.entries(s.properties)) compileSchema(sub, `${at}/properties/${k}`);++ if (s.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);++ if (s.items !== undefined) compileSchema(s.items, `${at}/items`);++}++++function typeOf(v) {++ if (v === null) return 'null';++ if (Array.isArray(v)) return 'array';++ return typeof v;++}++function isType(v, t) {++ if (t === 'integer') return Number.isInteger(v);++ if (t === 'number') return typeof v === 'number' && Number.isFinite(v);++ return typeOf(v) === t;++}++++/** Messages name the rule, never the value (a value may be a secret). */++function checkSchema(s, v, path, errors) {++ if (s === true || s === undefined) return;++ if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }++ if (s.type !== undefined) {++ const types = [].concat(s.type);++ if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }++ }++ if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: `must be one of ${s.enum.map((e) => JSON.stringify(e)).join(', ')}` });++ if (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });++ if (typeof v === 'number') {++ if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });++ if (s.maximum !== undefined && v > s.maximum) errors.push({ path, message: `must be <= ${s.maximum}` });++ if (s.exclusiveMinimum !== undefined && v <= s.exclusiveMinimum) errors.push({ path, message: `must be > ${s.exclusiveMinimum}` });++ if (s.exclusiveMaximum !== undefined && v >= s.exclusiveMaximum) errors.push({ path, message: `must be < ${s.exclusiveMaximum}` });++ if (s.multipleOf !== undefined && Math.abs(v / s.multipleOf - Math.round(v / s.multipleOf)) > 1e-9) errors.push({ path, message: `must be a multiple of ${s.multipleOf}` });++ }++ if (typeof v === 'string') {++ const len = [...v].length;++ if (s.minLength !== undefined && len < s.minLength) errors.push({ path, message: `must be at least ${s.minLength} characters` });++ if (s.maxLength !== undefined && len > s.maxLength) errors.push({ path, message: `must be at most ${s.maxLength} characters` });++ if (s.pattern !== undefined && !(patterns.get(s) || new RegExp(s.pattern, 'u')).test(v)) errors.push({ path, message: `must match ${s.pattern}` });++ }++ if (Array.isArray(v)) {++ if (s.minItems !== undefined && v.length < s.minItems) errors.push({ path, message: `must have at least ${s.minItems} items` });++ if (s.maxItems !== undefined && v.length > s.maxItems) errors.push({ path, message: `must have at most ${s.maxItems} items` });++ if (s.uniqueItems && new Set(v.map(canonical)).size !== v.length) errors.push({ path, message: 'must not repeat items' });++ if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors));++ }++ if (isPlainObject(v)) {++ const keys = Object.keys(v);++ for (const r of s.required || []) if (!has(v, r)) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });++ if (s.minProperties !== undefined && keys.length < s.minProperties) errors.push({ path, message: `must have at least ${s.minProperties} keys` });++ if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });++ for (const k of keys) {++ if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors);++ else if (s.additionalProperties === false) errors.push({ path: `${path}${pointer(k)}`, message: 'is not a known key' });++ else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors);++ }++ }++}++++/** validate(values) may return true/undefined, false, a message, a list, or { valid, errors } (Ajv/contracts style). */++function validateResult(r) {++ const norm = (e) => (typeof e === 'string' ? { path: '', message: e } : { path: String(e.path ?? e.instancePath ?? ''), message: String((e && e.message) || 'is invalid') });++ if (r === true || r == null) return [];++ if (r === false) return [{ path: '', message: 'rejected by validate()' }];++ if (typeof r === 'string') return [{ path: '', message: r }];++ if (Array.isArray(r)) return r.map(norm);++ if (typeof r === 'object') {++ if (r.valid === false || (Array.isArray(r.errors) && r.errors.length)) return (r.errors && r.errors.length ? r.errors : ['is invalid']).map(norm);++ return [];++ }++ return [{ path: '', message: 'rejected by validate()' }];++}++++// ── Subjects and text ───────────────────────────────────────────++++/** A common subject reference ({ type, id }), or a prefixed id string (usr_…, gst_…, app_…, mod_…). */++function subjectRef(actor) {++ if (typeof actor === 'string') {++ const type = PREFIXED[actor.slice(0, 4)];++ if (type && SUBJECT_ID[type].test(actor)) return { type, id: actor };++ return null;++ }++ if (actor && typeof actor === 'object' && SUBJECT_ID[actor.type] && typeof actor.id === 'string' && SUBJECT_ID[actor.type].test(actor.id)) {++ return { type: actor.type, id: actor.id };++ }++ return null;++}++++/** One line, no credentials in URLs or key=value pairs, and never any of the given secret values. */++function scrub(text, secrets = []) {++ let s = String(text == null ? '' : text).split('\n')[0];++ for (const x of secrets) if (x.length >= 4) s = s.split(x).join('[redacted]');++ s = s.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')++ .replace(/\b(key|token|secret|password|signature)=[^\s&]+/gi, '$1=…');++ return s.slice(0, ERROR_MAX) || 'failed';++}++++const errText = (err) => (err instanceof Error ? err.message : String(err));++const summarize = (errors) => errors.map((e) => (e.path ? `${e.path} ${e.message}` : e.message)).join('; ');++++function makeLog(log) {++ const noop = () => {};++ if (log === false || log === null) return { info: noop, warn: noop, error: noop };++ if (typeof log === 'function') return { info: (m) => log(m, 'info'), warn: (m) => log(m, 'warn'), error: (m) => log(m, 'error') };++ const l = log || console;++ return { info: (m) => (l.info || l.log || noop).call(l, m), warn: (m) => (l.warn || l.log || noop).call(l, m), error: (m) => (l.error || l.warn || noop).call(l, m) };++}++++/**++ * Typed key-value rows ({ key, value, type }, as site_settings/media_settings keep them) as a values++ * object: number, boolean ('true'), json (parsed; the raw text if it does not parse), anything else++ * a string. `prefix` keeps only the keys that start with it and strips it; `type` forces one type.++ */++function fromRows(rows, { prefix = '', type = null } = {}) {++ const out = {};++ for (const r of rows || []) {++ if (!r || typeof r.key !== 'string' || !r.key.startsWith(prefix) || RESERVED.has(r.key.slice(prefix.length))) continue;++ const raw = r.value == null ? '' : String(r.value);++ const t = type || r.type || 'string';++ let v = raw;++ if (t === 'number') v = raw.trim() === '' ? null : Number(raw);++ else if (t === 'boolean') v = raw === 'true';++ else if (t === 'json') { try { v = JSON.parse(raw); } catch { v = raw; } }++ if (typeof v === 'number' && !Number.isFinite(v)) v = raw;++ out[r.key.slice(prefix.length)] = v;++ }++ return out;++}++++module.exports = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows };@@@ -1,11 +1,11 @@'use strict';--// openvibe-shared/config on PostgreSQL (ADR-035): the same store as config.js, on an openvibe-sdk/db handle. Every++// openvibe-shared/config on PostgreSQL (ADR-035): the configuration store on an openvibe-sdk/db handle. Every// method that reads or writes the database is async; get() and revision() stay in memory. createConfigStore picks this// implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or// seeded its active revision when it resolves). The tables come from// the service's migrations: configSchema() is their DDL.const crypto = require('crypto');--const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows } = require('./config')._internal;++const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows } = require('./config-core');/** The DDL of config_snapshots and config_keys, for a service's migrations. */function configSchema() {@@@ -1,686 +1,12 @@'use strict';--// ═══════════════════════════════════════════════════════════════--// openvibe-shared/config — the configuration model (roadmap WS-C task 7). One namespace of a--// service's configuration kept as immutable revisions (common.config-snapshot@1) in the service's--// own SQLite database: validated, classified, activated atomically, with the last-known-good--// revision retained and restored when an activation fails.--//--// const config = require('openvibe-shared/config');--// const tiering = config.createConfigStore({--// db, // the service's better-sqlite3 handle--// service: 'media', namespace: 'media.storage_tier',--// schema: { type: 'object', additionalProperties: false, properties: { minAgeDays: { type: 'integer', minimum: 0 } } },--// validate: (values) => values.minFreeGb < values.targetFreeGb || 'minFreeGb must be below targetFreeGb',--// classify: { b2_secret: 'secret' }, // or (key, value) => 'public' | 'internal' | 'secret'--// defaults: DEFAULTS, // revision 1 of a new namespace; also under every revision--// legacy: () => config.fromRows(rows, { prefix: 'storage_tier.', type: 'json' }), // revision 1 instead--// onActivate: async (values, previous) => { restartSweep(values); },--// });--// tiering.get('minAgeDays'); // in memory, never a DB read--// await tiering.apply({ minAgeDays: 3 }, { merge: true, actor: { type: 'user', id: 'usr_…' }, reason: '…' });--// await tiering.rollback({ actor, reason });--// config.adminRoutes([tiering], { requireAdmin }).mount(app); // /api/admin/config…--//--// A revision's values never change. Its state moves proposed -> active -> superseded | rolled_back,--// or proposed -> rejected. activate() switches the active revision in one transaction, then runs--// onActivate(values, previous); when that throws, the previous revision is active again (in memory--// and in the database), the new one is rejected with the error, onActivate runs once more with the--// restored values ({ restoring: true }), and the error is thrown. get() is the defaults overlaid--// with the active revision's values. A secret-class value stays in the row (so it can be activated--// again) but leaves only as { redacted: true, fingerprint }: an HMAC-SHA256 under a random 32-byte--// key kept per namespace in config_keys, which never leaves the database and is never logged. Equal--// fingerprints in one namespace mean an unchanged value; without the key nothing can be guessed. A--// snapshot's checksum is the sha256 of its values as shown, so it reveals nothing more either.--// ═══════════════════════════════════════════════════════════════----const crypto = require('crypto');----const CLASSES = ['public', 'internal', 'secret'];--const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;--const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/;--const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;--const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);--const validKey = (k) => KEY_RE.test(k) && !RESERVED.has(k);--const SUBJECT_ID = {-- user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/,-- app: /^app_[0-9A-HJKMNP-TV-Z]{26}$/, mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,-- service: SERVICE_RE, system: SERVICE_RE,--};--const PREFIXED = { usr_: 'user', gst_: 'guest', app_: 'app', mod_: 'mod' };--const REASON_MAX = 500;--const ERROR_MAX = 2000;----class ConfigError extends Error {-- constructor(code, message, { status = 400, errors = null, revision = null, cause } = {}) {-- super(message, cause === undefined ? undefined : { cause });-- this.name = 'ConfigError';-- this.code = code;-- this.status = status;-- if (errors) this.errors = errors;-- if (revision != null) this.revision = revision;-- }--}----// ── JSON helpers ────────────────────────────────────────────────----/** Canonical JSON: object keys sorted at every depth, no whitespace. */--function canonical(v) {-- if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;-- if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;-- return JSON.stringify(v);--}--const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');--const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));--const pointer = (k) => `/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`;--const clone = (v) => JSON.parse(JSON.stringify(v));--const same = (a, b) => canonical(a) === canonical(b);--const has = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);----function deepFreeze(v) {-- if (v && typeof v === 'object' && !Object.isFrozen(v)) { Object.freeze(v); for (const x of Object.values(v)) deepFreeze(x); }-- return v;--}----/** Only what JSON keeps as it is: no undefined, functions, NaN/Infinity, Dates or class instances. */--function jsonErrors(v, path, errors, depth = 0) {-- if (depth > 32) { errors.push({ path, message: 'is nested too deeply' }); return; }-- if (v === null || typeof v === 'string' || typeof v === 'boolean') return;-- if (typeof v === 'number') { if (!Number.isFinite(v)) errors.push({ path, message: 'must be a finite number' }); return; }-- if (Array.isArray(v)) { v.forEach((x, i) => jsonErrors(x, `${path}/${i}`, errors, depth + 1)); return; }-- if (isPlainObject(v)) { for (const [k, x] of Object.entries(v)) jsonErrors(x, `${path}${pointer(k)}`, errors, depth + 1); return; }-- errors.push({ path, message: 'is not a JSON value' });--}----const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v).length === 2 && typeof v.fingerprint === 'string';--/** An object with a redacted member is reserved for markers (common.config-snapshot@1). */--const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');--const sameHex = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));----// ── A small JSON Schema subset ──────────────────────────────────--// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,--// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,--// multipleOf. Annotations (title, description, default, format, x-…) are ignored. Anything else--// throws when the store is created, so a schema is never silently checked less than it says.----const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'minProperties', 'maxProperties',-- 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum',-- 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);--const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);--const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];--const patterns = new WeakMap();----function compileSchema(s, at = '#') {-- if (typeof s === 'boolean') return;-- if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);-- for (const k of Object.keys(s)) {-- if (!KEYWORDS.has(k) && !ANNOTATIONS.has(k) && !k.startsWith('x-')) throw new TypeError(`config schema ${at}: unsupported keyword "${k}"`);-- }-- if (s.type !== undefined && ![].concat(s.type).every((t) => TYPES.includes(t))) throw new TypeError(`config schema ${at}: unknown type`);-- if (s.pattern !== undefined) patterns.set(s, new RegExp(s.pattern, 'u'));-- if (s.properties) for (const [k, sub] of Object.entries(s.properties)) compileSchema(sub, `${at}/properties/${k}`);-- if (s.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);-- if (s.items !== undefined) compileSchema(s.items, `${at}/items`);--}----function typeOf(v) {-- if (v === null) return 'null';-- if (Array.isArray(v)) return 'array';-- return typeof v;--}--function isType(v, t) {-- if (t === 'integer') return Number.isInteger(v);-- if (t === 'number') return typeof v === 'number' && Number.isFinite(v);-- return typeOf(v) === t;--}----/** Messages name the rule, never the value (a value may be a secret). */--function checkSchema(s, v, path, errors) {-- if (s === true || s === undefined) return;-- if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }-- if (s.type !== undefined) {-- const types = [].concat(s.type);-- if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }-- }-- if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: `must be one of ${s.enum.map((e) => JSON.stringify(e)).join(', ')}` });-- if (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });-- if (typeof v === 'number') {-- if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });-- if (s.maximum !== undefined && v > s.maximum) errors.push({ path, message: `must be <= ${s.maximum}` });-- if (s.exclusiveMinimum !== undefined && v <= s.exclusiveMinimum) errors.push({ path, message: `must be > ${s.exclusiveMinimum}` });-- if (s.exclusiveMaximum !== undefined && v >= s.exclusiveMaximum) errors.push({ path, message: `must be < ${s.exclusiveMaximum}` });-- if (s.multipleOf !== undefined && Math.abs(v / s.multipleOf - Math.round(v / s.multipleOf)) > 1e-9) errors.push({ path, message: `must be a multiple of ${s.multipleOf}` });-- }-- if (typeof v === 'string') {-- const len = [...v].length;-- if (s.minLength !== undefined && len < s.minLength) errors.push({ path, message: `must be at least ${s.minLength} characters` });-- if (s.maxLength !== undefined && len > s.maxLength) errors.push({ path, message: `must be at most ${s.maxLength} characters` });-- if (s.pattern !== undefined && !(patterns.get(s) || new RegExp(s.pattern, 'u')).test(v)) errors.push({ path, message: `must match ${s.pattern}` });-- }-- if (Array.isArray(v)) {-- if (s.minItems !== undefined && v.length < s.minItems) errors.push({ path, message: `must have at least ${s.minItems} items` });-- if (s.maxItems !== undefined && v.length > s.maxItems) errors.push({ path, message: `must have at most ${s.maxItems} items` });-- if (s.uniqueItems && new Set(v.map(canonical)).size !== v.length) errors.push({ path, message: 'must not repeat items' });-- if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors));-- }-- if (isPlainObject(v)) {-- const keys = Object.keys(v);-- for (const r of s.required || []) if (!has(v, r)) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });-- if (s.minProperties !== undefined && keys.length < s.minProperties) errors.push({ path, message: `must have at least ${s.minProperties} keys` });-- if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });-- for (const k of keys) {-- if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors);-- else if (s.additionalProperties === false) errors.push({ path: `${path}${pointer(k)}`, message: 'is not a known key' });-- else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors);-- }-- }--}----/** validate(values) may return true/undefined, false, a message, a list, or { valid, errors } (Ajv/contracts style). */--function validateResult(r) {-- const norm = (e) => (typeof e === 'string' ? { path: '', message: e } : { path: String(e.path ?? e.instancePath ?? ''), message: String((e && e.message) || 'is invalid') });-- if (r === true || r == null) return [];-- if (r === false) return [{ path: '', message: 'rejected by validate()' }];-- if (typeof r === 'string') return [{ path: '', message: r }];-- if (Array.isArray(r)) return r.map(norm);-- if (typeof r === 'object') {-- if (r.valid === false || (Array.isArray(r.errors) && r.errors.length)) return (r.errors && r.errors.length ? r.errors : ['is invalid']).map(norm);-- return [];-- }-- return [{ path: '', message: 'rejected by validate()' }];--}----// ── Subjects and text ───────────────────────────────────────────----/** A common subject reference ({ type, id }), or a prefixed id string (usr_…, gst_…, app_…, mod_…). */--function subjectRef(actor) {-- if (typeof actor === 'string') {-- const type = PREFIXED[actor.slice(0, 4)];-- if (type && SUBJECT_ID[type].test(actor)) return { type, id: actor };-- return null;-- }-- if (actor && typeof actor === 'object' && SUBJECT_ID[actor.type] && typeof actor.id === 'string' && SUBJECT_ID[actor.type].test(actor.id)) {-- return { type: actor.type, id: actor.id };-- }-- return null;--}----/** One line, no credentials in URLs or key=value pairs, and never any of the given secret values. */--function scrub(text, secrets = []) {-- let s = String(text == null ? '' : text).split('\n')[0];-- for (const x of secrets) if (x.length >= 4) s = s.split(x).join('[redacted]');-- s = s.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')-- .replace(/\b(key|token|secret|password|signature)=[^\s&]+/gi, '$1=…');-- return s.slice(0, ERROR_MAX) || 'failed';--}----const errText = (err) => (err instanceof Error ? err.message : String(err));--const summarize = (errors) => errors.map((e) => (e.path ? `${e.path} ${e.message}` : e.message)).join('; ');----function makeLog(log) {-- const noop = () => {};-- if (log === false || log === null) return { info: noop, warn: noop, error: noop };-- if (typeof log === 'function') return { info: (m) => log(m, 'info'), warn: (m) => log(m, 'warn'), error: (m) => log(m, 'error') };-- const l = log || console;-- return { info: (m) => (l.info || l.log || noop).call(l, m), warn: (m) => (l.warn || l.log || noop).call(l, m), error: (m) => (l.error || l.warn || noop).call(l, m) };--}----// ── Storage ─────────────────────────────────────────────────────----function ensureTables(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS config_snapshots (-- namespace TEXT NOT NULL,-- revision INTEGER NOT NULL,-- service TEXT NOT NULL,-- previous_revision INTEGER,-- state TEXT NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),-- values_json TEXT NOT NULL, -- the full values, secrets included (never served)-- classification_json TEXT NOT NULL,-- values_checksum TEXT NOT NULL, -- sha256 of the full values: internal, never served-- created_at TEXT NOT NULL,-- created_by TEXT NOT NULL,-- activated_at TEXT,-- activated_by TEXT,-- reason TEXT,-- error TEXT,-- copied_from INTEGER,-- good INTEGER NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)-- PRIMARY KEY (namespace, revision)-- );-- CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';-- -- The fingerprint key of each namespace. Never served, never logged.-- CREATE TABLE IF NOT EXISTS config_keys (-- namespace TEXT PRIMARY KEY,-- hmac_key BLOB NOT NULL,-- created_at TEXT NOT NULL-- );-- `);--}----/**-- * Typed key-value rows ({ key, value, type }, as site_settings/media_settings keep them) as a values-- * object: number, boolean ('true'), json (parsed; the raw text if it does not parse), anything else-- * a string. `prefix` keeps only the keys that start with it and strips it; `type` forces one type.-- */--function fromRows(rows, { prefix = '', type = null } = {}) {-- const out = {};-- for (const r of rows || []) {-- if (!r || typeof r.key !== 'string' || !r.key.startsWith(prefix) || RESERVED.has(r.key.slice(prefix.length))) continue;-- const raw = r.value == null ? '' : String(r.value);-- const t = type || r.type || 'string';-- let v = raw;-- if (t === 'number') v = raw.trim() === '' ? null : Number(raw);-- else if (t === 'boolean') v = raw === 'true';-- else if (t === 'json') { try { v = JSON.parse(raw); } catch { v = raw; } }-- if (typeof v === 'number' && !Number.isFinite(v)) v = raw;-- out[r.key.slice(prefix.length)] = v;-- }-- return out;--}----// ── The store ───────────────────────────────────────────────────++/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */++const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, makeLog, scrub, errText } = require('./config-core');function createConfigStore(opts = {}) {-- // On an openvibe-sdk/db handle (PostgreSQL, ADR-035): config-pg.js, which returns a promise of the store.-- if (opts.db && typeof opts.db.tx === 'function') return require('./config-pg').createPgConfigStore(opts);-- const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;-- if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') throw new TypeError('createConfigStore: db must be a better-sqlite3 handle');-- if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');-- if (!NAMESPACE_RE.test(String(namespace || '')) || !namespace.startsWith(`${service}.`) || namespace.length > 120) {-- throw new TypeError(`createConfigStore: namespace must be <service>.<name>, e.g. ${service}.settings`);-- }-- if (schema !== null) compileSchema(schema);-- if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function');-- if (onActivate !== null && typeof onActivate !== 'function') throw new TypeError('createConfigStore: onActivate must be a function');-- if (classify !== null && typeof classify !== 'function' && !isPlainObject(classify)) throw new TypeError('createConfigStore: classify must be a map or a function');-- if (opts.defaults != null && !isPlainObject(opts.defaults)) throw new TypeError('createConfigStore: defaults must be an object');-- const keep = Math.max(1, Math.floor(opts.keep || 50));-- const maxBytes = opts.maxBytes || 1024 * 1024;-- const log = makeLog(opts.log);-- const now = opts.now || (() => new Date());-- const iso = () => { const d = now(); return (d instanceof Date ? d : new Date(d)).toISOString(); };-- const self = { type: 'service', id: service };-- const defaults = deepFreeze(clone(opts.defaults || {}));-- const tag = `[config] ${namespace}`;---- ensureTables(db);-- const q = {-- row: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?'),-- active: db.prepare("SELECT * FROM config_snapshots WHERE namespace = ? AND state = 'active'"),-- any: db.prepare('SELECT 1 AS x FROM config_snapshots WHERE namespace = ? LIMIT 1'),-- max: db.prepare('SELECT COALESCE(MAX(revision), 0) AS n FROM config_snapshots WHERE namespace = ?'),-- lastGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('active', 'superseded', 'rolled_back') ORDER BY revision DESC LIMIT 1"),-- previousGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('superseded', 'rolled_back') AND revision < ? AND values_checksum != ? ORDER BY revision DESC LIMIT 1"),-- history: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision < ? ORDER BY revision DESC LIMIT ?'),-- insert: db.prepare(`INSERT INTO config_snapshots (namespace, revision, service, previous_revision, state, values_json, classification_json,-- values_checksum, created_at, created_by, activated_at, activated_by, reason, copied_from, good)-- VALUES (@namespace, @revision, @service, @previous_revision, @state, @values_json, @classification_json,-- @values_checksum, @created_at, @created_by, @activated_at, @activated_by, @reason, @copied_from, @good)`),-- setState: db.prepare('UPDATE config_snapshots SET state = ? WHERE namespace = ? AND revision = ?'),-- activate: db.prepare("UPDATE config_snapshots SET state = 'active', activated_at = ?, activated_by = ?, good = ? WHERE namespace = ? AND revision = ?"),-- good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'),-- reject: db.prepare("UPDATE config_snapshots SET state = 'rejected', error = ?, activated_at = NULL, activated_by = NULL, good = 0 WHERE namespace = ? AND revision = ?"),-- prune: db.prepare("DELETE FROM config_snapshots WHERE namespace = ? AND revision <= ? AND state != 'active' AND revision != ?"),-- };-- const tx = (fn) => db.transaction(fn).immediate();---- // ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ──-- const hmacKey = (() => {-- const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');-- let row = read.get(namespace);-- if (!row) {-- db.prepare('INSERT OR IGNORE INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?)').run(namespace, crypto.randomBytes(32), iso());-- row = read.get(namespace);-- }-- const k = row && Buffer.isBuffer(row.hmac_key) ? Buffer.from(row.hmac_key) : null;-- if (!k || k.length < 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a 32-byte key`);-- return k;-- })();-- const fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex');-- const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) });---- // ── classification and redaction ──-- const RANK = { public: 0, internal: 1, secret: 2 };-- function classOf(key, value) {-- let c;-- try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; }-- if (c == null) return 'internal';-- if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave "${String(c).slice(0, 20)}" for ${key}; treating it as secret`); return 'secret'; }-- return c;-- }-- const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOf(k, values[k])]));-- /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */-- function classesFor(values, stored = {}) {-- const out = classifyAll(values);-- for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k];-- return out;-- }-- const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerFor(v) : v]));-- function secretStrings(...valueSets) {-- const out = [];-- for (const values of valueSets) {-- if (!values) continue;-- for (const [k, v] of Object.entries(values)) {-- if (classOf(k, v) !== 'secret' || v == null) continue;-- out.push(typeof v === 'string' ? v : JSON.stringify(v));-- }-- }-- return out.sort((a, b) => b.length - a.length);-- }---- function toSnapshot(row) {-- const values = JSON.parse(row.values_json);-- const classification = classesFor(values, JSON.parse(row.classification_json));-- const shown = redact(values, classification);-- return {-- service: row.service,-- namespace: row.namespace,-- revision: row.revision,-- previous_revision: row.previous_revision == null ? null : row.previous_revision,-- state: row.state,-- values: shown,-- classification,-- checksum: sha256(canonical(shown)),-- created_at: row.created_at,-- created_by: JSON.parse(row.created_by),-- activated_at: row.activated_at || null,-- activated_by: row.activated_by ? JSON.parse(row.activated_by) : null,-- reason: row.reason == null ? null : row.reason,-- error: row.error == null ? null : row.error,-- copied_from: row.copied_from == null ? null : row.copied_from,-- };-- }---- // ── in-memory state ──-- let current = null; // { revision, values (stored), effective (frozen), classification }-- const emptyEffective = defaults;-- function load(row) {-- const values = JSON.parse(row.values_json);-- return { revision: row.revision, checksum: row.values_checksum, values, effective: deepFreeze({ ...clone(defaults), ...clone(values) }), classification: JSON.parse(row.classification_json) };-- }-- const effectiveNow = () => (current ? current.effective : emptyEffective);---- // ── validation ──-- function problems(values) {-- const errors = [];-- if (!isPlainObject(values)) return [{ path: '', message: 'must be an object' }];-- for (const k of Object.keys(values)) if (!validKey(k)) errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' });-- for (const [k, v] of Object.entries(values)) if (looksRedacted(v)) errors.push({ path: pointer(k), message: 'is an object with a redacted member, which only a redaction marker may be' });-- jsonErrors(values, '', errors);-- if (errors.length) return errors;-- const size = Buffer.byteLength(canonical(values));-- if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }];-- const effective = { ...defaults, ...values };-- if (schema !== null) checkSchema(schema, effective, '', errors);-- if (validate) {-- let r;-- try { r = validate(effective); } catch (err) { r = [{ path: '', message: errText(err) }]; }-- if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)');-- errors.push(...validateResult(r));-- }-- const secrets = secretStrings(values, effectiveNow());-- return errors.map((e) => ({ path: e.path, message: scrub(e.message, secrets) }));++ if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {++ throw new TypeError('createConfigStore: pass an openvibe-sdk/db handle as db');}-- const invalid = (errors, revision) => new ConfigError('config.invalid', `invalid configuration: ${summarize(errors)}`.slice(0, ERROR_MAX), { status: 422, errors, revision });---- /** values (+ merge/unset) as the complete values a new revision would hold. A redaction marker keeps the value it stands for. */-- function resolve(input, { merge = false, unset = [] } = {}) {-- if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]);-- if (!Array.isArray(unset) || !unset.every((k) => typeof k === 'string')) throw invalid([{ path: '', message: 'unset must be a list of keys' }]);-- const base = merge && current ? clone(current.values) : {};-- const errors = [];-- for (const [k, v] of Object.entries(input)) {-- if (!validKey(k)) { errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); continue; }-- if (!isMarker(v)) { base[k] = v; continue; }-- if (current && has(current.values, k) && sameHex(fingerprint(current.values[k]), v.fingerprint)) base[k] = clone(current.values[k]);-- else if (has(defaults, k) && sameHex(fingerprint(defaults[k]), v.fingerprint)) delete base[k];-- else errors.push({ path: pointer(k), message: 'is a redacted value that is not the current one; send the value itself' });-- }-- if (errors.length) throw invalid(errors);-- for (const k of unset) delete base[k];-- return base;-- }---- function changedKeys(before, after) {-- const keys = new Set([...Object.keys(before), ...Object.keys(after)]);-- return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort();-- }-- const listKeys = (keys) => (keys.length ? keys.slice(0, 20).join(', ') + (keys.length > 20 ? ` (+${keys.length - 20})` : '') : 'nothing');-- const who = (a) => `${a.type}:${a.id}`;---- function actorOf(actor) {-- if (actor == null) return self;-- const ref = subjectRef(actor);-- if (!ref) throw new ConfigError('config.bad_actor', 'actor must be a subject reference ({ type, id })', { status: 400 });-- return ref;-- }-- function reasonOf(reason) {-- if (reason == null || reason === '') return null;-- if (typeof reason !== 'string') throw invalid([{ path: '/reason', message: 'must be a string' }]);-- return reason.slice(0, REASON_MAX);-- }---- function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {-- let row;-- tx(() => {-- const revision = q.max.get(namespace).n + 1;-- q.insert.run({-- namespace, revision, service, previous_revision: previous, state,-- values_json: JSON.stringify(values), classification_json: JSON.stringify(classifyAll(values)),-- values_checksum: sha256(canonical(values)), created_at: iso(), created_by: JSON.stringify(actor),-- activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null,-- reason, copied_from: copiedFrom, good,-- });-- row = q.row.get(namespace, revision);-- });-- return row;-- }---- function prune() {-- const cutoff = q.max.get(namespace).n - keep;-- if (cutoff < 1) return;-- const good = q.lastGood.get(namespace);-- q.prune.run(namespace, cutoff, good ? good.revision : -1);-- }---- // ── boot: load the active revision, recover an interrupted activation, or seed revision 1 ──-- function seed(values, reason, { strict }) {-- if (!strict) {-- // A legacy key the contract cannot carry is left behind, by name, rather than imported.-- const bad = Object.keys(values).filter((k) => !validKey(k) || looksRedacted(values[k]));-- if (bad.length) {-- log.warn(`${tag}: ${bad.length} legacy key(s) are not valid config keys and were not imported: ${bad.slice(0, 5).map((k) => JSON.stringify(k.slice(0, 64))).join(', ')}`);-- values = Object.fromEntries(Object.entries(values).filter(([k]) => validKey(k)));-- }-- }-- const errors = problems(values);-- if (errors.length && strict) throw new ConfigError('config.invalid_defaults', `${namespace} defaults do not validate: ${summarize(errors)}`, { status: 500, errors });-- if (errors.length) log.warn(`${tag}: the imported values do not validate (${summarize(errors).slice(0, 300)}); imported as they are`);-- const row = insertRow(clone(values), { state: 'active', actor: self, reason, previous: null, activatedAt: iso(), good: 1 });-- current = load(row);-- log.info(`${tag}: revision ${row.revision} ${reason} (${Object.keys(values).length} keys)`);-- return toSnapshot(row);-- }---- function importLegacy(legacy, { reason = 'imported from the legacy source' } = {}) {-- if (q.any.get(namespace)) return null;-- let v = typeof legacy === 'function' ? legacy() : legacy;-- if (Array.isArray(v)) v = fromRows(v);-- if (v == null) return null;-- if (!isPlainObject(v)) throw new TypeError('import: legacy must give an object or key-value rows');-- return seed(v, reasonOf(reason) || 'imported from the legacy source', { strict: false });-- }---- (function boot() {-- let row = q.active.get(namespace);-- if (row && !row.good) {-- // The process stopped between the switch and the end of onActivate: that revision never-- // proved itself, so the last-known-good one is active again.-- const good = q.lastGood.get(namespace);-- tx(() => {-- q.reject.run('activation interrupted: the service stopped before onActivate finished', namespace, row.revision);-- if (good) q.setState.run('active', namespace, good.revision);-- });-- log.warn(`${tag}: revision ${row.revision} was interrupted while activating; ${good ? `revision ${good.revision} restored` : 'no earlier revision to restore'}`);-- row = good ? q.row.get(namespace, good.revision) : null;-- }-- if (row) {-- current = load(row);-- const errors = problems(current.values);-- if (errors.length) log.warn(`${tag}: active revision ${row.revision} does not validate under this release (${summarize(errors).slice(0, 300)}); serving it as it is`);-- return;-- }-- if (q.any.get(namespace)) return;-- if (opts.legacy !== undefined && importLegacy(opts.legacy)) return;-- if (opts.defaults != null) seed(defaults, 'seeded from defaults', { strict: true });-- })();---- // ── activation (serialized per store) ──-- let queue = Promise.resolve();-- function serialized(fn) {-- const run = queue.then(fn, fn);-- queue = run.catch(() => {});-- return run;-- }---- async function activateNow(revision, { actor, rollingBack = false } = {}) {-- const by = actorOf(actor);-- const row = q.row.get(namespace, revision);-- if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${revision}`, { status: 404, revision });-- if (row.state !== 'proposed') throw new ConfigError('config.not_proposed', `revision ${revision} is ${row.state}, not proposed`, { status: 409, revision });-- const values = JSON.parse(row.values_json);-- const errors = problems(values);-- if (errors.length) {-- q.reject.run(summarize(errors).slice(0, ERROR_MAX), namespace, revision);-- log.warn(`${tag}: revision ${revision} rejected: ${summarize(errors).slice(0, 300)}`);-- throw invalid(errors, revision);-- }-- const prev = current;-- const at = iso();-- tx(() => {-- const dbActive = q.active.get(namespace);-- const activeRev = dbActive ? dbActive.revision : null;-- if ((row.previous_revision == null ? null : row.previous_revision) !== activeRev) {-- throw new ConfigError('config.stale', `revision ${revision} was proposed on ${row.previous_revision ? `revision ${row.previous_revision}` : 'an empty namespace'}, but ${activeRev ? `revision ${activeRev}` : 'nothing'} is active now; propose it again`, { status: 409, revision });-- }-- if (dbActive) q.setState.run(rollingBack ? 'rolled_back' : 'superseded', namespace, dbActive.revision);-- q.activate.run(at, JSON.stringify(by), onActivate ? 0 : 1, namespace, revision);-- });-- current = load(q.row.get(namespace, revision));-- const changed = changedKeys(prev ? prev.effective : emptyEffective, current.effective);-- if (onActivate) {-- const next = current;-- try {-- await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });-- } catch (err) {-- const message = scrub(`onActivate: ${errText(err)}`, secretStrings(next.values, prev && prev.values));-- tx(() => {-- q.reject.run(message, namespace, revision);-- if (prev) q.setState.run('active', namespace, prev.revision);-- });-- current = prev;-- log.warn(`${tag}: revision ${revision} rejected (${message}); ${prev ? `revision ${prev.revision} restored` : 'nothing was active before'}`);-- if (prev) {-- try {-- await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });-- } catch (again) {-- log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), secretStrings(next.values, prev.values))}`);-- }-- }-- throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err });-- }-- q.good.run(namespace, revision);-- }-- prune();-- log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);-- return toSnapshot(q.row.get(namespace, revision));-- }---- function propose(values, o = {}) {-- const actor = actorOf(o.actor);-- const reason = reasonOf(o.reason);-- const next = resolve(values, o);-- const errors = problems(next);-- if (errors.length) throw invalid(errors);-- const row = insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });-- prune();-- log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), { ...defaults, ...next }))}`);-- return toSnapshot(row);-- }---- function rollbackTarget(to) {-- if (!current) throw new ConfigError('config.nothing_active', `${namespace} has no active revision to roll back`, { status: 409 });-- if (to == null) {-- const r = q.previousGood.get(namespace, current.revision, current.checksum);-- if (!r) throw new ConfigError('config.no_previous', `${namespace} has no earlier good revision to roll back to`, { status: 409 });-- return r.revision;-- }-- const rev = Number(to);-- const row = Number.isInteger(rev) ? q.row.get(namespace, rev) : null;-- if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${to}`, { status: 404, revision: Number.isInteger(rev) ? rev : null });-- if (row.revision === current.revision) throw new ConfigError('config.already_active', `revision ${rev} is the active one`, { status: 409, revision: rev });-- if (!row.good) throw new ConfigError('config.not_good', `revision ${rev} never activated successfully (${row.state})`, { status: 409, revision: rev });-- return rev;-- }---- const store = {-- service,-- namespace,-- /** The active values over the defaults (frozen), or one of them. Never reads the database. */-- get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; },-- /** The active revision number, or null. */-- revision() { return current ? current.revision : null; },-- propose,-- activate(revision, o = {}) { return serialized(() => activateNow(Number(revision), { actor: o.actor })); },-- /** propose + activate, in turn with every other activation of this store. */-- apply(values, o = {}) { return serialized(() => activateNow(propose(values, o).revision, { actor: o.actor })); },-- /** A new revision copying the previous good one (or `to`), activated; the one it replaces becomes rolled_back. */-- rollback(o = {}) {-- return serialized(() => {-- const actor = actorOf(o.actor);-- const target = rollbackTarget(o.to);-- const values = JSON.parse(q.row.get(namespace, target).values_json);-- const row = insertRow(values, { state: 'proposed', actor, reason: reasonOf(o.reason) || `rollback to revision ${target}`, previous: current.revision, copiedFrom: target });-- log.info(`${tag}: revision ${row.revision} rolls back to revision ${target} (by ${who(actor)})`);-- return activateNow(row.revision, { actor, rollingBack: true });-- });-- },-- rollbackTarget,-- lastKnownGood() { const r = q.lastGood.get(namespace); return r ? toSnapshot(q.row.get(namespace, r.revision)) : null; },-- snapshot(revision) { const row = q.row.get(namespace, Number(revision)); return row ? toSnapshot(row) : null; },-- history({ limit = 20, before = null } = {}) {-- const n = Math.min(200, Math.max(1, Math.floor(Number(limit)) || 20));-- const b = before == null || before === '' ? Number.MAX_SAFE_INTEGER : Math.floor(Number(before));-- return q.history.all(namespace, Number.isFinite(b) ? b : Number.MAX_SAFE_INTEGER, n).map(toSnapshot);-- },-- /** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */-- changes(values, o = {}) {-- const next = o.revision != null ? JSON.parse((q.row.get(namespace, Number(o.revision)) || { values_json: '{}' }).values_json) : resolve(values, o);-- return changedKeys(effectiveNow(), { ...defaults, ...next });-- },-- /** What /api/admin/config shows: the active revision, its effective values redacted, and the last-known-good. */-- summary() {-- const effective = effectiveNow();-- const classification = classesFor(effective, current ? current.classification : {});-- return {-- service, namespace,-- revision: current ? current.revision : null,-- values: redact(effective, classification),-- classification,-- active: current ? store.snapshot(current.revision) : null,-- last_known_good: store.lastKnownGood(),-- };-- },-- /** Seed revision 1 from a legacy source when the namespace has no revisions yet (else null). */-- import: importLegacy,-- /** Re-read the active revision (another process changed it). onActivate is not run. */-- reload() {-- const row = q.active.get(namespace);-- current = row ? load(row) : null;-- return store.revision();-- },-- };-- return store;++ return require('./config-pg').createPgConfigStore(opts);}// ── /api/admin/config ───────────────────────────────────────────@@@ -866,5 +192,3 @@ function adminRoutes(stores, o = {}) {}module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, configSchema: (...a) => require('./config-pg').configSchema(...a) };--// The helpers the PostgreSQL store (config-pg.js) shares with this one.--module.exports._internal = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows };@@@ -24,7 +24,7 @@ ships as one package, `openvibe-shared`. The facts behind the decision:requires. Browser pages load only the files they name. A split would not reduce what a siteloads.- **The package is small.** Its one runtime dependency is `jsonwebtoken`. The analytics module-- is dependency-free: the caller passes in `better-sqlite3`.++ is dependency-free: the caller passes an openvibe-sdk/db handle.- **There is one release process.** A split means N changelogs and N tags per change, and acompatibility matrix between them. There is no second team or release cadence to justifythat.@@@ -38,8 +38,8 @@ Keep **one package, `openvibe-shared`, with explicit subpath exports**:no entry is private. `test/package.test.js` checks that every module file, including`analytics/*.js`, has an entry and that every entry resolves.2. **Related modules share a folder and a subpath prefix.** Example: `openvibe-shared/analytics`-- plus `analytics/privacy`, `analytics/tracker`, `analytics/retention`, `analytics/schema`,-- `analytics/event`, `analytics/prune-cli` and `analytics/event.v1.json`. Put new families the++ plus `analytics/privacy`, `analytics/pg`, `analytics/retention`,++ `analytics/event` and `analytics/event.v1.json`. Put new families thesame way, for example `openvibe-shared/seo/...`. Existing flat subpaths such as `./navbar`and `./legal` stay where they are, because moving them would be a major release.3. **The charter's package names become groups of subpaths.** They are not separate packages.@@@ -63,7 +63,7 @@ Keep **one package, `openvibe-shared`, with explicit subpath exports**:Split out one part, as its own repository and tag, only if one of these becomes true:- A module needs a heavy or native runtime dependency that most consumers would not want.-- Example: analytics bundling `better-sqlite3` instead of receiving it from the caller.++ Example: analytics bundling a database driver instead of receiving a handle from the caller.- A part gains an outside audience that needs its own semver line. Example: a publishedthird-party SDK.- Shared starts publishing to an npm registry. Scoped packages would then cost little, and a@@@ -5,7 +5,7 @@ const { OpenVibeAuthClient } = require('./auth-client');const { CSS_VARIABLES, DEFAULT_VARS, BUILTIN_THEMES, applyTheme, resolveBuiltinTheme, sanitizeCssValue, loadFromStorage, saveToStorage, syncThemeToServer } = require('./theme-sync');const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth } = require('./middleware');const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');--const { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');++const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg, classifyRequest, parseUserAgent, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');const seo = require('./seo');@@@ -38,10 +38,11 @@ module.exports = {createNotification,DEFAULT_NOTIFICATION_PREFS,// Analytics-- AnalyticsTracker,++ AnalyticsTrackerPg,++ analyticsSchema,++ pruneRawEventsPg,classifyRequest,parseUserAgent,-- ANALYTICS_SCHEMA,BOT_USER_AGENTS,SUSPICIOUS_PATTERNS,// URL Registry Resolver@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "2.21.2",++ "version": "3.0.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "2.21.2",++ "version": "3.0.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -16,7 +16,6 @@},"devDependencies": {"@electric-sql/pglite": "^0.5.8",-- "better-sqlite3": "^11.10.0","express": "^4.22.3","linkedom": "^0.18.13","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",@@@ -86,61 +85,6 @@"dev": true,"license": "MIT"},-- "node_modules/base64-js": {-- "version": "1.5.1",-- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",-- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",-- "dev": true,-- "funding": [-- {-- "type": "github",-- "url": "https://github.com/sponsors/feross"-- },-- {-- "type": "patreon",-- "url": "https://www.patreon.com/feross"-- },-- {-- "type": "consulting",-- "url": "https://feross.org/support"-- }-- ],-- "license": "MIT"-- },-- "node_modules/better-sqlite3": {-- "version": "11.10.0",-- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",-- "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",-- "dev": true,-- "hasInstallScript": true,-- "license": "MIT",-- "dependencies": {-- "bindings": "^1.5.0",-- "prebuild-install": "^7.1.1"-- }-- },-- "node_modules/bindings": {-- "version": "1.5.0",-- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",-- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "file-uri-to-path": "1.0.0"-- }-- },-- "node_modules/bl": {-- "version": "4.1.0",-- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",-- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "buffer": "^5.5.0",-- "inherits": "^2.0.4",-- "readable-stream": "^3.4.0"-- }-- },"node_modules/body-parser": {"version": "1.20.8","resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",@@@ -180,31 +124,6 @@"url": "https://github.com/sponsors/fb55"}},-- "node_modules/buffer": {-- "version": "5.7.1",-- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",-- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",-- "dev": true,-- "funding": [-- {-- "type": "github",-- "url": "https://github.com/sponsors/feross"-- },-- {-- "type": "patreon",-- "url": "https://www.patreon.com/feross"-- },-- {-- "type": "consulting",-- "url": "https://feross.org/support"-- }-- ],-- "license": "MIT",-- "dependencies": {-- "base64-js": "^1.3.1",-- "ieee754": "^1.1.13"-- }-- },"node_modules/buffer-equal-constant-time": {"version": "1.0.1","resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",@@@ -252,13 +171,6 @@"url": "https://github.com/sponsors/ljharb"}},-- "node_modules/chownr": {-- "version": "1.1.4",-- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",-- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",-- "dev": true,-- "license": "ISC"-- },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -358,32 +270,6 @@"dev": true,"license": "MIT"},-- "node_modules/decompress-response": {-- "version": "6.0.0",-- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",-- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "mimic-response": "^3.1.0"-- },-- "engines": {-- "node": ">=10"-- },-- "funding": {-- "url": "https://github.com/sponsors/sindresorhus"-- }-- },-- "node_modules/deep-extend": {-- "version": "0.6.0",-- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",-- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",-- "dev": true,-- "license": "MIT",-- "engines": {-- "node": ">=4.0.0"-- }-- },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -405,16 +291,6 @@"npm": "1.2.8000 || >= 1.4.16"}},-- "node_modules/detect-libc": {-- "version": "2.1.2",-- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",-- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",-- "dev": true,-- "license": "Apache-2.0",-- "engines": {-- "node": ">=8"-- }-- },"node_modules/dom-serializer": {"version": "3.1.1","resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",@@@ -527,16 +403,6 @@"node": ">= 0.8"}},-- "node_modules/end-of-stream": {-- "version": "1.4.5",-- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",-- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "once": "^1.4.0"-- }-- },"node_modules/entities": {"version": "8.1.0","resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",@@@ -600,16 +466,6 @@"node": ">= 0.6"}},-- "node_modules/expand-template": {-- "version": "2.0.3",-- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",-- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",-- "dev": true,-- "license": "(MIT OR WTFPL)",-- "engines": {-- "node": ">=6"-- }-- },"node_modules/express": {"version": "4.22.3","resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",@@@ -681,13 +537,6 @@],"license": "BSD-3-Clause"},-- "node_modules/file-uri-to-path": {-- "version": "1.0.0",-- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",-- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",-- "dev": true,-- "license": "MIT"-- },"node_modules/finalhandler": {"version": "1.3.2","resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",@@@ -727,13 +576,6 @@"node": ">= 0.6"}},-- "node_modules/fs-constants": {-- "version": "1.0.0",-- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",-- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",-- "dev": true,-- "license": "MIT"-- },"node_modules/function-bind": {"version": "1.1.2","resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",@@@ -783,13 +625,6 @@"node": ">= 0.4"}},-- "node_modules/github-from-package": {-- "version": "0.0.0",-- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",-- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",-- "dev": true,-- "license": "MIT"-- },"node_modules/gopd": {"version": "1.2.0","resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",@@@ -975,27 +810,6 @@"node": ">=0.10.0"}},-- "node_modules/ieee754": {-- "version": "1.2.1",-- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",-- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",-- "dev": true,-- "funding": [-- {-- "type": "github",-- "url": "https://github.com/sponsors/feross"-- },-- {-- "type": "patreon",-- "url": "https://www.patreon.com/feross"-- },-- {-- "type": "consulting",-- "url": "https://feross.org/support"-- }-- ],-- "license": "BSD-3-Clause"-- },"node_modules/inherits": {"version": "2.0.4","resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",@@@ -1003,13 +817,6 @@"dev": true,"license": "ISC"},-- "node_modules/ini": {-- "version": "1.3.8",-- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",-- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",-- "dev": true,-- "license": "ISC"-- },"node_modules/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -1213,49 +1020,12 @@"node": ">= 0.6"}},-- "node_modules/mimic-response": {-- "version": "3.1.0",-- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",-- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",-- "dev": true,-- "license": "MIT",-- "engines": {-- "node": ">=10"-- },-- "funding": {-- "url": "https://github.com/sponsors/sindresorhus"-- }-- },-- "node_modules/minimist": {-- "version": "1.2.8",-- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",-- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",-- "dev": true,-- "license": "MIT",-- "funding": {-- "url": "https://github.com/sponsors/ljharb"-- }-- },-- "node_modules/mkdirp-classic": {-- "version": "0.5.3",-- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",-- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",-- "dev": true,-- "license": "MIT"-- },"node_modules/ms": {"version": "2.1.3","resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz","integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==","license": "MIT"},-- "node_modules/napi-build-utils": {-- "version": "2.0.0",-- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",-- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",-- "dev": true,-- "license": "MIT"-- },"node_modules/negotiator": {"version": "0.6.3","resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",@@@ -1266,19 +1036,6 @@"node": ">= 0.6"}},-- "node_modules/node-abi": {-- "version": "3.96.0",-- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",-- "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "semver": "^7.3.5"-- },-- "engines": {-- "node": ">=10"-- }-- },"node_modules/nth-check": {"version": "3.0.1","resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",@@@ -1322,16 +1079,6 @@"node": ">= 0.8"}},-- "node_modules/once": {-- "version": "1.4.0",-- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",-- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",-- "dev": true,-- "license": "ISC",-- "dependencies": {-- "wrappy": "1"-- }-- },"node_modules/openvibe-contracts": {"version": "0.127.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",@@@ -1396,34 +1143,6 @@"dev": true,"license": "MIT"},-- "node_modules/prebuild-install": {-- "version": "7.1.3",-- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",-- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",-- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "detect-libc": "^2.0.0",-- "expand-template": "^2.0.3",-- "github-from-package": "0.0.0",-- "minimist": "^1.2.3",-- "mkdirp-classic": "^0.5.3",-- "napi-build-utils": "^2.0.0",-- "node-abi": "^3.3.0",-- "pump": "^3.0.0",-- "rc": "^1.2.7",-- "simple-get": "^4.0.0",-- "tar-fs": "^2.0.0",-- "tunnel-agent": "^0.6.0"-- },-- "bin": {-- "prebuild-install": "bin.js"-- },-- "engines": {-- "node": ">=10"-- }-- },"node_modules/proxy-addr": {"version": "2.0.8","resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",@@@ -1442,17 +1161,6 @@"url": "https://opencollective.com/express"}},-- "node_modules/pump": {-- "version": "3.0.4",-- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",-- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "end-of-stream": "^1.1.0",-- "once": "^1.3.1"-- }-- },"node_modules/qs": {"version": "6.16.0","resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",@@@ -1496,37 +1204,6 @@"node": ">= 0.8"}},-- "node_modules/rc": {-- "version": "1.2.8",-- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",-- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",-- "dev": true,-- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",-- "dependencies": {-- "deep-extend": "^0.6.0",-- "ini": "~1.3.0",-- "minimist": "^1.2.0",-- "strip-json-comments": "~2.0.1"-- },-- "bin": {-- "rc": "cli.js"-- }-- },-- "node_modules/readable-stream": {-- "version": "3.6.2",-- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",-- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "inherits": "^2.0.3",-- "string_decoder": "^1.1.1",-- "util-deprecate": "^1.0.1"-- },-- "engines": {-- "node": ">= 6"-- }-- },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1700,53 +1377,6 @@"url": "https://github.com/sponsors/ljharb"}},-- "node_modules/simple-concat": {-- "version": "1.0.1",-- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",-- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",-- "dev": true,-- "funding": [-- {-- "type": "github",-- "url": "https://github.com/sponsors/feross"-- },-- {-- "type": "patreon",-- "url": "https://www.patreon.com/feross"-- },-- {-- "type": "consulting",-- "url": "https://feross.org/support"-- }-- ],-- "license": "MIT"-- },-- "node_modules/simple-get": {-- "version": "4.0.1",-- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",-- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",-- "dev": true,-- "funding": [-- {-- "type": "github",-- "url": "https://github.com/sponsors/feross"-- },-- {-- "type": "patreon",-- "url": "https://www.patreon.com/feross"-- },-- {-- "type": "consulting",-- "url": "https://feross.org/support"-- }-- ],-- "license": "MIT",-- "dependencies": {-- "decompress-response": "^6.0.0",-- "once": "^1.3.1",-- "simple-concat": "^1.0.0"-- }-- },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1757,56 +1387,6 @@"node": ">= 0.8"}},-- "node_modules/string_decoder": {-- "version": "1.3.0",-- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",-- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "safe-buffer": "~5.2.0"-- }-- },-- "node_modules/strip-json-comments": {-- "version": "2.0.1",-- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",-- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",-- "dev": true,-- "license": "MIT",-- "engines": {-- "node": ">=0.10.0"-- }-- },-- "node_modules/tar-fs": {-- "version": "2.1.5",-- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",-- "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "chownr": "^1.1.1",-- "mkdirp-classic": "^0.5.2",-- "pump": "^3.0.0",-- "tar-stream": "^2.1.4"-- }-- },-- "node_modules/tar-stream": {-- "version": "2.2.0",-- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",-- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",-- "dev": true,-- "license": "MIT",-- "dependencies": {-- "bl": "^4.0.3",-- "end-of-stream": "^1.4.1",-- "fs-constants": "^1.0.0",-- "inherits": "^2.0.3",-- "readable-stream": "^3.1.1"-- },-- "engines": {-- "node": ">=6"-- }-- },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -1817,19 +1397,6 @@"node": ">=0.6"}},-- "node_modules/tunnel-agent": {-- "version": "0.6.0",-- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",-- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",-- "dev": true,-- "license": "Apache-2.0",-- "dependencies": {-- "safe-buffer": "^5.0.1"-- },-- "engines": {-- "node": "*"-- }-- },"node_modules/type-is": {"version": "1.6.18","resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",@@@ -1861,13 +1428,6 @@"node": ">= 0.8"}},-- "node_modules/util-deprecate": {-- "version": "1.0.2",-- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",-- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",-- "dev": true,-- "license": "MIT"-- },"node_modules/utils-merge": {"version": "1.0.1","resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",@@@ -1887,13 +1447,6 @@"engines": {"node": ">= 0.8"}-- },-- "node_modules/wrappy": {-- "version": "1.0.2",-- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",-- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",-- "dev": true,-- "license": "ISC"}}}@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "2.21.2",++ "version": "3.0.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -14,10 +14,7 @@"./analytics/event.v1.json": "./docs/schemas/analytics-event.v1.json","./analytics/privacy": "./analytics/privacy.js","./analytics/pg": "./analytics/pg.js",-- "./analytics/prune-cli": "./analytics/prune-cli.js","./analytics/retention": "./analytics/retention.js",-- "./analytics/schema": "./analytics/schema.js",-- "./analytics/tracker": "./analytics/tracker.js","./app-icon": "./app-icon.js","./assets": "./assets.js","./auth-client": "./auth-client.js",@@@ -102,7 +99,6 @@},"devDependencies": {"@electric-sql/pglite": "^0.5.8",-- "better-sqlite3": "^11.10.0","express": "^4.22.3","linkedom": "^0.18.13","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",@@@ -86,9 +86,8 @@ function ruleA(callee, asyncNamesSet) {return false;}--// asyncDb: the repository's db handle is asynchronous (no better-sqlite3 dependency): only then are db.run/get/all/exec--// promises. A better-sqlite3 service's db calls are synchronous and never float.--function ruleB(callee, args, { asyncDb = true } = {}) {++// Database handles and outboxes return promises.++function ruleB(callee, args) {if (callee.type === 'Identifier') return callee.name === 'fetch';if (callee.type !== 'MemberExpression') return false;const obj = callee.object;@@@ -98,11 +97,11 @@ function ruleB(callee, args, { asyncDb = true } = {}) {if (obj.type === 'MemberExpression' && obj.object.type === 'Identifier' && obj.object.name === 'fs' && memberProp(obj) === 'promises') return true; // fs.promises.*if (prop === 'tx') return true; // <anything>.tx(if (prop === 'transaction' && args.some(isAsyncFn)) return true; // .transaction(async …)-- if (asyncDb && (objName === 'db' || objName === 'tx') && DB_METHODS.has(prop)) return true; // db.run/get/all/exec/query, tx.run/get/all/exec++ if ((objName === 'db' || objName === 'tx') && DB_METHODS.has(prop)) return true; // db.run/get/all/exec/query, tx.run/get/all/execif (objName === 'pool' && prop === 'query') return true;if (objName === 'client' && prop === 'query') return true;-- if (prop === 'enqueue' && asyncDb) return true; // an outbox on better-sqlite3 enqueues synchronously-- if (prop === 'close' && CLOSE_OBJECTS.has(objName) && (asyncDb || objName !== 'db')) return true; // better-sqlite3's db.close() is synchronous // .close() on db/pool/client/server/store++ if (prop === 'enqueue') return true; // outbox enqueue++ if (prop === 'close' && CLOSE_OBJECTS.has(objName)) return true; // .close() on db/pool/client/server/storereturn false;}@@@ -142,15 +141,7 @@ function walkTree(dir) {const isServerPath = (rel) => { const s = rel.split(path.sep); return s[0] === 'server' || (s[0] === 'apps' && s[2] === 'server'); };/** Scan a repository directory. → { findings, allowed, files, errors } */--/** True when the repository's package.json (or an apps/<app>/package.json) depends on better-sqlite3. */--function usesSyncSqlite(dir) {-- const has = (p) => { try { const j = JSON.parse(fs.readFileSync(p, 'utf8')); return Boolean((j.dependencies || {})['better-sqlite3']); } catch { return false; } };-- if (has(path.join(dir, 'package.json'))) return true;-- try { return fs.readdirSync(path.join(dir, 'apps')).some((a) => has(path.join(dir, 'apps', a, 'package.json'))); } catch { return false; }--}--function scan(dir = '.') {-- const asyncDb = !usesSyncSqlite(dir);const acorn = load('acorn');const walk = load('acorn-walk');const findings = [];@@@ -176,7 +167,7 @@ function scan(dir = '.') {if (prop === 'then' || prop === 'catch' || prop === 'finally') return; // rejection is handledlet rule = null;if (ruleA(callee, names)) rule = 'a';-- else if (ruleB(callee, expr.arguments, { asyncDb })) rule = 'b';++ else if (ruleB(callee, expr.arguments)) rule = 'b';if (!rule) return;const inside = ancestors.some((a) => a !== node && isFn(a));if (!inside && !server) return; // top-level script: allowed to exit with the processddeleted file mode 100644@@@ -1,166 +0,0 @@--'use strict';--/**-- * The analytics-prune command (analytics/prune-cli.js), as Live, Tools and Network wire it:-- * - a dry run (with and without --scrub) changes nothing;-- * - --apply needs --backup <new file|dir> or --no-backup, refuses an existing target and days > 30;-- * - the backup is verified and owner-only (it can hold pre-ADR IPs), then prune + scrub run and the-- * rollup totals are unchanged;-- * - several databases (Tools' apps) back up into a directory; a service's defaults, extra options and-- * path options (Network) are honoured.-- *-- * node test/analytics-cli.test.js-- */--const assert = require('assert');--const crypto = require('crypto');--const fs = require('fs');--const os = require('os');--const path = require('path');--const Database = require('better-sqlite3');--const { AnalyticsTracker, retention } = require('openvibe-shared/analytics');--const { sqlTime } = require('openvibe-shared/analytics/tracker');--const cli = require('openvibe-shared/analytics/prune-cli');----let failures = 0;--async function check(name, fn) {-- try { await fn(); console.log(' ✓', name); }-- catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }--}----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shared-cli-'));--const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';--const NETWORK = { paramPrefixes: ['avatar', 'anon', 'projects'], pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']] };--const out = [];--const log = (l) => out.push(l);--const main = (argv, extra = {}) => cli.main(argv, { Database, log, ...extra });--const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');----function dumpAll(file) {-- const db = new Database(file, { readonly: true });-- try {-- return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").pluck().all()-- .map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');-- } finally { db.close(); }--}----/** A legacy database: 10 rows past the cutoff, 3 inside it (edge rows 2 min clear of the CLI's own clock). */--function legacyDb(file) {-- fs.mkdirSync(path.dirname(file), { recursive: true });-- const db = new Database(file);-- db.pragma('journal_mode = WAL');-- db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT); INSERT INTO users VALUES (7, 'keepme');");-- new AnalyticsTracker(db, 'live', { timers: false }).destroy();-- const nowMs = Date.now() + 120000;-- const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, method, status_code, user_id, session_id, ip, city, user_agent, referer, created_at)-- VALUES ('live', 'pageview', ?, 'GET', 200, ?, ?, ?, ?, ?, ?, ?)`);-- for (let i = 0; i < 10; i++) ins.run(`/@old${i}?x=${i}`, 7, 'tokentail' + i, '198.51.100.' + i, 'Lyon', CHROME, 'https://t.co/x?y=1', sqlTime(nowMs - (40 + i) * 86400000));-- ins.run('/internal/users/by-username/keepme', null, null, '127.0.0.1', null, 'node', '', sqlTime(nowMs - 3 * 86400000));-- ins.run('/avatar/keepme?s=96', 9, 'abcdef0123456789', '198.51.100.52', 'Oslo', CHROME, 'https://www.reddit.com/r/a', sqlTime(nowMs - 2 * 86400000));-- ins.run('/vod/12345?t=9', null, null, '198.51.100.53', null, CHROME, '', sqlTime(nowMs - 1 * 86400000));-- db.prepare('INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, top_paths, top_referers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')-- .run('live', '2026-07-01', 100, 40, 30, 10, 2, JSON.stringify([{ path: '/@keepme', cnt: 3 }, { path: '/internal/users/by-username/keepme', cnt: 2 }]), JSON.stringify([{ referer: 'https://www.google.com/search?q=keepme', cnt: 4 }]));-- db.pragma('wal_checkpoint(TRUNCATE)');-- db.close();-- return file;--}----(async () => {-- await check('argument parsing: defaults, repeatable --db, extra options, refusals', () => {-- const a = cli.parseArgs(['--db', 'a.db', '--db', 'b.db', '--app', 'yt', '--scrub'], { '--app': 'apps' });-- assert.deepStrictEqual([a.dbs, a.db, a.apps, a.scrub, a.apply, a.days, a.batch], [['a.db', 'b.db'], 'a.db', ['yt'], true, false, 30, 5000]);-- assert.throws(() => cli.parseArgs(['--days', '31']), /1 to 30/);-- assert.throws(() => cli.parseArgs(['--batch', '0']), /batch/);-- assert.throws(() => cli.parseArgs(['--backup', 'x', '--no-backup']), /exclusive/);-- assert.throws(() => cli.parseArgs(['--app', 'yt']), /unknown option --app/);-- assert.throws(() => cli.parseArgs(['--db']), /needs a value/);-- assert.deepStrictEqual(cli.namedTargets(['/srv/tools/apps/yt/data/analytics.db', '/x/analytics.db', '/y/analytics.db']).map((t) => t.name), ['yt', 'analytics', 'analytics-2']);-- });---- await check('dry run (with and without --scrub) changes nothing; the default database is the service\'s', async () => {-- const file = legacyDb(path.join(tmp, 'dry', 'analytics.db'));-- const before = { hash: sha(file), dump: dumpAll(file) };-- assert.strictEqual(await main([], { defaultDb: file }), 0);-- assert.strictEqual(await main(['--scrub', '--days', '7'], { defaultDb: (args, env) => env.TEST_ANALYTICS_DB, env: { TEST_ANALYTICS_DB: file } }), 0);-- assert.strictEqual(sha(file), before.hash);-- assert.strictEqual(dumpAll(file), before.dump);-- assert.ok(out.some((l) => /dry run: nothing changed/.test(l)));-- assert.ok(out.some((l) => /prune\s+10 rows/.test(l)), out.join('\n'));-- assert.strictEqual(await main([]), 2, 'no database at all');-- assert.strictEqual(await main(['--db', path.join(tmp, 'missing.db')]), 2);-- assert.strictEqual(await main(['--help'], { usage: 'custom usage' }), 0);-- assert.strictEqual(out[out.length - 1], 'custom usage');-- await assert.rejects(cli.main([], {}), /Database/);-- });---- await check('--apply refuses without a backup choice, an existing target, or days > 30', async () => {-- const file = legacyDb(path.join(tmp, 'refuse.db'));-- const before = dumpAll(file);-- assert.strictEqual(await main(['--db', file, '--apply']), 2);-- const existing = path.join(tmp, 'exists.bak');-- fs.writeFileSync(existing, 'x');-- assert.strictEqual(await main(['--db', file, '--apply', '--backup', existing]), 2);-- assert.strictEqual(await main(['--db', file, '--apply', '--no-backup', '--days', '31']), 2);-- assert.strictEqual(await main(['--db', file, '--apply', '--no-backup', '--backup', existing]), 2);-- assert.strictEqual(dumpAll(file), before);-- assert.strictEqual(fs.readFileSync(existing, 'utf8'), 'x');-- });---- await check('--apply --scrub --backup: owner-only verified backup, prune + scrub, rollups equal, other tables kept', async () => {-- const file = legacyDb(path.join(tmp, 'apply.db'));-- const src = new Database(file, { readonly: true });-- const totals = retention.rollupTotals(src);-- src.close();-- const bak = path.join(tmp, 'apply.backup.db');-- assert.strictEqual(await main(['--db', file, '--apply', '--scrub', '--backup', bak, '--batch', '2'], NETWORK), 0, out.join('\n'));-- assert.strictEqual(fs.statSync(bak).mode & 0o777, 0o600);-- const b = new Database(bak, { readonly: true });-- assert.strictEqual(b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 13, 'backup has every row');-- b.close();-- const db = new Database(file, { readonly: true });-- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY created_at').pluck().all(),-- ['/internal/users/:param/:username', '/avatar/:param', '/vod/:param']);-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events WHERE ip IS NOT NULL OR user_id IS NOT NULL OR city IS NOT NULL').pluck().get(), 0);-- assert.deepStrictEqual(JSON.parse(db.prepare('SELECT top_paths FROM analytics_daily').pluck().get()),-- [{ path: '/@:user', cnt: 3 }, { path: '/internal/users/:param/:username', cnt: 2 }]);-- assert.deepStrictEqual(retention.rollupTotals(db), totals);-- assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = 7').pluck().get(), 'keepme');-- db.close();-- assert.ok(out.some((l) => /mode 0600, quick_check ok/.test(l)));-- // Prune-only with an explicit --no-backup works too (nothing left to prune).-- assert.strictEqual(await main(['--db', file, '--apply', '--no-backup', '--no-vacuum']), 0);-- });---- await check('several databases (Tools apps): discovery hook, extra option, backups into a directory', async () => {-- const apps = path.join(tmp, 'apps');-- const yt = legacyDb(path.join(apps, 'yt', 'data', 'analytics.db'));-- const food = legacyDb(path.join(apps, 'food', 'data', 'analytics.db'));-- const tools = {-- options: { '--app': 'apps' },-- targets: (args) => (args.apps.length ? args.apps : ['food', 'yt']).map((name) => ({ name, file: path.join(apps, name, 'data', 'analytics.db') })),-- };-- const hashes = [sha(yt), sha(food)];-- assert.strictEqual(await main(['--scrub'], tools), 0);-- assert.strictEqual(await main(['--app', 'yt'], tools), 0);-- assert.deepStrictEqual([sha(yt), sha(food)], hashes, 'dry run changed a file');-- const bdir = path.join(tmp, 'backups');-- assert.strictEqual(await main(['--apply', '--scrub', '--backup', bdir], tools), 0, out.join('\n'));-- for (const [name, file] of [['yt', yt], ['food', food]]) {-- const bfile = path.join(bdir, `${name}.analytics.db`);-- assert.strictEqual(fs.statSync(bfile).mode & 0o777, 0o600);-- const b = new Database(bfile, { readonly: true });-- assert.strictEqual(b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 13);-- b.close();-- const db = new Database(file, { readonly: true });-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 3);-- assert.strictEqual(db.prepare('SELECT SUM(pageviews) FROM analytics_daily').pluck().get(), 100);-- db.close();-- }-- assert.strictEqual(await main(['--apply', '--backup', bdir], tools), 2, 'existing backup targets are refused');-- const afile = path.join(tmp, 'a-file');-- fs.writeFileSync(afile, 'x');-- assert.strictEqual(await main(['--apply', '--backup', afile], tools), 2, 'several databases need a directory');-- });---- fs.rmSync(tmp, { recursive: true, force: true });-- if (failures) { console.log(`\n${failures} failed`); process.exit(1); }-- console.log('\nanalytics prune CLI: all passed');--})();@@@ -1,7 +1,7 @@'use strict';/*** openvibe-shared/analytics/pg on PGlite: request rows reach the database only at flush; the day's salt is shared-- * across trackers (two processes count one visitor once); rollups and dashboards keep the SQLite tracker's shapes;++ * across trackers (two processes count one visitor once); rollups and dashboards keep their response shapes;* opted-out requests record nothing; retention prunes old rows. No IP ever reaches the database.*/const assert = require('assert');@@@ -12,16 +12,11 @@* node test/analytics-privacy.test.js*/const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const express = require('express');--const Database = require('better-sqlite3');const analytics = require('openvibe-shared/analytics');--const { sqlTime } = require('openvibe-shared/analytics/tracker');++const { sqlTime } = require('../analytics/core');const EVENT_V1 = require('openvibe-shared/analytics/event.v1.json');--const { AnalyticsTracker, privacy, event } = analytics;++const { privacy, event } = analytics;let failures = 0;async function check(name, fn) {@@@ -29,28 +24,11 @@ async function check(name, fn) {catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }}--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shared-analytics-'));--const newDb = (name) => { const db = new Database(path.join(tmp, name)); db.pragma('journal_mode = WAL'); return db; };const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';--const SUBJECT = 'usr_01J8ZQ4K7M2N3P4Q5R6S7T8V9W';// Network's route shapes, given as options (they were hard-coded in its server/analytics/network.js).const NETWORK = { paramPrefixes: ['avatar', 'anon', 'projects'], pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']] };--/** Every value in every table, for "is this anywhere in the file" checks. */--function dumpAll(db) {-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").pluck().all();-- return tables.map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');--}----async function serve(app, fn) {-- const server = app.listen(0, '127.0.0.1');-- await new Promise((r) => server.once('listening', r));-- try { return await fn(`http://127.0.0.1:${server.address().port}`); }-- finally { await new Promise((r) => server.close(r)); }--}--const settle = () => new Promise((r) => setTimeout(r, 50));--(async () => {// ── Reducers ─────────────────────────────────────────────await check('normalisePath strips queries and replaces ids, slugs and usernames', () => {@@@ -151,189 +129,6 @@ const settle = () => new Promise((r) => setTimeout(r, 50));assert.strictEqual(analytics.optedOut, privacy.optedOut);});-- // ── A tracked request ────────────────────────────────────-- await check('a tracked request stores no IP, user id, city, raw UA, raw referer or query', async () => {-- const db = newDb('track.db');-- let clock = Date.parse('2026-09-23T10:15:00Z');-- const tracker = new AnalyticsTracker(db, 'live', { timers: false, now: () => clock });-- const app = express();-- app.set('trust proxy', true);-- app.use(tracker.middleware());-- app.use((req, res, next) => { if (req.headers.authorization) req.user = { id: 42, sub: SUBJECT, username: 'alex' }; next(); });-- const router = express.Router();-- router.get('/things/:thingId', (req, res) => res.json({ ok: true }));-- app.use('/api', router);-- app.get('*', (req, res) => res.send('<html></html>'));-- const hdr = (extra) => ({ 'user-agent': CHROME, 'x-forwarded-for': '203.0.113.77', referer: 'https://www.google.com/search?q=secret-query', 'cf-ipcountry': 'NL', 'cf-ipcity': 'Amsterdam', ...extra });-- await serve(app, async (base) => {-- await (await fetch(`${base}/api/things/98765?token=supersecret`, { headers: hdr({ authorization: 'Bearer x' }) })).text();-- await (await fetch(`${base}/@alex/main?stream=12`, { headers: hdr() })).text();-- await (await fetch(`${base}/vod/424242`, { headers: hdr({ 'user-agent': FIREFOX, 'x-forwarded-for': '198.51.100.9' }) })).text();-- await settle();-- });-- tracker.flush();---- const rows = db.prepare('SELECT * FROM analytics_events ORDER BY id').all();-- assert.strictEqual(rows.length, 3);-- for (const r of rows) {-- assert.strictEqual(r.ip, null);-- assert.strictEqual(r.user_id, null);-- assert.strictEqual(r.city, null);-- assert.ok(!/[?#]/.test(r.path), r.path);-- assert.strictEqual(r.referer, 'https://www.google.com');-- assert.ok(/^[0-9a-f]{16}$/.test(r.session_id), r.session_id);-- assert.strictEqual(r.country, 'NL');-- assert.deepStrictEqual(event.checkRow(r), [], JSON.stringify(r));-- }-- assert.deepStrictEqual(rows.map((r) => r.path), ['/api/things/:thingId', '/@:user/:param', '/vod/:param']);-- assert.deepStrictEqual(rows.map((r) => r.user_agent), ['chrome/windows/desktop', 'chrome/windows/desktop', 'firefox/linux/desktop']);-- assert.deepStrictEqual(rows.map((r) => r.authenticated), [1, 0, 0]);-- assert.deepStrictEqual(rows.map((r) => r.event_type), ['api_call', 'pageview', 'pageview']);-- assert.strictEqual(rows[0].session_id, rows[1].session_id, 'same visitor, same session');-- assert.notStrictEqual(rows[0].session_id, rows[2].session_id);---- const everything = dumpAll(db);-- for (const needle of ['203.0.113.77', '198.51.100.9', '127.0.0.1', SUBJECT, 'Amsterdam', 'supersecret', 'secret-query', '98765', '424242', 'Mozilla/5.0', '"alex"']) {-- assert.ok(!everything.includes(needle), `found ${needle} in analytics.db`);-- }-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);-- assert.ok(!db.prepare("SELECT name FROM sqlite_master WHERE name IN ('idx_analytics_events_ip', 'idx_analytics_events_user')").get());---- // Rollups: two distinct (ip, ua) visitors, one of them signed in.-- tracker.aggregate();-- const hourly = db.prepare("SELECT * FROM analytics_hourly WHERE hour = '2026-09-23 10:00:00'").get();-- assert.strictEqual(hourly.pageviews, 2);-- assert.strictEqual(hourly.api_calls, 1);-- assert.strictEqual(hourly.unique_visitors, 2);-- assert.strictEqual(hourly.unique_users, 1);-- const daily = db.prepare("SELECT * FROM analytics_daily WHERE date = '2026-09-23'").get();-- assert.strictEqual(daily.unique_visitors, 2);-- assert.strictEqual(daily.unique_users, 1);-- assert.strictEqual(daily.new_users, null);-- assert.ok(JSON.parse(daily.top_paths).every((p) => !/\d{3,}/.test(p.path)));-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_visitor_days').pluck().get(), 2);-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_day_salts').pluck().get(), 1);---- // Next day: the finished day is rolled up for the last time, then its hashes and salt go.-- clock = Date.parse('2026-09-24T00:20:00Z');-- tracker.aggregate();-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_visitor_days').pluck().get(), 0);-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_day_salts').pluck().get(), 0);-- assert.strictEqual(db.prepare("SELECT unique_visitors FROM analytics_daily WHERE date = '2026-09-23'").pluck().get(), 2, 'uniques survive the hashes');-- clock = Date.parse('2026-09-24T01:20:00Z');-- tracker.aggregate();-- assert.strictEqual(db.prepare("SELECT unique_visitors FROM analytics_daily WHERE date = '2026-09-23'").pluck().get(), 2, 'a recompute never lowers uniques');---- // The same visitor gets a new session id (and hash) on a new day.-- assert.strictEqual(tracker.record({ headers: { 'user-agent': CHROME }, ip: '203.0.113.77', method: 'GET' }, { statusCode: 200 }, '/', 3), true);-- tracker.flush();-- const last = db.prepare('SELECT session_id FROM analytics_events ORDER BY id DESC LIMIT 1').pluck().get();-- assert.notStrictEqual(last, rows[0].session_id);---- // Dashboards keep the pre-ADR shapes.-- const st = tracker.getStats({ days: 30 });-- for (const k of ['summary', 'realtime', 'daily', 'hourly', 'topPages', 'authBreakdown', 'visitorTypes', 'authTrend']) assert.ok(k in st, k);-- assert.ok(tracker.getStats({ hours: 6 }).timeBuckets);-- const bots = tracker.getBotAnalysis(30);-- for (const k of ['topBotIPs', 'botTrend', 'botTypes', 'suspiciousIPs']) assert.ok(Array.isArray(bots[k]), k);-- assert.ok(tracker.getOverview(30).services.length >= 1);-- tracker.destroy();-- db.close();-- });---- await check('Sec-GPC: 1 and DNT: 1 requests leave nothing: no row, hash, session or rate counter', async () => {-- const db = newDb('optout.db');-- const clock = Date.parse('2026-09-23T10:15:00Z');-- const tracker = new AnalyticsTracker(db, 'live', { timers: false, now: () => clock });-- const app = express();-- app.set('trust proxy', true);-- app.use(tracker.middleware());-- app.get('*', (req, res) => res.send('ok'));-- const hdr = (extra) => ({ 'user-agent': CHROME, 'x-forwarded-for': '203.0.113.5', 'cf-ipcountry': 'FR', ...extra });-- await serve(app, async (base) => {-- await (await fetch(`${base}/@gpc-user`, { headers: hdr({ 'sec-gpc': '1' }) })).text();-- await (await fetch(`${base}/@dnt-user`, { headers: hdr({ dnt: '1' }) })).text();-- for (let i = 0; i < 70; i++) await (await fetch(`${base}/flood`, { headers: hdr({ 'sec-gpc': '1', 'x-forwarded-for': '203.0.113.6' }) })).text();-- await (await fetch(`${base}/counted`, { headers: hdr({ dnt: '0', 'sec-gpc': '0' }) })).text();-- await settle();-- });-- tracker.flush();-- tracker.aggregate();-- const rows = db.prepare('SELECT * FROM analytics_events').all();-- assert.deepStrictEqual(rows.map((r) => r.path), ['/counted'], 'only the request without an opt-out is recorded');-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_visitor_days').pluck().get(), 1);-- assert.strictEqual(tracker._sessions.size, 1, 'no session for an opted-out visitor');-- assert.ok(!tracker._rates.has('203.0.113.6'), 'no rate counter for an opted-out client');-- const hourly = db.prepare('SELECT pageviews, unique_visitors FROM analytics_hourly').get();-- assert.deepStrictEqual({ ...hourly }, { pageviews: 1, unique_visitors: 1 });-- // Direct calls honour it too.-- assert.strictEqual(tracker.record({ headers: { 'user-agent': CHROME, dnt: '1' }, ip: '192.0.2.9', method: 'GET' }, { statusCode: 200 }, '/x', 1), false);-- assert.strictEqual(tracker.trackEvent('share_clicked', { headers: { 'sec-gpc': '1' }, path: '/@bob' }), false);-- assert.strictEqual(tracker.trackEvent('share_clicked', { headers: { 'user-agent': CHROME }, path: '/@bob' }), true);-- tracker.flush();-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 2);-- const all = dumpAll(db);-- for (const needle of ['gpc-user', 'dnt-user', 'flood', '203.0.113.', '192.0.2.9']) assert.ok(!all.includes(needle), needle);-- tracker.destroy();-- db.close();-- });---- await check('Network options on the tracker: unmatched paths use its rules', async () => {-- const db = newDb('network.db');-- const tracker = new AnalyticsTracker(db, 'openvibe-network', { timers: false, ...NETWORK });-- const app = express();-- app.use(tracker.middleware());-- app.get('/avatar/:username', (req, res) => res.send('img'));-- app.use('/internal', (req, res) => res.status(403).end()); // refused before routing-- app.use('/api/auth/anon', (req, res) => res.status(404).end());-- await serve(app, async (base) => {-- for (const p of ['/avatar/alexsecretname?s=96', '/internal/users/by-username/carolsecretname', '/api/auth/anon/0123456789abcdefanon']) {-- await (await fetch(base + p, { headers: { 'user-agent': CHROME } })).arrayBuffer();-- }-- await settle();-- });-- tracker.flush();-- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY id').pluck().all(),-- ['/avatar/:username', '/internal/users/:param/:username', '/api/auth/anon/:param']);-- const all = dumpAll(db);-- for (const needle of ['alexsecretname', 'carolsecretname', '0123456789abcdefanon']) assert.ok(!all.includes(needle), needle);-- tracker.destroy();-- db.close();-- });---- await check('rate check keeps IPs in memory only and still flags floods; custom events are reduced', () => {-- const db = newDb('rate.db');-- const clock = Date.parse('2026-09-23T12:00:30Z');-- const tracker = new AnalyticsTracker(db, 'live', { timers: false, now: () => clock });-- for (let i = 0; i < 70; i++) tracker.record({ headers: { 'user-agent': CHROME }, ip: '192.0.2.1', method: 'GET' }, { statusCode: 200 }, '/api/x', 1);-- tracker.flush();-- assert.ok(db.prepare("SELECT COUNT(*) FROM analytics_events WHERE bot_type = 'rate_limit'").pluck().get() >= 10);-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);-- assert.ok(!dumpAll(db).includes('192.0.2.1'));-- tracker.trackEvent('custom', {-- ip: '192.0.2.2', user_id: 7, city: 'Paris', path: '/@bob?x=1', referer: 'https://a.example/x?y=1', user_agent: CHROME,-- session_id: 'user-7', method: 'post', status_code: 999, response_time_ms: -4, browser: 'MyBrowser 1.0', bot_type: 'Weird Type',-- });-- tracker.flush();-- const e = db.prepare("SELECT * FROM analytics_events WHERE event_type = 'custom'").get();-- assert.strictEqual(e.path, '/@:user');-- assert.strictEqual(e.referer, 'https://a.example');-- assert.strictEqual(e.session_id, null);-- assert.strictEqual(e.user_agent, 'chrome/windows/desktop');-- assert.strictEqual(e.method, 'POST');-- assert.strictEqual(e.status_code, null);-- assert.strictEqual(e.response_time_ms, null);-- assert.strictEqual(e.browser, 'chrome');-- assert.strictEqual(e.bot_type, null);-- assert.deepStrictEqual(event.checkRow(e), []);-- assert.ok(!dumpAll(db).includes('Paris') && !dumpAll(db).includes('192.0.2.2'));-- assert.throws(() => tracker.trackEvent('has spaces'), TypeError);-- assert.throws(() => tracker.trackEvent('x'.repeat(65)), TypeError);-- for (const r of db.prepare('SELECT * FROM analytics_events').all()) assert.deepStrictEqual(event.checkRow(r), []);-- tracker.destroy();-- db.close();-- });--// ── analytics/event.v1 ───────────────────────────────────await check('event.v1: the schema file is what the package exports, and a pre-ADR row fails it', () => {assert.strictEqual(event.EVENT_V1, EVENT_V1);@@@ -374,29 +169,6 @@ const settle = () => new Promise((r) => setTimeout(r, 50));assert.strictEqual(event.toEvent(legacy).timestamp, '2026-09-23T10:15:00Z');});-- await check('the pre-ADR export names are still there (1.x), backed by the ADR-021 code', () => {-- const root = require('openvibe-shared');-- assert.strictEqual(root.AnalyticsTracker, AnalyticsTracker);-- assert.strictEqual(root.classifyRequest, privacy.classifyRequest);-- assert.strictEqual(root.parseUserAgent, privacy.parseUserAgent);-- assert.strictEqual(root.BOT_USER_AGENTS, privacy.BOT_USER_AGENTS);-- assert.strictEqual(root.ANALYTICS_SCHEMA, analytics.schema.ANALYTICS_SCHEMA);-- assert.strictEqual(root.SUSPICIOUS_PATTERNS.highRequestRate, privacy.HIGH_REQUEST_RATE);-- assert.ok(Object.isFrozen(root.SUSPICIOUS_PATTERNS));-- assert.deepStrictEqual(privacy.classifyRequest({ headers: { 'user-agent': 'curl/8.5.0' } }).botType, 'known_crawler');-- // The old constructor call (db, service) still works; the prune timer is on by default.-- const db = newDb('compat.db');-- const t = new AnalyticsTracker(db, 'live');-- assert.ok(t.pruneJob && typeof t.pruneJob.run === 'function');-- t.destroy();-- assert.strictEqual(t.pruneJob, null);-- const off = new AnalyticsTracker(db, 'live', { retention: false });-- assert.strictEqual(off.pruneJob, null);-- off.destroy();-- db.close();-- });---- fs.rmSync(tmp, { recursive: true, force: true });if (failures) { console.log(`\n${failures} failed`); process.exit(1); }console.log('\nanalytics privacy: all passed');})();@@@ -1,160 +1,11 @@'use strict';--/**-- * ADR-021 retention (analytics/retention.js):-- * - prune deletes strictly-older raw rows in bounded batches and never touches rollups;-- * - schedulePrune (the tracker's timer, or a service's own job) prunes and reports;-- * - scrub rewrites legacy rows and rollup top lists without changing any rollup counter, with a-- * service's path options (Network's by-username rule), and touches no other table.-- *-- * node test/analytics-retention.test.js-- */++// Backend-independent analytics retention bounds.const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const Database = require('better-sqlite3');--const { AnalyticsTracker, retention, event } = require('openvibe-shared/analytics');--const { sqlTime } = require('openvibe-shared/analytics/tracker');----let failures = 0;--async function check(name, fn) {-- try { await fn(); console.log(' ✓', name); }-- catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }--}----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shared-retention-'));--const newDb = (name) => { const db = new Database(path.join(tmp, name)); db.pragma('journal_mode = WAL'); return db; };--const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';--const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';--const NETWORK = { paramPrefixes: ['avatar', 'anon', 'projects'], pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']] };----function dumpAll(db) {-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").pluck().all();-- return tables.map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');--}----/** Rows as the pre-ADR tracker wrote them, around a 30-day cutoff from nowMs. */--function seedLegacy(db, nowMs) {-- new AnalyticsTracker(db, 'live', { timers: false }).destroy(); // schema-- const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, method, status_code, response_time_ms, user_id, session_id, ip, city, user_agent, referer, is_bot, created_at)-- VALUES ('live', 'pageview', ?, 'GET', 200, 5, ?, ?, ?, ?, ?, ?, 0, ?)`);-- const at = (days, s = 0) => sqlTime(nowMs - days * 86400000 + s * 1000);-- const cutoffMs = nowMs - 30 * 86400000;-- const rows = [];-- for (let i = 0; i < 10; i++) rows.push(['/@old' + i, 5, 'eyJhbGciOi.tokentail' + i, '198.51.100.' + i, 'Lyon', CHROME, 'https://t.co/abc?x=1', at(45 + i)]);-- rows.push(['/@edge-older', 5, null, '198.51.100.50', null, CHROME, '', sqlTime(cutoffMs - 1000)]);-- rows.push(['/@edge-exact', 5, null, '198.51.100.51', null, CHROME, '', sqlTime(cutoffMs)]);-- rows.push(['/@edge-newer', null, null, '198.51.100.52', null, FIREFOX, '', sqlTime(cutoffMs + 1000)]);-- rows.push(['/vod/12345?t=9', 9, 'abcdef0123456789', '198.51.100.53', 'Oslo', FIREFOX, 'https://www.reddit.com/r/x/comments/1', at(2)]);-- for (const r of rows) ins.run(...r);-- const hr = db.prepare('INSERT INTO analytics_hourly (service, hour, pageviews, api_calls, unique_visitors, unique_users, top_paths, top_referers) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');-- const dy = db.prepare('INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, top_paths, top_referers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');-- const tops = JSON.stringify([{ path: '/@alex', cnt: 3 }, { path: '/@bob', cnt: 2 }, { path: '/vods', cnt: 1 }]);-- const refs = JSON.stringify([{ referer: 'https://www.google.com/search?q=alex', cnt: 4 }, { referer: 'https://www.google.com/', cnt: 1 }]);-- hr.run('live', at(60).slice(0, 13) + ':00:00', 10, 4, 3, 1, tops, refs);-- dy.run('live', at(60).slice(0, 10), 100, 40, 30, 10, 2, tops, refs);-- dy.run('live', at(2).slice(0, 10), 50, 20, 15, 5, 1, tops, refs);-- db.prepare("INSERT INTO analytics_rate_tracking (ip, window_start, hit_count) VALUES ('198.51.100.99', 1, 1)").run();--}----(async () => {-- await check('prune: strictly older than the cutoff, bounded batches, rollups untouched', async () => {-- const db = newDb('prune.db');-- const nowMs = Date.parse('2026-09-23T12:00:00Z');-- seedLegacy(db, nowMs);-- const totals = retention.rollupTotals(db);-- const bounded = await retention.pruneRawEvents(db, { days: 30, batchSize: 3, maxBatches: 2, now: () => nowMs });-- assert.strictEqual(bounded.deleted, 6);-- assert.strictEqual(bounded.complete, false);-- const out = await retention.pruneRawEvents(db, { days: 30, batchSize: 3, now: () => nowMs });-- assert.strictEqual(out.deleted, 5);-- assert.strictEqual(out.cutoff, '2026-08-24 12:00:00');-- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY created_at').pluck().all(), ['/@edge-exact', '/@edge-newer', '/vod/12345?t=9']);-- assert.deepStrictEqual(retention.rollupTotals(db), totals);-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_daily').pluck().get(), 2, 'old rollups stay');-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);-- assert.strictEqual((await retention.pruneRawEvents(db, { days: 30, now: () => nowMs })).deleted, 0);-- assert.throws(() => retention.cutoffFor(31), /1 to 30/);-- assert.throws(() => retention.cutoffFor(0), /1 to 30/);-- db.close();-- });---- await check('schedulePrune: run() prunes and logs, overlapping runs are skipped, days > 30 refused', async () => {-- const db = newDb('job.db');-- const nowMs = Date.parse('2026-09-23T12:00:00Z');-- seedLegacy(db, nowMs);-- const totals = retention.rollupTotals(db);-- const logs = [];-- const job = retention.schedulePrune(db, { initialDelayMs: 1e9, intervalMs: 1e9, label: 'Analytics:test', log: (l) => logs.push(l), now: () => nowMs });-- try {-- const [a, b] = await Promise.all([job.run(), job.run()]);-- assert.strictEqual(a.deleted, 11);-- assert.strictEqual(b, null, 'overlap skipped');-- assert.ok(/^\[Analytics:test\] pruned 11 raw events older than 2026-08-24 12:00:00$/.test(logs[0]), logs.join('\n'));-- } finally { job.stop(); }-- assert.deepStrictEqual(retention.rollupTotals(db), totals);-- assert.throws(() => retention.schedulePrune(db, { days: 90 }), /1 to 30/);-- // The tracker's own timer is the same job.-- const t = new AnalyticsTracker(db, 'live', { retention: { initialDelayMs: 1e9, intervalMs: 1e9, log: () => {} }, now: () => nowMs });-- assert.strictEqual((await t.pruneJob.run()).deleted, 0);-- t.destroy();-- db.close();-- });---- await check('scrub: legacy rows and rollup top lists reduced, counters unchanged, rows now event.v1', async () => {-- const db = newDb('scrub.db');-- const nowMs = Date.parse('2026-09-23T12:00:00Z');-- seedLegacy(db, nowMs);-- await retention.pruneRawEvents(db, { days: 30, now: () => nowMs });-- const totals = retention.rollupTotals(db);-- const s = await retention.scrubEvents(db, { batchSize: 2 });-- assert.strictEqual(s.rows, 3);-- assert.deepStrictEqual(retention.scrubRollups(db), { hourly: 1, daily: 2 });-- assert.deepStrictEqual(retention.rollupTotals(db), totals);-- const rows = db.prepare('SELECT * FROM analytics_events ORDER BY created_at').all();-- assert.deepStrictEqual(rows.map((x) => x.path), ['/@:user', '/@:user', '/vod/:param']);-- assert.deepStrictEqual(rows.map((x) => x.authenticated), [1, 0, 1]);-- assert.deepStrictEqual(rows.map((x) => x.session_id), [null, null, 'abcdef0123456789']);-- assert.deepStrictEqual(rows.map((x) => x.referer), ['', '', 'https://www.reddit.com']);-- assert.deepStrictEqual(rows.map((x) => x.user_agent), ['chrome/windows/desktop', 'firefox/linux/desktop', 'firefox/linux/desktop']);-- for (const x of rows) assert.deepStrictEqual(event.checkRow(x), [], JSON.stringify(x));-- const daily = db.prepare('SELECT top_paths, top_referers FROM analytics_daily ORDER BY date LIMIT 1').get();-- assert.deepStrictEqual(JSON.parse(daily.top_paths), [{ path: '/@:user', cnt: 5 }, { path: '/vods', cnt: 1 }]);-- assert.deepStrictEqual(JSON.parse(daily.top_referers), [{ referer: 'https://www.google.com', cnt: 5 }]);-- const all = dumpAll(db);-- for (const needle of ['198.51.100.', 'Lyon', 'Oslo', 'eyJhbGciOi', 'q=alex', '@alex', '/vod/12345', 'Mozilla/5.0']) assert.ok(!all.includes(needle), needle);-- await retention.scrubEvents(db);-- assert.deepStrictEqual(retention.scrubRollups(db), { hourly: 0, daily: 0 }, 'idempotent');-- assert.deepStrictEqual(retention.rollupTotals(db), totals);-- db.close();-- });---- await check('scrub with Network path options; other tables in the same database untouched', async () => {-- const db = newDb('network-scrub.db');-- db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT); INSERT INTO users VALUES (7, 'keepme');");-- new AnalyticsTracker(db, 'openvibe-network', { timers: false }).destroy();-- const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, user_id, ip, user_agent, created_at) VALUES ('openvibe-network', 'pageview', ?, 7, '127.0.0.1', 'node', ?)`);-- const now = sqlTime(Date.now());-- for (const p of ['/avatar/keepme?s=1', '/api/auth/anon/tok0X9zz99', '/internal/users/by-username/keepme']) ins.run(p, now);-- db.prepare("INSERT INTO analytics_daily (service, date, pageviews, top_paths) VALUES ('openvibe-network', '2026-07-01', 5, ?)")-- .run(JSON.stringify([{ path: '/internal/users/by-username/keepme', cnt: 2 }, { path: '/avatar/keepme', cnt: 3 }]));-- const before = retention.inspect(db, NETWORK);-- assert.strictEqual(before.toScrub.paths, 3);-- await retention.scrubEvents(db, NETWORK);-- retention.scrubRollups(db, NETWORK);-- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY id').pluck().all(),-- ['/avatar/:param', '/api/auth/anon/:param', '/internal/users/:param/:username']);-- assert.deepStrictEqual(JSON.parse(db.prepare('SELECT top_paths FROM analytics_daily').pluck().get()),-- [{ path: '/avatar/:param', cnt: 3 }, { path: '/internal/users/:param/:username', cnt: 2 }]);-- assert.strictEqual(retention.inspect(db, NETWORK).toScrub.paths, 0);-- assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = 7').pluck().get(), 'keepme');-- const analyticsOnly = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'analytics_%'").pluck().all()-- .map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');-- for (const needle of ['keepme', 'X9zz99', '127.0.0.1']) assert.ok(!analyticsOnly.includes(needle), needle);-- db.close();-- });---- fs.rmSync(tmp, { recursive: true, force: true });-- if (failures) { console.log(`\n${failures} failed`); process.exit(1); }-- console.log('\nanalytics retention: all passed');--})();++const { retention } = require('openvibe-shared/analytics');++++assert.strictEqual(retention.MAX_DAYS, 30);++assert.strictEqual(retention.DEFAULT_BATCH, 5000);++assert.strictEqual(retention.checkDays('30'), 30);++assert.strictEqual(retention.cutoffFor(30, Date.parse('2026-09-23T12:00:00Z')), '2026-08-24 12:00:00');++for (const days of [0, 31, 1.5, 'x']) assert.throws(() => retention.checkDays(days), /1 to 30/);++console.log('analytics-retention: all checks passed');@@@ -81,7 +81,7 @@ function counting(db) {await db.query(configSchema());// ── Construction ──-- await assert.rejects(async () => await createConfigStore({}), /better-sqlite3/);++ await assert.rejects(async () => await createConfigStore({}), /openvibe-sdk\/db/);await assert.rejects(async () => await createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);ddeleted file mode 100644@@@ -1,635 +0,0 @@--'use strict';--// config.js: validation, classification and redaction, atomic activation with last-known-good--// restored when onActivate throws (memory and database), rollback as a new revision, legacy import,--// two namespaces in one database, restart and an interrupted activation, serialized applies,--// pruning that keeps the last-known-good, and /api/admin/config on Express and on plain http.--const assert = require('assert');--const crypto = require('crypto');--const http = require('http');--const Database = require('better-sqlite3');--const express = require('express');--const { createConfigStore, adminRoutes, fromRows, canonical, ConfigError } = require('../config');----let contracts = null;--try { contracts = require('openvibe-contracts'); } catch { /* optional */ }--const hasSnapshotContract = (() => { try { return !!(contracts && contracts.resolve('common.config-snapshot@1')); } catch { return false; } })();----const USER = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };--const SECRET = 'sk_live_TOPSECRET_9f8e7d6c5b4a';--const sha = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');--/** The namespace's fingerprint key, read the way only the service itself can, and a fingerprint under it. */--const keyOf = (db, ns) => db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?').get(ns).hmac_key;--const fp = (db, ns, v) => crypto.createHmac('sha256', keyOf(db, ns)).update(canonical(v)).digest('hex');--/** Every form a key could leak in, for every namespace in a database, and the unkeyed hash of SECRET. */--const leakForms = (db) => db.prepare('SELECT hmac_key FROM config_keys').all()-- .flatMap((r) => [r.hmac_key.toString('hex'), r.hmac_key.toString('base64'), r.hmac_key.toString('base64url')])-- .concat([sha(SECRET)]);--const assertNoLeak = (text, forms, what) => { for (const f of forms) assert.ok(!String(text).includes(f), `${what} leaks a key or an unkeyed hash`); };--const sleep = (ms) => new Promise((r) => setTimeout(r, ms));--let t = Date.parse('2026-09-25T12:00:00.000Z');--const clock = () => new Date(t += 1000);--const logs = [];--const log = (m, level) => logs.push(`${level} ${m}`);----/** The common.config-snapshot@1 shape, checked here and by the contract when the pinned copy has it. */--function assertSnapshot(s) {-- const keys = ['service', 'namespace', 'revision', 'previous_revision', 'state', 'values', 'classification', 'checksum', 'created_at', 'created_by', 'activated_at', 'activated_by', 'reason', 'error', 'copied_from'];-- assert.deepStrictEqual(Object.keys(s).sort(), [...keys].sort());-- assert.ok(['proposed', 'active', 'superseded', 'rejected', 'rolled_back'].includes(s.state), s.state);-- assert.ok(Number.isInteger(s.revision) && s.revision >= 1);-- assert.match(s.checksum, /^[0-9a-f]{64}$/);-- assert.deepStrictEqual(Object.keys(s.values).sort(), Object.keys(s.classification).sort(), 'every key classified');-- for (const [k, v] of Object.entries(s.values)) {-- const marker = !!(v && typeof v === 'object' && v.redacted === true);-- assert.strictEqual(marker, s.classification[k] === 'secret', `${k} redacted exactly when secret`);-- if (marker) { assert.deepStrictEqual(Object.keys(v).sort(), ['fingerprint', 'redacted']); assert.match(v.fingerprint, /^[0-9a-f]{64}$/); }-- }-- assert.strictEqual(s.checksum, sha(s.values), 'the checksum covers the values as shown');-- if (s.state === 'rejected') assert.ok(s.error, 'a rejected revision names its error');-- if (['active', 'superseded', 'rolled_back'].includes(s.state)) assert.ok(s.activated_at, `${s.state} has activated_at`);-- if (hasSnapshotContract) {-- const r = contracts.validate('common.config-snapshot@1', s);-- assert.ok(r.valid, JSON.stringify(r.errors));-- }-- return s;--}--const assertProblem = (body, status, code) => {-- assert.strictEqual(body.status, status);-- assert.strictEqual(body.code, code);-- assert.strictEqual(body.type, `https://openvibe.network/problems/${code}`);-- if (contracts) assert.ok(contracts.validate('errors.problem@1', body).valid, JSON.stringify(body));--};----/** A handle that counts statement executions, to prove get() never touches the database. */--function counting(db) {-- const c = { n: 0 };-- return {-- c,-- exec: (s) => db.exec(s),-- transaction: (fn) => db.transaction(fn),-- prepare(sql) {-- const st = db.prepare(sql);-- return { get: (...a) => (c.n++, st.get(...a)), all: (...a) => (c.n++, st.all(...a)), run: (...a) => (c.n++, st.run(...a)) };-- },-- };--}--const rowOf = (db, ns, rev) => db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?').get(ns, rev);----(async () => {-- const db = new Database(':memory:');---- // ── Construction ──-- assert.throws(() => createConfigStore({}), /better-sqlite3/);-- assert.throws(() => createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);-- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);-- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);-- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { oneOf: [] } }), /unsupported keyword "oneOf"/);-- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { type: 'object', properties: { a: { $ref: '#' } } } }), /unsupported keyword "\$ref"/);-- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', classify: 'secret' }), /classify/);-- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.bad_defaults', schema: { type: 'object', properties: { a: { type: 'string' } } }, defaults: { a: 1 }, log }),-- (e) => e.code === 'config.invalid_defaults');---- // ── Seeded from defaults ──-- const SCHEMA = {-- $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'Live site settings',-- type: 'object', additionalProperties: false, required: ['site_name'],-- properties: {-- site_name: { type: 'string', minLength: 1, maxLength: 60, description: 'shown in the navbar' },-- max_bitrate_kbps: { type: 'integer', minimum: 500, maximum: 20000 },-- mode: { enum: ['open', 'invite', 'closed'] },-- support_email: { type: 'string', pattern: '^[^@\\s]+@[^@\\s]+$', format: 'email' },-- youtube_api_key: { type: 'string' },-- tags: { type: 'array', items: { type: 'string' }, maxItems: 3, uniqueItems: true },-- banner: { type: ['object', 'null'], additionalProperties: false, properties: { text: { type: 'string' } } },-- },-- };-- const DEFAULTS = { site_name: 'OpenVibe.Live', max_bitrate_kbps: 6000, mode: 'open', youtube_api_key: '' };-- const classify = (k) => (/api_key|secret|token/.test(k) ? 'secret' : ['site_name', 'mode'].includes(k) ? 'public' : undefined);-- let activations = [];-- let failNext = null;-- let tighten = false;-- const liveOpts = {-- db, service: 'live', namespace: 'live.site_settings', schema: SCHEMA, classify, defaults: DEFAULTS, log, now: clock,-- validate: (v) => {-- if (v.mode === 'closed' && v.max_bitrate_kbps > 10000) return [{ path: '/max_bitrate_kbps', message: 'must be at most 10000 while closed' }];-- if (v.youtube_api_key && !/^[A-Za-z0-9_-]+$/.test(v.youtube_api_key)) return `youtube_api_key ${v.youtube_api_key} has characters keys never have`;-- if (tighten && v.max_bitrate_kbps < 7500) return 'max_bitrate_kbps is below the new floor';-- return true;-- },-- onActivate: async (values, previous, info) => {-- activations.push({ values, previous, info });-- await sleep(1);-- if (failNext && !info.restoring) { const e = failNext; failNext = null; throw e; }-- },-- };-- const counted = counting(db);-- const live = createConfigStore({ ...liveOpts, db: counted });-- assert.strictEqual(live.revision(), 1);-- assert.deepStrictEqual(live.get(), DEFAULTS);-- assert.strictEqual(live.get('mode'), 'open');-- assert.ok(Object.isFrozen(live.get()));-- assert.throws(() => { live.get().mode = 'closed'; }, TypeError);-- const seeded = assertSnapshot(live.lastKnownGood());-- assert.strictEqual(seeded.revision, 1);-- assert.strictEqual(seeded.state, 'active');-- assert.strictEqual(seeded.previous_revision, null);-- assert.strictEqual(seeded.reason, 'seeded from defaults');-- assert.deepStrictEqual(seeded.created_by, { type: 'service', id: 'live' });-- assert.strictEqual(activations.length, 0, 'seeding does not run onActivate');-- const reads = counted.c.n;-- for (let i = 0; i < 1000; i++) { live.get(); live.get('mode'); live.revision(); }-- assert.strictEqual(counted.c.n, reads, 'get() and revision() never read the database');---- // ── Validation rejects, and nothing is stored ──-- const before = live.history({ limit: 200 }).length;-- const cases = [-- [{ ...DEFAULTS, max_bitrate_kbps: 100 }, '/max_bitrate_kbps'],-- [{ ...DEFAULTS, max_bitrate_kbps: 6000.5 }, '/max_bitrate_kbps'],-- [{ ...DEFAULTS, max_bitrate_kbps: NaN }, '/max_bitrate_kbps'],-- [{ ...DEFAULTS, mode: 'party' }, '/mode'],-- [{ ...DEFAULTS, support_email: 'nope' }, '/support_email'],-- [{ ...DEFAULTS, unknown_key: 1 }, '/unknown_key'],-- [{ ...DEFAULTS, site_name: '' }, '/site_name'],-- [{ ...DEFAULTS, tags: ['a', 'a'] }, '/tags'],-- [{ ...DEFAULTS, tags: ['a', 1] }, '/tags/1'],-- [{ ...DEFAULTS, banner: { text: 'x', extra: true } }, '/banner/extra'],-- [{ ...DEFAULTS, mode: 'closed', max_bitrate_kbps: 15000 }, '/max_bitrate_kbps'],-- [{ ...DEFAULTS, 'bad key': 1 }, '/bad key'],-- [{ ...DEFAULTS, when: new Date() }, '/when'],-- ];-- for (const [values, path] of cases) {-- const ok = (e) => e instanceof ConfigError && e.code === 'config.invalid' && e.status === 422 && e.errors.some((x) => x.path === path);-- assert.throws(() => live.propose(values, { actor: USER }), ok, `${path} is refused`);-- await assert.rejects(live.apply(values, { actor: USER }), ok);-- }-- assert.throws(() => live.propose('nope'), (e) => e.code === 'config.invalid');-- const required = createConfigStore({ db, service: 'live', namespace: 'live.required', schema: { type: 'object', required: ['a'] }, log });-- assert.strictEqual(required.revision(), null, 'no defaults and no legacy: nothing active');-- assert.deepStrictEqual(required.get(), {});-- assert.throws(() => required.propose({ b: 1 }), (e) => e.errors[0].path === '/a' && e.errors[0].message === 'is required');-- assert.strictEqual(live.history({ limit: 200 }).length, before, 'a refused proposal is not stored');-- assert.strictEqual(live.revision(), 1);-- // Messages name the rule, never the value; a validate() that echoes a secret is scrubbed.-- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: 12345678 }), (e) => !JSON.stringify(e.errors).includes('12345678') && !e.message.includes('12345678'));-- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: `${SECRET} !` }), (e) => !e.message.includes(SECRET) && e.message.includes('[redacted]'));-- assert.throws(() => live.propose(DEFAULTS, { actor: { type: 'user', id: '42' } }), (e) => e.code === 'config.bad_actor');---- // ── Propose, then activate ──-- const v2 = { ...DEFAULTS, max_bitrate_kbps: 8000, youtube_api_key: SECRET };-- const p = assertSnapshot(live.propose(v2, { actor: USER.id, reason: 'raise the cap' }));-- assert.strictEqual(p.state, 'proposed');-- assert.strictEqual(p.revision, 2);-- assert.strictEqual(p.previous_revision, 1);-- assert.strictEqual(p.activated_at, null);-- assert.deepStrictEqual(p.created_by, USER, 'a usr_ id string is a user subject');-- assert.deepStrictEqual(p.values.youtube_api_key, { redacted: true, fingerprint: fp(db, 'live.site_settings', SECRET) });-- assert.strictEqual(p.classification.youtube_api_key, 'secret');-- assert.strictEqual(p.classification.site_name, 'public');-- assert.strictEqual(p.classification.max_bitrate_kbps, 'internal', 'an unclassified key is internal');-- assert.notStrictEqual(p.checksum, sha(v2), 'an unkeyed checksum of the full values is never served');-- assert.ok(!JSON.stringify(p).includes(sha(SECRET)), 'nor an unkeyed hash of the secret');-- assert.strictEqual(live.get('max_bitrate_kbps'), 6000, 'a proposal changes nothing');-- const a = assertSnapshot(await live.activate(2, { actor: USER }));-- assert.strictEqual(a.state, 'active');-- assert.deepStrictEqual(a.activated_by, USER);-- assert.strictEqual(live.revision(), 2);-- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the service itself gets the real value');-- assert.strictEqual(activations.length, 1);-- assert.strictEqual(activations[0].values.max_bitrate_kbps, 8000);-- assert.strictEqual(activations[0].previous.max_bitrate_kbps, 6000);-- assert.deepStrictEqual(activations[0].info, { namespace: 'live.site_settings', revision: 2, previous_revision: 1, restoring: false });-- assert.strictEqual(live.snapshot(1).state, 'superseded');-- assert.strictEqual(live.lastKnownGood().revision, 2);-- assert.ok(rowOf(db, 'live.site_settings', 2).values_json.includes(SECRET), 'the row keeps the secret so it can be activated again');-- await assert.rejects(live.activate(2), (e) => e.code === 'config.not_proposed' && e.status === 409);-- await assert.rejects(live.activate(99), (e) => e.code === 'config.revision_not_found' && e.status === 404);-- // A proposal made on an older active revision is stale.-- const stale = live.propose({ mode: 'invite' }, { merge: true, actor: USER });-- await live.apply({ mode: 'closed' }, { merge: true, actor: USER, reason: 'maintenance' });-- await assert.rejects(live.activate(stale.revision, { actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);-- assert.strictEqual(live.snapshot(stale.revision).state, 'proposed', 'a stale proposal is left as it was');-- const goodRev = live.revision();-- const goodValues = live.get();-- assert.deepStrictEqual({ mode: goodValues.mode, max: goodValues.max_bitrate_kbps, key: goodValues.youtube_api_key }, { mode: 'closed', max: 8000, key: SECRET }, 'merge keeps the other keys');---- // ── A throwing onActivate restores the last-known-good, in memory and in the database ──-- activations = [];-- failNext = new Error(`bucket refused key ${SECRET}`);-- let thrown;-- await assert.rejects(live.apply({ max_bitrate_kbps: 9000, youtube_api_key: `${SECRET}x` }, { merge: true, actor: USER }), (e) => { thrown = e; return true; });-- assert.ok(thrown instanceof ConfigError);-- assert.strictEqual(thrown.code, 'config.activation_failed');-- assert.strictEqual(thrown.status, 422);-- assert.ok(thrown.message.includes('bucket refused key [redacted]') && !thrown.message.includes(SECRET), thrown.message);-- assert.ok(thrown.cause.message.includes('bucket refused'), 'the original error is the cause');-- const failedRev = thrown.revision;-- assert.strictEqual(failedRev, goodRev + 1);-- assert.strictEqual(live.revision(), goodRev, 'memory: the previous revision is active again');-- assert.strictEqual(live.get(), goodValues, 'memory: the same values object as before');-- const failedRow = rowOf(db, 'live.site_settings', failedRev);-- assert.strictEqual(failedRow.state, 'rejected', 'database: the new revision is rejected');-- assert.strictEqual(failedRow.activated_at, null);-- assert.ok(failedRow.error.startsWith('onActivate: bucket refused key [redacted]') && !failedRow.error.includes(SECRET));-- assert.strictEqual(rowOf(db, 'live.site_settings', goodRev).state, 'active', 'database: the previous revision is active again');-- assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM config_snapshots WHERE namespace = 'live.site_settings' AND state = 'active'").get().n, 1);-- assert.strictEqual(activations.length, 2, 'onActivate ran for the new values, then again to restore');-- assert.strictEqual(activations[1].info.restoring, true);-- assert.strictEqual(activations[1].values, goodValues);-- assert.strictEqual(activations[1].previous.max_bitrate_kbps, 9000);-- assert.strictEqual(live.lastKnownGood().revision, goodRev);-- assertSnapshot(live.snapshot(failedRev));-- const restarted = createConfigStore({ ...liveOpts, db });-- assert.strictEqual(restarted.revision(), goodRev, 'a restart after the failure serves the last-known-good');---- // Validation that fails at activation time (the rules changed since the proposal) rejects it too.-- const p2 = live.propose({ max_bitrate_kbps: 7000 }, { merge: true, actor: USER });-- tighten = true;-- await assert.rejects(live.activate(p2.revision, { actor: USER }), (e) => e.code === 'config.invalid' && e.revision === p2.revision);-- tighten = false;-- assert.strictEqual(live.snapshot(p2.revision).state, 'rejected');-- assert.match(live.snapshot(p2.revision).error, /below the new floor/);-- assert.strictEqual(live.revision(), goodRev);---- // ── Rollback: a new revision copying an older good one; history is never rewritten ──-- const goodSnap = live.snapshot(goodRev);-- const lowered = await live.apply({ max_bitrate_kbps: 5000 }, { merge: true, actor: USER, reason: 'lower' });-- const r = assertSnapshot(await live.rollback({ actor: USER, reason: 'too low' }));-- assert.strictEqual(r.copied_from, goodRev);-- assert.strictEqual(r.previous_revision, lowered.revision);-- assert.strictEqual(r.reason, 'too low');-- assert.strictEqual(r.checksum, goodSnap.checksum);-- assert.strictEqual(live.snapshot(lowered.revision).state, 'rolled_back');-- assert.strictEqual(live.get('max_bitrate_kbps'), 8000);-- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the secret comes back from the row');-- assert.deepStrictEqual({ ...live.snapshot(goodRev), state: null }, { ...goodSnap, state: null }, 'the copied revision is untouched (only its state moved)');-- assert.strictEqual(live.snapshot(goodRev).state, 'superseded');-- const again = await live.rollback({ actor: USER });-- assert.strictEqual(again.copied_from, lowered.revision, 'rolling back a rollback undoes it');-- assert.strictEqual(again.reason, `rollback to revision ${lowered.revision}`);-- assert.strictEqual(live.get('max_bitrate_kbps'), 5000);-- await live.rollback({ actor: USER, to: 1 });-- assert.deepStrictEqual(live.get(), DEFAULTS);-- await assert.rejects(live.rollback({ to: live.revision() }), (e) => e.code === 'config.already_active' && e.status === 409);-- await assert.rejects(live.rollback({ to: failedRev }), (e) => e.code === 'config.not_good' && e.status === 409);-- await assert.rejects(live.rollback({ to: 999 }), (e) => e.code === 'config.revision_not_found' && e.status === 404);-- failNext = new Error('nope');-- const beforeFailedRollback = live.revision();-- await assert.rejects(live.rollback({ actor: USER, to: goodRev }), (e) => e.code === 'config.activation_failed');-- assert.strictEqual(live.revision(), beforeFailedRollback);-- assert.strictEqual(live.snapshot(beforeFailedRollback).state, 'active', 'a failed rollback puts the rolled_back revision back');-- const fresh = createConfigStore({ db, service: 'live', namespace: 'live.fresh', defaults: { a: 1 }, log });-- await assert.rejects(fresh.rollback(), (e) => e.code === 'config.no_previous');-- await assert.rejects(required.rollback(), (e) => e.code === 'config.nothing_active');---- // ── Secrets: redacted everywhere they leave, kept by a redacted round trip ──-- await live.rollback({ actor: USER, to: goodRev });-- const summary = live.summary();-- assert.deepStrictEqual(summary.values.youtube_api_key, { redacted: true, fingerprint: fp(db, 'live.site_settings', SECRET) });-- assert.strictEqual(summary.values.youtube_api_key.fingerprint, crypto.createHmac('sha256', keyOf(db, 'live.site_settings')).update(JSON.stringify(SECRET)).digest('hex'), 'HMAC-SHA256 of the canonical JSON under the stored key');-- assertSnapshot(summary.active);-- assertSnapshot(summary.last_known_good);-- const roundTrip = await live.apply({ ...summary.values, max_bitrate_kbps: 7777 }, { actor: USER });-- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'sending the marker back keeps the secret');-- assert.strictEqual(roundTrip.values.youtube_api_key.fingerprint, summary.values.youtube_api_key.fingerprint, 'an unchanged value keeps its fingerprint');-- const refusedMarker = (e) => e.code === 'config.invalid' && e.errors[0].path === '/youtube_api_key';-- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: fp(db, 'live.site_settings', 'other') } }), refusedMarker, 'a fingerprint of another value is refused');-- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: sha(SECRET) } }), refusedMarker, 'an unkeyed hash of the right value is refused');-- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, sha256: sha(SECRET) } }), refusedMarker, 'the old { redacted, sha256 } shape is refused');-- assert.throws(() => live.propose({ ...DEFAULTS, banner: { redacted: false, text: 'x' } }), (e) => e.errors.some((x) => x.path === '/banner'), 'a redacted member is reserved for markers');-- const history = live.history({ limit: 200 });-- const keyFps = new Set(history.filter((h) => h.state !== 'rejected' && live.snapshot(h.revision)).map((h) => h.values.youtube_api_key.fingerprint));-- assert.ok(keyFps.has(fp(db, 'live.site_settings', SECRET)) && keyFps.has(fp(db, 'live.site_settings', '')), 'fingerprints differ exactly when the value does');-- for (const s of live.history({ limit: 200 })) assertSnapshot(s);-- assert.ok(!JSON.stringify(live.history({ limit: 200 })).includes(SECRET));-- assert.ok(!JSON.stringify(live.summary()).includes(SECRET));-- // A key classified secret later is redacted in the revisions stored before.-- const flags = createConfigStore({ db, service: 'live', namespace: 'live.flags', defaults: {}, log });-- await flags.apply({ webhook_url: 'https://hooks.example/T0K3N' }, { actor: USER });-- assert.strictEqual(flags.history()[0].values.webhook_url, 'https://hooks.example/T0K3N');-- const flags2 = createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });-- assert.ok(flags2.history().every((s) => !JSON.stringify(s).includes('T0K3N')));-- assert.strictEqual(flags2.history()[0].classification.webhook_url, 'secret');-- // A lost key is replaced on the next start: fingerprints change and older markers stop round-tripping.-- const oldMarker = flags2.summary().values.webhook_url;-- db.prepare("DELETE FROM config_keys WHERE namespace = 'live.flags'").run();-- const flags3 = createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });-- assert.notDeepStrictEqual(flags3.summary().values.webhook_url, oldMarker);-- assert.throws(() => flags3.propose({ webhook_url: oldMarker }), (e) => e.code === 'config.invalid');-- assert.strictEqual(flags3.propose({ webhook_url: flags3.summary().values.webhook_url }).values.webhook_url.fingerprint, flags3.summary().values.webhook_url.fingerprint);---- // ── Two stores (and two services) in one database ──-- const TIER_SCHEMA = { type: 'object', additionalProperties: false, properties: { minAgeDays: { type: 'integer', minimum: 0 }, r2Enabled: { type: 'boolean' }, b2_key: { type: 'string' } } };-- const media = createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });-- const liveRev = live.revision();-- assert.strictEqual(media.revision(), 1, 'each namespace numbers its own revisions');-- await media.apply({ minAgeDays: 3, b2_key: SECRET }, { merge: true, actor: USER });-- assert.strictEqual(media.revision(), 2);-- assert.strictEqual(live.revision(), liveRev, 'the other namespace is untouched');-- assert.ok(media.history().every((s) => s.namespace === 'media.storage_tier' && s.service === 'media'));-- assert.ok(live.history({ limit: 200 }).every((s) => s.namespace === 'live.site_settings'));-- // The same secret in two namespaces: different keys, different fingerprints, and one's marker is refused by the other.-- assert.strictEqual(live.get('youtube_api_key'), media.get('b2_key'));-- assert.notStrictEqual(live.summary().values.youtube_api_key.fingerprint, media.summary().values.b2_key.fingerprint);-- assert.throws(() => live.propose({ youtube_api_key: media.summary().values.b2_key }, { merge: true }), (e) => e.code === 'config.invalid');-- const keyRows = db.prepare('SELECT namespace, hmac_key FROM config_keys').all();-- assert.ok(keyRows.length >= 5 && keyRows.every((k) => Buffer.isBuffer(k.hmac_key) && k.hmac_key.length === 32), 'a random 32-byte key per namespace');-- assert.strictEqual(new Set(keyRows.map((k) => k.hmac_key.toString('hex'))).size, keyRows.length, 'no two namespaces share a key');---- // ── Restart: a new store on the same database serves the same active values ──-- const live2 = createConfigStore({ ...liveOpts, db });-- assert.deepStrictEqual(live2.get(), live.get());-- assert.strictEqual(live2.revision(), live.revision());-- assert.strictEqual(live2.lastKnownGood().revision, live.lastKnownGood().revision);-- assert.deepStrictEqual(live2.summary(), live.summary(), 'the key survives a restart: same fingerprints, same checksums');-- assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM config_keys WHERE namespace = 'live.site_settings'").get().n, 1);-- const media2 = createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });-- assert.deepStrictEqual(media2.get(), { minAgeDays: 3, r2Enabled: true, b2_key: SECRET });-- // A release that adds a default serves it at once, without a new revision.-- const media3 = createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true, r2MinViews: 20 }, log });-- assert.strictEqual(media3.get('r2MinViews'), 20);-- assert.strictEqual(media3.revision(), 2);-- assert.deepStrictEqual(media3.changes({ minAgeDays: 4 }, { merge: true }), ['minAgeDays']);-- await media3.apply({}, { merge: true, unset: ['minAgeDays'], actor: USER });-- assert.strictEqual(media3.get('minAgeDays'), 7, 'an unset key falls back to its default');-- // Another store (another process) moved the namespace: this one's copy is stale until reload().-- await assert.rejects(media.apply({ minAgeDays: 6 }, { merge: true, actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);-- assert.strictEqual(media.get('minAgeDays'), 3);-- assert.strictEqual(media.reload(), media3.revision());-- assert.strictEqual(media.get('minAgeDays'), 7);---- // An activation interrupted by a stop (onActivate never finished) is rejected at the next boot,-- // and pruning while it hangs keeps the last-known-good even outside the window.-- const hanging = createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, keep: 2, log, onActivate: () => new Promise(() => {}) });-- hanging.apply({ n: 1 }, { actor: USER }).catch(() => {});-- await sleep(5);-- assert.strictEqual(rowOf(db, 'live.hang', 2).state, 'active');-- assert.strictEqual(hanging.lastKnownGood().revision, 1, 'not good until onActivate finished');-- for (let i = 0; i < 3; i++) hanging.propose({ n: 10 + i }, { actor: USER });-- const hangRevs = db.prepare("SELECT revision FROM config_snapshots WHERE namespace = 'live.hang' ORDER BY revision").all().map((x) => x.revision);-- assert.deepStrictEqual(hangRevs, [1, 2, 4, 5], 'pruned to the newest two, plus the active and the last-known-good');-- const rebooted = createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, log });-- assert.strictEqual(rebooted.revision(), 1);-- assert.strictEqual(rebooted.get('n'), 0);-- assert.strictEqual(rowOf(db, 'live.hang', 2).state, 'rejected');-- assert.match(rowOf(db, 'live.hang', 2).error, /interrupted/);---- // ── Concurrency: applies run one at a time and get increasing revisions ──-- let running = 0; let overlapped = false;-- const conc = createConfigStore({ db, service: 'live', namespace: 'live.concurrency', defaults: { n: 0 }, log, onActivate: async () => { running++; if (running > 1) overlapped = true; await sleep(5); running--; } });-- const [s1, s2, s3] = await Promise.all([conc.apply({ n: 1 }, { actor: USER }), conc.apply({ n: 2 }, { actor: USER }), conc.apply({ n: 3 }, { actor: USER })]);-- assert.deepStrictEqual([s1.revision, s2.revision, s3.revision], [2, 3, 4]);-- assert.deepStrictEqual([s1.previous_revision, s2.previous_revision, s3.previous_revision], [1, 2, 3]);-- assert.ok(!overlapped, 'onActivate never overlaps');-- assert.strictEqual(conc.get('n'), 3);-- assert.deepStrictEqual(conc.history().map((s) => s.state), ['active', 'superseded', 'superseded', 'superseded']);-- const b1 = await conc.apply({ n: 4 }, { actor: USER });-- const b2 = await conc.apply({ n: 5 }, { actor: USER });-- assert.strictEqual(b2.revision, b1.revision + 1);-- const conc2 = createConfigStore({ db, service: 'live', namespace: 'live.concurrency', log });-- const [c1, c2] = await Promise.all([conc.apply({ n: 6 }, { actor: USER }), Promise.resolve().then(() => conc2.propose({ n: 7 }, { actor: USER }))]);-- assert.ok(c2.revision !== c1.revision, 'two stores on one namespace never reuse a revision');---- // ── Pruning: the newest N, and never the active one ──-- const pr = createConfigStore({ db, service: 'live', namespace: 'live.prune', defaults: { n: 0 }, keep: 3, log });-- for (let i = 1; i <= 6; i++) await pr.apply({ n: i }, { actor: USER });-- assert.deepStrictEqual(pr.history().map((s) => s.revision), [7, 6, 5]);-- const pr2 = createConfigStore({ db, service: 'live', namespace: 'live.prune2', defaults: { n: 0 }, keep: 2, log, onActivate: (v) => { if (v.n > 0) throw new Error('refused'); } });-- for (let i = 1; i <= 5; i++) await assert.rejects(pr2.apply({ n: i }, { actor: USER }), (e) => e.code === 'config.activation_failed');-- assert.deepStrictEqual(pr2.history().map((s) => `${s.revision}:${s.state}`), ['6:rejected', '5:rejected', '1:active']);-- assert.strictEqual(pr2.lastKnownGood().revision, 1);-- assert.deepStrictEqual(pr2.history({ limit: 1, before: 6 }).map((s) => s.revision), [5], 'paged with before');---- // ── Legacy import ──-- const rows = [-- { key: 'site_name', value: 'Old Live', type: 'string' },-- { key: 'max_bitrate_kbps', value: '4500', type: 'number' },-- { key: 'registration_open', value: 'true', type: 'boolean' },-- { key: 'banner', value: '{"text":"hi"}', type: 'json' },-- { key: 'broken_json', value: '{', type: 'json' },-- { key: 'resend_api_key', value: 're_abcdef123456', type: 'secret' },-- { key: 'bad key', value: 'x' },-- { key: '__proto__', value: 'x' },-- ];-- assert.deepStrictEqual(fromRows(rows), { site_name: 'Old Live', max_bitrate_kbps: 4500, registration_open: true, banner: { text: 'hi' }, broken_json: '{', resend_api_key: 're_abcdef123456', 'bad key': 'x' });-- assert.deepStrictEqual(fromRows([{ key: 'storage_tier.minAgeDays', value: '3' }, { key: 'storage_tier.r2Enabled', value: 'false' }, { key: 'other', value: '1' }], { prefix: 'storage_tier.', type: 'json' }), { minAgeDays: 3, r2Enabled: false });-- const legacyDb = new Database(':memory:');-- let legacyReads = 0;-- const importOpts = { db: legacyDb, service: 'network', namespace: 'network.site_settings', classify: (k) => (/api_key/.test(k) ? 'secret' : undefined), defaults: { site_name: 'OpenVibe' }, legacy: () => { legacyReads++; return rows; }, log };-- const imp = createConfigStore(importOpts);-- assert.strictEqual(imp.revision(), 1);-- assert.strictEqual(imp.snapshot(1).reason, 'imported from the legacy source');-- assert.strictEqual(imp.get('site_name'), 'Old Live', 'legacy wins over defaults for revision 1');-- assert.strictEqual(imp.get('max_bitrate_kbps'), 4500);-- assert.ok(!('bad key' in imp.get()), 'a key the contract cannot carry is left behind');-- assert.ok(logs.some((l) => l.includes('network.site_settings') && l.includes('not imported') && l.includes('"bad key"')));-- assert.deepStrictEqual(imp.snapshot(1).values.resend_api_key, { redacted: true, fingerprint: fp(legacyDb, 'network.site_settings', 're_abcdef123456') });-- assertSnapshot(imp.snapshot(1));-- createConfigStore(importOpts);-- assert.strictEqual(legacyReads, 1, 'the legacy source is read once, when the namespace is new');-- assert.strictEqual(imp.import(rows), null, 'import is a no-op once the namespace has revisions');-- const imp2 = createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.other', log });-- assert.strictEqual(imp2.revision(), null);-- const imported = imp2.import({ a: 1 }, { reason: 'from site_settings' });-- assert.strictEqual(imported.revision, 1);-- assert.strictEqual(imported.reason, 'from site_settings');-- assert.strictEqual(imp2.get('a'), 1);-- const strictImport = createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.strict', schema: { type: 'object', properties: { n: { type: 'integer' } } }, legacy: { n: 'not a number' }, log });-- assert.strictEqual(strictImport.get('n'), 'not a number', 'what runs today is imported even when it does not validate');-- assert.ok(logs.some((l) => l.startsWith('warn') && l.includes('network.strict') && l.includes('do not validate')));---- // ── Secrets never reach the log ──-- assert.ok(logs.length > 10);-- assert.ok(!logs.join('\n').includes(SECRET), 'no secret in the log');-- assertNoLeak(logs.join('\n'), leakForms(db).concat(leakForms(legacyDb)), 'the log');-- assert.ok(!logs.join('\n').includes('re_abcdef123456'));-- assert.ok(logs.some((l) => /live\.site_settings: revision \d+ active \(was \d+\) by user:usr_/.test(l)));---- // ── /api/admin/config on Express ──-- const USER2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';-- const app = express();-- app.use(express.json());-- app.use((req, _res, next) => {-- const who = req.headers['x-test-user'];-- if (who === 'admin') req.user = { id: 1, role: 'admin', subject_id: USER.id };-- else if (who === 'owner') req.user = { id: 2, role: 'admin', subject_id: USER2, owner: true };-- else if (who === 'nosubject') req.user = { id: 3, role: 'admin' };-- else if (who === 'viewer') req.user = { id: 4, role: 'user', subject_id: USER.id };-- next();-- });-- let handlerRan = 0;-- const requireAdmin = (req, res, next) => (req.user && req.user.role === 'admin' ? next() : res.status(403).json({ error: 'Admin only' }));-- const authorize = (req, { action, keys }) => { handlerRan++; return action === 'read' || !!(req.user && req.user.owner) || !keys.some((k) => classify(k) === 'secret'); };-- assert.throws(() => adminRoutes([live]), /requireAdmin/);-- assert.throws(() => adminRoutes([live, live2], { requireAdmin }), /twice/);-- adminRoutes([live, media], { requireAdmin, authorize, router: app });-- const server = app.listen(0);-- await new Promise((r) => server.once('listening', r));-- const base = `http://127.0.0.1:${server.address().port}`;-- const texts = [];-- async function call(method, path, { user = 'admin', body } = {}) {-- const res = await fetch(base + path, { method, headers: { 'x-test-user': user, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });-- const text = await res.text();-- texts.push(text);-- return { status: res.status, type: res.headers.get('content-type') || '', cache: res.headers.get('cache-control'), body: text ? JSON.parse(text) : null };-- }---- let res = await call('GET', '/api/admin/config', { user: 'viewer' });-- assert.strictEqual(res.status, 403);-- assert.deepStrictEqual(res.body, { error: 'Admin only' }, "requireAdmin's own answer");-- assert.strictEqual(handlerRan, 0, 'the handler never ran');-- res = await call('GET', '/api/admin/config');-- assert.strictEqual(res.status, 200);-- assert.match(res.type, /^application\/json/);-- assert.strictEqual(res.cache, 'no-store');-- assert.deepStrictEqual(res.body.namespaces.map((n) => n.namespace), ['live.site_settings', 'media.storage_tier']);-- const liveEntry = res.body.namespaces[0];-- assert.strictEqual(liveEntry.revision, live.revision());-- assert.deepStrictEqual(liveEntry.values.youtube_api_key, { redacted: true, fingerprint: fp(db, 'live.site_settings', SECRET) });-- assert.strictEqual(liveEntry.last_known_good.revision, live.lastKnownGood().revision);-- assertSnapshot(liveEntry.active);-- res = await call('GET', '/api/admin/config/media.storage_tier');-- assert.strictEqual(res.status, 200);-- assert.deepStrictEqual(res.body.values.b2_key, { redacted: true, fingerprint: fp(db, 'media.storage_tier', SECRET) });-- // A form round trip: the marker it was shown keeps the secret, even for an admin who may not change it.-- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: liveEntry.values.youtube_api_key }, merge: true, reason: 'form round trip' } });-- assert.strictEqual(res.status, 200, JSON.stringify(res.body));-- assert.strictEqual(live.get('youtube_api_key'), SECRET);-- assert.deepStrictEqual(res.body.values.youtube_api_key, liveEntry.values.youtube_api_key);-- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: { redacted: true, fingerprint: '0'.repeat(64) } }, merge: true } });-- assertProblem(res.body, 422, 'config.invalid');-- assert.strictEqual(res.body.errors[0].path, '/youtube_api_key');-- res = await call('GET', '/api/admin/config/nope.settings');-- assert.strictEqual(res.status, 404);-- assert.strictEqual(res.type, 'application/problem+json');-- assertProblem(res.body, 404, 'config.namespace_not_found');---- res = await call('GET', '/api/admin/config/live.site_settings/history?limit=2');-- assert.strictEqual(res.status, 200);-- assert.strictEqual(res.body.snapshots.length, 2);-- res.body.snapshots.forEach(assertSnapshot);-- assert.strictEqual(res.body.next_before, res.body.snapshots[1].revision);-- const older = await call('GET', `/api/admin/config/live.site_settings/history?limit=200&before=${res.body.next_before}`);-- assert.ok(older.body.snapshots.every((s) => s.revision < res.body.next_before));-- assert.strictEqual(older.body.next_before, null);---- const liveBefore = live.revision();-- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { mode: 'invite' }, merge: true, reason: 'soft launch' } });-- assert.strictEqual(res.status, 200, JSON.stringify(res.body));-- assertSnapshot(res.body);-- assert.strictEqual(res.body.revision, liveBefore + 1);-- assert.strictEqual(res.body.state, 'active');-- assert.deepStrictEqual(res.body.created_by, USER, 'the actor comes from req.user.subject_id');-- assert.strictEqual(res.body.reason, 'soft launch');-- assert.strictEqual(live.get('mode'), 'invite');-- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { max_bitrate_kbps: 1 }, merge: true } });-- assert.strictEqual(res.status, 422);-- assert.strictEqual(res.type, 'application/problem+json');-- assertProblem(res.body, 422, 'config.invalid');-- assert.deepStrictEqual(res.body.errors, [{ path: '/max_bitrate_kbps', message: 'must be >= 500' }]);-- res = await call('POST', '/api/admin/config/live.site_settings', { body: { reason: 'no values' } });-- assertProblem(res.body, 400, 'config.bad_request');-- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });-- assertProblem(res.body, 403, 'config.forbidden');-- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'authorize() refused a secret change');-- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });-- assert.strictEqual(res.status, 200);-- assert.strictEqual(res.body.created_by.id, USER2);-- assert.strictEqual(live.get('youtube_api_key'), 'AIzaNEW');-- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'nosubject', body: { values: { mode: 'open' }, merge: true } });-- assertProblem(res.body, 403, 'config.actor_required');-- failNext = new Error(`Media said no to ${SECRET}`);-- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: SECRET, mode: 'open' }, merge: true } });-- assertProblem(res.body, 422, 'config.activation_failed');-- assert.strictEqual(live.get('mode'), 'invite', 'the failure restored the previous values');---- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { body: { reason: 'undo the key' } });-- assertProblem(res.body, 403, 'config.forbidden');-- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { reason: 'undo the key' } });-- assert.strictEqual(res.status, 200, JSON.stringify(res.body));-- assertSnapshot(res.body);-- assert.ok(res.body.copied_from);-- assert.strictEqual(live.get('youtube_api_key'), SECRET);-- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 'one' } });-- assertProblem(res.body, 400, 'config.bad_request');-- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 99999 } });-- assertProblem(res.body, 404, 'config.revision_not_found');-- const emptyStore = createConfigStore({ db, service: 'media', namespace: 'media.empty', defaults: { a: 1 }, log });-- const emptyApp = express();-- emptyApp.use(express.json());-- adminRoutes(emptyStore, { requireAdmin: (_req, _res, next) => next(), actor: () => USER }).mount(emptyApp);-- const emptyServer = emptyApp.listen(0);-- await new Promise((r) => emptyServer.once('listening', r));-- const er = await fetch(`http://127.0.0.1:${emptyServer.address().port}/api/admin/config/media.empty/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });-- const erBody = await er.json();-- assertProblem(erBody, 409, 'config.no_previous');-- emptyServer.close();-- assert.ok(texts.every((x) => !x.includes(SECRET)), 'no response ever carries the secret');-- assert.ok(!logs.join('\n').includes(SECRET));-- const forms = leakForms(db);-- texts.forEach((x, i) => assertNoLeak(x, forms, `route response ${i}`));-- assertNoLeak(JSON.stringify([live.summary(), media.summary(), live.history({ limit: 200 }), media.history({ limit: 200 }), live.lastKnownGood()]), forms, 'summary and history');-- server.closeAllConnections();-- server.close();---- // ── Plain http, a basePath under an app-scoped router, and direct handlers ──-- const plain = adminRoutes([media], {-- requireAdmin: (req, res, next) => (req.headers.authorization === 'Bearer admin' ? next() : (res.statusCode = 401, res.end())),-- actor: () => ({ type: 'service', id: 'live' }),-- });-- const plainServer = http.createServer((req, res) => plain.handle(req, res));-- plainServer.listen(0);-- await new Promise((r) => plainServer.once('listening', r));-- const pbase = `http://127.0.0.1:${plainServer.address().port}`;-- const auth = { authorization: 'Bearer admin' };-- assert.strictEqual((await fetch(`${pbase}/api/admin/config`)).status, 401);-- let pr0 = await fetch(`${pbase}/api/admin/config`, { headers: auth });-- assert.strictEqual(pr0.status, 200);-- assert.strictEqual((await pr0.json()).namespaces[0].namespace, 'media.storage_tier');-- pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify({ values: { minAgeDays: 5 }, merge: true, reason: 'plain http' }) });-- const plainSnap = await pr0.json();-- assert.strictEqual(pr0.status, 200, JSON.stringify(plainSnap));-- assert.deepStrictEqual(plainSnap.created_by, { type: 'service', id: 'live' });-- assert.strictEqual(media.get('minAgeDays'), 5);-- pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier/history?limit=1`, { headers: auth });-- assert.strictEqual((await pr0.json()).snapshots.length, 1);-- pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: auth, body: '{not json' });-- assertProblem(await pr0.json(), 400, 'config.bad_request');-- pr0 = await fetch(`${pbase}/elsewhere`, { headers: auth });-- assertProblem(await pr0.json(), 404, 'config.route_not_found');-- for (const path of ['/api/admin/config', '/api/admin/config/media.storage_tier', '/api/admin/config/media.storage_tier/history?limit=200']) {-- assertNoLeak(await (await fetch(pbase + path, { headers: auth })).text(), leakForms(db), `plain ${path}`);-- }-- plainServer.close();---- const scoped = express();-- scoped.use(express.json());-- const sub = express.Router({ mergeParams: true });-- adminRoutes([media], { requireAdmin: (_req, _res, next) => next(), basePath: '/config', actor: (req) => ({ type: 'service', id: req.params.app || 'live' }) }).mount(sub);-- scoped.use('/api/v1/:app/admin', sub);-- const scopedServer = scoped.listen(0);-- await new Promise((r) => scopedServer.once('listening', r));-- const sres = await fetch(`http://127.0.0.1:${scopedServer.address().port}/api/v1/live/admin/config/media.storage_tier`);-- assert.strictEqual(sres.status, 200);-- assert.strictEqual((await sres.json()).namespace, 'media.storage_tier');-- scopedServer.close();---- const direct = adminRoutes([media], { requireAdmin: (req, res, next) => (req.admin ? next() : (res.statusCode = 403, res.end('no'))) });-- const fakeRes = () => ({ headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; this.done = true; } });-- const denied = fakeRes();-- await direct.list({ url: '/api/admin/config' }, denied);-- assert.strictEqual(denied.statusCode, 403, 'a handler used directly is still guarded');-- const allowedRes = fakeRes();-- direct.list({ url: '/api/admin/config', admin: true }, allowedRes);-- await sleep(5);-- assert.strictEqual(allowedRes.statusCode, 200);-- assert.strictEqual(direct.routes.length, 5);-- assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), [-- 'GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history',-- 'POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback',-- ]);---- assertNoLeak(logs.join('\n'), leakForms(db).concat(leakForms(legacyDb)), 'the whole log');-- assert.ok(!logs.join('\n').includes(SECRET));---- console.log(`config: store, validation, last-known-good, rollback, secrets, legacy, restart, concurrency and routes ok${hasSnapshotContract ? ' (snapshots checked against common.config-snapshot@1)' : ''}`);--})().catch((err) => { console.error(err); process.exit(1); });@@@ -55,13 +55,11 @@ try {assert.deepStrictEqual(callees(res), ['save', 'this.save']);}-- // A better-sqlite3 service: its db calls are synchronous, so db.run() never floats there (fetch still does).++ // PostgreSQL database and outbox methods return promises.{const src = ['async function handler() {', ' db.run("x");', ' outbox.enqueue(e);', ' db.close();', ' fetch("u");', '}'].join('\n') + '\n';-- const sync = fixture({ 'package.json': JSON.stringify({ dependencies: { 'better-sqlite3': '^11' } }), 'server/s.js': src });-- assert.deepStrictEqual(callees(scan(sync)), ['fetch'], 'db.run, an outbox enqueue and db.close are synchronous with better-sqlite3');const pg = fixture({ 'package.json': JSON.stringify({ dependencies: { pg: '^8' } }), 'server/s.js': src });-- assert.deepStrictEqual(callees(scan(pg)), ['db.close', 'db.run', 'fetch', 'outbox.enqueue'], 'all promises without better-sqlite3');++ assert.deepStrictEqual(callees(scan(pg)), ['db.close', 'db.run', 'fetch', 'outbox.enqueue']);}// Rule b: every known async API.@@@ -1,5 +1,5 @@'use strict';--// Every subpath consumers use, and every module file, resolves through the package's own name++// Every public subpath resolves through the package's own name// (Node self-reference), so require('openvibe-shared/<x>') works the same once installed.const assert = require('assert');const fs = require('fs');@@@ -10,7 +10,10 @@ const ROOT = path.join(__dirname, '..');const modules = fs.readdirSync(ROOT).filter((f) => f.endsWith('.js')).concat(fs.readdirSync(path.join(ROOT, 'analytics')).filter((f) => f.endsWith('.js')).map((f) => `analytics/${f}`));const targets = new Set(Object.values(pkg.exports).map((t) => t.replace(/^\.\//, '')));--for (const f of modules) assert.ok(targets.has(f), `${f} is exported`);++for (const f of modules) if (!['config-core.js', 'analytics/core.js'].includes(f)) assert.ok(targets.has(f), `${f} is exported`);++for (const sub of ['analytics/tracker', 'analytics/schema', 'analytics/prune-cli']) assert.ok(!pkg.exports[`./${sub}`], `${sub} was removed`);++assert.strictEqual(require('openvibe-shared/analytics').AnalyticsTracker, undefined);++assert.throws(() => require('openvibe-shared/config').createConfigStore({ db: { prepare() {}, transaction() {} } }), /openvibe-sdk\/db/);// Shared ADR 0001: the exports map is the public surface, one explicit entry per subpath, no patterns.for (const sub of Object.keys(pkg.exports)) assert.ok(!sub.includes('*'), `${sub}: no wildcard subpaths`);for (const [sub, target] of Object.entries(pkg.exports)) {
@@ -30,7 +30,7 @@ jobs:
@@ -30,7 +30,7 @@ jobs:
git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); require('openvibe-shared/release'); require('openvibe-shared/release-compat'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/prune-cli'); require('openvibe-shared/analytics/event.v1.json'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
+ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); require('openvibe-shared/release'); require('openvibe-shared/release-compat'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/pg'); require('openvibe-shared/analytics/event.v1.json'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
# Secret scan (gitleaks) and dependency audit, the workflow every OpenVibers repo calls (Track Q).
# Secret scan (gitleaks) and dependency audit, the workflow every OpenVibers repo calls (Track Q).
security:
security:
uses: ./.github/workflows/security.yml
uses: ./.github/workflows/security.yml
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 3.0.0 — 2026-10-10
+
+- Removed the SQLite configuration store. `createConfigStore` now requires an openvibe-sdk/db handle and returns a promise of the ready PostgreSQL store. `fromRows`, `adminRoutes`, `configSchema`, and other non-SQLite exports remain.
+- Removed SQLite analytics: `AnalyticsTracker`, `analytics/tracker`, `analytics/schema`, `analytics/prune-cli`, the SQLite retention and scrub functions, and the SQLite `ANALYTICS_SCHEMA` and `ensureSchema` exports. Use `analytics/pg` (`AnalyticsTrackerPg`, `analyticsSchema`, `pruneRawEventsPg`). `analytics/retention` still exports `MAX_DAYS`.
+- Consumers already passing an openvibe-sdk/db handle to `createConfigStore` and using `analytics/pg` need no code changes. Live's `test/shared-pins.test.js` still lists `openvibe-shared/analytics/prune-cli` in `RESOLVE_ONLY`; remove that entry when updating its pin.
+- `better-sqlite3` is gone from devDependencies (and the lockfile).
+
## 2.21.2 — 2026-10-10
## 2.21.2 — 2026-10-10
- `release-watch.js` reopens its release-notification stream with `last_event_id` set to the last SSE id Events sent
- `release-watch.js` reopens its release-notification stream with `last_event_id` set to the last SSE id Events sent
@@ -20,7 +20,7 @@ were reconciled first.
@@ -20,7 +20,7 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
@@ -73,7 +73,7 @@ hand-copy files into `node_modules/` or a `vendor/` directory.
@@ -73,7 +73,7 @@ hand-copy files into `node_modules/` or a `vendor/` directory.
```js
```js
const legal = require('openvibe-shared/legal');
const legal = require('openvibe-shared/legal');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
-const { AnalyticsTracker } = require('openvibe-shared/analytics');
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg');
```
```
### SEO kit
### SEO kit
@@ -246,35 +246,34 @@ never `true`: it reports status `skipped` with the reason and is listed in `skip
@@ -246,35 +246,34 @@ never `true`: it reports status `skipped` with the reason and is listed in `skip
### Configuration (server, WS-C task 7)
### Configuration (server, WS-C task 7)
`openvibe-shared/config` keeps one namespace of a service's configuration as immutable revisions
`openvibe-shared/config` keeps one namespace of a service's configuration as immutable revisions
-(`common.config-snapshot@1`) in the service's own SQLite database. No new dependency: pass the
-better-sqlite3 handle. Several namespaces share one database (tables `config_snapshots` and
-`config_keys`).
-
-On PostgreSQL (an openvibe-sdk/db handle, ADR-035) `createConfigStore` returns a promise of the store (`config-pg.js`): `const tiering = await config.createConfigStore({ db, … })`. Its reading and writing methods are async, while `get()` and `revision()` stay in memory. The service's migrations create the tables from `config.configSchema()`.
+(`common.config-snapshot@1`) in the service's PostgreSQL database. Pass an openvibe-sdk/db handle.
+Several namespaces share the `config_snapshots` and `config_keys` tables, created by the service's
+migrations from `config.configSchema()`. `createConfigStore` returns a promise of the ready store.
+Its database methods are async; `get()` and `revision()` stay in memory.
```js
```js
const config = require('openvibe-shared/config');
const config = require('openvibe-shared/config');
-const settings = config.createConfigStore({
+const settings = await config.createConfigStore({
db, service: 'live', namespace: 'live.site_settings',
db, service: 'live', namespace: 'live.site_settings',
schema, // JSON Schema subset: type, enum, const, required, properties,
schema, // JSON Schema subset: type, enum, const, required, properties,
// additionalProperties, min/max*, pattern, items, uniqueItems, multipleOf
// additionalProperties, min/max*, pattern, items, uniqueItems, multipleOf
validate: (values) => true, // or false, a message, a list, { valid, errors }; synchronous
validate: (values) => true, // or false, a message, a list, { valid, errors }; synchronous
classify: (key) => (/api_key|secret|token/.test(key) ? 'secret' : undefined), // or { key: class }; unknown = internal
classify: (key) => (/api_key|secret|token/.test(key) ? 'secret' : undefined), // or { key: class }; unknown = internal
defaults, // revision 1 of a new namespace, and under every revision
defaults, // revision 1 of a new namespace, and under every revision
- legacy: () => config.fromRows(db.prepare('SELECT * FROM site_settings').all()), // revision 1 instead
+ legacy: async () => config.fromRows(await db.all('SELECT * FROM site_settings')), // revision 1 instead
onActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },
onActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },
keep: 50, log: console, now: () => new Date(),
keep: 50, log: console, now: () => new Date(),
});
});
settings.get(); // defaults overlaid with the active values: frozen, in memory
settings.get(); // defaults overlaid with the active values: frozen, in memory
settings.get('max_bitrate_kbps'); // one value; never a database read
settings.get('max_bitrate_kbps'); // one value; never a database read
settings.revision(); // the active revision, or null
settings.revision(); // the active revision, or null
-settings.propose(values, { actor, reason, merge, unset }); // validated, stored as proposed (redacted snapshot)
+await settings.propose(values, { actor, reason, merge, unset }); // validated, stored as proposed (redacted snapshot)
await settings.activate(revision, { actor });
await settings.activate(revision, { actor });
await settings.apply(values, { actor, reason, merge, unset }); // propose + activate
await settings.apply(values, { actor, reason, merge, unset }); // propose + activate
await settings.rollback({ actor, reason, to }); // a new revision copying the previous good one (or `to`)
await settings.rollback({ actor, reason, to }); // a new revision copying the previous good one (or `to`)
-settings.lastKnownGood(); settings.history({ limit, before }); settings.snapshot(revision); settings.summary();
-settings.import(legacy); // revision 1 from a legacy source when the namespace has none
-settings.reload(); // another process changed it: re-read the active revision
+await settings.lastKnownGood(); await settings.history({ limit, before }); await settings.snapshot(revision); await settings.summary();
+await settings.import(legacy); // revision 1 from a legacy source when the namespace has none
+await settings.reload(); // another process changed it: re-read the active revision
```
```
- **Values** are a complete snapshot; `merge: true` starts from the active values and `unset` removes
- **Values** are a complete snapshot; `merge: true` starts from the active values and `unset` removes
@@ -435,25 +434,22 @@ page's timers and events.
@@ -435,25 +434,22 @@ page's timers and events.
### Analytics (server, ADR-021)
### Analytics (server, ADR-021)
```js
```js
-const Database = require('better-sqlite3'); // the service's own; Shared has no native dependency
-const { AnalyticsTracker } = require('openvibe-shared/analytics');
-const db = new Database('data/analytics.db');
-db.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(db, 'live', {
- retention: { days: 30 }, // nightly prune (the default); false if you schedule it yourself
- paramPrefixes: ['avatar'], // extra words whose next segment is a parameter
- pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']], // parameters the segment rules can't see
+const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');
+// Put analyticsSchema() in the service's database migrations.
+const analytics = new AnalyticsTrackerPg(db, 'live', {
+ retention: { days: 30 },
+ paramPrefixes: ['avatar'],
+ pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']],
});
});
app.use(analytics.middleware());
app.use(analytics.middleware());
+// A service may call pruneRawEventsPg(db, { days: 30 }) in its own job runner.
```
```
A raw event has no IP, user or subject id, or city. It has a route template (never a raw URL), a
A raw event has no IP, user or subject id, or city. It has a route template (never a raw URL), a
referer origin, a user-agent class, a rotating session id and a country. Raw events are kept for
referer origin, a user-agent class, a rotating session id and a country. Raw events are kept for
30 days, and the rollups for longer. A request with `Sec-GPC: 1` or `DNT: 1` is not recorded at
30 days, and the rollups for longer. A request with `Sec-GPC: 1` or `DNT: 1` is not recorded at
all. The row shape is `analytics/event.v1` (`docs/schemas/analytics-event.v1.json`), and
all. The row shape is `analytics/event.v1` (`docs/schemas/analytics-event.v1.json`), and
-`require('openvibe-shared/analytics/event').checkRow(row)` checks a stored row against it. The
-prune and one-time scrub command is `openvibe-shared/analytics/prune-cli`: a service wraps it in
-its own `scripts/analytics-prune.js` and passes in its `Database` and default paths.
+`require('openvibe-shared/analytics/event').checkRow(row)` checks a stored row against it. PostgreSQL retention uses `pruneRawEventsPg(db, { days })`; `retention.MAX_DAYS` is 30.
### Browser side: two options
### Browser side: two options
@@ -697,12 +693,10 @@ Tests are plain Node with stubbed browser globals. Some tests guard the release:
@@ -697,12 +693,10 @@ Tests are plain Node with stubbed browser globals. Some tests guard the release:
- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.
- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.
- **Browser bundles**: browser files must be free of `require()` and Node globals
- **Browser bundles**: browser files must be free of `require()` and Node globals
(`test/browser-bundles.test.js`).
(`test/browser-bundles.test.js`).
-- **Exports**: every export must resolve, and every module file (including `analytics/*.js`) must
- have one.
-- **Analytics (ADR-021)**: no personal data in any table after real requests, opt-out requests
- leave nothing, every row is a valid `analytics/event.v1`, prune/scrub keep rollups intact, and
- the prune CLI's dry run changes nothing (`test/analytics-*.test.js`, with `better-sqlite3` as a
- dev dependency).
+- **Exports**: every export must resolve, and every public module file must
+ have one. Internal `config-core.js` and `analytics/core.js` are private.
+- **Analytics (ADR-021)**: PostgreSQL tests cover request privacy, opt-out, rollups, and raw-event retention;
+ pure privacy and event helpers have their own tests.
CI runs on Node 22.22.1 and installs a tag-style tarball into an empty project.
CI runs on Node 22.22.1 and installs a tag-style tarball into an empty project.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "2.11.0",
+ "version": "3.0.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+'use strict';
+// Request reduction shared by the PostgreSQL tracker.
+const crypto = require('crypto');
+const privacy = require('./privacy');
+const { allowed } = require('./event');
+
+const SESSION_IDLE_MS = 30 * 60 * 1000;
+const MAX_TRACKED_CLIENTS = 200000;
+const MAX_BUFFER = 5000;
+const SESSION_ID_RE = /^[0-9a-f]{16}$/;
+const METHOD_RE = /^[A-Z][A-Z-]{0,15}$/;
+const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
+const ALLOWED = { device: allowed('device'), browser: allowed('browser'), os: allowed('os') };
+
+const pad = (n) => String(n).padStart(2, '0');
+/** 'YYYY-MM-DD HH:MM:SS' in UTC, the format of CURRENT_TIMESTAMP. */
+function sqlTime(ms) {
+ const d = new Date(ms);
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
+}
+const dayOf = (ms) => sqlTime(ms).slice(0, 10);
+const hourOf = (ms) => sqlTime(ms).slice(0, 13) + ':00:00';
+
+function isStaticOrHealth(path) {
+ return path === '/api/health' || path === '/health' ||
+ /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path);
+}
+
+class AnalyticsTrackerCore {
+ constructor(db, service, opts = {}) {
+ this.db = db;
+ this.service = service;
+ this._now = opts.now || Date.now;
+ this._pathOpts = privacy.pathOptions({ paramPrefixes: opts.paramPrefixes, pathRules: opts.pathRules });
+ this.pruneJob = null;
+ this._buffer = [];
+ this._visitors = new Map();
+ this._sessions = new Map();
+ this._rates = new Map();
+ this._saltDay = null;
+ this._salt = null;
+ this._timers = [];
+ }
+
+ _sessionId(vhash, nowMs) {
+ let s = this._sessions.get(vhash);
+ if (!s || nowMs - s.last > SESSION_IDLE_MS) {
+ s = { sid: crypto.randomBytes(8).toString('hex'), last: nowMs };
+ this._sessions.set(vhash, s);
+ }
+ s.last = nowMs;
+ return s.sid;
+ }
+
+ /** Hits from this client in the current and previous minute (memory only). */
+ _rateHit(ip, nowMs) {
+ const minute = Math.floor(nowMs / 60000);
+ let r = this._rates.get(ip);
+ if (!r) { r = { minute, count: 0, prev: 0 }; this._rates.set(ip, r); }
+ if (r.minute !== minute) {
+ r.prev = r.minute === minute - 1 ? r.count : 0;
+ r.count = 0;
+ r.minute = minute;
+ }
+ r.count++;
+ return r.count + r.prev;
+ }
+
+ _sweep() {
+ const nowMs = this._now();
+ const minute = Math.floor(nowMs / 60000);
+ for (const [ip, r] of this._rates) if (r.minute < minute - 1) this._rates.delete(ip);
+ for (const [h, s] of this._sessions) if (nowMs - s.last > SESSION_IDLE_MS) this._sessions.delete(h);
+ if (this._rates.size > MAX_TRACKED_CLIENTS) this._rates.clear();
+ if (this._sessions.size > MAX_TRACKED_CLIENTS) this._sessions.clear();
+ }
+
+ _push(event, visitor) {
+ if (this._buffer.length >= MAX_BUFFER) this._buffer.shift();
+ this._buffer.push(event);
+ if (visitor) {
+ const key = `${visitor.hour}|${visitor.vhash}`;
+ const prev = this._visitors.get(key);
+ if (!prev) this._visitors.set(key, visitor);
+ else if (visitor.authenticated) prev.authenticated = true;
+ }
+ if (this._buffer.length >= 100) this.flush();
+ }
+
+ /**
+ * Record one finished request. `entryPath` is the path as it arrived (req.path inside a mounted
+ * router is relative by the time the response finishes). Returns false, recording nothing, for an
+ * opted-out request (Sec-GPC: 1 / DNT: 1); true otherwise.
+ */
+ record(req, res, entryPath, responseTimeMs) {
+ const headers = req.headers || {};
+ if (privacy.optedOut(headers)) return false;
+ entryPath = String(entryPath || '');
+ const nowMs = this._now();
+ const ua = String(headers['user-agent'] || '');
+ const ip = String(req.ip || (req.socket && req.socket.remoteAddress) || '');
+ const bot = privacy.classifyRequest({ headers, path: entryPath });
+ if (this._rateHit(ip, nowMs) > privacy.HIGH_REQUEST_RATE && !bot.isBot) {
+ bot.isBot = true;
+ bot.botType = 'rate_limit';
+ bot.confidence = 0.80;
+ }
+ const parsed = privacy.parseUserAgent(ua);
+ const day = dayOf(nowMs);
+ const vhash = this._visitorHash(ip, ua, day);
+ const authenticated = !!(req.user && (req.user.id != null || req.user.sub != null));
+ const isApi = entryPath.startsWith('/api/') || entryPath.startsWith('/internal/') || entryPath.startsWith('/oauth/');
+ this._push({
+ service: this.service,
+ event_type: isApi ? 'api_call' : 'pageview',
+ path: privacy.routeTemplate(req, entryPath, this._pathOpts),
+ method: METHOD_RE.test(String(req.method || '')) ? req.method : null,
+ status_code: res.statusCode,
+ response_time_ms: responseTimeMs,
+ session_id: this._sessionId(vhash, nowMs),
+ country: privacy.countryCode(headers['cf-ipcountry']),
+ user_agent: privacy.uaClass(ua),
+ referer: privacy.refererOrigin(headers.referer || headers.referrer),
+ is_bot: bot.isBot,
+ bot_type: bot.botType,
+ device_type: parsed.device,
+ browser: parsed.browser,
+ os: parsed.os,
+ authenticated,
+ created_at: sqlTime(nowMs),
+ }, { day, hour: hourOf(nowMs), vhash, authenticated });
+ return true;
+ }
+
+ /**
+ * Express middleware: one row per finished request (health checks, static assets and opted-out
+ * requests skipped).
+ */
+ middleware() {
+ return (req, res, next) => {
+ const entryPath = req.path || '';
+ if (isStaticOrHealth(entryPath) || privacy.optedOut(req.headers)) return next();
+ const started = Date.now();
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ try { this.record(req, res, entryPath, Date.now() - started); }
+ catch (err) { console.error(`[Analytics:${this.service}] record error:`, err.message); }
+ };
+ res.once('finish', finish);
+ res.once('close', finish);
+ next();
+ };
+ }
+
+ /**
+ * A custom (non-HTTP) event. Personal fields in `data` (ip, user_id, city) are ignored; path,
+ * referer and user_agent are reduced like a request's; session_id is kept only if it is one of
+ * this tracker's rotating ids; any other value outside analytics/event.v1 is stored as NULL, and
+ * an event name outside it throws a TypeError. Pass the triggering request's headers as
+ * `data.headers` when there is one: an opted-out request (Sec-GPC / DNT) records nothing.
+ * Returns whether it recorded.
+ */
+ trackEvent(eventType, data = {}) {
+ const name = String(eventType == null ? 'event' : eventType);
+ if (!EVENT_NAME_RE.test(name)) throw new TypeError(`analytics event name must match ${EVENT_NAME_RE} (analytics/event.v1)`);
+ if (privacy.optedOut(data.headers)) return false;
+ const nowMs = this._now();
+ const ua = data.user_agent ? String(data.user_agent) : '';
+ const parsed = ua ? privacy.parseUserAgent(ua) : {};
+ const method = data.method ? String(data.method).toUpperCase() : null;
+ const status = Number(data.status_code);
+ const ms = Number(data.response_time_ms);
+ const botType = data.bot_type ? String(data.bot_type) : null;
+ const pick = (field, given, fallback) => (ALLOWED[field].includes(given) ? given : (fallback || null));
+ this._push({
+ service: this.service,
+ event_type: name,
+ path: data.path ? privacy.normalisePath(data.path, this._pathOpts) : null,
+ method: method && METHOD_RE.test(method) ? method : null,
+ status_code: Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
+ response_time_ms: Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : null,
+ session_id: SESSION_ID_RE.test(String(data.session_id || '')) ? data.session_id : null,
+ country: privacy.countryCode(data.country),
+ user_agent: ua ? privacy.uaClass(ua) : null,
+ referer: privacy.refererOrigin(data.referer),
+ is_bot: !!data.is_bot,
+ bot_type: botType && /^[a-z0-9_]{1,32}$/.test(botType) ? botType : null,
+ device_type: pick('device', data.device_type, parsed.device),
+ browser: pick('browser', data.browser, parsed.browser),
+ os: pick('os', data.os, parsed.os),
+ authenticated: !!data.authenticated,
+ created_at: sqlTime(nowMs),
+ }, null);
+ return true;
+ }
+
+}
+
+module.exports = { AnalyticsTrackerCore, sqlTime, dayOf, hourOf };
@@ -85,7 +85,7 @@ function validateEvent(ev) {
@@ -85,7 +85,7 @@ function validateEvent(ev) {
const nullable = (v) => (v === undefined || v === '' ? null : v);
const nullable = (v) => (v === undefined || v === '' ? null : v);
const bool = (v) => v === true || v === 1 || v === '1';
const bool = (v) => v === true || v === 1 || v === '1';
-/** 'YYYY-MM-DD HH:MM:SS' (SQLite CURRENT_TIMESTAMP, UTC) -> 'YYYY-MM-DDTHH:MM:SSZ'; ISO input passes. */
+/** 'YYYY-MM-DD HH:MM:SS' (UTC database timestamp) -> 'YYYY-MM-DDTHH:MM:SSZ'; ISO input passes. */
function isoTime(v) {
function isoTime(v) {
if (v == null) return v;
if (v == null) return v;
const s = String(v);
const s = String(v);
@@ -1,49 +1,21 @@
@@ -1,49 +1,21 @@
'use strict';
'use strict';
-/**
- * openvibe-shared/analytics: request analytics within ADR-021 (OpenVibe.Contracts
- * docs/adr/ADR-021-analytics.md). A raw event has no IP, user or subject id, or city. Paths are route
- * templates and referers are reduced to origins. A user agent is stored as a class, sessions are
- * rotating ids, and raw events are pruned after 30 days. A request with `Sec-GPC: 1` or `DNT: 1` is
- * not recorded. The event shape is analytics/event.v1 (docs/schemas/analytics-event.v1.json).
- *
- * const { AnalyticsTracker } = require('openvibe-shared/analytics');
- * const tracker = new AnalyticsTracker(db, 'live', { retention: { days: 30 } }); // db: better-sqlite3
- * app.use(tracker.middleware());
- *
- * Parts (also available as subpaths): tracker, privacy, retention, schema, event, prune-cli.
- * No dependencies: the caller passes its own better-sqlite3 handle (and Database, for the CLI).
- */
-
-const { AnalyticsTracker } = require('./tracker');
+/** PostgreSQL analytics and the shared privacy and event helpers. */
const privacy = require('./privacy');
const privacy = require('./privacy');
const retention = require('./retention');
const retention = require('./retention');
-const schema = require('./schema');
const event = require('./event');
const event = require('./event');
-
-/**
- * @deprecated The pre-ADR-021 module's tuning object, kept so the 1.x export list stays complete
- * (register item C-80). It is frozen and read-only, and the tracker does not read it. It will be
- * removed in 2.0.0.
- */
+const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('./pg');
const SUSPICIOUS_PATTERNS = Object.freeze({
const SUSPICIOUS_PATTERNS = Object.freeze({
highRequestRate: privacy.HIGH_REQUEST_RATE,
highRequestRate: privacy.HIGH_REQUEST_RATE,
rapidPageViews: 20,
rapidPageViews: 20,
noJsExecution: true,
noJsExecution: true,
honeypotPaths: Object.freeze([...privacy.HONEYPOT_PATHS]),
honeypotPaths: Object.freeze([...privacy.HONEYPOT_PATHS]),
});
});
-
module.exports = {
module.exports = {
- AnalyticsTracker,
- privacy,
- retention,
- schema,
- event,
- ensureSchema: schema.ensureSchema,
+ AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg,
+ privacy, retention, event,
optedOut: privacy.optedOut,
optedOut: privacy.optedOut,
- // Names the pre-ADR module exported. They are kept for 1.x and now use the ADR-021 behaviour.
classifyRequest: privacy.classifyRequest,
classifyRequest: privacy.classifyRequest,
parseUserAgent: privacy.parseUserAgent,
parseUserAgent: privacy.parseUserAgent,
- ANALYTICS_SCHEMA: schema.ANALYTICS_SCHEMA,
BOT_USER_AGENTS: privacy.BOT_USER_AGENTS,
BOT_USER_AGENTS: privacy.BOT_USER_AGENTS,
SUSPICIOUS_PATTERNS,
SUSPICIOUS_PATTERNS,
};
};
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* Request analytics on PostgreSQL (ADR-035; ADR-021's privacy bounds unchanged). The same tracker, tables and
* Request analytics on PostgreSQL (ADR-035; ADR-021's privacy bounds unchanged). The same tracker, tables and
- * getStats/getOverview/getBotAnalysis shapes as ./tracker.js, on an openvibe-sdk/db handle:
+ * getStats/getOverview/getBotAnalysis shapes on an openvibe-sdk/db handle:
*
*
* const { AnalyticsTrackerPg, analyticsSchema } = require('openvibe-shared/analytics/pg');
* const { AnalyticsTrackerPg, analyticsSchema } = require('openvibe-shared/analytics/pg');
* // migrations/NNNN_analytics.sql holds analyticsSchema()
* // migrations/NNNN_analytics.sql holds analyticsSchema()
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* app.use(tracker.middleware());
* app.use(tracker.middleware());
* await tracker.flush(); await tracker.aggregate(); await tracker.getStats({ days: 7 });
* await tracker.flush(); await tracker.aggregate(); await tracker.getStats({ days: 7 });
*
*
- * What differs from the SQLite tracker:
+ * PostgreSQL request handling:
* - the request path never waits for the database: record() buffers the row in memory, and flush() (every
* - the request path never waits for the database: record() buffers the row in memory, and flush() (every
* 5 s, and at 100 rows) writes the buffer in one transaction;
* 5 s, and at 100 rows) writes the buffer in one transaction;
* - uniques across processes: the visitor hash needs the day's salt that every process of the service shares
* - uniques across processes: the visitor hash needs the day's salt that every process of the service shares
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
* - reads and rollups are async; SUM() over bigint comes back as numeric (exact), so every sum is cast.
* - reads and rollups are async; SUM() over bigint comes back as numeric (exact), so every sum is cast.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const { AnalyticsTracker, sqlTime, dayOf, hourOf } = require('./tracker');
+const { AnalyticsTrackerCore, sqlTime, dayOf, hourOf } = require('./core');
const privacy = require('./privacy');
const privacy = require('./privacy');
const MAX_BUFFER = 5000;
const MAX_BUFFER = 5000;
@@ -86,15 +86,15 @@ CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_d
@@ -86,15 +86,15 @@ CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_d
`.trim();
`.trim();
}
}
-class AnalyticsTrackerPg extends AnalyticsTracker {
+class AnalyticsTrackerPg extends AnalyticsTrackerCore {
/**
/**
* @param {object} db openvibe-sdk/db handle (the service's own database, analyticsSchema() migrated)
* @param {object} db openvibe-sdk/db handle (the service's own database, analyticsSchema() migrated)
* @param {string} service
* @param {string} service
- * @param {object} [opts] as AnalyticsTracker: paramPrefixes, pathRules, retention ({ days = 30, intervalMs,
+ * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, intervalMs,
* initialDelayMs } or false), timers, now
* initialDelayMs } or false), timers, now
*/
*/
constructor(db, service, opts = {}) {
constructor(db, service, opts = {}) {
- super(db, service, { ...opts, timers: false, __pg: true });
+ super(db, service, opts);
this._pending = []; // visitors waiting for the shared salt: { day, hour, ip, ua, authenticated }
this._pending = []; // visitors waiting for the shared salt: { day, hour, ip, ua, authenticated }
this._localKeys = new Map(); // day → process-local session key
this._localKeys = new Map(); // day → process-local session key
this._salts = new Map(); // day → shared salt (cached after the first read)
this._salts = new Map(); // day → shared salt (cached after the first read)
@@ -281,7 +281,7 @@ class AnalyticsTrackerPg extends AnalyticsTracker {
@@ -281,7 +281,7 @@ class AnalyticsTrackerPg extends AnalyticsTracker {
JSON.stringify(topCountries), JSON.stringify(devices), JSON.stringify(browsers)]);
JSON.stringify(topCountries), JSON.stringify(devices), JSON.stringify(browsers)]);
}
}
- // ── Reads (admin dashboards): the SQLite tracker's shapes ──
+ // ── Reads (admin dashboards): dashboard shapes ──
async getStats(options = {}) {
async getStats(options = {}) {
const { days = 30, hours, service } = options;
const { days = 30, hours, service } = options;
deleted file mode 100644
deleted file mode 100644
@@ -1,235 +0,0 @@
@@ -1,235 +0,0 @@
-'use strict';
-/**
- * The analytics-prune command (ADR-021): raw-event retention and the one-time scrub of pre-ADR rows,
- * for one or more analytics databases. A service keeps a thin scripts/analytics-prune.js that passes
- * its better-sqlite3 and its defaults:
- *
- * const Database = require('better-sqlite3');
- * const cli = require('openvibe-shared/analytics/prune-cli');
- * const main = (argv, log) => cli.main(argv, { Database, log, defaultDb: process.env.ANALYTICS_DB || 'data/analytics.db' });
- * if (require.main === module) cli.run(main);
- * module.exports = { main };
- *
- * Options given by the service (second argument of main):
- * Database better-sqlite3's constructor (required; this package has no native dependency)
- * log line logger (default console.log)
- * defaultDb the database when no --db is given: a path, or (args, env) => path
- * targets (args) => [{ name, file }]: the databases when no --db is given (several apps)
- * options extra repeatable value flags, { '--app': 'apps' } -> args.apps = [...]
- * paramPrefixes the service's extra parameter words } as for the tracker, so legacy rows are
- * pathRules the service's [RegExp, replacement] } templated like new ones
- * backupMode file mode of a backup (default 0o600: a pre-scrub backup holds the old IPs)
- * usage the help text (default: USAGE below)
- * root base for relative --db / --backup paths (default process.cwd())
- * env environment (default process.env)
- *
- * Exit codes: 0 done (or dry run), 1 a check failed (backup unreadable, rollup totals changed),
- * 2 refused (bad arguments, no backup choice, existing backup target, missing database).
- */
-
-const fs = require('fs');
-const path = require('path');
-const retention = require('./retention');
-
-const USAGE = `Raw analytics retention and one-time scrub (ADR-021).
-
- analytics-prune dry run: counts only, changes nothing
- analytics-prune --scrub dry run including what the scrub would rewrite
- analytics-prune --apply --backup <path> online backup, then prune
- analytics-prune --apply --scrub --backup <path>
- analytics-prune --apply --no-backup prune without a backup (explicit)
-
-Options:
- --db <file> an analytics database (repeatable; default: the service's own)
- --days <n> keep raw events newer than n days, 1..30 (default 30)
- --scrub after pruning, rewrite the remaining rows: ip/user_id/city -> NULL, path -> route
- template, referer -> origin, user_agent -> class, legacy session ids -> NULL; same
- path/referer reduction in the rollups' top lists (their counts are untouched)
- --backup <path> one database: a new file; several: a directory, each backup <name>.analytics.db
- in it (sqlite online backup, owner-only, verified with quick_check and a row count)
- --no-backup explicitly skip the backup
- --no-vacuum skip the VACUUM that follows an --apply (VACUUM rewrites the file so pruned and
- scrubbed values do not survive in free pages; it needs ~2x the database size free
- and holds the database's write lock while it runs)
- --batch <n> rows per write batch (default 5000)
-
-every database; any difference exits 1. Prune and scrub are safe while the service is up (short
-batches; the tracker retries a busy flush).`;
-
-function parseArgs(argv, extra = {}) {
- const a = {
- apply: false, scrub: false, vacuum: true, backup: null, noBackup: false,
- days: retention.MAX_DAYS, batch: retention.DEFAULT_BATCH, dbs: [], db: null,
- };
- for (const key of Object.values(extra)) a[key] = [];
- for (let i = 0; i < argv.length; i++) {
- const k = argv[i];
- const val = () => { if (i + 1 >= argv.length) throw new Error(`${k} needs a value`); return argv[++i]; };
- if (k === '--apply') a.apply = true;
- else if (k === '--scrub') a.scrub = true;
- else if (k === '--no-vacuum') a.vacuum = false;
- else if (k === '--no-backup') a.noBackup = true;
- else if (k === '--backup') a.backup = val();
- else if (k === '--days') a.days = Number(val());
- else if (k === '--batch') a.batch = Number(val());
- else if (k === '--db') a.dbs.push(val());
- else if (Object.prototype.hasOwnProperty.call(extra, k)) a[extra[k]].push(val());
- else if (k === '-h' || k === '--help') a.help = true;
- else throw new Error(`unknown option ${k}`);
- }
- a.db = a.dbs[0] || null;
- retention.checkDays(a.days);
- if (!Number.isInteger(a.batch) || a.batch < 1 || a.batch > 100000) throw new Error('--batch must be 1..100000');
- if (a.backup && a.noBackup) throw new Error('--backup and --no-backup are exclusive');
- return a;
-}
-
-/** [{ name, file }] for explicit --db paths: apps/<app>/data/analytics.db -> "<app>", else the file's base name. */
-function namedTargets(files, root = process.cwd()) {
- const seen = new Map();
- return files.map((f) => {
- const file = path.resolve(root, f);
- let name = path.basename(path.dirname(file)) === 'data'
- ? path.basename(path.dirname(path.dirname(file)))
- : path.basename(file, path.extname(file));
- const n = (seen.get(name) || 0) + 1;
- seen.set(name, n);
- if (n > 1) name = `${name}-${n}`;
- return { name, file };
- });
-}
-
-function resolveTargets(args, opts) {
- const root = opts.root || process.cwd();
- if (args.dbs.length) return namedTargets(args.dbs, root);
- if (opts.targets) return opts.targets(args);
- const d = typeof opts.defaultDb === 'function' ? opts.defaultDb(args, opts.env || process.env) : opts.defaultDb;
- if (!d) return [];
- return namedTargets([d], root);
-}
-
-const mb = (n) => `${(n / 1048576).toFixed(1)} MB`;
-function fileBytes(f) { try { return fs.statSync(f).size; } catch { return 0; } }
-function freeBytes(dir) { try { const s = fs.statfsSync(dir); return s.bavail * s.bsize; } catch { return null; } }
-const sameTotals = (a, b) => JSON.stringify(a) === JSON.stringify(b);
-
-async function backupTo(Database, db, target, need, mode, expectRows, log) {
- if (fs.existsSync(target)) { log(`error: backup target ${target} already exists; choose a new path`); return 2; }
- const free = freeBytes(path.dirname(target));
- if (free != null && free < need * 1.1) { log(`error: ${mb(free)} free at ${path.dirname(target)}, the backup needs about ${mb(need)}`); return 2; }
- // The backup may hold pre-ADR rows (IPs, user ids): owner-only from the first byte.
- const umask = process.umask(0o077);
- try { await db.backup(target); } finally { process.umask(umask); }
- fs.chmodSync(target, mode);
- const b = new Database(target, { readonly: true });
- try {
- const ok = b.pragma('quick_check', { simple: true });
- const n = b.prepare("SELECT COUNT(*) FROM sqlite_master WHERE name = 'analytics_events'").pluck().get()
- ? b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get() : 0;
- if (ok !== 'ok' || n < expectRows) { log(`error: backup check failed (quick_check=${ok}, rows=${n})`); return 1; }
- } finally { b.close(); }
- log(`backup ${target} (${mb(fileBytes(target))}, mode ${(mode & 0o777).toString(8).padStart(4, '0')}, quick_check ok)`);
- return 0;
-}
-
-async function runOne(t, args, backupTarget, opts, log) {
- const { Database } = opts;
- const pathOpts = { paramPrefixes: opts.paramPrefixes, pathRules: opts.pathRules };
- if (!fs.existsSync(t.file)) { log(`error: ${t.file} does not exist`); return 2; }
- const db = new Database(t.file, args.apply ? { fileMustExist: true } : { readonly: true, fileMustExist: true });
- try {
- db.pragma('busy_timeout = 5000');
- const walBytes = fileBytes(t.file + '-wal');
- const before = retention.inspect(db, { days: args.days, ...pathOpts });
- log(`\n[${t.name}] ${t.file}`);
- log(`size ${mb(before.bytes)} (+ ${mb(walBytes)} WAL); a backup needs about ${mb(before.bytes + walBytes)}; VACUUM about ${mb(2 * before.bytes)} more`);
- log(`raw events ${before.events} (oldest ${before.oldest || '-'}, newest ${before.newest || '-'})`);
- log(`prune ${before.older} rows created before ${before.cutoff} UTC (${args.days} days); ${before.remaining} stay`);
- if (before.toScrub) {
- const s = before.toScrub;
- log(`scrub of the rows that stay: ${s.personal} with ip/user_id/city, ${s.paths} paths to template, ${s.referers} referers to origin, ${s.user_agents} user agents to class, ${s.sessions} legacy session ids${args.scrub ? '' : ' (needs --scrub)'}`);
- }
- log(`rate rows ${before.rateRows || 0} (IP counters; the tracker keeps them in memory now)`);
- for (const [k, v] of Object.entries(before.rollups)) log(`rollups ${k}: ${v.rows} rows, ${v.pageviews} pageviews, ${v.api_calls} api calls (kept)`);
- if (!args.apply) return 0;
-
- if (backupTarget) {
- const code = await backupTo(Database, db, backupTarget, before.bytes + walBytes, opts.backupMode, before.events, log);
- if (code) return code;
- }
-
- db.pragma('secure_delete = ON');
- const totalsBefore = retention.rollupTotals(db);
- const pruned = await retention.pruneRawEvents(db, { days: args.days, batchSize: args.batch });
- log(`pruned ${pruned.deleted} rows in ${pruned.batches} batches`);
- if (args.scrub) {
- const s = await retention.scrubEvents(db, { batchSize: args.batch, ...pathOpts });
- const r = retention.scrubRollups(db, pathOpts);
- log(`scrubbed ${s.rows} raw rows in ${s.batches} batches; rollup top lists rewritten in ${r.hourly} hourly and ${r.daily} daily rows`);
- }
- const totalsAfter = retention.rollupTotals(db);
- if (!sameTotals(totalsBefore, totalsAfter)) {
- log(`error: rollup totals changed!\n before ${JSON.stringify(totalsBefore)}\n after ${JSON.stringify(totalsAfter)}`);
- return 1;
- }
- log('rollups totals unchanged');
- if (args.vacuum && (pruned.deleted || args.scrub)) {
- db.pragma('wal_checkpoint(TRUNCATE)');
- db.exec('VACUUM');
- db.pragma('wal_checkpoint(TRUNCATE)');
- log(`vacuumed ${mb(fileBytes(t.file))}`);
- }
- const after = retention.inspect(db, { days: args.days, ...pathOpts });
- log(`now ${after.events} raw events, ${after.older} older than ${args.days} days${after.toScrub ? `, ${after.toScrub.personal} with ip/user_id/city` : ''}`);
- return 0;
- } finally {
- db.close();
- }
-}
-
-/** Run the command. Resolves the exit code; never calls process.exit. */
-async function main(argv, opts = {}) {
- const log = opts.log || console.log;
- if (typeof opts.Database !== 'function') throw new TypeError('prune-cli: pass { Database } (better-sqlite3)');
- const o = { backupMode: 0o600, ...opts };
- let args;
- try { args = parseArgs(argv, o.options || {}); } catch (e) { log(`error: ${e.message}`); return 2; }
- if (args.help) { log(o.usage || USAGE); return 0; }
- const list = resolveTargets(args, o);
- if (!list.length) { log('no analytics databases found (use --db)'); return 2; }
- if (args.apply && !args.backup && !args.noBackup) {
- log('refusing --apply without a backup: pass --backup <new file or directory> (sqlite online backup) or --no-backup');
- return 2;
- }
- let backupFor = () => null;
- if (args.apply && args.backup) {
- const target = path.resolve(o.root || process.cwd(), args.backup);
- if (list.length === 1) {
- backupFor = () => target;
- } else {
- if (fs.existsSync(target) && !fs.statSync(target).isDirectory()) { log(`error: ${target} is a file; several databases need a directory`); return 2; }
- const clash = list.map((t) => path.join(target, `${t.name}.analytics.db`)).filter((f) => fs.existsSync(f));
- if (clash.length) { log(`error: backup targets already exist: ${clash.join(', ')}`); return 2; }
- fs.mkdirSync(target, { recursive: true, mode: 0o700 });
- backupFor = (t) => path.join(target, `${t.name}.analytics.db`);
- }
- }
- let worst = 0;
- for (const t of list) {
- const code = await runOne(t, args, backupFor(t), o, log);
- worst = Math.max(worst, code);
- if (code) { if (list.length > 1) log(`[${t.name}] stopped (exit ${code}); later databases not touched`); break; }
- }
- if (!args.apply && worst === 0) log('\ndry run: nothing changed. Re-run with --apply --backup <file|dir> (or --no-backup).');
- return worst;
-}
-
-/** For a service's script: `if (require.main === module) run(main)` — exits with main's code. */
-function run(mainFn, argv = process.argv.slice(2)) {
- return Promise.resolve()
- .then(() => mainFn(argv))
- .then((code) => process.exit(code), (err) => { console.error(err); process.exit(1); });
-}
-
-module.exports = { USAGE, main, run, parseArgs, namedTargets, resolveTargets };
@@ -1,41 +1,9 @@
@@ -1,41 +1,9 @@
'use strict';
'use strict';
-/**
- * Raw analytics retention and the one-time scrub (ADR-021).
- *
- * pruneRawEvents(db, { days }) delete analytics_events rows older than `days` (≤ 30) in bounded
- * batches, yielding to the event loop between batches. Rollups
- * (analytics_hourly / analytics_daily) are never touched. Also clears
- * leftovers: visitor hashes older than yesterday, salts of past days,
- * and analytics_rate_tracking (IP counters live in memory now).
- * scrubEvents(db) rewrite the rows written before ADR-021: ip / user_id / city →
- * NULL (authenticated = 1 where a user id was present), path → route
- * template, referer → origin, user_agent → class, session ids that are
- * not rotating ids → NULL.
- * scrubRollups(db) the same path/referer reduction inside the rollups' top_paths /
- * top_referers JSON (counts merged, never changed).
- * inspect(db, { days }) read-only counts for a dry run.
- * rollupTotals(db) sums of every rollup counter, to prove a run left them alone.
- * schedulePrune(db, opts) the nightly prune on unref'd timers (first run 5 min after boot,
- * then every 24 h); returns { run, stop }.
- *
- * scrubEvents / scrubRollups / inspect take the service's path options ({ paramPrefixes, pathRules },
- * see privacy.normalisePath) so legacy rows are templated with the same rules as new ones.
- *
- * Used by the tracker (opts.retention), a service's own job runner, and ./prune-cli.js.
- */
-
-const { ensureSchema } = require('./schema');
-const privacy = require('./privacy');
+/** Backend-independent analytics retention bounds. */
+const { sqlTime } = require('./core');
const MAX_DAYS = 30;
const MAX_DAYS = 30;
const DEFAULT_BATCH = 5000;
const DEFAULT_BATCH = 5000;
-const ROTATING_SESSION_RE = /^[0-9a-f]{16}$/;
-
-const pad = (n) => String(n).padStart(2, '0');
-function sqlTime(ms) {
- const d = new Date(ms);
- return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
-}
function checkDays(days) {
function checkDays(days) {
const n = Number(days);
const n = Number(days);
@@ -50,199 +18,4 @@ function cutoffFor(days, nowMs = Date.now()) {
@@ -50,199 +18,4 @@ function cutoffFor(days, nowMs = Date.now()) {
return sqlTime(nowMs - checkDays(days) * 86400000);
return sqlTime(nowMs - checkDays(days) * 86400000);
}
}
-const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
-const columnExists = (db, table, col) => db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
-const tick = () => new Promise((r) => setImmediate(r));
-
-/**
- * Delete raw events older than `days` in batches of `batchSize` (each batch its own short write
- * transaction). Returns { cutoff, deleted, batches, complete }.
- */
-async function pruneRawEvents(db, { days = MAX_DAYS, batchSize = DEFAULT_BATCH, maxBatches = Infinity, now = Date.now } = {}) {
- const cutoff = cutoffFor(days, now());
- const out = { cutoff, deleted: 0, batches: 0, complete: true };
- if (!tableExists(db, 'analytics_events')) return out;
- const del = db.prepare('DELETE FROM analytics_events WHERE id IN (SELECT id FROM analytics_events WHERE created_at < ? LIMIT ?)');
- for (;;) {
- if (out.batches >= maxBatches) { out.complete = false; break; }
- const n = del.run(cutoff, batchSize).changes;
- out.batches++;
- out.deleted += n;
- if (n < batchSize) break;
- await tick();
- }
- const today = sqlTime(now()).slice(0, 10);
- const yesterday = sqlTime(now() - 86400000).slice(0, 10);
- if (tableExists(db, 'analytics_visitor_days')) db.prepare('DELETE FROM analytics_visitor_days WHERE day < ?').run(yesterday);
- if (tableExists(db, 'analytics_day_salts')) db.prepare('DELETE FROM analytics_day_salts WHERE day < ?').run(today);
- if (tableExists(db, 'analytics_rate_tracking')) db.prepare('DELETE FROM analytics_rate_tracking').run();
- return out;
-}
-
-/** SQL functions for the scrub and the dry-run counts (connection-scoped, safe on a read-only handle). */
-function registerFunctions(db, { paramPrefixes, pathRules } = {}) {
- const opts = privacy.pathOptions({ paramPrefixes, pathRules });
- db.function('ov_route_template', { deterministic: true }, (p) => (p == null ? null : privacy.normalisePath(p, opts)));
- db.function('ov_referer_origin', { deterministic: true }, (r) => (r == null || r === '' ? r : privacy.refererOrigin(r)));
- db.function('ov_ua_class', { deterministic: true }, (u) => privacy.uaClass(u));
- db.function('ov_rotating_session', { deterministic: true }, (s) => (s != null && ROTATING_SESSION_RE.test(String(s)) ? s : null));
-}
-
-/** Rewrite every raw row to the ADR-021 fields, in id-range batches. Returns { rows, batches }. */
-async function scrubEvents(db, { batchSize = DEFAULT_BATCH, paramPrefixes, pathRules } = {}) {
- ensureSchema(db);
- registerFunctions(db, { paramPrefixes, pathRules });
- const { lo, hi } = db.prepare('SELECT MIN(id) AS lo, MAX(id) AS hi FROM analytics_events').get();
- const out = { rows: 0, batches: 0 };
- if (lo == null) return out;
- const upd = db.prepare(`
- UPDATE analytics_events SET
- authenticated = CASE WHEN user_id IS NOT NULL THEN 1 ELSE authenticated END,
- ip = NULL, user_id = NULL, city = NULL,
- session_id = ov_rotating_session(session_id),
- path = ov_route_template(path),
- referer = ov_referer_origin(referer),
- user_agent = ov_ua_class(user_agent)
- WHERE id >= ? AND id < ?
- `);
- for (let from = lo; from <= hi; from += batchSize) {
- out.rows += upd.run(from, from + batchSize).changes;
- out.batches++;
- await tick();
- }
- return out;
-}
-
-/** Re-template a rollup's top list ([{ path|referer, cnt }]) — counts are summed, never altered. */
-function reduceTopList(json, key, fn) {
- let list;
- try { list = JSON.parse(json); } catch { return null; }
- if (!Array.isArray(list)) return null;
- const merged = new Map();
- for (const item of list) {
- if (!item || typeof item !== 'object') continue;
- const k = fn(item[key]);
- if (k == null) continue;
- merged.set(k, (merged.get(k) || 0) + (Number(item.cnt) || 0));
- }
- const next = [...merged].map(([k, cnt]) => ({ [key]: k, cnt })).sort((a, b) => b.cnt - a.cnt);
- const text = JSON.stringify(next);
- return text === json ? null : text;
-}
-
-/** Scrub top_paths / top_referers in both rollup tables. Returns { hourly, daily } rows changed. */
-function scrubRollups(db, { paramPrefixes, pathRules } = {}) {
- const opts = privacy.pathOptions({ paramPrefixes, pathRules });
- const tpl = (p) => (p == null ? null : privacy.normalisePath(p, opts));
- const out = { hourly: 0, daily: 0 };
- for (const table of ['analytics_hourly', 'analytics_daily']) {
- if (!tableExists(db, table)) continue;
- const rows = db.prepare(`SELECT id, top_paths, top_referers FROM ${table}`).all();
- const upd = db.prepare(`UPDATE ${table} SET top_paths = ?, top_referers = ? WHERE id = ?`);
- db.transaction(() => {
- for (const r of rows) {
- const paths = r.top_paths ? reduceTopList(r.top_paths, 'path', tpl) : null;
- const refs = r.top_referers ? reduceTopList(r.top_referers, 'referer', privacy.refererOrigin) : null;
- if (paths == null && refs == null) continue;
- upd.run(paths != null ? paths : r.top_paths, refs != null ? refs : r.top_referers, r.id);
- out[table === 'analytics_hourly' ? 'hourly' : 'daily']++;
- }
- })();
- }
- return out;
-}
-
-/** Sums of every rollup counter; equal before and after a prune or scrub. */
-function rollupTotals(db) {
- const out = {};
- if (tableExists(db, 'analytics_hourly')) {
- out.hourly = db.prepare(`SELECT COUNT(*) AS rows, TOTAL(pageviews) AS pageviews, TOTAL(api_calls) AS api_calls,
- TOTAL(unique_visitors) AS unique_visitors, TOTAL(unique_users) AS unique_users, TOTAL(bot_hits) AS bot_hits,
- TOTAL(error_count) AS error_count FROM analytics_hourly`).get();
- }
- if (tableExists(db, 'analytics_daily')) {
- out.daily = db.prepare(`SELECT COUNT(*) AS rows, TOTAL(pageviews) AS pageviews, TOTAL(api_calls) AS api_calls,
- TOTAL(unique_visitors) AS unique_visitors, TOTAL(unique_users) AS unique_users, TOTAL(new_users) AS new_users,
- TOTAL(bot_hits) AS bot_hits, TOTAL(error_count) AS error_count FROM analytics_daily`).get();
- }
- return out;
-}
-
-/** Read-only counts for a dry run. */
-function inspect(db, { days = MAX_DAYS, now = Date.now, paramPrefixes, pathRules } = {}) {
- const cutoff = cutoffFor(days, now());
- const out = { cutoff, events: 0, older: 0, remaining: 0, oldest: null, newest: null };
- const pageSize = db.pragma('page_size', { simple: true });
- out.bytes = db.pragma('page_count', { simple: true }) * pageSize;
- out.freeBytes = db.pragma('freelist_count', { simple: true }) * pageSize;
- out.rollups = rollupTotals(db);
- if (!tableExists(db, 'analytics_events')) return out;
- registerFunctions(db, { paramPrefixes, pathRules });
- const e = db.prepare('SELECT COUNT(*) AS n, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM analytics_events').get();
- out.events = e.n;
- out.oldest = e.oldest;
- out.newest = e.newest;
- out.older = db.prepare('SELECT COUNT(*) FROM analytics_events WHERE created_at < ?').pluck().get(cutoff);
- out.remaining = out.events - out.older;
- const personal = ['ip', 'user_id', 'city'].filter((c) => columnExists(db, 'analytics_events', c))
- .map((c) => `${c} IS NOT NULL`).join(' OR ') || '0';
- const r = db.prepare(`
- SELECT
- COUNT(*) FILTER (WHERE ${personal}) AS personal,
- COUNT(*) FILTER (WHERE path IS NOT ov_route_template(path)) AS paths,
- COUNT(*) FILTER (WHERE referer IS NOT ov_referer_origin(referer)) AS referers,
- COUNT(*) FILTER (WHERE user_agent IS NOT ov_ua_class(user_agent)) AS user_agents,
- COUNT(*) FILTER (WHERE session_id IS NOT ov_rotating_session(session_id)) AS sessions
- FROM analytics_events WHERE created_at >= ?
- `).get(cutoff);
- out.toScrub = r;
- out.rateRows = tableExists(db, 'analytics_rate_tracking') ? db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get() : 0;
- return out;
-}
-
-/**
- * Run pruneRawEvents(db, { days }) on timers: first after `initialDelayMs` (default 5 min), then every
- * `intervalMs` (default 24 h). Timers are unref'd, so they never hold the process open. An overlapping
- * run is skipped (resolves null). `label` names the service in log lines.
- * Returns { run, stop }: run() prunes now (resolves the prune result, or null on error/overlap).
- */
-function schedulePrune(db, {
- days = MAX_DAYS, batchSize = DEFAULT_BATCH, initialDelayMs = 5 * 60 * 1000, intervalMs = 24 * 60 * 60 * 1000,
- label = 'Analytics', log = console.log, logError = console.error, now = Date.now,
-} = {}) {
- checkDays(days);
- let running = false;
- const run = async () => {
- if (running) return null;
- running = true;
- try {
- const out = await pruneRawEvents(db, { days, batchSize, now });
- if (out.deleted) log(`[${label}] pruned ${out.deleted} raw events older than ${out.cutoff}`);
- return out;
- } catch (err) {
- logError(`[${label}] analytics-prune error:`, err.message);
- return null;
- } finally {
- running = false;
- }
- };
- const first = setTimeout(run, initialDelayMs);
- const every = setInterval(run, intervalMs);
- if (first.unref) first.unref();
- if (every.unref) every.unref();
- return { run, stop() { clearTimeout(first); clearInterval(every); } };
-}
-
-module.exports = {
- MAX_DAYS,
- DEFAULT_BATCH,
- checkDays,
- cutoffFor,
- pruneRawEvents,
- scrubEvents,
- scrubRollups,
- rollupTotals,
- inspect,
- reduceTopList,
- schedulePrune,
-};
+module.exports = { MAX_DAYS, DEFAULT_BATCH, checkDays, cutoffFor };
deleted file mode 100644
deleted file mode 100644
@@ -1,123 +0,0 @@
@@ -1,123 +0,0 @@
-'use strict';
-/**
- * Analytics database schema (ADR-021). Same tables as the openvibe-shared tracker it replaces, so the
- * existing analytics.db files and the admin dashboards keep working; the columns that used to hold
- * personal data (ip, user_id, city) stay in the table for compatibility but are always NULL now, and
- * user_agent / referer / path hold the reduced forms (class, origin, route template).
- *
- * Added:
- * analytics_events.authenticated 0/1 — whether the request was signed in (a flag, never who)
- * analytics_visitor_days the day's salted visitor hashes, for the unique-visitor rollups;
- * rows are deleted once the day's rollup is final (≤ ~25 h)
- * analytics_day_salts the random salt of the current UTC day (so a restart does not
- * double-count); deleted together with that day's hashes
- * Emptied: analytics_rate_tracking (the rate check keeps per-IP counters in memory only now)
- */
-
-const ANALYTICS_SCHEMA = `
- CREATE TABLE IF NOT EXISTS analytics_events (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- service TEXT NOT NULL,
- event_type TEXT NOT NULL DEFAULT 'pageview',
- path TEXT,
- method TEXT DEFAULT 'GET',
- status_code INTEGER,
- response_time_ms INTEGER,
- user_id INTEGER,
- session_id TEXT,
- ip TEXT,
- country TEXT,
- city TEXT,
- user_agent TEXT,
- referer TEXT,
- is_bot INTEGER DEFAULT 0,
- bot_type TEXT,
- device_type TEXT,
- browser TEXT,
- os TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE IF NOT EXISTS analytics_hourly (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- service TEXT NOT NULL,
- hour TEXT NOT NULL,
- pageviews INTEGER DEFAULT 0,
- api_calls INTEGER DEFAULT 0,
- unique_visitors INTEGER DEFAULT 0,
- unique_users INTEGER DEFAULT 0,
- bot_hits INTEGER DEFAULT 0,
- avg_response_ms INTEGER DEFAULT 0,
- error_count INTEGER DEFAULT 0,
- bandwidth_bytes INTEGER DEFAULT 0,
- top_paths TEXT,
- top_referers TEXT,
- UNIQUE(service, hour)
- );
-
- CREATE TABLE IF NOT EXISTS analytics_daily (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- service TEXT NOT NULL,
- date TEXT NOT NULL,
- pageviews INTEGER DEFAULT 0,
- api_calls INTEGER DEFAULT 0,
- unique_visitors INTEGER DEFAULT 0,
- unique_users INTEGER DEFAULT 0,
- new_users INTEGER DEFAULT 0,
- bot_hits INTEGER DEFAULT 0,
- avg_response_ms INTEGER DEFAULT 0,
- error_count INTEGER DEFAULT 0,
- top_paths TEXT,
- top_referers TEXT,
- top_countries TEXT,
- device_breakdown TEXT,
- browser_breakdown TEXT,
- UNIQUE(service, date)
- );
-
- CREATE TABLE IF NOT EXISTS analytics_rate_tracking (
- ip TEXT NOT NULL,
- window_start INTEGER NOT NULL,
- hit_count INTEGER DEFAULT 1,
- PRIMARY KEY (ip, window_start)
- );
-
- CREATE TABLE IF NOT EXISTS analytics_visitor_days (
- service TEXT NOT NULL,
- day TEXT NOT NULL,
- hour TEXT NOT NULL,
- vhash TEXT NOT NULL,
- authenticated INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (service, hour, vhash)
- );
-
- CREATE TABLE IF NOT EXISTS analytics_day_salts (
- service TEXT NOT NULL,
- day TEXT NOT NULL,
- salt TEXT NOT NULL,
- PRIMARY KEY (service, day)
- );
-
- CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events(created_at);
- CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events(service, created_at);
- CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events(service, path, created_at);
- CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events(is_bot, created_at);
- CREATE INDEX IF NOT EXISTS idx_analytics_hourly_lookup ON analytics_hourly(service, hour);
- CREATE INDEX IF NOT EXISTS idx_analytics_daily_lookup ON analytics_daily(service, date);
- CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_days(service, day);
-
- -- Indexes over the retired personal columns.
- DROP INDEX IF EXISTS idx_analytics_events_ip;
- DROP INDEX IF EXISTS idx_analytics_events_user;
-`;
-
-/** Create or upgrade the analytics tables in place. Idempotent; never touches rows. */
-function ensureSchema(db) {
- db.exec(ANALYTICS_SCHEMA);
- const cols = db.prepare('PRAGMA table_info(analytics_events)').all().map((c) => c.name);
- if (!cols.includes('authenticated')) {
- db.exec('ALTER TABLE analytics_events ADD COLUMN authenticated INTEGER NOT NULL DEFAULT 0');
- }
-}
-
-module.exports = { ANALYTICS_SCHEMA, ensureSchema };
deleted file mode 100644
deleted file mode 100644
@@ -1,754 +0,0 @@
@@ -1,754 +0,0 @@
-'use strict';
-/**
- * Request analytics within the ADR-021 privacy bounds. Drop-in replacement for the pre-ADR
- * openvibe-shared AnalyticsTracker (same constructor, tables and getStats/getOverview/getBotAnalysis
- * shapes), minus the personal data:
- *
- * raw row service, event_type, route template, method, status, response time, rotating session
- * id, country (CDN header), user-agent class + browser/os/device, referer ORIGIN,
- * bot flags, signed-in flag, timestamp. ip / user_id / city are always NULL.
- * session id random 16 hex chars, held in memory against the day's visitor hash; a new one after
- * 30 minutes idle and at every UTC midnight. Never derived from, or equal to, a user id.
- * rate check per-IP hit counters for the current and previous minute, IN MEMORY only (dropped
- * after 2 minutes idle; analytics_rate_tracking is emptied on boot and no longer written).
- * uniques visitor hash = HMAC-SHA256(day salt, ip + "\n" + user agent), first 16 hex chars.
- * The salt is random per UTC day and service (analytics_day_salts, deleted as soon as
- * the day is over); hashes go to analytics_visitor_days only, never to raw events,
- * and are deleted by the first aggregation after their day ends, right after that day's
- * final hourly/daily rollup — so no hash or salt outlives its day by more than about
- * one aggregation interval (1 h). Rollups keep only the counts.
- *
- * opt-out a request with `Sec-GPC: 1` or `DNT: 1` is not recorded at all (privacy.optedOut):
- * no row, no visitor hash, no session id, no rate counter.
- *
- * Retention of raw events (30 days) is ./retention.js; this tracker writes, rolls up and (with
- * opts.retention) schedules the prune. The row shape is analytics/event.v1 (./event.js).
- */
-
-const crypto = require('crypto');
-const { ensureSchema } = require('./schema');
-const privacy = require('./privacy');
-const retention = require('./retention');
-const { allowed } = require('./event');
-
-const SESSION_IDLE_MS = 30 * 60 * 1000;
-const MAX_TRACKED_CLIENTS = 200000;
-const MAX_BUFFER = 5000;
-const SESSION_ID_RE = /^[0-9a-f]{16}$/;
-const METHOD_RE = /^[A-Z][A-Z-]{0,15}$/;
-const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
-const ALLOWED = { device: allowed('device'), browser: allowed('browser'), os: allowed('os') };
-
-const pad = (n) => String(n).padStart(2, '0');
-/** 'YYYY-MM-DD HH:MM:SS' in UTC, the format of CURRENT_TIMESTAMP. */
-function sqlTime(ms) {
- const d = new Date(ms);
- return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
-}
-const dayOf = (ms) => sqlTime(ms).slice(0, 10);
-const hourOf = (ms) => sqlTime(ms).slice(0, 13) + ':00:00';
-const parseSqlTime = (s) => Date.parse(s.replace(' ', 'T') + 'Z');
-
-function isStaticOrHealth(path) {
- return path === '/api/health' || path === '/health' ||
- /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path);
-}
-
-class AnalyticsTracker {
- /**
- * @param {object} db better-sqlite3 handle (its own analytics database)
- * @param {string} service service name ('live', 'openvibe-yt', …)
- * @param {object} [opts]
- * @param {string[]} [opts.paramPrefixes] extra words whose next path segment is a parameter
- * (added to privacy.DEFAULT_PARAM_PREFIXES), e.g. Network's ['avatar', 'anon', 'projects']
- * @param {Array<[RegExp, string]>} [opts.pathRules] rewrites applied to a raw path before it is
- * templated, for parameters the segment rules cannot see (privacy.normalisePath)
- * @param {false|object} [opts.retention] { days = 30, intervalMs = 24 h, initialDelayMs = 5 min }:
- * the raw-event prune on a timer (retention.schedulePrune; the job is this.pruneJob). On by
- * default (ADR-021: raw events at most 30 days); pass false only when the service schedules
- * retention.pruneRawEvents itself.
- * @param {boolean} [opts.timers=true] false: no flush/aggregate timers (tests call flush/aggregate)
- * @param {function} [opts.now] clock (tests)
- */
- constructor(db, service, opts = {}) {
- this.db = db;
- this.service = service;
- this._now = opts.now || Date.now;
- this._pathOpts = privacy.pathOptions({ paramPrefixes: opts.paramPrefixes, pathRules: opts.pathRules });
- this.pruneJob = null;
- this._buffer = [];
- this._visitors = new Map(); // `${hour}|${vhash}` -> { day, hour, vhash, authenticated }
- this._sessions = new Map(); // vhash -> { sid, last }
- this._rates = new Map(); // client ip -> { minute, count, prev } (memory only)
- this._saltDay = null;
- this._salt = null;
- this._timers = [];
-
- if (opts.__pg) return; // analytics/pg.js: an openvibe-sdk/db handle, its own writes and timers
- ensureSchema(db);
- try { db.pragma('secure_delete = ON'); } catch { /* not fatal */ }
- try { db.pragma('busy_timeout = 250'); } catch { /* not fatal */ }
- db.exec('DELETE FROM analytics_rate_tracking');
-
- this._insertEvent = db.prepare(`
- INSERT INTO analytics_events
- (service, event_type, path, method, status_code, response_time_ms,
- user_id, session_id, ip, country, city, user_agent, referer,
- is_bot, bot_type, device_type, browser, os, authenticated, created_at)
- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `);
- this._upsertVisitor = db.prepare(`
- INSERT INTO analytics_visitor_days (service, day, hour, vhash, authenticated) VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(service, hour, vhash) DO UPDATE SET authenticated = MAX(authenticated, excluded.authenticated)
- `);
- this._write = db.transaction((events, visitors) => {
- for (const e of events) {
- this._insertEvent.run(
- e.service, e.event_type, e.path, e.method, e.status_code, e.response_time_ms,
- e.session_id, e.country, e.user_agent, e.referer,
- e.is_bot ? 1 : 0, e.bot_type, e.device_type, e.browser, e.os, e.authenticated ? 1 : 0, e.created_at,
- );
- }
- for (const v of visitors) this._upsertVisitor.run(this.service, v.day, v.hour, v.vhash, v.authenticated ? 1 : 0);
- });
-
- if (opts.timers !== false) {
- const every = (fn, ms) => { const t = setInterval(fn, ms); if (t.unref) t.unref(); this._timers.push(t); };
- const once = (fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); this._timers.push(t); };
- every(() => { this.flush(); this._sweep(); }, 5000);
- every(() => this.aggregate(), 60 * 60 * 1000);
- once(() => this.aggregate(), 10_000);
- if (opts.retention !== false) {
- const r = opts.retention && typeof opts.retention === 'object' ? opts.retention : {};
- this.pruneJob = retention.schedulePrune(db, { label: `Analytics:${service}`, now: this._now, ...r });
- }
- }
- }
-
- // ── Per-request reducers ──────────────────────────────────
-
- _daySalt(day) {
- if (this._saltDay === day) return this._salt;
- let row = this.db.prepare('SELECT salt FROM analytics_day_salts WHERE service = ? AND day = ?').get(this.service, day);
- if (!row) {
- this.db.prepare('INSERT OR IGNORE INTO analytics_day_salts (service, day, salt) VALUES (?, ?, ?)')
- .run(this.service, day, crypto.randomBytes(32).toString('hex'));
- row = this.db.prepare('SELECT salt FROM analytics_day_salts WHERE service = ? AND day = ?').get(this.service, day);
- }
- // Earlier days' salts are never needed again: their hashes are already written.
- this.db.prepare('DELETE FROM analytics_day_salts WHERE service = ? AND day < ?').run(this.service, day);
- this._saltDay = day;
- this._salt = row.salt;
- this._sessions.clear();
- return this._salt;
- }
-
- _visitorHash(ip, ua, day) {
- return crypto.createHmac('sha256', this._daySalt(day)).update(`${ip}\n${ua}`).digest('hex').slice(0, 16);
- }
-
- _sessionId(vhash, nowMs) {
- let s = this._sessions.get(vhash);
- if (!s || nowMs - s.last > SESSION_IDLE_MS) {
- s = { sid: crypto.randomBytes(8).toString('hex'), last: nowMs };
- this._sessions.set(vhash, s);
- }
- s.last = nowMs;
- return s.sid;
- }
-
- /** Hits from this client in the current and previous minute (memory only). */
- _rateHit(ip, nowMs) {
- const minute = Math.floor(nowMs / 60000);
- let r = this._rates.get(ip);
- if (!r) { r = { minute, count: 0, prev: 0 }; this._rates.set(ip, r); }
- if (r.minute !== minute) {
- r.prev = r.minute === minute - 1 ? r.count : 0;
- r.count = 0;
- r.minute = minute;
- }
- r.count++;
- return r.count + r.prev;
- }
-
- _sweep() {
- const nowMs = this._now();
- const minute = Math.floor(nowMs / 60000);
- for (const [ip, r] of this._rates) if (r.minute < minute - 1) this._rates.delete(ip);
- for (const [h, s] of this._sessions) if (nowMs - s.last > SESSION_IDLE_MS) this._sessions.delete(h);
- if (this._rates.size > MAX_TRACKED_CLIENTS) this._rates.clear();
- if (this._sessions.size > MAX_TRACKED_CLIENTS) this._sessions.clear();
- }
-
- _push(event, visitor) {
- if (this._buffer.length >= MAX_BUFFER) this._buffer.shift();
- this._buffer.push(event);
- if (visitor) {
- const key = `${visitor.hour}|${visitor.vhash}`;
- const prev = this._visitors.get(key);
- if (!prev) this._visitors.set(key, visitor);
- else if (visitor.authenticated) prev.authenticated = true;
- }
- if (this._buffer.length >= 100) this.flush();
- }
-
- /**
- * Record one finished request. `entryPath` is the path as it arrived (req.path inside a mounted
- * router is relative by the time the response finishes). Returns false, recording nothing, for an
- * opted-out request (Sec-GPC: 1 / DNT: 1); true otherwise.
- */
- record(req, res, entryPath, responseTimeMs) {
- const headers = req.headers || {};
- if (privacy.optedOut(headers)) return false;
- entryPath = String(entryPath || '');
- const nowMs = this._now();
- const ua = String(headers['user-agent'] || '');
- const ip = String(req.ip || (req.socket && req.socket.remoteAddress) || '');
- const bot = privacy.classifyRequest({ headers, path: entryPath });
- if (this._rateHit(ip, nowMs) > privacy.HIGH_REQUEST_RATE && !bot.isBot) {
- bot.isBot = true;
- bot.botType = 'rate_limit';
- bot.confidence = 0.80;
- }
- const parsed = privacy.parseUserAgent(ua);
- const day = dayOf(nowMs);
- const vhash = this._visitorHash(ip, ua, day);
- const authenticated = !!(req.user && (req.user.id != null || req.user.sub != null));
- const isApi = entryPath.startsWith('/api/') || entryPath.startsWith('/internal/') || entryPath.startsWith('/oauth/');
- this._push({
- service: this.service,
- event_type: isApi ? 'api_call' : 'pageview',
- path: privacy.routeTemplate(req, entryPath, this._pathOpts),
- method: METHOD_RE.test(String(req.method || '')) ? req.method : null,
- status_code: res.statusCode,
- response_time_ms: responseTimeMs,
- session_id: this._sessionId(vhash, nowMs),
- country: privacy.countryCode(headers['cf-ipcountry']),
- user_agent: privacy.uaClass(ua),
- referer: privacy.refererOrigin(headers.referer || headers.referrer),
- is_bot: bot.isBot,
- bot_type: bot.botType,
- device_type: parsed.device,
- browser: parsed.browser,
- os: parsed.os,
- authenticated,
- created_at: sqlTime(nowMs),
- }, { day, hour: hourOf(nowMs), vhash, authenticated });
- return true;
- }
-
- /**
- * Express middleware: one row per finished request (health checks, static assets and opted-out
- * requests skipped).
- */
- middleware() {
- return (req, res, next) => {
- const entryPath = req.path || '';
- if (isStaticOrHealth(entryPath) || privacy.optedOut(req.headers)) return next();
- const started = Date.now();
- let done = false;
- const finish = () => {
- if (done) return;
- done = true;
- try { this.record(req, res, entryPath, Date.now() - started); }
- catch (err) { console.error(`[Analytics:${this.service}] record error:`, err.message); }
- };
- res.once('finish', finish);
- res.once('close', finish);
- next();
- };
- }
-
- /**
- * A custom (non-HTTP) event. Personal fields in `data` (ip, user_id, city) are ignored; path,
- * referer and user_agent are reduced like a request's; session_id is kept only if it is one of
- * this tracker's rotating ids; any other value outside analytics/event.v1 is stored as NULL, and
- * an event name outside it throws a TypeError. Pass the triggering request's headers as
- * `data.headers` when there is one: an opted-out request (Sec-GPC / DNT) records nothing.
- * Returns whether it recorded.
- */
- trackEvent(eventType, data = {}) {
- const name = String(eventType == null ? 'event' : eventType);
- if (!EVENT_NAME_RE.test(name)) throw new TypeError(`analytics event name must match ${EVENT_NAME_RE} (analytics/event.v1)`);
- if (privacy.optedOut(data.headers)) return false;
- const nowMs = this._now();
- const ua = data.user_agent ? String(data.user_agent) : '';
- const parsed = ua ? privacy.parseUserAgent(ua) : {};
- const method = data.method ? String(data.method).toUpperCase() : null;
- const status = Number(data.status_code);
- const ms = Number(data.response_time_ms);
- const botType = data.bot_type ? String(data.bot_type) : null;
- const pick = (field, given, fallback) => (ALLOWED[field].includes(given) ? given : (fallback || null));
- this._push({
- service: this.service,
- event_type: name,
- path: data.path ? privacy.normalisePath(data.path, this._pathOpts) : null,
- method: method && METHOD_RE.test(method) ? method : null,
- status_code: Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
- response_time_ms: Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : null,
- session_id: SESSION_ID_RE.test(String(data.session_id || '')) ? data.session_id : null,
- country: privacy.countryCode(data.country),
- user_agent: ua ? privacy.uaClass(ua) : null,
- referer: privacy.refererOrigin(data.referer),
- is_bot: !!data.is_bot,
- bot_type: botType && /^[a-z0-9_]{1,32}$/.test(botType) ? botType : null,
- device_type: pick('device', data.device_type, parsed.device),
- browser: pick('browser', data.browser, parsed.browser),
- os: pick('os', data.os, parsed.os),
- authenticated: !!data.authenticated,
- created_at: sqlTime(nowMs),
- }, null);
- return true;
- }
-
- /** Write buffered events and visitor hashes. On a busy database they are kept for the next flush. */
- flush() {
- if (!this._buffer.length && !this._visitors.size) return;
- const events = this._buffer.splice(0);
- const visitors = [...this._visitors.values()];
- this._visitors.clear();
- try {
- this._write(events, visitors);
- } catch (err) {
- console.error(`[Analytics:${this.service}] Flush error:`, err.message);
- this._buffer = events.slice(-MAX_BUFFER);
- for (const v of visitors) this._visitors.set(`${v.hour}|${v.vhash}`, v);
- }
- }
-
- // ── Rollups ───────────────────────────────────────────────
-
- /**
- * Hourly + daily rollups for the last 2 hours / 2 days, plus every earlier day that still has
- * visitor hashes (finalised here), after which those hashes are deleted.
- */
- aggregate() {
- try {
- this.flush();
- const nowMs = this._now();
- const today = dayOf(nowMs);
- const hours = new Set([hourOf(nowMs), hourOf(nowMs - 3600000)]);
- const days = new Set([today, dayOf(nowMs - 86400000)]);
- const pending = this.db.prepare('SELECT DISTINCT day, hour FROM analytics_visitor_days WHERE service = ? AND day < ?').all(this.service, today);
- for (const p of pending) { hours.add(p.hour); days.add(p.day); }
- for (const h of hours) this._aggregateHour(h);
- for (const d of days) this._aggregateDay(d);
- this.db.prepare('DELETE FROM analytics_visitor_days WHERE service = ? AND day < ?').run(this.service, today);
- this.db.prepare('DELETE FROM analytics_day_salts WHERE service = ? AND day < ?').run(this.service, today);
- } catch (err) {
- console.error(`[Analytics:${this.service}] Aggregation error:`, err.message);
- }
- }
-
- _aggregateHour(hourStart) {
- const nextHour = sqlTime(parseSqlTime(hourStart) + 3600000);
- const svc = this.service;
- const stats = this.db.prepare(`
- SELECT
- COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
- COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
- COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
- FROM analytics_events
- WHERE service = ? AND created_at >= ? AND created_at < ?
- `).get(svc, hourStart, nextHour);
- if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) return;
-
- // Uniques only grow within a period, so never lower a stored count (restarts, or rows
- // counted before this tracker when the old one still stored IPs).
- const v = this.db.prepare('SELECT COUNT(*) AS visitors, COALESCE(SUM(authenticated), 0) AS users FROM analytics_visitor_days WHERE service = ? AND hour = ?').get(svc, hourStart);
- const prev = this.db.prepare('SELECT unique_visitors, unique_users FROM analytics_hourly WHERE service = ? AND hour = ?').get(svc, hourStart) || {};
-
- const topPaths = this.db.prepare(`
- SELECT path, COUNT(*) as cnt FROM analytics_events
- WHERE service = ? AND created_at >= ? AND created_at < ? AND is_bot = 0
- GROUP BY path ORDER BY cnt DESC LIMIT 10
- `).all(svc, hourStart, nextHour);
- const topReferers = this.db.prepare(`
- SELECT referer, COUNT(*) as cnt FROM analytics_events
- WHERE service = ? AND created_at >= ? AND created_at < ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0
- GROUP BY referer ORDER BY cnt DESC LIMIT 10
- `).all(svc, hourStart, nextHour);
-
- this.db.prepare(`
- INSERT INTO analytics_hourly
- (service, hour, pageviews, api_calls, unique_visitors, unique_users, bot_hits, avg_response_ms, error_count, top_paths, top_referers)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(service, hour) DO UPDATE SET
- pageviews = excluded.pageviews, api_calls = excluded.api_calls,
- unique_visitors = excluded.unique_visitors, unique_users = excluded.unique_users,
- bot_hits = excluded.bot_hits, avg_response_ms = excluded.avg_response_ms,
- error_count = excluded.error_count, top_paths = excluded.top_paths, top_referers = excluded.top_referers
- `).run(
- svc, hourStart, stats.pageviews, stats.api_calls,
- Math.max(prev.unique_visitors || 0, v.visitors || 0), Math.max(prev.unique_users || 0, v.users || 0),
- stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,
- JSON.stringify(topPaths), JSON.stringify(topReferers),
- );
- }
-
- _aggregateDay(date) {
- const dayStart = date + ' 00:00:00';
- const dayEnd = date + ' 23:59:59';
- const svc = this.service;
- const q = (sql) => this.db.prepare(sql);
- const stats = q(`
- SELECT
- COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
- COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
- COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
- FROM analytics_events
- WHERE service = ? AND created_at >= ? AND created_at <= ?
- `).get(svc, dayStart, dayEnd);
- if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) return;
-
- const v = q(`
- SELECT COUNT(DISTINCT vhash) AS visitors,
- COUNT(DISTINCT CASE WHEN authenticated = 1 THEN vhash END) AS users
- FROM analytics_visitor_days WHERE service = ? AND day = ?
- `).get(svc, date);
- const prev = q('SELECT unique_visitors, unique_users, new_users FROM analytics_daily WHERE service = ? AND date = ?').get(svc, date) || {};
-
- const range = [svc, dayStart, dayEnd];
- const topPaths = q(`SELECT path, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0 GROUP BY path ORDER BY cnt DESC LIMIT 15`).all(...range);
- const topReferers = q(`SELECT referer, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0 GROUP BY referer ORDER BY cnt DESC LIMIT 10`).all(...range);
- const topCountries = q(`SELECT country, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND country IS NOT NULL AND is_bot = 0 GROUP BY country ORDER BY cnt DESC LIMIT 10`).all(...range);
- const devices = q(`SELECT device_type, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0 GROUP BY device_type ORDER BY cnt DESC`).all(...range);
- const browsers = q(`SELECT browser, COUNT(*) as cnt FROM analytics_events WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0 GROUP BY browser ORDER BY cnt DESC`).all(...range);
-
- q(`
- INSERT INTO analytics_daily
- (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, bot_hits,
- avg_response_ms, error_count, top_paths, top_referers, top_countries, device_breakdown, browser_breakdown)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(service, date) DO UPDATE SET
- pageviews = excluded.pageviews, api_calls = excluded.api_calls,
- unique_visitors = excluded.unique_visitors, unique_users = excluded.unique_users,
- new_users = excluded.new_users, bot_hits = excluded.bot_hits,
- avg_response_ms = excluded.avg_response_ms, error_count = excluded.error_count,
- top_paths = excluded.top_paths, top_referers = excluded.top_referers,
- top_countries = excluded.top_countries, device_breakdown = excluded.device_breakdown,
- browser_breakdown = excluded.browser_breakdown
- `).run(
- svc, date, stats.pageviews, stats.api_calls,
- Math.max(prev.unique_visitors || 0, v.visitors || 0), Math.max(prev.unique_users || 0, v.users || 0),
- // New users needs a user id history, which analytics no longer has: keep what was
- // recorded before, NULL (not measured) from now on.
- prev.new_users != null ? prev.new_users : null,
- stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,
- JSON.stringify(topPaths), JSON.stringify(topReferers), JSON.stringify(topCountries),
- JSON.stringify(devices), JSON.stringify(browsers),
- );
- }
-
- // ── Reads (admin dashboards) ─────────────────────────────
- // Same shapes as before. Where the old queries counted distinct IPs or user ids over raw events,
- // these count distinct rotating session ids (all sessions / signed-in sessions); rollup-based
- // numbers (daily/hourly unique_visitors) are true unique visitors per period.
-
- getStats(options = {}) {
- const { days = 30, hours, service } = options;
- const svc = service || this.service;
- const nowMs = this._now();
- const rangeMs = hours ? hours * 3600000 : days * 86400000;
- const rangeCutoff = sqlTime(nowMs - rangeMs);
- const isSubDay = hours && hours < 24;
- const effectiveDays = hours ? hours / 24 : days;
- const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
- const bucketExpr = `strftime('%Y-%m-%d %H:', created_at) || CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT)`;
- const q = (sql) => this.db.prepare(sql);
- const safe = (fn, fallback) => { try { return fn() || fallback; } catch { return fallback; } };
-
- let daily = [];
- if (!isSubDay) {
- daily = q('SELECT * FROM analytics_daily WHERE service = ? AND date >= ? ORDER BY date ASC').all(svc, dayOf(nowMs - rangeMs));
- }
-
- const hourlyLimit = hours ? Math.min(hours, 168) : 24;
- const hourlyCutoff = sqlTime(nowMs - hourlyLimit * 3600000);
- const hourly = q('SELECT * FROM analytics_hourly WHERE service = ? AND hour >= ? ORDER BY hour ASC').all(svc, hourlyCutoff);
-
- let timeBuckets = [];
- if (isSubDay) {
- timeBuckets = q(`
- SELECT ${bucketExpr} as bucket,
- COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS pageviews,
- COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS api_calls,
- COUNT(DISTINCT session_id) FILTER (WHERE is_bot = 0) AS unique_visitors,
- COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
- COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
- FROM analytics_events
- WHERE service = ? AND created_at >= ?
- GROUP BY bucket ORDER BY bucket ASC
- `).all(svc, rangeCutoff);
- }
-
- let summary;
- if (hours && hours < 48) {
- summary = q(`
- SELECT
- COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS total_pageviews,
- COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS total_api_calls,
- COUNT(DISTINCT session_id) FILTER (WHERE is_bot = 0) AS total_unique_visitors,
- COUNT(DISTINCT session_id) FILTER (WHERE is_bot = 0 AND authenticated = 1) AS total_unique_users,
- COUNT(*) FILTER (WHERE is_bot = 1) AS total_bot_hits,
- COUNT(*) FILTER (WHERE status_code >= 400) AS total_errors,
- CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
- FROM analytics_events
- WHERE service = ? AND created_at >= ?
- `).get(svc, rangeCutoff);
- } else {
- summary = q(`
- SELECT
- SUM(pageviews) as total_pageviews,
- SUM(api_calls) as total_api_calls,
- SUM(unique_visitors) as total_unique_visitors,
- SUM(unique_users) as total_unique_users,
- SUM(bot_hits) as total_bot_hits,
- SUM(error_count) as total_errors,
- CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
- FROM analytics_daily
- WHERE service = ? AND date >= ?
- `).get(svc, dayOf(nowMs - rangeMs));
- }
-
- const realtime = q(`
- SELECT COUNT(*) as requests, COUNT(DISTINCT session_id) as visitors, COUNT(*) FILTER (WHERE is_bot = 1) as bots
- FROM analytics_events WHERE service = ? AND created_at >= ?
- `).get(svc, sqlTime(nowMs - 300000));
-
- const human = [svc, rangeCutoff];
- const topPages = q(`
- SELECT path, COUNT(*) as hits, COUNT(DISTINCT session_id) as visitors
- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0
- GROUP BY path ORDER BY hits DESC LIMIT 20
- `).all(...human);
- const topReferers = q(`
- SELECT referer, COUNT(*) as hits FROM analytics_events
- WHERE service = ? AND created_at >= ? AND referer IS NOT NULL AND referer != '' AND is_bot = 0
- GROUP BY referer ORDER BY hits DESC LIMIT 15
- `).all(...human);
- const botBreakdown = q(`
- SELECT bot_type, COUNT(*) as hits FROM analytics_events
- WHERE service = ? AND created_at >= ? AND is_bot = 1
- GROUP BY bot_type ORDER BY hits DESC LIMIT 15
- `).all(...human);
- const statusCodes = q(`
- SELECT CASE
- WHEN status_code >= 200 AND status_code < 300 THEN '2xx'
- WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
- WHEN status_code >= 400 AND status_code < 500 THEN '4xx'
- WHEN status_code >= 500 THEN '5xx'
- ELSE 'other' END as group_code,
- COUNT(*) as cnt
- FROM analytics_events WHERE service = ? AND created_at >= ?
- GROUP BY group_code ORDER BY cnt DESC
- `).all(...human);
- const breakdown = (col) => q(`
- SELECT ${col}, COUNT(*) as cnt FROM analytics_events
- WHERE service = ? AND created_at >= ? AND is_bot = 0
- GROUP BY ${col} ORDER BY cnt DESC
- `).all(...human);
- const deviceBreakdown = breakdown('device_type');
- const browserBreakdown = breakdown('browser');
- const osBreakdown = breakdown('os');
- const countryBreakdown = q(`
- SELECT country, COUNT(*) as cnt FROM analytics_events
- WHERE service = ? AND created_at >= ? AND country IS NOT NULL AND is_bot = 0
- GROUP BY country ORDER BY cnt DESC LIMIT 20
- `).all(...human);
-
- const responsePercentiles = safe(() => {
- const vals = q(`
- SELECT response_time_ms FROM analytics_events
- WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
- ORDER BY response_time_ms ASC
- `).pluck().all(...human);
- if (!vals.length) return {};
- const pct = (p) => vals[Math.min(Math.floor(vals.length * p), vals.length - 1)];
- return { p50: pct(0.5), p90: pct(0.9), p95: pct(0.95), p99: pct(0.99), max: vals[vals.length - 1] };
- }, {});
-
- const bandwidth = safe(() => {
- const bw = q(`
- SELECT COUNT(*) as total_requests,
- COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,
- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests,
- SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END) AS estimated_bytes
- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0
- `).get(...human);
- return bw && {
- total_requests: bw.total_requests || 0,
- page_requests: bw.page_requests || 0,
- api_requests: bw.api_requests || 0,
- estimated_bytes: bw.estimated_bytes || 0,
- };
- }, {});
-
- const authBreakdown = safe(() => q(`
- SELECT
- COUNT(*) FILTER (WHERE authenticated = 1) AS authenticated,
- COUNT(*) FILTER (WHERE authenticated = 0) AS anonymous,
- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS unique_authenticated_users,
- COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions
- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0
- `).get(...human), {});
-
- const errorTrend = safe(() => (isSubDay
- ? q(`
- SELECT ${bucketExpr} as bucket, COUNT(*) AS total,
- COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
- COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors
- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0
- GROUP BY bucket ORDER BY bucket ASC
- `).all(...human)
- : q(`
- SELECT hour as bucket, (pageviews + api_calls) as total, error_count as errors
- FROM analytics_hourly WHERE service = ? AND hour >= ? ORDER BY hour ASC
- `).all(svc, hourlyCutoff)), []);
-
- const peakHours = safe(() => q(`
- SELECT CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day, COUNT(*) AS hits,
- COUNT(DISTINCT session_id) AS visitors, CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms
- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0
- GROUP BY hour_of_day ORDER BY hour_of_day ASC
- `).all(...human), []);
-
- const topApiEndpoints = safe(() => q(`
- SELECT path, method, COUNT(*) as hits, CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,
- COUNT(*) FILTER (WHERE status_code >= 400) AS errors
- FROM analytics_events WHERE service = ? AND created_at >= ? AND event_type = 'api_call' AND is_bot = 0
- GROUP BY path, method ORDER BY hits DESC LIMIT 20
- `).all(...human), []);
-
- const sessionCount = safe(() => q(`
- SELECT COUNT(DISTINCT session_id) FROM analytics_events
- WHERE service = ? AND created_at >= ? AND session_id IS NOT NULL AND is_bot = 0
- `).pluck().get(...human), 0);
-
- // New vs returning needs a visitor identity that outlives a day; analytics keeps none.
- const visitorTypes = { new_visitors: null, returning_visitors: null, not_collected: 'ADR-021' };
-
- const slowestEndpoints = safe(() => q(`
- SELECT path, method, COUNT(*) as hits, CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms, MAX(response_time_ms) AS max_ms
- FROM analytics_events WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
- GROUP BY path, method HAVING hits >= 3 ORDER BY avg_ms DESC LIMIT 10
- `).all(...human), []);
-
- const authTrend = safe(() => q(`
- SELECT ${isSubDay ? bucketExpr : "strftime('%Y-%m-%d', created_at)"} AS bucket,
- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS auth_visitors,
- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 0) AS anon_visitors,
- COUNT(*) FILTER (WHERE authenticated = 1) AS auth_hits,
- COUNT(*) FILTER (WHERE authenticated = 0) AS anon_hits
- FROM analytics_events WHERE service = ? AND created_at >= ? AND is_bot = 0
- GROUP BY bucket ORDER BY bucket ASC
- `).all(...human), []);
-
- return {
- service: svc,
- period_days: effectiveDays,
- period_hours: hours || null,
- summary: summary || {},
- realtime: realtime || {},
- daily,
- hourly,
- timeBuckets,
- topPages,
- topReferers,
- botBreakdown,
- statusCodes,
- deviceBreakdown,
- browserBreakdown,
- osBreakdown,
- countryBreakdown,
- responsePercentiles,
- bandwidth,
- authBreakdown,
- errorTrend,
- peakHours,
- topApiEndpoints,
- sessionCount,
- visitorTypes,
- slowestEndpoints,
- authTrend,
- };
- }
-
- getOverview(days = 30) {
- const cutoff = dayOf(this._now() - days * 86400000);
- const services = this.db.prepare(`
- SELECT service, SUM(pageviews) as pageviews, SUM(api_calls) as api_calls,
- SUM(unique_visitors) as unique_visitors, SUM(unique_users) as unique_users,
- SUM(bot_hits) as bot_hits, SUM(error_count) as errors,
- CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
- FROM analytics_daily WHERE date >= ? GROUP BY service ORDER BY pageviews DESC
- `).all(cutoff);
- const totals = this.db.prepare(`
- SELECT SUM(pageviews) as pageviews, SUM(api_calls) as api_calls, SUM(unique_visitors) as unique_visitors,
- SUM(bot_hits) as bot_hits, SUM(error_count) as errors
- FROM analytics_daily WHERE date >= ?
- `).get(cutoff);
- const dailyTrend = this.db.prepare(`
- SELECT date, SUM(pageviews) as pageviews, SUM(api_calls) as api_calls,
- SUM(unique_visitors) as unique_visitors, SUM(bot_hits) as bot_hits
- FROM analytics_daily WHERE date >= ? GROUP BY date ORDER BY date ASC
- `).all(cutoff);
- const realtime = this.db.prepare(`
- SELECT service, COUNT(*) as requests, COUNT(DISTINCT session_id) as visitors, COUNT(*) FILTER (WHERE is_bot = 1) as bots
- FROM analytics_events WHERE created_at >= ? GROUP BY service
- `).all(sqlTime(this._now() - 300000));
- return { period_days: days, totals: totals || {}, services, dailyTrend, realtime };
- }
-
- /**
- * Bot analysis. There are no IPs any more: the "top bot" rows are user-agent classes
- * (`bot:<family>`), the "suspicious" rows are high-volume rotating sessions. The `ip` field is
- * kept (null) so existing dashboards render; `ua_class` / `session_id` say what the row is.
- */
- getBotAnalysis(days = 30) {
- const since = sqlTime(this._now() - Math.min(Math.max(1, days), 365) * 86400000);
- const topBotIPs = this.db.prepare(`
- SELECT NULL AS ip, user_agent AS ua_class, bot_type, COUNT(*) as hits,
- COUNT(DISTINCT session_id) AS sessions,
- MIN(created_at) as first_seen, MAX(created_at) as last_seen
- FROM analytics_events WHERE is_bot = 1 AND created_at >= ?
- GROUP BY user_agent, bot_type ORDER BY hits DESC LIMIT 25
- `).all(since);
- const botTrend = this.db.prepare(`
- SELECT date, SUM(pageviews) + SUM(api_calls) as human_hits, SUM(bot_hits) as bot_hits
- FROM analytics_daily WHERE date >= ? GROUP BY date ORDER BY date ASC
- `).all(since.slice(0, 10));
- const botTypes = this.db.prepare(`
- SELECT bot_type, COUNT(*) as hits, NULL AS unique_ips, COUNT(DISTINCT session_id) AS unique_sessions
- FROM analytics_events WHERE is_bot = 1 AND created_at >= ?
- GROUP BY bot_type ORDER BY hits DESC
- `).all(since);
- const suspiciousIPs = this.db.prepare(`
- SELECT NULL AS ip, session_id, COUNT(*) as total_hits,
- COUNT(*) FILTER (WHERE status_code >= 400) as error_hits,
- COUNT(DISTINCT path) as unique_paths,
- MIN(created_at) as first_seen, MAX(created_at) as last_seen
- FROM analytics_events
- WHERE created_at >= ? AND is_bot = 0 AND session_id IS NOT NULL
- GROUP BY session_id HAVING total_hits > 500
- ORDER BY total_hits DESC LIMIT 20
- `).all(sqlTime(this._now() - 7 * 86400000));
- return { topBotIPs, botTrend, botTypes, suspiciousIPs };
- }
-
- destroy() {
- this.flush();
- for (const t of this._timers) { clearInterval(t); clearTimeout(t); }
- this._timers = [];
- if (this.pruneJob) { this.pruneJob.stop(); this.pruneJob = null; }
- this._rates.clear();
- this._sessions.clear();
- }
-}
-
-module.exports = { AnalyticsTracker, sqlTime, dayOf, hourOf, SESSION_IDLE_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,215 @@
@@ -0,0 +1,215 @@
+'use strict';
+// Shared validation, redaction and row conversion for the PostgreSQL config store.
+const crypto = require('crypto');
+
+const CLASSES = ['public', 'internal', 'secret'];
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/;
+const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
+const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
+const validKey = (k) => KEY_RE.test(k) && !RESERVED.has(k);
+const SUBJECT_ID = {
+ user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/,
+ app: /^app_[0-9A-HJKMNP-TV-Z]{26}$/, mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,
+ service: SERVICE_RE, system: SERVICE_RE,
+};
+const PREFIXED = { usr_: 'user', gst_: 'guest', app_: 'app', mod_: 'mod' };
+const REASON_MAX = 500;
+const ERROR_MAX = 2000;
+
+class ConfigError extends Error {
+ constructor(code, message, { status = 400, errors = null, revision = null, cause } = {}) {
+ super(message, cause === undefined ? undefined : { cause });
+ this.name = 'ConfigError';
+ this.code = code;
+ this.status = status;
+ if (errors) this.errors = errors;
+ if (revision != null) this.revision = revision;
+ }
+}
+
+// ── JSON helpers ────────────────────────────────────────────────
+
+/** Canonical JSON: object keys sorted at every depth, no whitespace. */
+function canonical(v) {
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
+ return JSON.stringify(v);
+}
+const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
+const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
+const pointer = (k) => `/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`;
+const clone = (v) => JSON.parse(JSON.stringify(v));
+const same = (a, b) => canonical(a) === canonical(b);
+const has = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
+
+function deepFreeze(v) {
+ if (v && typeof v === 'object' && !Object.isFrozen(v)) { Object.freeze(v); for (const x of Object.values(v)) deepFreeze(x); }
+ return v;
+}
+
+/** Only what JSON keeps as it is: no undefined, functions, NaN/Infinity, Dates or class instances. */
+function jsonErrors(v, path, errors, depth = 0) {
+ if (depth > 32) { errors.push({ path, message: 'is nested too deeply' }); return; }
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return;
+ if (typeof v === 'number') { if (!Number.isFinite(v)) errors.push({ path, message: 'must be a finite number' }); return; }
+ if (Array.isArray(v)) { v.forEach((x, i) => jsonErrors(x, `${path}/${i}`, errors, depth + 1)); return; }
+ if (isPlainObject(v)) { for (const [k, x] of Object.entries(v)) jsonErrors(x, `${path}${pointer(k)}`, errors, depth + 1); return; }
+ errors.push({ path, message: 'is not a JSON value' });
+}
+
+const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v).length === 2 && typeof v.fingerprint === 'string';
+/** An object with a redacted member is reserved for markers (common.config-snapshot@1). */
+const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');
+const sameHex = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
+
+// ── A small JSON Schema subset ──────────────────────────────────
+// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,
+// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,
+// multipleOf. Annotations (title, description, default, format, x-…) are ignored. Anything else
+// throws when the store is created, so a schema is never silently checked less than it says.
+
+const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'minProperties', 'maxProperties',
+ 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum',
+ 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);
+const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);
+const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];
+const patterns = new WeakMap();
+
+function compileSchema(s, at = '#') {
+ if (typeof s === 'boolean') return;
+ if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);
+ for (const k of Object.keys(s)) {
+ if (!KEYWORDS.has(k) && !ANNOTATIONS.has(k) && !k.startsWith('x-')) throw new TypeError(`config schema ${at}: unsupported keyword "${k}"`);
+ }
+ if (s.type !== undefined && ![].concat(s.type).every((t) => TYPES.includes(t))) throw new TypeError(`config schema ${at}: unknown type`);
+ if (s.pattern !== undefined) patterns.set(s, new RegExp(s.pattern, 'u'));
+ if (s.properties) for (const [k, sub] of Object.entries(s.properties)) compileSchema(sub, `${at}/properties/${k}`);
+ if (s.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);
+ if (s.items !== undefined) compileSchema(s.items, `${at}/items`);
+}
+
+function typeOf(v) {
+ if (v === null) return 'null';
+ if (Array.isArray(v)) return 'array';
+ return typeof v;
+}
+function isType(v, t) {
+ if (t === 'integer') return Number.isInteger(v);
+ if (t === 'number') return typeof v === 'number' && Number.isFinite(v);
+ return typeOf(v) === t;
+}
+
+/** Messages name the rule, never the value (a value may be a secret). */
+function checkSchema(s, v, path, errors) {
+ if (s === true || s === undefined) return;
+ if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }
+ if (s.type !== undefined) {
+ const types = [].concat(s.type);
+ if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }
+ }
+ if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: `must be one of ${s.enum.map((e) => JSON.stringify(e)).join(', ')}` });
+ if (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });
+ if (typeof v === 'number') {
+ if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });
+ if (s.maximum !== undefined && v > s.maximum) errors.push({ path, message: `must be <= ${s.maximum}` });
+ if (s.exclusiveMinimum !== undefined && v <= s.exclusiveMinimum) errors.push({ path, message: `must be > ${s.exclusiveMinimum}` });
+ if (s.exclusiveMaximum !== undefined && v >= s.exclusiveMaximum) errors.push({ path, message: `must be < ${s.exclusiveMaximum}` });
+ if (s.multipleOf !== undefined && Math.abs(v / s.multipleOf - Math.round(v / s.multipleOf)) > 1e-9) errors.push({ path, message: `must be a multiple of ${s.multipleOf}` });
+ }
+ if (typeof v === 'string') {
+ const len = [...v].length;
+ if (s.minLength !== undefined && len < s.minLength) errors.push({ path, message: `must be at least ${s.minLength} characters` });
+ if (s.maxLength !== undefined && len > s.maxLength) errors.push({ path, message: `must be at most ${s.maxLength} characters` });
+ if (s.pattern !== undefined && !(patterns.get(s) || new RegExp(s.pattern, 'u')).test(v)) errors.push({ path, message: `must match ${s.pattern}` });
+ }
+ if (Array.isArray(v)) {
+ if (s.minItems !== undefined && v.length < s.minItems) errors.push({ path, message: `must have at least ${s.minItems} items` });
+ if (s.maxItems !== undefined && v.length > s.maxItems) errors.push({ path, message: `must have at most ${s.maxItems} items` });
+ if (s.uniqueItems && new Set(v.map(canonical)).size !== v.length) errors.push({ path, message: 'must not repeat items' });
+ if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors));
+ }
+ if (isPlainObject(v)) {
+ const keys = Object.keys(v);
+ for (const r of s.required || []) if (!has(v, r)) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });
+ if (s.minProperties !== undefined && keys.length < s.minProperties) errors.push({ path, message: `must have at least ${s.minProperties} keys` });
+ if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });
+ for (const k of keys) {
+ if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors);
+ else if (s.additionalProperties === false) errors.push({ path: `${path}${pointer(k)}`, message: 'is not a known key' });
+ else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors);
+ }
+ }
+}
+
+/** validate(values) may return true/undefined, false, a message, a list, or { valid, errors } (Ajv/contracts style). */
+function validateResult(r) {
+ const norm = (e) => (typeof e === 'string' ? { path: '', message: e } : { path: String(e.path ?? e.instancePath ?? ''), message: String((e && e.message) || 'is invalid') });
+ if (r === true || r == null) return [];
+ if (r === false) return [{ path: '', message: 'rejected by validate()' }];
+ if (typeof r === 'string') return [{ path: '', message: r }];
+ if (Array.isArray(r)) return r.map(norm);
+ if (typeof r === 'object') {
+ if (r.valid === false || (Array.isArray(r.errors) && r.errors.length)) return (r.errors && r.errors.length ? r.errors : ['is invalid']).map(norm);
+ return [];
+ }
+ return [{ path: '', message: 'rejected by validate()' }];
+}
+
+// ── Subjects and text ───────────────────────────────────────────
+
+/** A common subject reference ({ type, id }), or a prefixed id string (usr_…, gst_…, app_…, mod_…). */
+function subjectRef(actor) {
+ if (typeof actor === 'string') {
+ const type = PREFIXED[actor.slice(0, 4)];
+ if (type && SUBJECT_ID[type].test(actor)) return { type, id: actor };
+ return null;
+ }
+ if (actor && typeof actor === 'object' && SUBJECT_ID[actor.type] && typeof actor.id === 'string' && SUBJECT_ID[actor.type].test(actor.id)) {
+ return { type: actor.type, id: actor.id };
+ }
+ return null;
+}
+
+/** One line, no credentials in URLs or key=value pairs, and never any of the given secret values. */
+function scrub(text, secrets = []) {
+ let s = String(text == null ? '' : text).split('\n')[0];
+ for (const x of secrets) if (x.length >= 4) s = s.split(x).join('[redacted]');
+ s = s.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
+ .replace(/\b(key|token|secret|password|signature)=[^\s&]+/gi, '$1=…');
+ return s.slice(0, ERROR_MAX) || 'failed';
+}
+
+const errText = (err) => (err instanceof Error ? err.message : String(err));
+const summarize = (errors) => errors.map((e) => (e.path ? `${e.path} ${e.message}` : e.message)).join('; ');
+
+function makeLog(log) {
+ const noop = () => {};
+ if (log === false || log === null) return { info: noop, warn: noop, error: noop };
+ if (typeof log === 'function') return { info: (m) => log(m, 'info'), warn: (m) => log(m, 'warn'), error: (m) => log(m, 'error') };
+ const l = log || console;
+ return { info: (m) => (l.info || l.log || noop).call(l, m), warn: (m) => (l.warn || l.log || noop).call(l, m), error: (m) => (l.error || l.warn || noop).call(l, m) };
+}
+
+/**
+ * Typed key-value rows ({ key, value, type }, as site_settings/media_settings keep them) as a values
+ * object: number, boolean ('true'), json (parsed; the raw text if it does not parse), anything else
+ * a string. `prefix` keeps only the keys that start with it and strips it; `type` forces one type.
+ */
+function fromRows(rows, { prefix = '', type = null } = {}) {
+ const out = {};
+ for (const r of rows || []) {
+ if (!r || typeof r.key !== 'string' || !r.key.startsWith(prefix) || RESERVED.has(r.key.slice(prefix.length))) continue;
+ const raw = r.value == null ? '' : String(r.value);
+ const t = type || r.type || 'string';
+ let v = raw;
+ if (t === 'number') v = raw.trim() === '' ? null : Number(raw);
+ else if (t === 'boolean') v = raw === 'true';
+ else if (t === 'json') { try { v = JSON.parse(raw); } catch { v = raw; } }
+ if (typeof v === 'number' && !Number.isFinite(v)) v = raw;
+ out[r.key.slice(prefix.length)] = v;
+ }
+ return out;
+}
+
+module.exports = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows };
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
'use strict';
'use strict';
-// openvibe-shared/config on PostgreSQL (ADR-035): the same store as config.js, on an openvibe-sdk/db handle. Every
+// openvibe-shared/config on PostgreSQL (ADR-035): the configuration store on an openvibe-sdk/db handle. Every
// method that reads or writes the database is async; get() and revision() stay in memory. createConfigStore picks this
// method that reads or writes the database is async; get() and revision() stay in memory. createConfigStore picks this
// implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or
// implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or
// seeded its active revision when it resolves). The tables come from
// seeded its active revision when it resolves). The tables come from
// the service's migrations: configSchema() is their DDL.
// the service's migrations: configSchema() is their DDL.
const crypto = require('crypto');
const crypto = require('crypto');
-const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows } = require('./config')._internal;
+const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows } = require('./config-core');
/** The DDL of config_snapshots and config_keys, for a service's migrations. */
/** The DDL of config_snapshots and config_keys, for a service's migrations. */
function configSchema() {
function configSchema() {
@@ -1,686 +1,12 @@
@@ -1,686 +1,12 @@
'use strict';
'use strict';
-// ═══════════════════════════════════════════════════════════════
-// openvibe-shared/config — the configuration model (roadmap WS-C task 7). One namespace of a
-// service's configuration kept as immutable revisions (common.config-snapshot@1) in the service's
-// own SQLite database: validated, classified, activated atomically, with the last-known-good
-// revision retained and restored when an activation fails.
-//
-// const config = require('openvibe-shared/config');
-// const tiering = config.createConfigStore({
-// db, // the service's better-sqlite3 handle
-// service: 'media', namespace: 'media.storage_tier',
-// schema: { type: 'object', additionalProperties: false, properties: { minAgeDays: { type: 'integer', minimum: 0 } } },
-// validate: (values) => values.minFreeGb < values.targetFreeGb || 'minFreeGb must be below targetFreeGb',
-// classify: { b2_secret: 'secret' }, // or (key, value) => 'public' | 'internal' | 'secret'
-// defaults: DEFAULTS, // revision 1 of a new namespace; also under every revision
-// legacy: () => config.fromRows(rows, { prefix: 'storage_tier.', type: 'json' }), // revision 1 instead
-// onActivate: async (values, previous) => { restartSweep(values); },
-// });
-// tiering.get('minAgeDays'); // in memory, never a DB read
-// await tiering.apply({ minAgeDays: 3 }, { merge: true, actor: { type: 'user', id: 'usr_…' }, reason: '…' });
-// await tiering.rollback({ actor, reason });
-// config.adminRoutes([tiering], { requireAdmin }).mount(app); // /api/admin/config…
-//
-// A revision's values never change. Its state moves proposed -> active -> superseded | rolled_back,
-// or proposed -> rejected. activate() switches the active revision in one transaction, then runs
-// onActivate(values, previous); when that throws, the previous revision is active again (in memory
-// and in the database), the new one is rejected with the error, onActivate runs once more with the
-// restored values ({ restoring: true }), and the error is thrown. get() is the defaults overlaid
-// with the active revision's values. A secret-class value stays in the row (so it can be activated
-// again) but leaves only as { redacted: true, fingerprint }: an HMAC-SHA256 under a random 32-byte
-// key kept per namespace in config_keys, which never leaves the database and is never logged. Equal
-// fingerprints in one namespace mean an unchanged value; without the key nothing can be guessed. A
-// snapshot's checksum is the sha256 of its values as shown, so it reveals nothing more either.
-// ═══════════════════════════════════════════════════════════════
-
-const crypto = require('crypto');
-
-const CLASSES = ['public', 'internal', 'secret'];
-const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
-const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/;
-const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
-const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
-const validKey = (k) => KEY_RE.test(k) && !RESERVED.has(k);
-const SUBJECT_ID = {
- user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/,
- app: /^app_[0-9A-HJKMNP-TV-Z]{26}$/, mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,
- service: SERVICE_RE, system: SERVICE_RE,
-};
-const PREFIXED = { usr_: 'user', gst_: 'guest', app_: 'app', mod_: 'mod' };
-const REASON_MAX = 500;
-const ERROR_MAX = 2000;
-
-class ConfigError extends Error {
- constructor(code, message, { status = 400, errors = null, revision = null, cause } = {}) {
- super(message, cause === undefined ? undefined : { cause });
- this.name = 'ConfigError';
- this.code = code;
- this.status = status;
- if (errors) this.errors = errors;
- if (revision != null) this.revision = revision;
- }
-}
-
-// ── JSON helpers ────────────────────────────────────────────────
-
-/** Canonical JSON: object keys sorted at every depth, no whitespace. */
-function canonical(v) {
- if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
- if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
- return JSON.stringify(v);
-}
-const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
-const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
-const pointer = (k) => `/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`;
-const clone = (v) => JSON.parse(JSON.stringify(v));
-const same = (a, b) => canonical(a) === canonical(b);
-const has = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
-
-function deepFreeze(v) {
- if (v && typeof v === 'object' && !Object.isFrozen(v)) { Object.freeze(v); for (const x of Object.values(v)) deepFreeze(x); }
- return v;
-}
-
-/** Only what JSON keeps as it is: no undefined, functions, NaN/Infinity, Dates or class instances. */
-function jsonErrors(v, path, errors, depth = 0) {
- if (depth > 32) { errors.push({ path, message: 'is nested too deeply' }); return; }
- if (v === null || typeof v === 'string' || typeof v === 'boolean') return;
- if (typeof v === 'number') { if (!Number.isFinite(v)) errors.push({ path, message: 'must be a finite number' }); return; }
- if (Array.isArray(v)) { v.forEach((x, i) => jsonErrors(x, `${path}/${i}`, errors, depth + 1)); return; }
- if (isPlainObject(v)) { for (const [k, x] of Object.entries(v)) jsonErrors(x, `${path}${pointer(k)}`, errors, depth + 1); return; }
- errors.push({ path, message: 'is not a JSON value' });
-}
-
-const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v).length === 2 && typeof v.fingerprint === 'string';
-/** An object with a redacted member is reserved for markers (common.config-snapshot@1). */
-const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');
-const sameHex = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
-
-// ── A small JSON Schema subset ──────────────────────────────────
-// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,
-// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,
-// multipleOf. Annotations (title, description, default, format, x-…) are ignored. Anything else
-// throws when the store is created, so a schema is never silently checked less than it says.
-
-const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'minProperties', 'maxProperties',
- 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum',
- 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);
-const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);
-const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];
-const patterns = new WeakMap();
-
-function compileSchema(s, at = '#') {
- if (typeof s === 'boolean') return;
- if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);
- for (const k of Object.keys(s)) {
- if (!KEYWORDS.has(k) && !ANNOTATIONS.has(k) && !k.startsWith('x-')) throw new TypeError(`config schema ${at}: unsupported keyword "${k}"`);
- }
- if (s.type !== undefined && ![].concat(s.type).every((t) => TYPES.includes(t))) throw new TypeError(`config schema ${at}: unknown type`);
- if (s.pattern !== undefined) patterns.set(s, new RegExp(s.pattern, 'u'));
- if (s.properties) for (const [k, sub] of Object.entries(s.properties)) compileSchema(sub, `${at}/properties/${k}`);
- if (s.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);
- if (s.items !== undefined) compileSchema(s.items, `${at}/items`);
-}
-
-function typeOf(v) {
- if (v === null) return 'null';
- if (Array.isArray(v)) return 'array';
- return typeof v;
-}
-function isType(v, t) {
- if (t === 'integer') return Number.isInteger(v);
- if (t === 'number') return typeof v === 'number' && Number.isFinite(v);
- return typeOf(v) === t;
-}
-
-/** Messages name the rule, never the value (a value may be a secret). */
-function checkSchema(s, v, path, errors) {
- if (s === true || s === undefined) return;
- if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }
- if (s.type !== undefined) {
- const types = [].concat(s.type);
- if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }
- }
- if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: `must be one of ${s.enum.map((e) => JSON.stringify(e)).join(', ')}` });
- if (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });
- if (typeof v === 'number') {
- if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });
- if (s.maximum !== undefined && v > s.maximum) errors.push({ path, message: `must be <= ${s.maximum}` });
- if (s.exclusiveMinimum !== undefined && v <= s.exclusiveMinimum) errors.push({ path, message: `must be > ${s.exclusiveMinimum}` });
- if (s.exclusiveMaximum !== undefined && v >= s.exclusiveMaximum) errors.push({ path, message: `must be < ${s.exclusiveMaximum}` });
- if (s.multipleOf !== undefined && Math.abs(v / s.multipleOf - Math.round(v / s.multipleOf)) > 1e-9) errors.push({ path, message: `must be a multiple of ${s.multipleOf}` });
- }
- if (typeof v === 'string') {
- const len = [...v].length;
- if (s.minLength !== undefined && len < s.minLength) errors.push({ path, message: `must be at least ${s.minLength} characters` });
- if (s.maxLength !== undefined && len > s.maxLength) errors.push({ path, message: `must be at most ${s.maxLength} characters` });
- if (s.pattern !== undefined && !(patterns.get(s) || new RegExp(s.pattern, 'u')).test(v)) errors.push({ path, message: `must match ${s.pattern}` });
- }
- if (Array.isArray(v)) {
- if (s.minItems !== undefined && v.length < s.minItems) errors.push({ path, message: `must have at least ${s.minItems} items` });
- if (s.maxItems !== undefined && v.length > s.maxItems) errors.push({ path, message: `must have at most ${s.maxItems} items` });
- if (s.uniqueItems && new Set(v.map(canonical)).size !== v.length) errors.push({ path, message: 'must not repeat items' });
- if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors));
- }
- if (isPlainObject(v)) {
- const keys = Object.keys(v);
- for (const r of s.required || []) if (!has(v, r)) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });
- if (s.minProperties !== undefined && keys.length < s.minProperties) errors.push({ path, message: `must have at least ${s.minProperties} keys` });
- if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });
- for (const k of keys) {
- if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors);
- else if (s.additionalProperties === false) errors.push({ path: `${path}${pointer(k)}`, message: 'is not a known key' });
- else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors);
- }
- }
-}
-
-/** validate(values) may return true/undefined, false, a message, a list, or { valid, errors } (Ajv/contracts style). */
-function validateResult(r) {
- const norm = (e) => (typeof e === 'string' ? { path: '', message: e } : { path: String(e.path ?? e.instancePath ?? ''), message: String((e && e.message) || 'is invalid') });
- if (r === true || r == null) return [];
- if (r === false) return [{ path: '', message: 'rejected by validate()' }];
- if (typeof r === 'string') return [{ path: '', message: r }];
- if (Array.isArray(r)) return r.map(norm);
- if (typeof r === 'object') {
- if (r.valid === false || (Array.isArray(r.errors) && r.errors.length)) return (r.errors && r.errors.length ? r.errors : ['is invalid']).map(norm);
- return [];
- }
- return [{ path: '', message: 'rejected by validate()' }];
-}
-
-// ── Subjects and text ───────────────────────────────────────────
-
-/** A common subject reference ({ type, id }), or a prefixed id string (usr_…, gst_…, app_…, mod_…). */
-function subjectRef(actor) {
- if (typeof actor === 'string') {
- const type = PREFIXED[actor.slice(0, 4)];
- if (type && SUBJECT_ID[type].test(actor)) return { type, id: actor };
- return null;
- }
- if (actor && typeof actor === 'object' && SUBJECT_ID[actor.type] && typeof actor.id === 'string' && SUBJECT_ID[actor.type].test(actor.id)) {
- return { type: actor.type, id: actor.id };
- }
- return null;
-}
-
-/** One line, no credentials in URLs or key=value pairs, and never any of the given secret values. */
-function scrub(text, secrets = []) {
- let s = String(text == null ? '' : text).split('\n')[0];
- for (const x of secrets) if (x.length >= 4) s = s.split(x).join('[redacted]');
- s = s.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
- .replace(/\b(key|token|secret|password|signature)=[^\s&]+/gi, '$1=…');
- return s.slice(0, ERROR_MAX) || 'failed';
-}
-
-const errText = (err) => (err instanceof Error ? err.message : String(err));
-const summarize = (errors) => errors.map((e) => (e.path ? `${e.path} ${e.message}` : e.message)).join('; ');
-
-function makeLog(log) {
- const noop = () => {};
- if (log === false || log === null) return { info: noop, warn: noop, error: noop };
- if (typeof log === 'function') return { info: (m) => log(m, 'info'), warn: (m) => log(m, 'warn'), error: (m) => log(m, 'error') };
- const l = log || console;
- return { info: (m) => (l.info || l.log || noop).call(l, m), warn: (m) => (l.warn || l.log || noop).call(l, m), error: (m) => (l.error || l.warn || noop).call(l, m) };
-}
-
-// ── Storage ─────────────────────────────────────────────────────
-
-function ensureTables(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS config_snapshots (
- namespace TEXT NOT NULL,
- revision INTEGER NOT NULL,
- service TEXT NOT NULL,
- previous_revision INTEGER,
- state TEXT NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
- values_json TEXT NOT NULL, -- the full values, secrets included (never served)
- classification_json TEXT NOT NULL,
- values_checksum TEXT NOT NULL, -- sha256 of the full values: internal, never served
- created_at TEXT NOT NULL,
- created_by TEXT NOT NULL,
- activated_at TEXT,
- activated_by TEXT,
- reason TEXT,
- error TEXT,
- copied_from INTEGER,
- good INTEGER NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
- PRIMARY KEY (namespace, revision)
- );
- CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
- -- The fingerprint key of each namespace. Never served, never logged.
- CREATE TABLE IF NOT EXISTS config_keys (
- namespace TEXT PRIMARY KEY,
- hmac_key BLOB NOT NULL,
- created_at TEXT NOT NULL
- );
- `);
-}
-
-/**
- * Typed key-value rows ({ key, value, type }, as site_settings/media_settings keep them) as a values
- * object: number, boolean ('true'), json (parsed; the raw text if it does not parse), anything else
- * a string. `prefix` keeps only the keys that start with it and strips it; `type` forces one type.
- */
-function fromRows(rows, { prefix = '', type = null } = {}) {
- const out = {};
- for (const r of rows || []) {
- if (!r || typeof r.key !== 'string' || !r.key.startsWith(prefix) || RESERVED.has(r.key.slice(prefix.length))) continue;
- const raw = r.value == null ? '' : String(r.value);
- const t = type || r.type || 'string';
- let v = raw;
- if (t === 'number') v = raw.trim() === '' ? null : Number(raw);
- else if (t === 'boolean') v = raw === 'true';
- else if (t === 'json') { try { v = JSON.parse(raw); } catch { v = raw; } }
- if (typeof v === 'number' && !Number.isFinite(v)) v = raw;
- out[r.key.slice(prefix.length)] = v;
- }
- return out;
-}
-
-// ── The store ───────────────────────────────────────────────────
+/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */
+const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, makeLog, scrub, errText } = require('./config-core');
function createConfigStore(opts = {}) {
function createConfigStore(opts = {}) {
- // On an openvibe-sdk/db handle (PostgreSQL, ADR-035): config-pg.js, which returns a promise of the store.
- if (opts.db && typeof opts.db.tx === 'function') return require('./config-pg').createPgConfigStore(opts);
- const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;
- if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') throw new TypeError('createConfigStore: db must be a better-sqlite3 handle');
- if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');
- if (!NAMESPACE_RE.test(String(namespace || '')) || !namespace.startsWith(`${service}.`) || namespace.length > 120) {
- throw new TypeError(`createConfigStore: namespace must be <service>.<name>, e.g. ${service}.settings`);
- }
- if (schema !== null) compileSchema(schema);
- if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function');
- if (onActivate !== null && typeof onActivate !== 'function') throw new TypeError('createConfigStore: onActivate must be a function');
- if (classify !== null && typeof classify !== 'function' && !isPlainObject(classify)) throw new TypeError('createConfigStore: classify must be a map or a function');
- if (opts.defaults != null && !isPlainObject(opts.defaults)) throw new TypeError('createConfigStore: defaults must be an object');
- const keep = Math.max(1, Math.floor(opts.keep || 50));
- const maxBytes = opts.maxBytes || 1024 * 1024;
- const log = makeLog(opts.log);
- const now = opts.now || (() => new Date());
- const iso = () => { const d = now(); return (d instanceof Date ? d : new Date(d)).toISOString(); };
- const self = { type: 'service', id: service };
- const defaults = deepFreeze(clone(opts.defaults || {}));
- const tag = `[config] ${namespace}`;
-
- ensureTables(db);
- const q = {
- row: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?'),
- active: db.prepare("SELECT * FROM config_snapshots WHERE namespace = ? AND state = 'active'"),
- any: db.prepare('SELECT 1 AS x FROM config_snapshots WHERE namespace = ? LIMIT 1'),
- max: db.prepare('SELECT COALESCE(MAX(revision), 0) AS n FROM config_snapshots WHERE namespace = ?'),
- lastGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('active', 'superseded', 'rolled_back') ORDER BY revision DESC LIMIT 1"),
- previousGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('superseded', 'rolled_back') AND revision < ? AND values_checksum != ? ORDER BY revision DESC LIMIT 1"),
- history: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision < ? ORDER BY revision DESC LIMIT ?'),
- insert: db.prepare(`INSERT INTO config_snapshots (namespace, revision, service, previous_revision, state, values_json, classification_json,
- values_checksum, created_at, created_by, activated_at, activated_by, reason, copied_from, good)
- VALUES (@namespace, @revision, @service, @previous_revision, @state, @values_json, @classification_json,
- @values_checksum, @created_at, @created_by, @activated_at, @activated_by, @reason, @copied_from, @good)`),
- setState: db.prepare('UPDATE config_snapshots SET state = ? WHERE namespace = ? AND revision = ?'),
- activate: db.prepare("UPDATE config_snapshots SET state = 'active', activated_at = ?, activated_by = ?, good = ? WHERE namespace = ? AND revision = ?"),
- good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'),
- reject: db.prepare("UPDATE config_snapshots SET state = 'rejected', error = ?, activated_at = NULL, activated_by = NULL, good = 0 WHERE namespace = ? AND revision = ?"),
- prune: db.prepare("DELETE FROM config_snapshots WHERE namespace = ? AND revision <= ? AND state != 'active' AND revision != ?"),
- };
- const tx = (fn) => db.transaction(fn).immediate();
-
- // ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ──
- const hmacKey = (() => {
- const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');
- let row = read.get(namespace);
- if (!row) {
- db.prepare('INSERT OR IGNORE INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?)').run(namespace, crypto.randomBytes(32), iso());
- row = read.get(namespace);
- }
- const k = row && Buffer.isBuffer(row.hmac_key) ? Buffer.from(row.hmac_key) : null;
- if (!k || k.length < 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a 32-byte key`);
- return k;
- })();
- const fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex');
- const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) });
-
- // ── classification and redaction ──
- const RANK = { public: 0, internal: 1, secret: 2 };
- function classOf(key, value) {
- let c;
- try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; }
- if (c == null) return 'internal';
- if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave "${String(c).slice(0, 20)}" for ${key}; treating it as secret`); return 'secret'; }
- return c;
- }
- const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOf(k, values[k])]));
- /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */
- function classesFor(values, stored = {}) {
- const out = classifyAll(values);
- for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k];
- return out;
- }
- const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerFor(v) : v]));
- function secretStrings(...valueSets) {
- const out = [];
- for (const values of valueSets) {
- if (!values) continue;
- for (const [k, v] of Object.entries(values)) {
- if (classOf(k, v) !== 'secret' || v == null) continue;
- out.push(typeof v === 'string' ? v : JSON.stringify(v));
- }
- }
- return out.sort((a, b) => b.length - a.length);
- }
-
- function toSnapshot(row) {
- const values = JSON.parse(row.values_json);
- const classification = classesFor(values, JSON.parse(row.classification_json));
- const shown = redact(values, classification);
- return {
- service: row.service,
- namespace: row.namespace,
- revision: row.revision,
- previous_revision: row.previous_revision == null ? null : row.previous_revision,
- state: row.state,
- values: shown,
- classification,
- checksum: sha256(canonical(shown)),
- created_at: row.created_at,
- created_by: JSON.parse(row.created_by),
- activated_at: row.activated_at || null,
- activated_by: row.activated_by ? JSON.parse(row.activated_by) : null,
- reason: row.reason == null ? null : row.reason,
- error: row.error == null ? null : row.error,
- copied_from: row.copied_from == null ? null : row.copied_from,
- };
- }
-
- // ── in-memory state ──
- let current = null; // { revision, values (stored), effective (frozen), classification }
- const emptyEffective = defaults;
- function load(row) {
- const values = JSON.parse(row.values_json);
- return { revision: row.revision, checksum: row.values_checksum, values, effective: deepFreeze({ ...clone(defaults), ...clone(values) }), classification: JSON.parse(row.classification_json) };
- }
- const effectiveNow = () => (current ? current.effective : emptyEffective);
-
- // ── validation ──
- function problems(values) {
- const errors = [];
- if (!isPlainObject(values)) return [{ path: '', message: 'must be an object' }];
- for (const k of Object.keys(values)) if (!validKey(k)) errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' });
- for (const [k, v] of Object.entries(values)) if (looksRedacted(v)) errors.push({ path: pointer(k), message: 'is an object with a redacted member, which only a redaction marker may be' });
- jsonErrors(values, '', errors);
- if (errors.length) return errors;
- const size = Buffer.byteLength(canonical(values));
- if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }];
- const effective = { ...defaults, ...values };
- if (schema !== null) checkSchema(schema, effective, '', errors);
- if (validate) {
- let r;
- try { r = validate(effective); } catch (err) { r = [{ path: '', message: errText(err) }]; }
- if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)');
- errors.push(...validateResult(r));
- }
- const secrets = secretStrings(values, effectiveNow());
- return errors.map((e) => ({ path: e.path, message: scrub(e.message, secrets) }));
+ if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {
+ throw new TypeError('createConfigStore: pass an openvibe-sdk/db handle as db');
}
}
- const invalid = (errors, revision) => new ConfigError('config.invalid', `invalid configuration: ${summarize(errors)}`.slice(0, ERROR_MAX), { status: 422, errors, revision });
-
- /** values (+ merge/unset) as the complete values a new revision would hold. A redaction marker keeps the value it stands for. */
- function resolve(input, { merge = false, unset = [] } = {}) {
- if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]);
- if (!Array.isArray(unset) || !unset.every((k) => typeof k === 'string')) throw invalid([{ path: '', message: 'unset must be a list of keys' }]);
- const base = merge && current ? clone(current.values) : {};
- const errors = [];
- for (const [k, v] of Object.entries(input)) {
- if (!validKey(k)) { errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); continue; }
- if (!isMarker(v)) { base[k] = v; continue; }
- if (current && has(current.values, k) && sameHex(fingerprint(current.values[k]), v.fingerprint)) base[k] = clone(current.values[k]);
- else if (has(defaults, k) && sameHex(fingerprint(defaults[k]), v.fingerprint)) delete base[k];
- else errors.push({ path: pointer(k), message: 'is a redacted value that is not the current one; send the value itself' });
- }
- if (errors.length) throw invalid(errors);
- for (const k of unset) delete base[k];
- return base;
- }
-
- function changedKeys(before, after) {
- const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
- return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort();
- }
- const listKeys = (keys) => (keys.length ? keys.slice(0, 20).join(', ') + (keys.length > 20 ? ` (+${keys.length - 20})` : '') : 'nothing');
- const who = (a) => `${a.type}:${a.id}`;
-
- function actorOf(actor) {
- if (actor == null) return self;
- const ref = subjectRef(actor);
- if (!ref) throw new ConfigError('config.bad_actor', 'actor must be a subject reference ({ type, id })', { status: 400 });
- return ref;
- }
- function reasonOf(reason) {
- if (reason == null || reason === '') return null;
- if (typeof reason !== 'string') throw invalid([{ path: '/reason', message: 'must be a string' }]);
- return reason.slice(0, REASON_MAX);
- }
-
- function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {
- let row;
- tx(() => {
- const revision = q.max.get(namespace).n + 1;
- q.insert.run({
- namespace, revision, service, previous_revision: previous, state,
- values_json: JSON.stringify(values), classification_json: JSON.stringify(classifyAll(values)),
- values_checksum: sha256(canonical(values)), created_at: iso(), created_by: JSON.stringify(actor),
- activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null,
- reason, copied_from: copiedFrom, good,
- });
- row = q.row.get(namespace, revision);
- });
- return row;
- }
-
- function prune() {
- const cutoff = q.max.get(namespace).n - keep;
- if (cutoff < 1) return;
- const good = q.lastGood.get(namespace);
- q.prune.run(namespace, cutoff, good ? good.revision : -1);
- }
-
- // ── boot: load the active revision, recover an interrupted activation, or seed revision 1 ──
- function seed(values, reason, { strict }) {
- if (!strict) {
- // A legacy key the contract cannot carry is left behind, by name, rather than imported.
- const bad = Object.keys(values).filter((k) => !validKey(k) || looksRedacted(values[k]));
- if (bad.length) {
- log.warn(`${tag}: ${bad.length} legacy key(s) are not valid config keys and were not imported: ${bad.slice(0, 5).map((k) => JSON.stringify(k.slice(0, 64))).join(', ')}`);
- values = Object.fromEntries(Object.entries(values).filter(([k]) => validKey(k)));
- }
- }
- const errors = problems(values);
- if (errors.length && strict) throw new ConfigError('config.invalid_defaults', `${namespace} defaults do not validate: ${summarize(errors)}`, { status: 500, errors });
- if (errors.length) log.warn(`${tag}: the imported values do not validate (${summarize(errors).slice(0, 300)}); imported as they are`);
- const row = insertRow(clone(values), { state: 'active', actor: self, reason, previous: null, activatedAt: iso(), good: 1 });
- current = load(row);
- log.info(`${tag}: revision ${row.revision} ${reason} (${Object.keys(values).length} keys)`);
- return toSnapshot(row);
- }
-
- function importLegacy(legacy, { reason = 'imported from the legacy source' } = {}) {
- if (q.any.get(namespace)) return null;
- let v = typeof legacy === 'function' ? legacy() : legacy;
- if (Array.isArray(v)) v = fromRows(v);
- if (v == null) return null;
- if (!isPlainObject(v)) throw new TypeError('import: legacy must give an object or key-value rows');
- return seed(v, reasonOf(reason) || 'imported from the legacy source', { strict: false });
- }
-
- (function boot() {
- let row = q.active.get(namespace);
- if (row && !row.good) {
- // The process stopped between the switch and the end of onActivate: that revision never
- // proved itself, so the last-known-good one is active again.
- const good = q.lastGood.get(namespace);
- tx(() => {
- q.reject.run('activation interrupted: the service stopped before onActivate finished', namespace, row.revision);
- if (good) q.setState.run('active', namespace, good.revision);
- });
- log.warn(`${tag}: revision ${row.revision} was interrupted while activating; ${good ? `revision ${good.revision} restored` : 'no earlier revision to restore'}`);
- row = good ? q.row.get(namespace, good.revision) : null;
- }
- if (row) {
- current = load(row);
- const errors = problems(current.values);
- if (errors.length) log.warn(`${tag}: active revision ${row.revision} does not validate under this release (${summarize(errors).slice(0, 300)}); serving it as it is`);
- return;
- }
- if (q.any.get(namespace)) return;
- if (opts.legacy !== undefined && importLegacy(opts.legacy)) return;
- if (opts.defaults != null) seed(defaults, 'seeded from defaults', { strict: true });
- })();
-
- // ── activation (serialized per store) ──
- let queue = Promise.resolve();
- function serialized(fn) {
- const run = queue.then(fn, fn);
- queue = run.catch(() => {});
- return run;
- }
-
- async function activateNow(revision, { actor, rollingBack = false } = {}) {
- const by = actorOf(actor);
- const row = q.row.get(namespace, revision);
- if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${revision}`, { status: 404, revision });
- if (row.state !== 'proposed') throw new ConfigError('config.not_proposed', `revision ${revision} is ${row.state}, not proposed`, { status: 409, revision });
- const values = JSON.parse(row.values_json);
- const errors = problems(values);
- if (errors.length) {
- q.reject.run(summarize(errors).slice(0, ERROR_MAX), namespace, revision);
- log.warn(`${tag}: revision ${revision} rejected: ${summarize(errors).slice(0, 300)}`);
- throw invalid(errors, revision);
- }
- const prev = current;
- const at = iso();
- tx(() => {
- const dbActive = q.active.get(namespace);
- const activeRev = dbActive ? dbActive.revision : null;
- if ((row.previous_revision == null ? null : row.previous_revision) !== activeRev) {
- throw new ConfigError('config.stale', `revision ${revision} was proposed on ${row.previous_revision ? `revision ${row.previous_revision}` : 'an empty namespace'}, but ${activeRev ? `revision ${activeRev}` : 'nothing'} is active now; propose it again`, { status: 409, revision });
- }
- if (dbActive) q.setState.run(rollingBack ? 'rolled_back' : 'superseded', namespace, dbActive.revision);
- q.activate.run(at, JSON.stringify(by), onActivate ? 0 : 1, namespace, revision);
- });
- current = load(q.row.get(namespace, revision));
- const changed = changedKeys(prev ? prev.effective : emptyEffective, current.effective);
- if (onActivate) {
- const next = current;
- try {
- await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });
- } catch (err) {
- const message = scrub(`onActivate: ${errText(err)}`, secretStrings(next.values, prev && prev.values));
- tx(() => {
- q.reject.run(message, namespace, revision);
- if (prev) q.setState.run('active', namespace, prev.revision);
- });
- current = prev;
- log.warn(`${tag}: revision ${revision} rejected (${message}); ${prev ? `revision ${prev.revision} restored` : 'nothing was active before'}`);
- if (prev) {
- try {
- await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });
- } catch (again) {
- log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), secretStrings(next.values, prev.values))}`);
- }
- }
- throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err });
- }
- q.good.run(namespace, revision);
- }
- prune();
- log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);
- return toSnapshot(q.row.get(namespace, revision));
- }
-
- function propose(values, o = {}) {
- const actor = actorOf(o.actor);
- const reason = reasonOf(o.reason);
- const next = resolve(values, o);
- const errors = problems(next);
- if (errors.length) throw invalid(errors);
- const row = insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });
- prune();
- log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), { ...defaults, ...next }))}`);
- return toSnapshot(row);
- }
-
- function rollbackTarget(to) {
- if (!current) throw new ConfigError('config.nothing_active', `${namespace} has no active revision to roll back`, { status: 409 });
- if (to == null) {
- const r = q.previousGood.get(namespace, current.revision, current.checksum);
- if (!r) throw new ConfigError('config.no_previous', `${namespace} has no earlier good revision to roll back to`, { status: 409 });
- return r.revision;
- }
- const rev = Number(to);
- const row = Number.isInteger(rev) ? q.row.get(namespace, rev) : null;
- if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${to}`, { status: 404, revision: Number.isInteger(rev) ? rev : null });
- if (row.revision === current.revision) throw new ConfigError('config.already_active', `revision ${rev} is the active one`, { status: 409, revision: rev });
- if (!row.good) throw new ConfigError('config.not_good', `revision ${rev} never activated successfully (${row.state})`, { status: 409, revision: rev });
- return rev;
- }
-
- const store = {
- service,
- namespace,
- /** The active values over the defaults (frozen), or one of them. Never reads the database. */
- get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; },
- /** The active revision number, or null. */
- revision() { return current ? current.revision : null; },
- propose,
- activate(revision, o = {}) { return serialized(() => activateNow(Number(revision), { actor: o.actor })); },
- /** propose + activate, in turn with every other activation of this store. */
- apply(values, o = {}) { return serialized(() => activateNow(propose(values, o).revision, { actor: o.actor })); },
- /** A new revision copying the previous good one (or `to`), activated; the one it replaces becomes rolled_back. */
- rollback(o = {}) {
- return serialized(() => {
- const actor = actorOf(o.actor);
- const target = rollbackTarget(o.to);
- const values = JSON.parse(q.row.get(namespace, target).values_json);
- const row = insertRow(values, { state: 'proposed', actor, reason: reasonOf(o.reason) || `rollback to revision ${target}`, previous: current.revision, copiedFrom: target });
- log.info(`${tag}: revision ${row.revision} rolls back to revision ${target} (by ${who(actor)})`);
- return activateNow(row.revision, { actor, rollingBack: true });
- });
- },
- rollbackTarget,
- lastKnownGood() { const r = q.lastGood.get(namespace); return r ? toSnapshot(q.row.get(namespace, r.revision)) : null; },
- snapshot(revision) { const row = q.row.get(namespace, Number(revision)); return row ? toSnapshot(row) : null; },
- history({ limit = 20, before = null } = {}) {
- const n = Math.min(200, Math.max(1, Math.floor(Number(limit)) || 20));
- const b = before == null || before === '' ? Number.MAX_SAFE_INTEGER : Math.floor(Number(before));
- return q.history.all(namespace, Number.isFinite(b) ? b : Number.MAX_SAFE_INTEGER, n).map(toSnapshot);
- },
- /** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */
- changes(values, o = {}) {
- const next = o.revision != null ? JSON.parse((q.row.get(namespace, Number(o.revision)) || { values_json: '{}' }).values_json) : resolve(values, o);
- return changedKeys(effectiveNow(), { ...defaults, ...next });
- },
- /** What /api/admin/config shows: the active revision, its effective values redacted, and the last-known-good. */
- summary() {
- const effective = effectiveNow();
- const classification = classesFor(effective, current ? current.classification : {});
- return {
- service, namespace,
- revision: current ? current.revision : null,
- values: redact(effective, classification),
- classification,
- active: current ? store.snapshot(current.revision) : null,
- last_known_good: store.lastKnownGood(),
- };
- },
- /** Seed revision 1 from a legacy source when the namespace has no revisions yet (else null). */
- import: importLegacy,
- /** Re-read the active revision (another process changed it). onActivate is not run. */
- reload() {
- const row = q.active.get(namespace);
- current = row ? load(row) : null;
- return store.revision();
- },
- };
- return store;
+ return require('./config-pg').createPgConfigStore(opts);
}
}
// ── /api/admin/config ───────────────────────────────────────────
// ── /api/admin/config ───────────────────────────────────────────
@@ -866,5 +192,3 @@ function adminRoutes(stores, o = {}) {
@@ -866,5 +192,3 @@ function adminRoutes(stores, o = {}) {
}
}
module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, configSchema: (...a) => require('./config-pg').configSchema(...a) };
module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, configSchema: (...a) => require('./config-pg').configSchema(...a) };
-// The helpers the PostgreSQL store (config-pg.js) shares with this one.
-module.exports._internal = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows };
@@ -24,7 +24,7 @@ ships as one package, `openvibe-shared`. The facts behind the decision:
@@ -24,7 +24,7 @@ ships as one package, `openvibe-shared`. The facts behind the decision:
requires. Browser pages load only the files they name. A split would not reduce what a site
requires. Browser pages load only the files they name. A split would not reduce what a site
loads.
loads.
- **The package is small.** Its one runtime dependency is `jsonwebtoken`. The analytics module
- **The package is small.** Its one runtime dependency is `jsonwebtoken`. The analytics module
- is dependency-free: the caller passes in `better-sqlite3`.
+ is dependency-free: the caller passes an openvibe-sdk/db handle.
- **There is one release process.** A split means N changelogs and N tags per change, and a
- **There is one release process.** A split means N changelogs and N tags per change, and a
compatibility matrix between them. There is no second team or release cadence to justify
compatibility matrix between them. There is no second team or release cadence to justify
that.
that.
@@ -38,8 +38,8 @@ Keep **one package, `openvibe-shared`, with explicit subpath exports**:
@@ -38,8 +38,8 @@ Keep **one package, `openvibe-shared`, with explicit subpath exports**:
no entry is private. `test/package.test.js` checks that every module file, including
no entry is private. `test/package.test.js` checks that every module file, including
`analytics/*.js`, has an entry and that every entry resolves.
`analytics/*.js`, has an entry and that every entry resolves.
2. **Related modules share a folder and a subpath prefix.** Example: `openvibe-shared/analytics`
2. **Related modules share a folder and a subpath prefix.** Example: `openvibe-shared/analytics`
- plus `analytics/privacy`, `analytics/tracker`, `analytics/retention`, `analytics/schema`,
- `analytics/event`, `analytics/prune-cli` and `analytics/event.v1.json`. Put new families the
+ plus `analytics/privacy`, `analytics/pg`, `analytics/retention`,
+ `analytics/event` and `analytics/event.v1.json`. Put new families the
same way, for example `openvibe-shared/seo/...`. Existing flat subpaths such as `./navbar`
same way, for example `openvibe-shared/seo/...`. Existing flat subpaths such as `./navbar`
and `./legal` stay where they are, because moving them would be a major release.
and `./legal` stay where they are, because moving them would be a major release.
3. **The charter's package names become groups of subpaths.** They are not separate packages.
3. **The charter's package names become groups of subpaths.** They are not separate packages.
@@ -63,7 +63,7 @@ Keep **one package, `openvibe-shared`, with explicit subpath exports**:
@@ -63,7 +63,7 @@ Keep **one package, `openvibe-shared`, with explicit subpath exports**:
Split out one part, as its own repository and tag, only if one of these becomes true:
Split out one part, as its own repository and tag, only if one of these becomes true:
- A module needs a heavy or native runtime dependency that most consumers would not want.
- A module needs a heavy or native runtime dependency that most consumers would not want.
- Example: analytics bundling `better-sqlite3` instead of receiving it from the caller.
+ Example: analytics bundling a database driver instead of receiving a handle from the caller.
- A part gains an outside audience that needs its own semver line. Example: a published
- A part gains an outside audience that needs its own semver line. Example: a published
third-party SDK.
third-party SDK.
- Shared starts publishing to an npm registry. Scoped packages would then cost little, and a
- Shared starts publishing to an npm registry. Scoped packages would then cost little, and a
@@ -5,7 +5,7 @@ const { OpenVibeAuthClient } = require('./auth-client');
@@ -5,7 +5,7 @@ const { OpenVibeAuthClient } = require('./auth-client');
const { CSS_VARIABLES, DEFAULT_VARS, BUILTIN_THEMES, applyTheme, resolveBuiltinTheme, sanitizeCssValue, loadFromStorage, saveToStorage, syncThemeToServer } = require('./theme-sync');
const { CSS_VARIABLES, DEFAULT_VARS, BUILTIN_THEMES, applyTheme, resolveBuiltinTheme, sanitizeCssValue, loadFromStorage, saveToStorage, syncThemeToServer } = require('./theme-sync');
const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth } = require('./middleware');
const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth } = require('./middleware');
const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');
const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');
-const { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');
+const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg, classifyRequest, parseUserAgent, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');
const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
const seo = require('./seo');
const seo = require('./seo');
@@ -38,10 +38,11 @@ module.exports = {
@@ -38,10 +38,11 @@ module.exports = {
createNotification,
createNotification,
DEFAULT_NOTIFICATION_PREFS,
DEFAULT_NOTIFICATION_PREFS,
// Analytics
// Analytics
- AnalyticsTracker,
+ AnalyticsTrackerPg,
+ analyticsSchema,
+ pruneRawEventsPg,
classifyRequest,
classifyRequest,
parseUserAgent,
parseUserAgent,
- ANALYTICS_SCHEMA,
BOT_USER_AGENTS,
BOT_USER_AGENTS,
SUSPICIOUS_PATTERNS,
SUSPICIOUS_PATTERNS,
// URL Registry Resolver
// URL Registry Resolver
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.21.2",
+ "version": "3.0.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.21.2",
+ "version": "3.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -16,7 +16,6 @@
@@ -16,7 +16,6 @@
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
- "better-sqlite3": "^11.10.0",
"express": "^4.22.3",
"express": "^4.22.3",
"linkedom": "^0.18.13",
"linkedom": "^0.18.13",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
@@ -86,61 +85,6 @@
@@ -86,61 +85,6 @@
"dev": true,
"dev": true,
"license": "MIT"
"license": "MIT"
},
},
- "node_modules/base64-js": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/better-sqlite3": {
- "version": "11.10.0",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
- "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "bindings": "^1.5.0",
- "prebuild-install": "^7.1.1"
- }
- },
- "node_modules/bindings": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "file-uri-to-path": "1.0.0"
- }
- },
- "node_modules/bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
"node_modules/body-parser": {
"node_modules/body-parser": {
"version": "1.20.8",
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -180,31 +124,6 @@
@@ -180,31 +124,6 @@
"url": "https://github.com/sponsors/fb55"
"url": "https://github.com/sponsors/fb55"
}
}
},
},
- "node_modules/buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -252,13 +171,6 @@
@@ -252,13 +171,6 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
- "node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -358,32 +270,6 @@
@@ -358,32 +270,6 @@
"dev": true,
"dev": true,
"license": "MIT"
"license": "MIT"
},
},
- "node_modules/decompress-response": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mimic-response": "^3.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/deep-extend": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4.0.0"
- }
- },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -405,16 +291,6 @@
@@ -405,16 +291,6 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/dom-serializer": {
"node_modules/dom-serializer": {
"version": "3.1.1",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
@@ -527,16 +403,6 @@
@@ -527,16 +403,6 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
- "node_modules/end-of-stream": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "once": "^1.4.0"
- }
- },
"node_modules/entities": {
"node_modules/entities": {
"version": "8.1.0",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
@@ -600,16 +466,6 @@
@@ -600,16 +466,6 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
- "node_modules/expand-template": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
- "dev": true,
- "license": "(MIT OR WTFPL)",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/express": {
"node_modules/express": {
"version": "4.22.3",
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
@@ -681,13 +537,6 @@
@@ -681,13 +537,6 @@
],
],
"license": "BSD-3-Clause"
"license": "BSD-3-Clause"
},
},
- "node_modules/file-uri-to-path": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/finalhandler": {
"node_modules/finalhandler": {
"version": "1.3.2",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -727,13 +576,6 @@
@@ -727,13 +576,6 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
- "node_modules/fs-constants": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/function-bind": {
"node_modules/function-bind": {
"version": "1.1.2",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -783,13 +625,6 @@
@@ -783,13 +625,6 @@
"node": ">= 0.4"
"node": ">= 0.4"
}
}
},
},
- "node_modules/github-from-package": {
- "version": "0.0.0",
- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/gopd": {
"node_modules/gopd": {
"version": "1.2.0",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -975,27 +810,6 @@
@@ -975,27 +810,6 @@
"node": ">=0.10.0"
"node": ">=0.10.0"
}
}
},
},
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "BSD-3-Clause"
- },
"node_modules/inherits": {
"node_modules/inherits": {
"version": "2.0.4",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1003,13 +817,6 @@
@@ -1003,13 +817,6 @@
"dev": true,
"dev": true,
"license": "ISC"
"license": "ISC"
},
},
- "node_modules/ini": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1213,49 +1020,12 @@
@@ -1213,49 +1020,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
- "node_modules/mimic-response": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/minimist": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/mkdirp-classic": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/ms": {
"node_modules/ms": {
"version": "2.1.3",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
"license": "MIT"
},
},
- "node_modules/napi-build-utils": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/negotiator": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1266,19 +1036,6 @@
@@ -1266,19 +1036,6 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
- "node_modules/node-abi": {
- "version": "3.96.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
- "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.3.5"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/nth-check": {
"node_modules/nth-check": {
"version": "3.0.1",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
@@ -1322,16 +1079,6 @@
@@ -1322,16 +1079,6 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "wrappy": "1"
- }
- },
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
"version": "0.127.0",
"version": "0.127.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
@@ -1396,34 +1143,6 @@
@@ -1396,34 +1143,6 @@
"dev": true,
"dev": true,
"license": "MIT"
"license": "MIT"
},
},
- "node_modules/prebuild-install": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "detect-libc": "^2.0.0",
- "expand-template": "^2.0.3",
- "github-from-package": "0.0.0",
- "minimist": "^1.2.3",
- "mkdirp-classic": "^0.5.3",
- "napi-build-utils": "^2.0.0",
- "node-abi": "^3.3.0",
- "pump": "^3.0.0",
- "rc": "^1.2.7",
- "simple-get": "^4.0.0",
- "tar-fs": "^2.0.0",
- "tunnel-agent": "^0.6.0"
- },
- "bin": {
- "prebuild-install": "bin.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/proxy-addr": {
"node_modules/proxy-addr": {
"version": "2.0.8",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1442,17 +1161,6 @@
@@ -1442,17 +1161,6 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
- "node_modules/pump": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "end-of-stream": "^1.1.0",
- "once": "^1.3.1"
- }
- },
"node_modules/qs": {
"node_modules/qs": {
"version": "6.16.0",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
@@ -1496,37 +1204,6 @@
@@ -1496,37 +1204,6 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
- "node_modules/rc": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
- "dev": true,
- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
- "dependencies": {
- "deep-extend": "^0.6.0",
- "ini": "~1.3.0",
- "minimist": "^1.2.0",
- "strip-json-comments": "~2.0.1"
- },
- "bin": {
- "rc": "cli.js"
- }
- },
- "node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1700,53 +1377,6 @@
@@ -1700,53 +1377,6 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
- "node_modules/simple-concat": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/simple-get": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "decompress-response": "^6.0.0",
- "once": "^1.3.1",
- "simple-concat": "^1.0.0"
- }
- },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1757,56 +1387,6 @@
@@ -1757,56 +1387,6 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
- "node_modules/string_decoder": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "~5.2.0"
- }
- },
- "node_modules/strip-json-comments": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tar-fs": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
- "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chownr": "^1.1.1",
- "mkdirp-classic": "^0.5.2",
- "pump": "^3.0.0",
- "tar-stream": "^2.1.4"
- }
- },
- "node_modules/tar-stream": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "bl": "^4.0.3",
- "end-of-stream": "^1.4.1",
- "fs-constants": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.1.1"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1817,19 +1397,6 @@
@@ -1817,19 +1397,6 @@
"node": ">=0.6"
"node": ">=0.6"
}
}
},
},
- "node_modules/tunnel-agent": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "safe-buffer": "^5.0.1"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/type-is": {
"node_modules/type-is": {
"version": "1.6.18",
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1861,13 +1428,6 @@
@@ -1861,13 +1428,6 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
- "node_modules/util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/utils-merge": {
"node_modules/utils-merge": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1887,13 +1447,6 @@
@@ -1887,13 +1447,6 @@
"engines": {
"engines": {
"node": ">= 0.8"
"node": ">= 0.8"
}
}
- },
- "node_modules/wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "dev": true,
- "license": "ISC"
}
}
}
}
}
}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.21.2",
+ "version": "3.0.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -14,10 +14,7 @@
@@ -14,10 +14,7 @@
"./analytics/event.v1.json": "./docs/schemas/analytics-event.v1.json",
"./analytics/event.v1.json": "./docs/schemas/analytics-event.v1.json",
"./analytics/privacy": "./analytics/privacy.js",
"./analytics/privacy": "./analytics/privacy.js",
"./analytics/pg": "./analytics/pg.js",
"./analytics/pg": "./analytics/pg.js",
- "./analytics/prune-cli": "./analytics/prune-cli.js",
"./analytics/retention": "./analytics/retention.js",
"./analytics/retention": "./analytics/retention.js",
- "./analytics/schema": "./analytics/schema.js",
- "./analytics/tracker": "./analytics/tracker.js",
"./app-icon": "./app-icon.js",
"./app-icon": "./app-icon.js",
"./assets": "./assets.js",
"./assets": "./assets.js",
"./auth-client": "./auth-client.js",
"./auth-client": "./auth-client.js",
@@ -102,7 +99,6 @@
@@ -102,7 +99,6 @@
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
- "better-sqlite3": "^11.10.0",
"express": "^4.22.3",
"express": "^4.22.3",
"linkedom": "^0.18.13",
"linkedom": "^0.18.13",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
@@ -86,9 +86,8 @@ function ruleA(callee, asyncNamesSet) {
@@ -86,9 +86,8 @@ function ruleA(callee, asyncNamesSet) {
return false;
return false;
}
}
-// asyncDb: the repository's db handle is asynchronous (no better-sqlite3 dependency): only then are db.run/get/all/exec
-// promises. A better-sqlite3 service's db calls are synchronous and never float.
-function ruleB(callee, args, { asyncDb = true } = {}) {
+// Database handles and outboxes return promises.
+function ruleB(callee, args) {
if (callee.type === 'Identifier') return callee.name === 'fetch';
if (callee.type === 'Identifier') return callee.name === 'fetch';
if (callee.type !== 'MemberExpression') return false;
if (callee.type !== 'MemberExpression') return false;
const obj = callee.object;
const obj = callee.object;
@@ -98,11 +97,11 @@ function ruleB(callee, args, { asyncDb = true } = {}) {
@@ -98,11 +97,11 @@ function ruleB(callee, args, { asyncDb = true } = {}) {
if (obj.type === 'MemberExpression' && obj.object.type === 'Identifier' && obj.object.name === 'fs' && memberProp(obj) === 'promises') return true; // fs.promises.*
if (obj.type === 'MemberExpression' && obj.object.type === 'Identifier' && obj.object.name === 'fs' && memberProp(obj) === 'promises') return true; // fs.promises.*
if (prop === 'tx') return true; // <anything>.tx(
if (prop === 'tx') return true; // <anything>.tx(
if (prop === 'transaction' && args.some(isAsyncFn)) return true; // .transaction(async …)
if (prop === 'transaction' && args.some(isAsyncFn)) return true; // .transaction(async …)
- if (asyncDb && (objName === 'db' || objName === 'tx') && DB_METHODS.has(prop)) return true; // db.run/get/all/exec/query, tx.run/get/all/exec
+ if ((objName === 'db' || objName === 'tx') && DB_METHODS.has(prop)) return true; // db.run/get/all/exec/query, tx.run/get/all/exec
if (objName === 'pool' && prop === 'query') return true;
if (objName === 'pool' && prop === 'query') return true;
if (objName === 'client' && prop === 'query') return true;
if (objName === 'client' && prop === 'query') return true;
- if (prop === 'enqueue' && asyncDb) return true; // an outbox on better-sqlite3 enqueues synchronously
- if (prop === 'close' && CLOSE_OBJECTS.has(objName) && (asyncDb || objName !== 'db')) return true; // better-sqlite3's db.close() is synchronous // .close() on db/pool/client/server/store
+ if (prop === 'enqueue') return true; // outbox enqueue
+ if (prop === 'close' && CLOSE_OBJECTS.has(objName)) return true; // .close() on db/pool/client/server/store
return false;
return false;
}
}
@@ -142,15 +141,7 @@ function walkTree(dir) {
@@ -142,15 +141,7 @@ function walkTree(dir) {
const isServerPath = (rel) => { const s = rel.split(path.sep); return s[0] === 'server' || (s[0] === 'apps' && s[2] === 'server'); };
const isServerPath = (rel) => { const s = rel.split(path.sep); return s[0] === 'server' || (s[0] === 'apps' && s[2] === 'server'); };
/** Scan a repository directory. → { findings, allowed, files, errors } */
/** Scan a repository directory. → { findings, allowed, files, errors } */
-/** True when the repository's package.json (or an apps/<app>/package.json) depends on better-sqlite3. */
-function usesSyncSqlite(dir) {
- const has = (p) => { try { const j = JSON.parse(fs.readFileSync(p, 'utf8')); return Boolean((j.dependencies || {})['better-sqlite3']); } catch { return false; } };
- if (has(path.join(dir, 'package.json'))) return true;
- try { return fs.readdirSync(path.join(dir, 'apps')).some((a) => has(path.join(dir, 'apps', a, 'package.json'))); } catch { return false; }
-}
-
function scan(dir = '.') {
function scan(dir = '.') {
- const asyncDb = !usesSyncSqlite(dir);
const acorn = load('acorn');
const acorn = load('acorn');
const walk = load('acorn-walk');
const walk = load('acorn-walk');
const findings = [];
const findings = [];
@@ -176,7 +167,7 @@ function scan(dir = '.') {
@@ -176,7 +167,7 @@ function scan(dir = '.') {
if (prop === 'then' || prop === 'catch' || prop === 'finally') return; // rejection is handled
if (prop === 'then' || prop === 'catch' || prop === 'finally') return; // rejection is handled
let rule = null;
let rule = null;
if (ruleA(callee, names)) rule = 'a';
if (ruleA(callee, names)) rule = 'a';
- else if (ruleB(callee, expr.arguments, { asyncDb })) rule = 'b';
+ else if (ruleB(callee, expr.arguments)) rule = 'b';
if (!rule) return;
if (!rule) return;
const inside = ancestors.some((a) => a !== node && isFn(a));
const inside = ancestors.some((a) => a !== node && isFn(a));
if (!inside && !server) return; // top-level script: allowed to exit with the process
if (!inside && !server) return; // top-level script: allowed to exit with the process
deleted file mode 100644
deleted file mode 100644
@@ -1,166 +0,0 @@
@@ -1,166 +0,0 @@
-'use strict';
-/**
- * The analytics-prune command (analytics/prune-cli.js), as Live, Tools and Network wire it:
- * - a dry run (with and without --scrub) changes nothing;
- * - --apply needs --backup <new file|dir> or --no-backup, refuses an existing target and days > 30;
- * - the backup is verified and owner-only (it can hold pre-ADR IPs), then prune + scrub run and the
- * rollup totals are unchanged;
- * - several databases (Tools' apps) back up into a directory; a service's defaults, extra options and
- * path options (Network) are honoured.
- *
- * node test/analytics-cli.test.js
- */
-const assert = require('assert');
-const crypto = require('crypto');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const Database = require('better-sqlite3');
-const { AnalyticsTracker, retention } = require('openvibe-shared/analytics');
-const { sqlTime } = require('openvibe-shared/analytics/tracker');
-const cli = require('openvibe-shared/analytics/prune-cli');
-
-let failures = 0;
-async function check(name, fn) {
- try { await fn(); console.log(' ✓', name); }
- catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }
-}
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shared-cli-'));
-const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
-const NETWORK = { paramPrefixes: ['avatar', 'anon', 'projects'], pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']] };
-const out = [];
-const log = (l) => out.push(l);
-const main = (argv, extra = {}) => cli.main(argv, { Database, log, ...extra });
-const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
-
-function dumpAll(file) {
- const db = new Database(file, { readonly: true });
- try {
- return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").pluck().all()
- .map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');
- } finally { db.close(); }
-}
-
-/** A legacy database: 10 rows past the cutoff, 3 inside it (edge rows 2 min clear of the CLI's own clock). */
-function legacyDb(file) {
- fs.mkdirSync(path.dirname(file), { recursive: true });
- const db = new Database(file);
- db.pragma('journal_mode = WAL');
- db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT); INSERT INTO users VALUES (7, 'keepme');");
- new AnalyticsTracker(db, 'live', { timers: false }).destroy();
- const nowMs = Date.now() + 120000;
- const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, method, status_code, user_id, session_id, ip, city, user_agent, referer, created_at)
- VALUES ('live', 'pageview', ?, 'GET', 200, ?, ?, ?, ?, ?, ?, ?)`);
- for (let i = 0; i < 10; i++) ins.run(`/@old${i}?x=${i}`, 7, 'tokentail' + i, '198.51.100.' + i, 'Lyon', CHROME, 'https://t.co/x?y=1', sqlTime(nowMs - (40 + i) * 86400000));
- ins.run('/internal/users/by-username/keepme', null, null, '127.0.0.1', null, 'node', '', sqlTime(nowMs - 3 * 86400000));
- ins.run('/avatar/keepme?s=96', 9, 'abcdef0123456789', '198.51.100.52', 'Oslo', CHROME, 'https://www.reddit.com/r/a', sqlTime(nowMs - 2 * 86400000));
- ins.run('/vod/12345?t=9', null, null, '198.51.100.53', null, CHROME, '', sqlTime(nowMs - 1 * 86400000));
- db.prepare('INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, top_paths, top_referers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
- .run('live', '2026-07-01', 100, 40, 30, 10, 2, JSON.stringify([{ path: '/@keepme', cnt: 3 }, { path: '/internal/users/by-username/keepme', cnt: 2 }]), JSON.stringify([{ referer: 'https://www.google.com/search?q=keepme', cnt: 4 }]));
- db.pragma('wal_checkpoint(TRUNCATE)');
- db.close();
- return file;
-}
-
-(async () => {
- await check('argument parsing: defaults, repeatable --db, extra options, refusals', () => {
- const a = cli.parseArgs(['--db', 'a.db', '--db', 'b.db', '--app', 'yt', '--scrub'], { '--app': 'apps' });
- assert.deepStrictEqual([a.dbs, a.db, a.apps, a.scrub, a.apply, a.days, a.batch], [['a.db', 'b.db'], 'a.db', ['yt'], true, false, 30, 5000]);
- assert.throws(() => cli.parseArgs(['--days', '31']), /1 to 30/);
- assert.throws(() => cli.parseArgs(['--batch', '0']), /batch/);
- assert.throws(() => cli.parseArgs(['--backup', 'x', '--no-backup']), /exclusive/);
- assert.throws(() => cli.parseArgs(['--app', 'yt']), /unknown option --app/);
- assert.throws(() => cli.parseArgs(['--db']), /needs a value/);
- assert.deepStrictEqual(cli.namedTargets(['/srv/tools/apps/yt/data/analytics.db', '/x/analytics.db', '/y/analytics.db']).map((t) => t.name), ['yt', 'analytics', 'analytics-2']);
- });
-
- await check('dry run (with and without --scrub) changes nothing; the default database is the service\'s', async () => {
- const file = legacyDb(path.join(tmp, 'dry', 'analytics.db'));
- const before = { hash: sha(file), dump: dumpAll(file) };
- assert.strictEqual(await main([], { defaultDb: file }), 0);
- assert.strictEqual(await main(['--scrub', '--days', '7'], { defaultDb: (args, env) => env.TEST_ANALYTICS_DB, env: { TEST_ANALYTICS_DB: file } }), 0);
- assert.strictEqual(sha(file), before.hash);
- assert.strictEqual(dumpAll(file), before.dump);
- assert.ok(out.some((l) => /dry run: nothing changed/.test(l)));
- assert.ok(out.some((l) => /prune\s+10 rows/.test(l)), out.join('\n'));
- assert.strictEqual(await main([]), 2, 'no database at all');
- assert.strictEqual(await main(['--db', path.join(tmp, 'missing.db')]), 2);
- assert.strictEqual(await main(['--help'], { usage: 'custom usage' }), 0);
- assert.strictEqual(out[out.length - 1], 'custom usage');
- await assert.rejects(cli.main([], {}), /Database/);
- });
-
- await check('--apply refuses without a backup choice, an existing target, or days > 30', async () => {
- const file = legacyDb(path.join(tmp, 'refuse.db'));
- const before = dumpAll(file);
- assert.strictEqual(await main(['--db', file, '--apply']), 2);
- const existing = path.join(tmp, 'exists.bak');
- fs.writeFileSync(existing, 'x');
- assert.strictEqual(await main(['--db', file, '--apply', '--backup', existing]), 2);
- assert.strictEqual(await main(['--db', file, '--apply', '--no-backup', '--days', '31']), 2);
- assert.strictEqual(await main(['--db', file, '--apply', '--no-backup', '--backup', existing]), 2);
- assert.strictEqual(dumpAll(file), before);
- assert.strictEqual(fs.readFileSync(existing, 'utf8'), 'x');
- });
-
- await check('--apply --scrub --backup: owner-only verified backup, prune + scrub, rollups equal, other tables kept', async () => {
- const file = legacyDb(path.join(tmp, 'apply.db'));
- const src = new Database(file, { readonly: true });
- const totals = retention.rollupTotals(src);
- src.close();
- const bak = path.join(tmp, 'apply.backup.db');
- assert.strictEqual(await main(['--db', file, '--apply', '--scrub', '--backup', bak, '--batch', '2'], NETWORK), 0, out.join('\n'));
- assert.strictEqual(fs.statSync(bak).mode & 0o777, 0o600);
- const b = new Database(bak, { readonly: true });
- assert.strictEqual(b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 13, 'backup has every row');
- b.close();
- const db = new Database(file, { readonly: true });
- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY created_at').pluck().all(),
- ['/internal/users/:param/:username', '/avatar/:param', '/vod/:param']);
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events WHERE ip IS NOT NULL OR user_id IS NOT NULL OR city IS NOT NULL').pluck().get(), 0);
- assert.deepStrictEqual(JSON.parse(db.prepare('SELECT top_paths FROM analytics_daily').pluck().get()),
- [{ path: '/@:user', cnt: 3 }, { path: '/internal/users/:param/:username', cnt: 2 }]);
- assert.deepStrictEqual(retention.rollupTotals(db), totals);
- assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = 7').pluck().get(), 'keepme');
- db.close();
- assert.ok(out.some((l) => /mode 0600, quick_check ok/.test(l)));
- // Prune-only with an explicit --no-backup works too (nothing left to prune).
- assert.strictEqual(await main(['--db', file, '--apply', '--no-backup', '--no-vacuum']), 0);
- });
-
- await check('several databases (Tools apps): discovery hook, extra option, backups into a directory', async () => {
- const apps = path.join(tmp, 'apps');
- const yt = legacyDb(path.join(apps, 'yt', 'data', 'analytics.db'));
- const food = legacyDb(path.join(apps, 'food', 'data', 'analytics.db'));
- const tools = {
- options: { '--app': 'apps' },
- targets: (args) => (args.apps.length ? args.apps : ['food', 'yt']).map((name) => ({ name, file: path.join(apps, name, 'data', 'analytics.db') })),
- };
- const hashes = [sha(yt), sha(food)];
- assert.strictEqual(await main(['--scrub'], tools), 0);
- assert.strictEqual(await main(['--app', 'yt'], tools), 0);
- assert.deepStrictEqual([sha(yt), sha(food)], hashes, 'dry run changed a file');
- const bdir = path.join(tmp, 'backups');
- assert.strictEqual(await main(['--apply', '--scrub', '--backup', bdir], tools), 0, out.join('\n'));
- for (const [name, file] of [['yt', yt], ['food', food]]) {
- const bfile = path.join(bdir, `${name}.analytics.db`);
- assert.strictEqual(fs.statSync(bfile).mode & 0o777, 0o600);
- const b = new Database(bfile, { readonly: true });
- assert.strictEqual(b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 13);
- b.close();
- const db = new Database(file, { readonly: true });
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 3);
- assert.strictEqual(db.prepare('SELECT SUM(pageviews) FROM analytics_daily').pluck().get(), 100);
- db.close();
- }
- assert.strictEqual(await main(['--apply', '--backup', bdir], tools), 2, 'existing backup targets are refused');
- const afile = path.join(tmp, 'a-file');
- fs.writeFileSync(afile, 'x');
- assert.strictEqual(await main(['--apply', '--backup', afile], tools), 2, 'several databases need a directory');
- });
-
- fs.rmSync(tmp, { recursive: true, force: true });
- if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
- console.log('\nanalytics prune CLI: all passed');
-})();
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* openvibe-shared/analytics/pg on PGlite: request rows reach the database only at flush; the day's salt is shared
* openvibe-shared/analytics/pg on PGlite: request rows reach the database only at flush; the day's salt is shared
- * across trackers (two processes count one visitor once); rollups and dashboards keep the SQLite tracker's shapes;
+ * across trackers (two processes count one visitor once); rollups and dashboards keep their response shapes;
* opted-out requests record nothing; retention prunes old rows. No IP ever reaches the database.
* opted-out requests record nothing; retention prunes old rows. No IP ever reaches the database.
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -12,16 +12,11 @@
@@ -12,16 +12,11 @@
* node test/analytics-privacy.test.js
* node test/analytics-privacy.test.js
*/
*/
const assert = require('assert');
const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const express = require('express');
-const Database = require('better-sqlite3');
const analytics = require('openvibe-shared/analytics');
const analytics = require('openvibe-shared/analytics');
-const { sqlTime } = require('openvibe-shared/analytics/tracker');
+const { sqlTime } = require('../analytics/core');
const EVENT_V1 = require('openvibe-shared/analytics/event.v1.json');
const EVENT_V1 = require('openvibe-shared/analytics/event.v1.json');
-const { AnalyticsTracker, privacy, event } = analytics;
+const { privacy, event } = analytics;
let failures = 0;
let failures = 0;
async function check(name, fn) {
async function check(name, fn) {
@@ -29,28 +24,11 @@ async function check(name, fn) {
@@ -29,28 +24,11 @@ async function check(name, fn) {
catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }
catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }
}
}
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shared-analytics-'));
-const newDb = (name) => { const db = new Database(path.join(tmp, name)); db.pragma('journal_mode = WAL'); return db; };
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
-const SUBJECT = 'usr_01J8ZQ4K7M2N3P4Q5R6S7T8V9W';
// Network's route shapes, given as options (they were hard-coded in its server/analytics/network.js).
// Network's route shapes, given as options (they were hard-coded in its server/analytics/network.js).
const NETWORK = { paramPrefixes: ['avatar', 'anon', 'projects'], pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']] };
const NETWORK = { paramPrefixes: ['avatar', 'anon', 'projects'], pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']] };
-/** Every value in every table, for "is this anywhere in the file" checks. */
-function dumpAll(db) {
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").pluck().all();
- return tables.map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');
-}
-
-async function serve(app, fn) {
- const server = app.listen(0, '127.0.0.1');
- await new Promise((r) => server.once('listening', r));
- try { return await fn(`http://127.0.0.1:${server.address().port}`); }
- finally { await new Promise((r) => server.close(r)); }
-}
-const settle = () => new Promise((r) => setTimeout(r, 50));
-
(async () => {
(async () => {
// ── Reducers ─────────────────────────────────────────────
// ── Reducers ─────────────────────────────────────────────
await check('normalisePath strips queries and replaces ids, slugs and usernames', () => {
await check('normalisePath strips queries and replaces ids, slugs and usernames', () => {
@@ -151,189 +129,6 @@ const settle = () => new Promise((r) => setTimeout(r, 50));
@@ -151,189 +129,6 @@ const settle = () => new Promise((r) => setTimeout(r, 50));
assert.strictEqual(analytics.optedOut, privacy.optedOut);
assert.strictEqual(analytics.optedOut, privacy.optedOut);
});
});
- // ── A tracked request ────────────────────────────────────
- await check('a tracked request stores no IP, user id, city, raw UA, raw referer or query', async () => {
- const db = newDb('track.db');
- let clock = Date.parse('2026-09-23T10:15:00Z');
- const tracker = new AnalyticsTracker(db, 'live', { timers: false, now: () => clock });
- const app = express();
- app.set('trust proxy', true);
- app.use(tracker.middleware());
- app.use((req, res, next) => { if (req.headers.authorization) req.user = { id: 42, sub: SUBJECT, username: 'alex' }; next(); });
- const router = express.Router();
- router.get('/things/:thingId', (req, res) => res.json({ ok: true }));
- app.use('/api', router);
- app.get('*', (req, res) => res.send('<html></html>'));
- const hdr = (extra) => ({ 'user-agent': CHROME, 'x-forwarded-for': '203.0.113.77', referer: 'https://www.google.com/search?q=secret-query', 'cf-ipcountry': 'NL', 'cf-ipcity': 'Amsterdam', ...extra });
- await serve(app, async (base) => {
- await (await fetch(`${base}/api/things/98765?token=supersecret`, { headers: hdr({ authorization: 'Bearer x' }) })).text();
- await (await fetch(`${base}/@alex/main?stream=12`, { headers: hdr() })).text();
- await (await fetch(`${base}/vod/424242`, { headers: hdr({ 'user-agent': FIREFOX, 'x-forwarded-for': '198.51.100.9' }) })).text();
- await settle();
- });
- tracker.flush();
-
- const rows = db.prepare('SELECT * FROM analytics_events ORDER BY id').all();
- assert.strictEqual(rows.length, 3);
- for (const r of rows) {
- assert.strictEqual(r.ip, null);
- assert.strictEqual(r.user_id, null);
- assert.strictEqual(r.city, null);
- assert.ok(!/[?#]/.test(r.path), r.path);
- assert.strictEqual(r.referer, 'https://www.google.com');
- assert.ok(/^[0-9a-f]{16}$/.test(r.session_id), r.session_id);
- assert.strictEqual(r.country, 'NL');
- assert.deepStrictEqual(event.checkRow(r), [], JSON.stringify(r));
- }
- assert.deepStrictEqual(rows.map((r) => r.path), ['/api/things/:thingId', '/@:user/:param', '/vod/:param']);
- assert.deepStrictEqual(rows.map((r) => r.user_agent), ['chrome/windows/desktop', 'chrome/windows/desktop', 'firefox/linux/desktop']);
- assert.deepStrictEqual(rows.map((r) => r.authenticated), [1, 0, 0]);
- assert.deepStrictEqual(rows.map((r) => r.event_type), ['api_call', 'pageview', 'pageview']);
- assert.strictEqual(rows[0].session_id, rows[1].session_id, 'same visitor, same session');
- assert.notStrictEqual(rows[0].session_id, rows[2].session_id);
-
- const everything = dumpAll(db);
- for (const needle of ['203.0.113.77', '198.51.100.9', '127.0.0.1', SUBJECT, 'Amsterdam', 'supersecret', 'secret-query', '98765', '424242', 'Mozilla/5.0', '"alex"']) {
- assert.ok(!everything.includes(needle), `found ${needle} in analytics.db`);
- }
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);
- assert.ok(!db.prepare("SELECT name FROM sqlite_master WHERE name IN ('idx_analytics_events_ip', 'idx_analytics_events_user')").get());
-
- // Rollups: two distinct (ip, ua) visitors, one of them signed in.
- tracker.aggregate();
- const hourly = db.prepare("SELECT * FROM analytics_hourly WHERE hour = '2026-09-23 10:00:00'").get();
- assert.strictEqual(hourly.pageviews, 2);
- assert.strictEqual(hourly.api_calls, 1);
- assert.strictEqual(hourly.unique_visitors, 2);
- assert.strictEqual(hourly.unique_users, 1);
- const daily = db.prepare("SELECT * FROM analytics_daily WHERE date = '2026-09-23'").get();
- assert.strictEqual(daily.unique_visitors, 2);
- assert.strictEqual(daily.unique_users, 1);
- assert.strictEqual(daily.new_users, null);
- assert.ok(JSON.parse(daily.top_paths).every((p) => !/\d{3,}/.test(p.path)));
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_visitor_days').pluck().get(), 2);
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_day_salts').pluck().get(), 1);
-
- // Next day: the finished day is rolled up for the last time, then its hashes and salt go.
- clock = Date.parse('2026-09-24T00:20:00Z');
- tracker.aggregate();
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_visitor_days').pluck().get(), 0);
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_day_salts').pluck().get(), 0);
- assert.strictEqual(db.prepare("SELECT unique_visitors FROM analytics_daily WHERE date = '2026-09-23'").pluck().get(), 2, 'uniques survive the hashes');
- clock = Date.parse('2026-09-24T01:20:00Z');
- tracker.aggregate();
- assert.strictEqual(db.prepare("SELECT unique_visitors FROM analytics_daily WHERE date = '2026-09-23'").pluck().get(), 2, 'a recompute never lowers uniques');
-
- // The same visitor gets a new session id (and hash) on a new day.
- assert.strictEqual(tracker.record({ headers: { 'user-agent': CHROME }, ip: '203.0.113.77', method: 'GET' }, { statusCode: 200 }, '/', 3), true);
- tracker.flush();
- const last = db.prepare('SELECT session_id FROM analytics_events ORDER BY id DESC LIMIT 1').pluck().get();
- assert.notStrictEqual(last, rows[0].session_id);
-
- // Dashboards keep the pre-ADR shapes.
- const st = tracker.getStats({ days: 30 });
- for (const k of ['summary', 'realtime', 'daily', 'hourly', 'topPages', 'authBreakdown', 'visitorTypes', 'authTrend']) assert.ok(k in st, k);
- assert.ok(tracker.getStats({ hours: 6 }).timeBuckets);
- const bots = tracker.getBotAnalysis(30);
- for (const k of ['topBotIPs', 'botTrend', 'botTypes', 'suspiciousIPs']) assert.ok(Array.isArray(bots[k]), k);
- assert.ok(tracker.getOverview(30).services.length >= 1);
- tracker.destroy();
- db.close();
- });
-
- await check('Sec-GPC: 1 and DNT: 1 requests leave nothing: no row, hash, session or rate counter', async () => {
- const db = newDb('optout.db');
- const clock = Date.parse('2026-09-23T10:15:00Z');
- const tracker = new AnalyticsTracker(db, 'live', { timers: false, now: () => clock });
- const app = express();
- app.set('trust proxy', true);
- app.use(tracker.middleware());
- app.get('*', (req, res) => res.send('ok'));
- const hdr = (extra) => ({ 'user-agent': CHROME, 'x-forwarded-for': '203.0.113.5', 'cf-ipcountry': 'FR', ...extra });
- await serve(app, async (base) => {
- await (await fetch(`${base}/@gpc-user`, { headers: hdr({ 'sec-gpc': '1' }) })).text();
- await (await fetch(`${base}/@dnt-user`, { headers: hdr({ dnt: '1' }) })).text();
- for (let i = 0; i < 70; i++) await (await fetch(`${base}/flood`, { headers: hdr({ 'sec-gpc': '1', 'x-forwarded-for': '203.0.113.6' }) })).text();
- await (await fetch(`${base}/counted`, { headers: hdr({ dnt: '0', 'sec-gpc': '0' }) })).text();
- await settle();
- });
- tracker.flush();
- tracker.aggregate();
- const rows = db.prepare('SELECT * FROM analytics_events').all();
- assert.deepStrictEqual(rows.map((r) => r.path), ['/counted'], 'only the request without an opt-out is recorded');
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_visitor_days').pluck().get(), 1);
- assert.strictEqual(tracker._sessions.size, 1, 'no session for an opted-out visitor');
- assert.ok(!tracker._rates.has('203.0.113.6'), 'no rate counter for an opted-out client');
- const hourly = db.prepare('SELECT pageviews, unique_visitors FROM analytics_hourly').get();
- assert.deepStrictEqual({ ...hourly }, { pageviews: 1, unique_visitors: 1 });
- // Direct calls honour it too.
- assert.strictEqual(tracker.record({ headers: { 'user-agent': CHROME, dnt: '1' }, ip: '192.0.2.9', method: 'GET' }, { statusCode: 200 }, '/x', 1), false);
- assert.strictEqual(tracker.trackEvent('share_clicked', { headers: { 'sec-gpc': '1' }, path: '/@bob' }), false);
- assert.strictEqual(tracker.trackEvent('share_clicked', { headers: { 'user-agent': CHROME }, path: '/@bob' }), true);
- tracker.flush();
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 2);
- const all = dumpAll(db);
- for (const needle of ['gpc-user', 'dnt-user', 'flood', '203.0.113.', '192.0.2.9']) assert.ok(!all.includes(needle), needle);
- tracker.destroy();
- db.close();
- });
-
- await check('Network options on the tracker: unmatched paths use its rules', async () => {
- const db = newDb('network.db');
- const tracker = new AnalyticsTracker(db, 'openvibe-network', { timers: false, ...NETWORK });
- const app = express();
- app.use(tracker.middleware());
- app.get('/avatar/:username', (req, res) => res.send('img'));
- app.use('/internal', (req, res) => res.status(403).end()); // refused before routing
- app.use('/api/auth/anon', (req, res) => res.status(404).end());
- await serve(app, async (base) => {
- for (const p of ['/avatar/alexsecretname?s=96', '/internal/users/by-username/carolsecretname', '/api/auth/anon/0123456789abcdefanon']) {
- await (await fetch(base + p, { headers: { 'user-agent': CHROME } })).arrayBuffer();
- }
- await settle();
- });
- tracker.flush();
- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY id').pluck().all(),
- ['/avatar/:username', '/internal/users/:param/:username', '/api/auth/anon/:param']);
- const all = dumpAll(db);
- for (const needle of ['alexsecretname', 'carolsecretname', '0123456789abcdefanon']) assert.ok(!all.includes(needle), needle);
- tracker.destroy();
- db.close();
- });
-
- await check('rate check keeps IPs in memory only and still flags floods; custom events are reduced', () => {
- const db = newDb('rate.db');
- const clock = Date.parse('2026-09-23T12:00:30Z');
- const tracker = new AnalyticsTracker(db, 'live', { timers: false, now: () => clock });
- for (let i = 0; i < 70; i++) tracker.record({ headers: { 'user-agent': CHROME }, ip: '192.0.2.1', method: 'GET' }, { statusCode: 200 }, '/api/x', 1);
- tracker.flush();
- assert.ok(db.prepare("SELECT COUNT(*) FROM analytics_events WHERE bot_type = 'rate_limit'").pluck().get() >= 10);
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);
- assert.ok(!dumpAll(db).includes('192.0.2.1'));
- tracker.trackEvent('custom', {
- ip: '192.0.2.2', user_id: 7, city: 'Paris', path: '/@bob?x=1', referer: 'https://a.example/x?y=1', user_agent: CHROME,
- session_id: 'user-7', method: 'post', status_code: 999, response_time_ms: -4, browser: 'MyBrowser 1.0', bot_type: 'Weird Type',
- });
- tracker.flush();
- const e = db.prepare("SELECT * FROM analytics_events WHERE event_type = 'custom'").get();
- assert.strictEqual(e.path, '/@:user');
- assert.strictEqual(e.referer, 'https://a.example');
- assert.strictEqual(e.session_id, null);
- assert.strictEqual(e.user_agent, 'chrome/windows/desktop');
- assert.strictEqual(e.method, 'POST');
- assert.strictEqual(e.status_code, null);
- assert.strictEqual(e.response_time_ms, null);
- assert.strictEqual(e.browser, 'chrome');
- assert.strictEqual(e.bot_type, null);
- assert.deepStrictEqual(event.checkRow(e), []);
- assert.ok(!dumpAll(db).includes('Paris') && !dumpAll(db).includes('192.0.2.2'));
- assert.throws(() => tracker.trackEvent('has spaces'), TypeError);
- assert.throws(() => tracker.trackEvent('x'.repeat(65)), TypeError);
- for (const r of db.prepare('SELECT * FROM analytics_events').all()) assert.deepStrictEqual(event.checkRow(r), []);
- tracker.destroy();
- db.close();
- });
-
// ── analytics/event.v1 ───────────────────────────────────
// ── analytics/event.v1 ───────────────────────────────────
await check('event.v1: the schema file is what the package exports, and a pre-ADR row fails it', () => {
await check('event.v1: the schema file is what the package exports, and a pre-ADR row fails it', () => {
assert.strictEqual(event.EVENT_V1, EVENT_V1);
assert.strictEqual(event.EVENT_V1, EVENT_V1);
@@ -374,29 +169,6 @@ const settle = () => new Promise((r) => setTimeout(r, 50));
@@ -374,29 +169,6 @@ const settle = () => new Promise((r) => setTimeout(r, 50));
assert.strictEqual(event.toEvent(legacy).timestamp, '2026-09-23T10:15:00Z');
assert.strictEqual(event.toEvent(legacy).timestamp, '2026-09-23T10:15:00Z');
});
});
- await check('the pre-ADR export names are still there (1.x), backed by the ADR-021 code', () => {
- const root = require('openvibe-shared');
- assert.strictEqual(root.AnalyticsTracker, AnalyticsTracker);
- assert.strictEqual(root.classifyRequest, privacy.classifyRequest);
- assert.strictEqual(root.parseUserAgent, privacy.parseUserAgent);
- assert.strictEqual(root.BOT_USER_AGENTS, privacy.BOT_USER_AGENTS);
- assert.strictEqual(root.ANALYTICS_SCHEMA, analytics.schema.ANALYTICS_SCHEMA);
- assert.strictEqual(root.SUSPICIOUS_PATTERNS.highRequestRate, privacy.HIGH_REQUEST_RATE);
- assert.ok(Object.isFrozen(root.SUSPICIOUS_PATTERNS));
- assert.deepStrictEqual(privacy.classifyRequest({ headers: { 'user-agent': 'curl/8.5.0' } }).botType, 'known_crawler');
- // The old constructor call (db, service) still works; the prune timer is on by default.
- const db = newDb('compat.db');
- const t = new AnalyticsTracker(db, 'live');
- assert.ok(t.pruneJob && typeof t.pruneJob.run === 'function');
- t.destroy();
- assert.strictEqual(t.pruneJob, null);
- const off = new AnalyticsTracker(db, 'live', { retention: false });
- assert.strictEqual(off.pruneJob, null);
- off.destroy();
- db.close();
- });
-
- fs.rmSync(tmp, { recursive: true, force: true });
if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
console.log('\nanalytics privacy: all passed');
console.log('\nanalytics privacy: all passed');
})();
})();
@@ -1,160 +1,11 @@
@@ -1,160 +1,11 @@
'use strict';
'use strict';
-/**
- * ADR-021 retention (analytics/retention.js):
- * - prune deletes strictly-older raw rows in bounded batches and never touches rollups;
- * - schedulePrune (the tracker's timer, or a service's own job) prunes and reports;
- * - scrub rewrites legacy rows and rollup top lists without changing any rollup counter, with a
- * service's path options (Network's by-username rule), and touches no other table.
- *
- * node test/analytics-retention.test.js
- */
+// Backend-independent analytics retention bounds.
const assert = require('assert');
const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const Database = require('better-sqlite3');
-const { AnalyticsTracker, retention, event } = require('openvibe-shared/analytics');
-const { sqlTime } = require('openvibe-shared/analytics/tracker');
-
-let failures = 0;
-async function check(name, fn) {
- try { await fn(); console.log(' ✓', name); }
- catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }
-}
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shared-retention-'));
-const newDb = (name) => { const db = new Database(path.join(tmp, name)); db.pragma('journal_mode = WAL'); return db; };
-const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
-const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
-const NETWORK = { paramPrefixes: ['avatar', 'anon', 'projects'], pathRules: [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']] };
-
-function dumpAll(db) {
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").pluck().all();
- return tables.map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');
-}
-
-/** Rows as the pre-ADR tracker wrote them, around a 30-day cutoff from nowMs. */
-function seedLegacy(db, nowMs) {
- new AnalyticsTracker(db, 'live', { timers: false }).destroy(); // schema
- const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, method, status_code, response_time_ms, user_id, session_id, ip, city, user_agent, referer, is_bot, created_at)
- VALUES ('live', 'pageview', ?, 'GET', 200, 5, ?, ?, ?, ?, ?, ?, 0, ?)`);
- const at = (days, s = 0) => sqlTime(nowMs - days * 86400000 + s * 1000);
- const cutoffMs = nowMs - 30 * 86400000;
- const rows = [];
- for (let i = 0; i < 10; i++) rows.push(['/@old' + i, 5, 'eyJhbGciOi.tokentail' + i, '198.51.100.' + i, 'Lyon', CHROME, 'https://t.co/abc?x=1', at(45 + i)]);
- rows.push(['/@edge-older', 5, null, '198.51.100.50', null, CHROME, '', sqlTime(cutoffMs - 1000)]);
- rows.push(['/@edge-exact', 5, null, '198.51.100.51', null, CHROME, '', sqlTime(cutoffMs)]);
- rows.push(['/@edge-newer', null, null, '198.51.100.52', null, FIREFOX, '', sqlTime(cutoffMs + 1000)]);
- rows.push(['/vod/12345?t=9', 9, 'abcdef0123456789', '198.51.100.53', 'Oslo', FIREFOX, 'https://www.reddit.com/r/x/comments/1', at(2)]);
- for (const r of rows) ins.run(...r);
- const hr = db.prepare('INSERT INTO analytics_hourly (service, hour, pageviews, api_calls, unique_visitors, unique_users, top_paths, top_referers) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
- const dy = db.prepare('INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, top_paths, top_referers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
- const tops = JSON.stringify([{ path: '/@alex', cnt: 3 }, { path: '/@bob', cnt: 2 }, { path: '/vods', cnt: 1 }]);
- const refs = JSON.stringify([{ referer: 'https://www.google.com/search?q=alex', cnt: 4 }, { referer: 'https://www.google.com/', cnt: 1 }]);
- hr.run('live', at(60).slice(0, 13) + ':00:00', 10, 4, 3, 1, tops, refs);
- dy.run('live', at(60).slice(0, 10), 100, 40, 30, 10, 2, tops, refs);
- dy.run('live', at(2).slice(0, 10), 50, 20, 15, 5, 1, tops, refs);
- db.prepare("INSERT INTO analytics_rate_tracking (ip, window_start, hit_count) VALUES ('198.51.100.99', 1, 1)").run();
-}
-
-(async () => {
- await check('prune: strictly older than the cutoff, bounded batches, rollups untouched', async () => {
- const db = newDb('prune.db');
- const nowMs = Date.parse('2026-09-23T12:00:00Z');
- seedLegacy(db, nowMs);
- const totals = retention.rollupTotals(db);
- const bounded = await retention.pruneRawEvents(db, { days: 30, batchSize: 3, maxBatches: 2, now: () => nowMs });
- assert.strictEqual(bounded.deleted, 6);
- assert.strictEqual(bounded.complete, false);
- const out = await retention.pruneRawEvents(db, { days: 30, batchSize: 3, now: () => nowMs });
- assert.strictEqual(out.deleted, 5);
- assert.strictEqual(out.cutoff, '2026-08-24 12:00:00');
- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY created_at').pluck().all(), ['/@edge-exact', '/@edge-newer', '/vod/12345?t=9']);
- assert.deepStrictEqual(retention.rollupTotals(db), totals);
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_daily').pluck().get(), 2, 'old rollups stay');
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);
- assert.strictEqual((await retention.pruneRawEvents(db, { days: 30, now: () => nowMs })).deleted, 0);
- assert.throws(() => retention.cutoffFor(31), /1 to 30/);
- assert.throws(() => retention.cutoffFor(0), /1 to 30/);
- db.close();
- });
-
- await check('schedulePrune: run() prunes and logs, overlapping runs are skipped, days > 30 refused', async () => {
- const db = newDb('job.db');
- const nowMs = Date.parse('2026-09-23T12:00:00Z');
- seedLegacy(db, nowMs);
- const totals = retention.rollupTotals(db);
- const logs = [];
- const job = retention.schedulePrune(db, { initialDelayMs: 1e9, intervalMs: 1e9, label: 'Analytics:test', log: (l) => logs.push(l), now: () => nowMs });
- try {
- const [a, b] = await Promise.all([job.run(), job.run()]);
- assert.strictEqual(a.deleted, 11);
- assert.strictEqual(b, null, 'overlap skipped');
- assert.ok(/^\[Analytics:test\] pruned 11 raw events older than 2026-08-24 12:00:00$/.test(logs[0]), logs.join('\n'));
- } finally { job.stop(); }
- assert.deepStrictEqual(retention.rollupTotals(db), totals);
- assert.throws(() => retention.schedulePrune(db, { days: 90 }), /1 to 30/);
- // The tracker's own timer is the same job.
- const t = new AnalyticsTracker(db, 'live', { retention: { initialDelayMs: 1e9, intervalMs: 1e9, log: () => {} }, now: () => nowMs });
- assert.strictEqual((await t.pruneJob.run()).deleted, 0);
- t.destroy();
- db.close();
- });
-
- await check('scrub: legacy rows and rollup top lists reduced, counters unchanged, rows now event.v1', async () => {
- const db = newDb('scrub.db');
- const nowMs = Date.parse('2026-09-23T12:00:00Z');
- seedLegacy(db, nowMs);
- await retention.pruneRawEvents(db, { days: 30, now: () => nowMs });
- const totals = retention.rollupTotals(db);
- const s = await retention.scrubEvents(db, { batchSize: 2 });
- assert.strictEqual(s.rows, 3);
- assert.deepStrictEqual(retention.scrubRollups(db), { hourly: 1, daily: 2 });
- assert.deepStrictEqual(retention.rollupTotals(db), totals);
- const rows = db.prepare('SELECT * FROM analytics_events ORDER BY created_at').all();
- assert.deepStrictEqual(rows.map((x) => x.path), ['/@:user', '/@:user', '/vod/:param']);
- assert.deepStrictEqual(rows.map((x) => x.authenticated), [1, 0, 1]);
- assert.deepStrictEqual(rows.map((x) => x.session_id), [null, null, 'abcdef0123456789']);
- assert.deepStrictEqual(rows.map((x) => x.referer), ['', '', 'https://www.reddit.com']);
- assert.deepStrictEqual(rows.map((x) => x.user_agent), ['chrome/windows/desktop', 'firefox/linux/desktop', 'firefox/linux/desktop']);
- for (const x of rows) assert.deepStrictEqual(event.checkRow(x), [], JSON.stringify(x));
- const daily = db.prepare('SELECT top_paths, top_referers FROM analytics_daily ORDER BY date LIMIT 1').get();
- assert.deepStrictEqual(JSON.parse(daily.top_paths), [{ path: '/@:user', cnt: 5 }, { path: '/vods', cnt: 1 }]);
- assert.deepStrictEqual(JSON.parse(daily.top_referers), [{ referer: 'https://www.google.com', cnt: 5 }]);
- const all = dumpAll(db);
- for (const needle of ['198.51.100.', 'Lyon', 'Oslo', 'eyJhbGciOi', 'q=alex', '@alex', '/vod/12345', 'Mozilla/5.0']) assert.ok(!all.includes(needle), needle);
- await retention.scrubEvents(db);
- assert.deepStrictEqual(retention.scrubRollups(db), { hourly: 0, daily: 0 }, 'idempotent');
- assert.deepStrictEqual(retention.rollupTotals(db), totals);
- db.close();
- });
-
- await check('scrub with Network path options; other tables in the same database untouched', async () => {
- const db = newDb('network-scrub.db');
- db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT); INSERT INTO users VALUES (7, 'keepme');");
- new AnalyticsTracker(db, 'openvibe-network', { timers: false }).destroy();
- const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, user_id, ip, user_agent, created_at) VALUES ('openvibe-network', 'pageview', ?, 7, '127.0.0.1', 'node', ?)`);
- const now = sqlTime(Date.now());
- for (const p of ['/avatar/keepme?s=1', '/api/auth/anon/tok0X9zz99', '/internal/users/by-username/keepme']) ins.run(p, now);
- db.prepare("INSERT INTO analytics_daily (service, date, pageviews, top_paths) VALUES ('openvibe-network', '2026-07-01', 5, ?)")
- .run(JSON.stringify([{ path: '/internal/users/by-username/keepme', cnt: 2 }, { path: '/avatar/keepme', cnt: 3 }]));
- const before = retention.inspect(db, NETWORK);
- assert.strictEqual(before.toScrub.paths, 3);
- await retention.scrubEvents(db, NETWORK);
- retention.scrubRollups(db, NETWORK);
- assert.deepStrictEqual(db.prepare('SELECT path FROM analytics_events ORDER BY id').pluck().all(),
- ['/avatar/:param', '/api/auth/anon/:param', '/internal/users/:param/:username']);
- assert.deepStrictEqual(JSON.parse(db.prepare('SELECT top_paths FROM analytics_daily').pluck().get()),
- [{ path: '/avatar/:param', cnt: 3 }, { path: '/internal/users/:param/:username', cnt: 2 }]);
- assert.strictEqual(retention.inspect(db, NETWORK).toScrub.paths, 0);
- assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = 7').pluck().get(), 'keepme');
- const analyticsOnly = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'analytics_%'").pluck().all()
- .map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');
- for (const needle of ['keepme', 'X9zz99', '127.0.0.1']) assert.ok(!analyticsOnly.includes(needle), needle);
- db.close();
- });
-
- fs.rmSync(tmp, { recursive: true, force: true });
- if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
- console.log('\nanalytics retention: all passed');
-})();
+const { retention } = require('openvibe-shared/analytics');
+
+assert.strictEqual(retention.MAX_DAYS, 30);
+assert.strictEqual(retention.DEFAULT_BATCH, 5000);
+assert.strictEqual(retention.checkDays('30'), 30);
+assert.strictEqual(retention.cutoffFor(30, Date.parse('2026-09-23T12:00:00Z')), '2026-08-24 12:00:00');
+for (const days of [0, 31, 1.5, 'x']) assert.throws(() => retention.checkDays(days), /1 to 30/);
+console.log('analytics-retention: all checks passed');
@@ -81,7 +81,7 @@ function counting(db) {
@@ -81,7 +81,7 @@ function counting(db) {
await db.query(configSchema());
await db.query(configSchema());
// ── Construction ──
// ── Construction ──
- await assert.rejects(async () => await createConfigStore({}), /better-sqlite3/);
+ await assert.rejects(async () => await createConfigStore({}), /openvibe-sdk\/db/);
await assert.rejects(async () => await createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);
await assert.rejects(async () => await createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);
await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);
await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);
await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);
await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);
deleted file mode 100644
deleted file mode 100644
@@ -1,635 +0,0 @@
@@ -1,635 +0,0 @@
-'use strict';
-// config.js: validation, classification and redaction, atomic activation with last-known-good
-// restored when onActivate throws (memory and database), rollback as a new revision, legacy import,
-// two namespaces in one database, restart and an interrupted activation, serialized applies,
-// pruning that keeps the last-known-good, and /api/admin/config on Express and on plain http.
-const assert = require('assert');
-const crypto = require('crypto');
-const http = require('http');
-const Database = require('better-sqlite3');
-const express = require('express');
-const { createConfigStore, adminRoutes, fromRows, canonical, ConfigError } = require('../config');
-
-let contracts = null;
-try { contracts = require('openvibe-contracts'); } catch { /* optional */ }
-const hasSnapshotContract = (() => { try { return !!(contracts && contracts.resolve('common.config-snapshot@1')); } catch { return false; } })();
-
-const USER = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
-const SECRET = 'sk_live_TOPSECRET_9f8e7d6c5b4a';
-const sha = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');
-/** The namespace's fingerprint key, read the way only the service itself can, and a fingerprint under it. */
-const keyOf = (db, ns) => db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?').get(ns).hmac_key;
-const fp = (db, ns, v) => crypto.createHmac('sha256', keyOf(db, ns)).update(canonical(v)).digest('hex');
-/** Every form a key could leak in, for every namespace in a database, and the unkeyed hash of SECRET. */
-const leakForms = (db) => db.prepare('SELECT hmac_key FROM config_keys').all()
- .flatMap((r) => [r.hmac_key.toString('hex'), r.hmac_key.toString('base64'), r.hmac_key.toString('base64url')])
- .concat([sha(SECRET)]);
-const assertNoLeak = (text, forms, what) => { for (const f of forms) assert.ok(!String(text).includes(f), `${what} leaks a key or an unkeyed hash`); };
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-let t = Date.parse('2026-09-25T12:00:00.000Z');
-const clock = () => new Date(t += 1000);
-const logs = [];
-const log = (m, level) => logs.push(`${level} ${m}`);
-
-/** The common.config-snapshot@1 shape, checked here and by the contract when the pinned copy has it. */
-function assertSnapshot(s) {
- const keys = ['service', 'namespace', 'revision', 'previous_revision', 'state', 'values', 'classification', 'checksum', 'created_at', 'created_by', 'activated_at', 'activated_by', 'reason', 'error', 'copied_from'];
- assert.deepStrictEqual(Object.keys(s).sort(), [...keys].sort());
- assert.ok(['proposed', 'active', 'superseded', 'rejected', 'rolled_back'].includes(s.state), s.state);
- assert.ok(Number.isInteger(s.revision) && s.revision >= 1);
- assert.match(s.checksum, /^[0-9a-f]{64}$/);
- assert.deepStrictEqual(Object.keys(s.values).sort(), Object.keys(s.classification).sort(), 'every key classified');
- for (const [k, v] of Object.entries(s.values)) {
- const marker = !!(v && typeof v === 'object' && v.redacted === true);
- assert.strictEqual(marker, s.classification[k] === 'secret', `${k} redacted exactly when secret`);
- if (marker) { assert.deepStrictEqual(Object.keys(v).sort(), ['fingerprint', 'redacted']); assert.match(v.fingerprint, /^[0-9a-f]{64}$/); }
- }
- assert.strictEqual(s.checksum, sha(s.values), 'the checksum covers the values as shown');
- if (s.state === 'rejected') assert.ok(s.error, 'a rejected revision names its error');
- if (['active', 'superseded', 'rolled_back'].includes(s.state)) assert.ok(s.activated_at, `${s.state} has activated_at`);
- if (hasSnapshotContract) {
- const r = contracts.validate('common.config-snapshot@1', s);
- assert.ok(r.valid, JSON.stringify(r.errors));
- }
- return s;
-}
-const assertProblem = (body, status, code) => {
- assert.strictEqual(body.status, status);
- assert.strictEqual(body.code, code);
- assert.strictEqual(body.type, `https://openvibe.network/problems/${code}`);
- if (contracts) assert.ok(contracts.validate('errors.problem@1', body).valid, JSON.stringify(body));
-};
-
-/** A handle that counts statement executions, to prove get() never touches the database. */
-function counting(db) {
- const c = { n: 0 };
- return {
- c,
- exec: (s) => db.exec(s),
- transaction: (fn) => db.transaction(fn),
- prepare(sql) {
- const st = db.prepare(sql);
- return { get: (...a) => (c.n++, st.get(...a)), all: (...a) => (c.n++, st.all(...a)), run: (...a) => (c.n++, st.run(...a)) };
- },
- };
-}
-const rowOf = (db, ns, rev) => db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?').get(ns, rev);
-
-(async () => {
- const db = new Database(':memory:');
-
- // ── Construction ──
- assert.throws(() => createConfigStore({}), /better-sqlite3/);
- assert.throws(() => createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);
- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);
- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);
- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { oneOf: [] } }), /unsupported keyword "oneOf"/);
- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { type: 'object', properties: { a: { $ref: '#' } } } }), /unsupported keyword "\$ref"/);
- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', classify: 'secret' }), /classify/);
- assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.bad_defaults', schema: { type: 'object', properties: { a: { type: 'string' } } }, defaults: { a: 1 }, log }),
- (e) => e.code === 'config.invalid_defaults');
-
- // ── Seeded from defaults ──
- const SCHEMA = {
- $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'Live site settings',
- type: 'object', additionalProperties: false, required: ['site_name'],
- properties: {
- site_name: { type: 'string', minLength: 1, maxLength: 60, description: 'shown in the navbar' },
- max_bitrate_kbps: { type: 'integer', minimum: 500, maximum: 20000 },
- mode: { enum: ['open', 'invite', 'closed'] },
- support_email: { type: 'string', pattern: '^[^@\\s]+@[^@\\s]+$', format: 'email' },
- youtube_api_key: { type: 'string' },
- tags: { type: 'array', items: { type: 'string' }, maxItems: 3, uniqueItems: true },
- banner: { type: ['object', 'null'], additionalProperties: false, properties: { text: { type: 'string' } } },
- },
- };
- const DEFAULTS = { site_name: 'OpenVibe.Live', max_bitrate_kbps: 6000, mode: 'open', youtube_api_key: '' };
- const classify = (k) => (/api_key|secret|token/.test(k) ? 'secret' : ['site_name', 'mode'].includes(k) ? 'public' : undefined);
- let activations = [];
- let failNext = null;
- let tighten = false;
- const liveOpts = {
- db, service: 'live', namespace: 'live.site_settings', schema: SCHEMA, classify, defaults: DEFAULTS, log, now: clock,
- validate: (v) => {
- if (v.mode === 'closed' && v.max_bitrate_kbps > 10000) return [{ path: '/max_bitrate_kbps', message: 'must be at most 10000 while closed' }];
- if (v.youtube_api_key && !/^[A-Za-z0-9_-]+$/.test(v.youtube_api_key)) return `youtube_api_key ${v.youtube_api_key} has characters keys never have`;
- if (tighten && v.max_bitrate_kbps < 7500) return 'max_bitrate_kbps is below the new floor';
- return true;
- },
- onActivate: async (values, previous, info) => {
- activations.push({ values, previous, info });
- await sleep(1);
- if (failNext && !info.restoring) { const e = failNext; failNext = null; throw e; }
- },
- };
- const counted = counting(db);
- const live = createConfigStore({ ...liveOpts, db: counted });
- assert.strictEqual(live.revision(), 1);
- assert.deepStrictEqual(live.get(), DEFAULTS);
- assert.strictEqual(live.get('mode'), 'open');
- assert.ok(Object.isFrozen(live.get()));
- assert.throws(() => { live.get().mode = 'closed'; }, TypeError);
- const seeded = assertSnapshot(live.lastKnownGood());
- assert.strictEqual(seeded.revision, 1);
- assert.strictEqual(seeded.state, 'active');
- assert.strictEqual(seeded.previous_revision, null);
- assert.strictEqual(seeded.reason, 'seeded from defaults');
- assert.deepStrictEqual(seeded.created_by, { type: 'service', id: 'live' });
- assert.strictEqual(activations.length, 0, 'seeding does not run onActivate');
- const reads = counted.c.n;
- for (let i = 0; i < 1000; i++) { live.get(); live.get('mode'); live.revision(); }
- assert.strictEqual(counted.c.n, reads, 'get() and revision() never read the database');
-
- // ── Validation rejects, and nothing is stored ──
- const before = live.history({ limit: 200 }).length;
- const cases = [
- [{ ...DEFAULTS, max_bitrate_kbps: 100 }, '/max_bitrate_kbps'],
- [{ ...DEFAULTS, max_bitrate_kbps: 6000.5 }, '/max_bitrate_kbps'],
- [{ ...DEFAULTS, max_bitrate_kbps: NaN }, '/max_bitrate_kbps'],
- [{ ...DEFAULTS, mode: 'party' }, '/mode'],
- [{ ...DEFAULTS, support_email: 'nope' }, '/support_email'],
- [{ ...DEFAULTS, unknown_key: 1 }, '/unknown_key'],
- [{ ...DEFAULTS, site_name: '' }, '/site_name'],
- [{ ...DEFAULTS, tags: ['a', 'a'] }, '/tags'],
- [{ ...DEFAULTS, tags: ['a', 1] }, '/tags/1'],
- [{ ...DEFAULTS, banner: { text: 'x', extra: true } }, '/banner/extra'],
- [{ ...DEFAULTS, mode: 'closed', max_bitrate_kbps: 15000 }, '/max_bitrate_kbps'],
- [{ ...DEFAULTS, 'bad key': 1 }, '/bad key'],
- [{ ...DEFAULTS, when: new Date() }, '/when'],
- ];
- for (const [values, path] of cases) {
- const ok = (e) => e instanceof ConfigError && e.code === 'config.invalid' && e.status === 422 && e.errors.some((x) => x.path === path);
- assert.throws(() => live.propose(values, { actor: USER }), ok, `${path} is refused`);
- await assert.rejects(live.apply(values, { actor: USER }), ok);
- }
- assert.throws(() => live.propose('nope'), (e) => e.code === 'config.invalid');
- const required = createConfigStore({ db, service: 'live', namespace: 'live.required', schema: { type: 'object', required: ['a'] }, log });
- assert.strictEqual(required.revision(), null, 'no defaults and no legacy: nothing active');
- assert.deepStrictEqual(required.get(), {});
- assert.throws(() => required.propose({ b: 1 }), (e) => e.errors[0].path === '/a' && e.errors[0].message === 'is required');
- assert.strictEqual(live.history({ limit: 200 }).length, before, 'a refused proposal is not stored');
- assert.strictEqual(live.revision(), 1);
- // Messages name the rule, never the value; a validate() that echoes a secret is scrubbed.
- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: 12345678 }), (e) => !JSON.stringify(e.errors).includes('12345678') && !e.message.includes('12345678'));
- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: `${SECRET} !` }), (e) => !e.message.includes(SECRET) && e.message.includes('[redacted]'));
- assert.throws(() => live.propose(DEFAULTS, { actor: { type: 'user', id: '42' } }), (e) => e.code === 'config.bad_actor');
-
- // ── Propose, then activate ──
- const v2 = { ...DEFAULTS, max_bitrate_kbps: 8000, youtube_api_key: SECRET };
- const p = assertSnapshot(live.propose(v2, { actor: USER.id, reason: 'raise the cap' }));
- assert.strictEqual(p.state, 'proposed');
- assert.strictEqual(p.revision, 2);
- assert.strictEqual(p.previous_revision, 1);
- assert.strictEqual(p.activated_at, null);
- assert.deepStrictEqual(p.created_by, USER, 'a usr_ id string is a user subject');
- assert.deepStrictEqual(p.values.youtube_api_key, { redacted: true, fingerprint: fp(db, 'live.site_settings', SECRET) });
- assert.strictEqual(p.classification.youtube_api_key, 'secret');
- assert.strictEqual(p.classification.site_name, 'public');
- assert.strictEqual(p.classification.max_bitrate_kbps, 'internal', 'an unclassified key is internal');
- assert.notStrictEqual(p.checksum, sha(v2), 'an unkeyed checksum of the full values is never served');
- assert.ok(!JSON.stringify(p).includes(sha(SECRET)), 'nor an unkeyed hash of the secret');
- assert.strictEqual(live.get('max_bitrate_kbps'), 6000, 'a proposal changes nothing');
- const a = assertSnapshot(await live.activate(2, { actor: USER }));
- assert.strictEqual(a.state, 'active');
- assert.deepStrictEqual(a.activated_by, USER);
- assert.strictEqual(live.revision(), 2);
- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the service itself gets the real value');
- assert.strictEqual(activations.length, 1);
- assert.strictEqual(activations[0].values.max_bitrate_kbps, 8000);
- assert.strictEqual(activations[0].previous.max_bitrate_kbps, 6000);
- assert.deepStrictEqual(activations[0].info, { namespace: 'live.site_settings', revision: 2, previous_revision: 1, restoring: false });
- assert.strictEqual(live.snapshot(1).state, 'superseded');
- assert.strictEqual(live.lastKnownGood().revision, 2);
- assert.ok(rowOf(db, 'live.site_settings', 2).values_json.includes(SECRET), 'the row keeps the secret so it can be activated again');
- await assert.rejects(live.activate(2), (e) => e.code === 'config.not_proposed' && e.status === 409);
- await assert.rejects(live.activate(99), (e) => e.code === 'config.revision_not_found' && e.status === 404);
- // A proposal made on an older active revision is stale.
- const stale = live.propose({ mode: 'invite' }, { merge: true, actor: USER });
- await live.apply({ mode: 'closed' }, { merge: true, actor: USER, reason: 'maintenance' });
- await assert.rejects(live.activate(stale.revision, { actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);
- assert.strictEqual(live.snapshot(stale.revision).state, 'proposed', 'a stale proposal is left as it was');
- const goodRev = live.revision();
- const goodValues = live.get();
- assert.deepStrictEqual({ mode: goodValues.mode, max: goodValues.max_bitrate_kbps, key: goodValues.youtube_api_key }, { mode: 'closed', max: 8000, key: SECRET }, 'merge keeps the other keys');
-
- // ── A throwing onActivate restores the last-known-good, in memory and in the database ──
- activations = [];
- failNext = new Error(`bucket refused key ${SECRET}`);
- let thrown;
- await assert.rejects(live.apply({ max_bitrate_kbps: 9000, youtube_api_key: `${SECRET}x` }, { merge: true, actor: USER }), (e) => { thrown = e; return true; });
- assert.ok(thrown instanceof ConfigError);
- assert.strictEqual(thrown.code, 'config.activation_failed');
- assert.strictEqual(thrown.status, 422);
- assert.ok(thrown.message.includes('bucket refused key [redacted]') && !thrown.message.includes(SECRET), thrown.message);
- assert.ok(thrown.cause.message.includes('bucket refused'), 'the original error is the cause');
- const failedRev = thrown.revision;
- assert.strictEqual(failedRev, goodRev + 1);
- assert.strictEqual(live.revision(), goodRev, 'memory: the previous revision is active again');
- assert.strictEqual(live.get(), goodValues, 'memory: the same values object as before');
- const failedRow = rowOf(db, 'live.site_settings', failedRev);
- assert.strictEqual(failedRow.state, 'rejected', 'database: the new revision is rejected');
- assert.strictEqual(failedRow.activated_at, null);
- assert.ok(failedRow.error.startsWith('onActivate: bucket refused key [redacted]') && !failedRow.error.includes(SECRET));
- assert.strictEqual(rowOf(db, 'live.site_settings', goodRev).state, 'active', 'database: the previous revision is active again');
- assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM config_snapshots WHERE namespace = 'live.site_settings' AND state = 'active'").get().n, 1);
- assert.strictEqual(activations.length, 2, 'onActivate ran for the new values, then again to restore');
- assert.strictEqual(activations[1].info.restoring, true);
- assert.strictEqual(activations[1].values, goodValues);
- assert.strictEqual(activations[1].previous.max_bitrate_kbps, 9000);
- assert.strictEqual(live.lastKnownGood().revision, goodRev);
- assertSnapshot(live.snapshot(failedRev));
- const restarted = createConfigStore({ ...liveOpts, db });
- assert.strictEqual(restarted.revision(), goodRev, 'a restart after the failure serves the last-known-good');
-
- // Validation that fails at activation time (the rules changed since the proposal) rejects it too.
- const p2 = live.propose({ max_bitrate_kbps: 7000 }, { merge: true, actor: USER });
- tighten = true;
- await assert.rejects(live.activate(p2.revision, { actor: USER }), (e) => e.code === 'config.invalid' && e.revision === p2.revision);
- tighten = false;
- assert.strictEqual(live.snapshot(p2.revision).state, 'rejected');
- assert.match(live.snapshot(p2.revision).error, /below the new floor/);
- assert.strictEqual(live.revision(), goodRev);
-
- // ── Rollback: a new revision copying an older good one; history is never rewritten ──
- const goodSnap = live.snapshot(goodRev);
- const lowered = await live.apply({ max_bitrate_kbps: 5000 }, { merge: true, actor: USER, reason: 'lower' });
- const r = assertSnapshot(await live.rollback({ actor: USER, reason: 'too low' }));
- assert.strictEqual(r.copied_from, goodRev);
- assert.strictEqual(r.previous_revision, lowered.revision);
- assert.strictEqual(r.reason, 'too low');
- assert.strictEqual(r.checksum, goodSnap.checksum);
- assert.strictEqual(live.snapshot(lowered.revision).state, 'rolled_back');
- assert.strictEqual(live.get('max_bitrate_kbps'), 8000);
- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the secret comes back from the row');
- assert.deepStrictEqual({ ...live.snapshot(goodRev), state: null }, { ...goodSnap, state: null }, 'the copied revision is untouched (only its state moved)');
- assert.strictEqual(live.snapshot(goodRev).state, 'superseded');
- const again = await live.rollback({ actor: USER });
- assert.strictEqual(again.copied_from, lowered.revision, 'rolling back a rollback undoes it');
- assert.strictEqual(again.reason, `rollback to revision ${lowered.revision}`);
- assert.strictEqual(live.get('max_bitrate_kbps'), 5000);
- await live.rollback({ actor: USER, to: 1 });
- assert.deepStrictEqual(live.get(), DEFAULTS);
- await assert.rejects(live.rollback({ to: live.revision() }), (e) => e.code === 'config.already_active' && e.status === 409);
- await assert.rejects(live.rollback({ to: failedRev }), (e) => e.code === 'config.not_good' && e.status === 409);
- await assert.rejects(live.rollback({ to: 999 }), (e) => e.code === 'config.revision_not_found' && e.status === 404);
- failNext = new Error('nope');
- const beforeFailedRollback = live.revision();
- await assert.rejects(live.rollback({ actor: USER, to: goodRev }), (e) => e.code === 'config.activation_failed');
- assert.strictEqual(live.revision(), beforeFailedRollback);
- assert.strictEqual(live.snapshot(beforeFailedRollback).state, 'active', 'a failed rollback puts the rolled_back revision back');
- const fresh = createConfigStore({ db, service: 'live', namespace: 'live.fresh', defaults: { a: 1 }, log });
- await assert.rejects(fresh.rollback(), (e) => e.code === 'config.no_previous');
- await assert.rejects(required.rollback(), (e) => e.code === 'config.nothing_active');
-
- // ── Secrets: redacted everywhere they leave, kept by a redacted round trip ──
- await live.rollback({ actor: USER, to: goodRev });
- const summary = live.summary();
- assert.deepStrictEqual(summary.values.youtube_api_key, { redacted: true, fingerprint: fp(db, 'live.site_settings', SECRET) });
- assert.strictEqual(summary.values.youtube_api_key.fingerprint, crypto.createHmac('sha256', keyOf(db, 'live.site_settings')).update(JSON.stringify(SECRET)).digest('hex'), 'HMAC-SHA256 of the canonical JSON under the stored key');
- assertSnapshot(summary.active);
- assertSnapshot(summary.last_known_good);
- const roundTrip = await live.apply({ ...summary.values, max_bitrate_kbps: 7777 }, { actor: USER });
- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'sending the marker back keeps the secret');
- assert.strictEqual(roundTrip.values.youtube_api_key.fingerprint, summary.values.youtube_api_key.fingerprint, 'an unchanged value keeps its fingerprint');
- const refusedMarker = (e) => e.code === 'config.invalid' && e.errors[0].path === '/youtube_api_key';
- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: fp(db, 'live.site_settings', 'other') } }), refusedMarker, 'a fingerprint of another value is refused');
- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: sha(SECRET) } }), refusedMarker, 'an unkeyed hash of the right value is refused');
- assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, sha256: sha(SECRET) } }), refusedMarker, 'the old { redacted, sha256 } shape is refused');
- assert.throws(() => live.propose({ ...DEFAULTS, banner: { redacted: false, text: 'x' } }), (e) => e.errors.some((x) => x.path === '/banner'), 'a redacted member is reserved for markers');
- const history = live.history({ limit: 200 });
- const keyFps = new Set(history.filter((h) => h.state !== 'rejected' && live.snapshot(h.revision)).map((h) => h.values.youtube_api_key.fingerprint));
- assert.ok(keyFps.has(fp(db, 'live.site_settings', SECRET)) && keyFps.has(fp(db, 'live.site_settings', '')), 'fingerprints differ exactly when the value does');
- for (const s of live.history({ limit: 200 })) assertSnapshot(s);
- assert.ok(!JSON.stringify(live.history({ limit: 200 })).includes(SECRET));
- assert.ok(!JSON.stringify(live.summary()).includes(SECRET));
- // A key classified secret later is redacted in the revisions stored before.
- const flags = createConfigStore({ db, service: 'live', namespace: 'live.flags', defaults: {}, log });
- await flags.apply({ webhook_url: 'https://hooks.example/T0K3N' }, { actor: USER });
- assert.strictEqual(flags.history()[0].values.webhook_url, 'https://hooks.example/T0K3N');
- const flags2 = createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });
- assert.ok(flags2.history().every((s) => !JSON.stringify(s).includes('T0K3N')));
- assert.strictEqual(flags2.history()[0].classification.webhook_url, 'secret');
- // A lost key is replaced on the next start: fingerprints change and older markers stop round-tripping.
- const oldMarker = flags2.summary().values.webhook_url;
- db.prepare("DELETE FROM config_keys WHERE namespace = 'live.flags'").run();
- const flags3 = createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });
- assert.notDeepStrictEqual(flags3.summary().values.webhook_url, oldMarker);
- assert.throws(() => flags3.propose({ webhook_url: oldMarker }), (e) => e.code === 'config.invalid');
- assert.strictEqual(flags3.propose({ webhook_url: flags3.summary().values.webhook_url }).values.webhook_url.fingerprint, flags3.summary().values.webhook_url.fingerprint);
-
- // ── Two stores (and two services) in one database ──
- const TIER_SCHEMA = { type: 'object', additionalProperties: false, properties: { minAgeDays: { type: 'integer', minimum: 0 }, r2Enabled: { type: 'boolean' }, b2_key: { type: 'string' } } };
- const media = createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });
- const liveRev = live.revision();
- assert.strictEqual(media.revision(), 1, 'each namespace numbers its own revisions');
- await media.apply({ minAgeDays: 3, b2_key: SECRET }, { merge: true, actor: USER });
- assert.strictEqual(media.revision(), 2);
- assert.strictEqual(live.revision(), liveRev, 'the other namespace is untouched');
- assert.ok(media.history().every((s) => s.namespace === 'media.storage_tier' && s.service === 'media'));
- assert.ok(live.history({ limit: 200 }).every((s) => s.namespace === 'live.site_settings'));
- // The same secret in two namespaces: different keys, different fingerprints, and one's marker is refused by the other.
- assert.strictEqual(live.get('youtube_api_key'), media.get('b2_key'));
- assert.notStrictEqual(live.summary().values.youtube_api_key.fingerprint, media.summary().values.b2_key.fingerprint);
- assert.throws(() => live.propose({ youtube_api_key: media.summary().values.b2_key }, { merge: true }), (e) => e.code === 'config.invalid');
- const keyRows = db.prepare('SELECT namespace, hmac_key FROM config_keys').all();
- assert.ok(keyRows.length >= 5 && keyRows.every((k) => Buffer.isBuffer(k.hmac_key) && k.hmac_key.length === 32), 'a random 32-byte key per namespace');
- assert.strictEqual(new Set(keyRows.map((k) => k.hmac_key.toString('hex'))).size, keyRows.length, 'no two namespaces share a key');
-
- // ── Restart: a new store on the same database serves the same active values ──
- const live2 = createConfigStore({ ...liveOpts, db });
- assert.deepStrictEqual(live2.get(), live.get());
- assert.strictEqual(live2.revision(), live.revision());
- assert.strictEqual(live2.lastKnownGood().revision, live.lastKnownGood().revision);
- assert.deepStrictEqual(live2.summary(), live.summary(), 'the key survives a restart: same fingerprints, same checksums');
- assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM config_keys WHERE namespace = 'live.site_settings'").get().n, 1);
- const media2 = createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });
- assert.deepStrictEqual(media2.get(), { minAgeDays: 3, r2Enabled: true, b2_key: SECRET });
- // A release that adds a default serves it at once, without a new revision.
- const media3 = createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true, r2MinViews: 20 }, log });
- assert.strictEqual(media3.get('r2MinViews'), 20);
- assert.strictEqual(media3.revision(), 2);
- assert.deepStrictEqual(media3.changes({ minAgeDays: 4 }, { merge: true }), ['minAgeDays']);
- await media3.apply({}, { merge: true, unset: ['minAgeDays'], actor: USER });
- assert.strictEqual(media3.get('minAgeDays'), 7, 'an unset key falls back to its default');
- // Another store (another process) moved the namespace: this one's copy is stale until reload().
- await assert.rejects(media.apply({ minAgeDays: 6 }, { merge: true, actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);
- assert.strictEqual(media.get('minAgeDays'), 3);
- assert.strictEqual(media.reload(), media3.revision());
- assert.strictEqual(media.get('minAgeDays'), 7);
-
- // An activation interrupted by a stop (onActivate never finished) is rejected at the next boot,
- // and pruning while it hangs keeps the last-known-good even outside the window.
- const hanging = createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, keep: 2, log, onActivate: () => new Promise(() => {}) });
- hanging.apply({ n: 1 }, { actor: USER }).catch(() => {});
- await sleep(5);
- assert.strictEqual(rowOf(db, 'live.hang', 2).state, 'active');
- assert.strictEqual(hanging.lastKnownGood().revision, 1, 'not good until onActivate finished');
- for (let i = 0; i < 3; i++) hanging.propose({ n: 10 + i }, { actor: USER });
- const hangRevs = db.prepare("SELECT revision FROM config_snapshots WHERE namespace = 'live.hang' ORDER BY revision").all().map((x) => x.revision);
- assert.deepStrictEqual(hangRevs, [1, 2, 4, 5], 'pruned to the newest two, plus the active and the last-known-good');
- const rebooted = createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, log });
- assert.strictEqual(rebooted.revision(), 1);
- assert.strictEqual(rebooted.get('n'), 0);
- assert.strictEqual(rowOf(db, 'live.hang', 2).state, 'rejected');
- assert.match(rowOf(db, 'live.hang', 2).error, /interrupted/);
-
- // ── Concurrency: applies run one at a time and get increasing revisions ──
- let running = 0; let overlapped = false;
- const conc = createConfigStore({ db, service: 'live', namespace: 'live.concurrency', defaults: { n: 0 }, log, onActivate: async () => { running++; if (running > 1) overlapped = true; await sleep(5); running--; } });
- const [s1, s2, s3] = await Promise.all([conc.apply({ n: 1 }, { actor: USER }), conc.apply({ n: 2 }, { actor: USER }), conc.apply({ n: 3 }, { actor: USER })]);
- assert.deepStrictEqual([s1.revision, s2.revision, s3.revision], [2, 3, 4]);
- assert.deepStrictEqual([s1.previous_revision, s2.previous_revision, s3.previous_revision], [1, 2, 3]);
- assert.ok(!overlapped, 'onActivate never overlaps');
- assert.strictEqual(conc.get('n'), 3);
- assert.deepStrictEqual(conc.history().map((s) => s.state), ['active', 'superseded', 'superseded', 'superseded']);
- const b1 = await conc.apply({ n: 4 }, { actor: USER });
- const b2 = await conc.apply({ n: 5 }, { actor: USER });
- assert.strictEqual(b2.revision, b1.revision + 1);
- const conc2 = createConfigStore({ db, service: 'live', namespace: 'live.concurrency', log });
- const [c1, c2] = await Promise.all([conc.apply({ n: 6 }, { actor: USER }), Promise.resolve().then(() => conc2.propose({ n: 7 }, { actor: USER }))]);
- assert.ok(c2.revision !== c1.revision, 'two stores on one namespace never reuse a revision');
-
- // ── Pruning: the newest N, and never the active one ──
- const pr = createConfigStore({ db, service: 'live', namespace: 'live.prune', defaults: { n: 0 }, keep: 3, log });
- for (let i = 1; i <= 6; i++) await pr.apply({ n: i }, { actor: USER });
- assert.deepStrictEqual(pr.history().map((s) => s.revision), [7, 6, 5]);
- const pr2 = createConfigStore({ db, service: 'live', namespace: 'live.prune2', defaults: { n: 0 }, keep: 2, log, onActivate: (v) => { if (v.n > 0) throw new Error('refused'); } });
- for (let i = 1; i <= 5; i++) await assert.rejects(pr2.apply({ n: i }, { actor: USER }), (e) => e.code === 'config.activation_failed');
- assert.deepStrictEqual(pr2.history().map((s) => `${s.revision}:${s.state}`), ['6:rejected', '5:rejected', '1:active']);
- assert.strictEqual(pr2.lastKnownGood().revision, 1);
- assert.deepStrictEqual(pr2.history({ limit: 1, before: 6 }).map((s) => s.revision), [5], 'paged with before');
-
- // ── Legacy import ──
- const rows = [
- { key: 'site_name', value: 'Old Live', type: 'string' },
- { key: 'max_bitrate_kbps', value: '4500', type: 'number' },
- { key: 'registration_open', value: 'true', type: 'boolean' },
- { key: 'banner', value: '{"text":"hi"}', type: 'json' },
- { key: 'broken_json', value: '{', type: 'json' },
- { key: 'resend_api_key', value: 're_abcdef123456', type: 'secret' },
- { key: 'bad key', value: 'x' },
- { key: '__proto__', value: 'x' },
- ];
- assert.deepStrictEqual(fromRows(rows), { site_name: 'Old Live', max_bitrate_kbps: 4500, registration_open: true, banner: { text: 'hi' }, broken_json: '{', resend_api_key: 're_abcdef123456', 'bad key': 'x' });
- assert.deepStrictEqual(fromRows([{ key: 'storage_tier.minAgeDays', value: '3' }, { key: 'storage_tier.r2Enabled', value: 'false' }, { key: 'other', value: '1' }], { prefix: 'storage_tier.', type: 'json' }), { minAgeDays: 3, r2Enabled: false });
- const legacyDb = new Database(':memory:');
- let legacyReads = 0;
- const importOpts = { db: legacyDb, service: 'network', namespace: 'network.site_settings', classify: (k) => (/api_key/.test(k) ? 'secret' : undefined), defaults: { site_name: 'OpenVibe' }, legacy: () => { legacyReads++; return rows; }, log };
- const imp = createConfigStore(importOpts);
- assert.strictEqual(imp.revision(), 1);
- assert.strictEqual(imp.snapshot(1).reason, 'imported from the legacy source');
- assert.strictEqual(imp.get('site_name'), 'Old Live', 'legacy wins over defaults for revision 1');
- assert.strictEqual(imp.get('max_bitrate_kbps'), 4500);
- assert.ok(!('bad key' in imp.get()), 'a key the contract cannot carry is left behind');
- assert.ok(logs.some((l) => l.includes('network.site_settings') && l.includes('not imported') && l.includes('"bad key"')));
- assert.deepStrictEqual(imp.snapshot(1).values.resend_api_key, { redacted: true, fingerprint: fp(legacyDb, 'network.site_settings', 're_abcdef123456') });
- assertSnapshot(imp.snapshot(1));
- createConfigStore(importOpts);
- assert.strictEqual(legacyReads, 1, 'the legacy source is read once, when the namespace is new');
- assert.strictEqual(imp.import(rows), null, 'import is a no-op once the namespace has revisions');
- const imp2 = createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.other', log });
- assert.strictEqual(imp2.revision(), null);
- const imported = imp2.import({ a: 1 }, { reason: 'from site_settings' });
- assert.strictEqual(imported.revision, 1);
- assert.strictEqual(imported.reason, 'from site_settings');
- assert.strictEqual(imp2.get('a'), 1);
- const strictImport = createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.strict', schema: { type: 'object', properties: { n: { type: 'integer' } } }, legacy: { n: 'not a number' }, log });
- assert.strictEqual(strictImport.get('n'), 'not a number', 'what runs today is imported even when it does not validate');
- assert.ok(logs.some((l) => l.startsWith('warn') && l.includes('network.strict') && l.includes('do not validate')));
-
- // ── Secrets never reach the log ──
- assert.ok(logs.length > 10);
- assert.ok(!logs.join('\n').includes(SECRET), 'no secret in the log');
- assertNoLeak(logs.join('\n'), leakForms(db).concat(leakForms(legacyDb)), 'the log');
- assert.ok(!logs.join('\n').includes('re_abcdef123456'));
- assert.ok(logs.some((l) => /live\.site_settings: revision \d+ active \(was \d+\) by user:usr_/.test(l)));
-
- // ── /api/admin/config on Express ──
- const USER2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
- const app = express();
- app.use(express.json());
- app.use((req, _res, next) => {
- const who = req.headers['x-test-user'];
- if (who === 'admin') req.user = { id: 1, role: 'admin', subject_id: USER.id };
- else if (who === 'owner') req.user = { id: 2, role: 'admin', subject_id: USER2, owner: true };
- else if (who === 'nosubject') req.user = { id: 3, role: 'admin' };
- else if (who === 'viewer') req.user = { id: 4, role: 'user', subject_id: USER.id };
- next();
- });
- let handlerRan = 0;
- const requireAdmin = (req, res, next) => (req.user && req.user.role === 'admin' ? next() : res.status(403).json({ error: 'Admin only' }));
- const authorize = (req, { action, keys }) => { handlerRan++; return action === 'read' || !!(req.user && req.user.owner) || !keys.some((k) => classify(k) === 'secret'); };
- assert.throws(() => adminRoutes([live]), /requireAdmin/);
- assert.throws(() => adminRoutes([live, live2], { requireAdmin }), /twice/);
- adminRoutes([live, media], { requireAdmin, authorize, router: app });
- const server = app.listen(0);
- await new Promise((r) => server.once('listening', r));
- const base = `http://127.0.0.1:${server.address().port}`;
- const texts = [];
- async function call(method, path, { user = 'admin', body } = {}) {
- const res = await fetch(base + path, { method, headers: { 'x-test-user': user, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
- const text = await res.text();
- texts.push(text);
- return { status: res.status, type: res.headers.get('content-type') || '', cache: res.headers.get('cache-control'), body: text ? JSON.parse(text) : null };
- }
-
- let res = await call('GET', '/api/admin/config', { user: 'viewer' });
- assert.strictEqual(res.status, 403);
- assert.deepStrictEqual(res.body, { error: 'Admin only' }, "requireAdmin's own answer");
- assert.strictEqual(handlerRan, 0, 'the handler never ran');
- res = await call('GET', '/api/admin/config');
- assert.strictEqual(res.status, 200);
- assert.match(res.type, /^application\/json/);
- assert.strictEqual(res.cache, 'no-store');
- assert.deepStrictEqual(res.body.namespaces.map((n) => n.namespace), ['live.site_settings', 'media.storage_tier']);
- const liveEntry = res.body.namespaces[0];
- assert.strictEqual(liveEntry.revision, live.revision());
- assert.deepStrictEqual(liveEntry.values.youtube_api_key, { redacted: true, fingerprint: fp(db, 'live.site_settings', SECRET) });
- assert.strictEqual(liveEntry.last_known_good.revision, live.lastKnownGood().revision);
- assertSnapshot(liveEntry.active);
- res = await call('GET', '/api/admin/config/media.storage_tier');
- assert.strictEqual(res.status, 200);
- assert.deepStrictEqual(res.body.values.b2_key, { redacted: true, fingerprint: fp(db, 'media.storage_tier', SECRET) });
- // A form round trip: the marker it was shown keeps the secret, even for an admin who may not change it.
- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: liveEntry.values.youtube_api_key }, merge: true, reason: 'form round trip' } });
- assert.strictEqual(res.status, 200, JSON.stringify(res.body));
- assert.strictEqual(live.get('youtube_api_key'), SECRET);
- assert.deepStrictEqual(res.body.values.youtube_api_key, liveEntry.values.youtube_api_key);
- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: { redacted: true, fingerprint: '0'.repeat(64) } }, merge: true } });
- assertProblem(res.body, 422, 'config.invalid');
- assert.strictEqual(res.body.errors[0].path, '/youtube_api_key');
- res = await call('GET', '/api/admin/config/nope.settings');
- assert.strictEqual(res.status, 404);
- assert.strictEqual(res.type, 'application/problem+json');
- assertProblem(res.body, 404, 'config.namespace_not_found');
-
- res = await call('GET', '/api/admin/config/live.site_settings/history?limit=2');
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.snapshots.length, 2);
- res.body.snapshots.forEach(assertSnapshot);
- assert.strictEqual(res.body.next_before, res.body.snapshots[1].revision);
- const older = await call('GET', `/api/admin/config/live.site_settings/history?limit=200&before=${res.body.next_before}`);
- assert.ok(older.body.snapshots.every((s) => s.revision < res.body.next_before));
- assert.strictEqual(older.body.next_before, null);
-
- const liveBefore = live.revision();
- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { mode: 'invite' }, merge: true, reason: 'soft launch' } });
- assert.strictEqual(res.status, 200, JSON.stringify(res.body));
- assertSnapshot(res.body);
- assert.strictEqual(res.body.revision, liveBefore + 1);
- assert.strictEqual(res.body.state, 'active');
- assert.deepStrictEqual(res.body.created_by, USER, 'the actor comes from req.user.subject_id');
- assert.strictEqual(res.body.reason, 'soft launch');
- assert.strictEqual(live.get('mode'), 'invite');
- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { max_bitrate_kbps: 1 }, merge: true } });
- assert.strictEqual(res.status, 422);
- assert.strictEqual(res.type, 'application/problem+json');
- assertProblem(res.body, 422, 'config.invalid');
- assert.deepStrictEqual(res.body.errors, [{ path: '/max_bitrate_kbps', message: 'must be >= 500' }]);
- res = await call('POST', '/api/admin/config/live.site_settings', { body: { reason: 'no values' } });
- assertProblem(res.body, 400, 'config.bad_request');
- res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });
- assertProblem(res.body, 403, 'config.forbidden');
- assert.strictEqual(live.get('youtube_api_key'), SECRET, 'authorize() refused a secret change');
- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.created_by.id, USER2);
- assert.strictEqual(live.get('youtube_api_key'), 'AIzaNEW');
- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'nosubject', body: { values: { mode: 'open' }, merge: true } });
- assertProblem(res.body, 403, 'config.actor_required');
- failNext = new Error(`Media said no to ${SECRET}`);
- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: SECRET, mode: 'open' }, merge: true } });
- assertProblem(res.body, 422, 'config.activation_failed');
- assert.strictEqual(live.get('mode'), 'invite', 'the failure restored the previous values');
-
- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { body: { reason: 'undo the key' } });
- assertProblem(res.body, 403, 'config.forbidden');
- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { reason: 'undo the key' } });
- assert.strictEqual(res.status, 200, JSON.stringify(res.body));
- assertSnapshot(res.body);
- assert.ok(res.body.copied_from);
- assert.strictEqual(live.get('youtube_api_key'), SECRET);
- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 'one' } });
- assertProblem(res.body, 400, 'config.bad_request');
- res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 99999 } });
- assertProblem(res.body, 404, 'config.revision_not_found');
- const emptyStore = createConfigStore({ db, service: 'media', namespace: 'media.empty', defaults: { a: 1 }, log });
- const emptyApp = express();
- emptyApp.use(express.json());
- adminRoutes(emptyStore, { requireAdmin: (_req, _res, next) => next(), actor: () => USER }).mount(emptyApp);
- const emptyServer = emptyApp.listen(0);
- await new Promise((r) => emptyServer.once('listening', r));
- const er = await fetch(`http://127.0.0.1:${emptyServer.address().port}/api/admin/config/media.empty/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
- const erBody = await er.json();
- assertProblem(erBody, 409, 'config.no_previous');
- emptyServer.close();
- assert.ok(texts.every((x) => !x.includes(SECRET)), 'no response ever carries the secret');
- assert.ok(!logs.join('\n').includes(SECRET));
- const forms = leakForms(db);
- texts.forEach((x, i) => assertNoLeak(x, forms, `route response ${i}`));
- assertNoLeak(JSON.stringify([live.summary(), media.summary(), live.history({ limit: 200 }), media.history({ limit: 200 }), live.lastKnownGood()]), forms, 'summary and history');
- server.closeAllConnections();
- server.close();
-
- // ── Plain http, a basePath under an app-scoped router, and direct handlers ──
- const plain = adminRoutes([media], {
- requireAdmin: (req, res, next) => (req.headers.authorization === 'Bearer admin' ? next() : (res.statusCode = 401, res.end())),
- actor: () => ({ type: 'service', id: 'live' }),
- });
- const plainServer = http.createServer((req, res) => plain.handle(req, res));
- plainServer.listen(0);
- await new Promise((r) => plainServer.once('listening', r));
- const pbase = `http://127.0.0.1:${plainServer.address().port}`;
- const auth = { authorization: 'Bearer admin' };
- assert.strictEqual((await fetch(`${pbase}/api/admin/config`)).status, 401);
- let pr0 = await fetch(`${pbase}/api/admin/config`, { headers: auth });
- assert.strictEqual(pr0.status, 200);
- assert.strictEqual((await pr0.json()).namespaces[0].namespace, 'media.storage_tier');
- pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify({ values: { minAgeDays: 5 }, merge: true, reason: 'plain http' }) });
- const plainSnap = await pr0.json();
- assert.strictEqual(pr0.status, 200, JSON.stringify(plainSnap));
- assert.deepStrictEqual(plainSnap.created_by, { type: 'service', id: 'live' });
- assert.strictEqual(media.get('minAgeDays'), 5);
- pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier/history?limit=1`, { headers: auth });
- assert.strictEqual((await pr0.json()).snapshots.length, 1);
- pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: auth, body: '{not json' });
- assertProblem(await pr0.json(), 400, 'config.bad_request');
- pr0 = await fetch(`${pbase}/elsewhere`, { headers: auth });
- assertProblem(await pr0.json(), 404, 'config.route_not_found');
- for (const path of ['/api/admin/config', '/api/admin/config/media.storage_tier', '/api/admin/config/media.storage_tier/history?limit=200']) {
- assertNoLeak(await (await fetch(pbase + path, { headers: auth })).text(), leakForms(db), `plain ${path}`);
- }
- plainServer.close();
-
- const scoped = express();
- scoped.use(express.json());
- const sub = express.Router({ mergeParams: true });
- adminRoutes([media], { requireAdmin: (_req, _res, next) => next(), basePath: '/config', actor: (req) => ({ type: 'service', id: req.params.app || 'live' }) }).mount(sub);
- scoped.use('/api/v1/:app/admin', sub);
- const scopedServer = scoped.listen(0);
- await new Promise((r) => scopedServer.once('listening', r));
- const sres = await fetch(`http://127.0.0.1:${scopedServer.address().port}/api/v1/live/admin/config/media.storage_tier`);
- assert.strictEqual(sres.status, 200);
- assert.strictEqual((await sres.json()).namespace, 'media.storage_tier');
- scopedServer.close();
-
- const direct = adminRoutes([media], { requireAdmin: (req, res, next) => (req.admin ? next() : (res.statusCode = 403, res.end('no'))) });
- const fakeRes = () => ({ headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; this.done = true; } });
- const denied = fakeRes();
- await direct.list({ url: '/api/admin/config' }, denied);
- assert.strictEqual(denied.statusCode, 403, 'a handler used directly is still guarded');
- const allowedRes = fakeRes();
- direct.list({ url: '/api/admin/config', admin: true }, allowedRes);
- await sleep(5);
- assert.strictEqual(allowedRes.statusCode, 200);
- assert.strictEqual(direct.routes.length, 5);
- assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), [
- 'GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history',
- 'POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback',
- ]);
-
- assertNoLeak(logs.join('\n'), leakForms(db).concat(leakForms(legacyDb)), 'the whole log');
- assert.ok(!logs.join('\n').includes(SECRET));
-
- console.log(`config: store, validation, last-known-good, rollback, secrets, legacy, restart, concurrency and routes ok${hasSnapshotContract ? ' (snapshots checked against common.config-snapshot@1)' : ''}`);
-})().catch((err) => { console.error(err); process.exit(1); });
@@ -55,13 +55,11 @@ try {
@@ -55,13 +55,11 @@ try {
assert.deepStrictEqual(callees(res), ['save', 'this.save']);
assert.deepStrictEqual(callees(res), ['save', 'this.save']);
}
}
- // A better-sqlite3 service: its db calls are synchronous, so db.run() never floats there (fetch still does).
+ // PostgreSQL database and outbox methods return promises.
{
{
const src = ['async function handler() {', ' db.run("x");', ' outbox.enqueue(e);', ' db.close();', ' fetch("u");', '}'].join('\n') + '\n';
const src = ['async function handler() {', ' db.run("x");', ' outbox.enqueue(e);', ' db.close();', ' fetch("u");', '}'].join('\n') + '\n';
- const sync = fixture({ 'package.json': JSON.stringify({ dependencies: { 'better-sqlite3': '^11' } }), 'server/s.js': src });
- assert.deepStrictEqual(callees(scan(sync)), ['fetch'], 'db.run, an outbox enqueue and db.close are synchronous with better-sqlite3');
const pg = fixture({ 'package.json': JSON.stringify({ dependencies: { pg: '^8' } }), 'server/s.js': src });
const pg = fixture({ 'package.json': JSON.stringify({ dependencies: { pg: '^8' } }), 'server/s.js': src });
- assert.deepStrictEqual(callees(scan(pg)), ['db.close', 'db.run', 'fetch', 'outbox.enqueue'], 'all promises without better-sqlite3');
+ assert.deepStrictEqual(callees(scan(pg)), ['db.close', 'db.run', 'fetch', 'outbox.enqueue']);
}
}
// Rule b: every known async API.
// Rule b: every known async API.
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// Every subpath consumers use, and every module file, resolves through the package's own name
+// Every public subpath resolves through the package's own name
// (Node self-reference), so require('openvibe-shared/<x>') works the same once installed.
// (Node self-reference), so require('openvibe-shared/<x>') works the same once installed.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -10,7 +10,10 @@ const ROOT = path.join(__dirname, '..');
@@ -10,7 +10,10 @@ const ROOT = path.join(__dirname, '..');
const modules = fs.readdirSync(ROOT).filter((f) => f.endsWith('.js'))
const modules = fs.readdirSync(ROOT).filter((f) => f.endsWith('.js'))
.concat(fs.readdirSync(path.join(ROOT, 'analytics')).filter((f) => f.endsWith('.js')).map((f) => `analytics/${f}`));
.concat(fs.readdirSync(path.join(ROOT, 'analytics')).filter((f) => f.endsWith('.js')).map((f) => `analytics/${f}`));
const targets = new Set(Object.values(pkg.exports).map((t) => t.replace(/^\.\//, '')));
const targets = new Set(Object.values(pkg.exports).map((t) => t.replace(/^\.\//, '')));
-for (const f of modules) assert.ok(targets.has(f), `${f} is exported`);
+for (const f of modules) if (!['config-core.js', 'analytics/core.js'].includes(f)) assert.ok(targets.has(f), `${f} is exported`);
+for (const sub of ['analytics/tracker', 'analytics/schema', 'analytics/prune-cli']) assert.ok(!pkg.exports[`./${sub}`], `${sub} was removed`);
+assert.strictEqual(require('openvibe-shared/analytics').AnalyticsTracker, undefined);
+assert.throws(() => require('openvibe-shared/config').createConfigStore({ db: { prepare() {}, transaction() {} } }), /openvibe-sdk\/db/);
// Shared ADR 0001: the exports map is the public surface, one explicit entry per subpath, no patterns.
// Shared ADR 0001: the exports map is the public surface, one explicit entry per subpath, no patterns.
for (const sub of Object.keys(pkg.exports)) assert.ok(!sub.includes('*'), `${sub}: no wildcard subpaths`);
for (const sub of Object.keys(pkg.exports)) assert.ok(!sub.includes('*'), `${sub}: no wildcard subpaths`);
for (const [sub, target] of Object.entries(pkg.exports)) {
for (const [sub, target] of Object.entries(pkg.exports)) {