openvibers/openvibe.shared Commits

openvibe-shared 3.0.0: delete the SQLite config store and SQLite analytics (#54)

OP OpenVibers committed 10 Oct 2026 · ad92e38c10 · 28 files, +508 −3770

* openvibe-shared 3.0.0: delete the SQLite config store and SQLite analytics

Plan T1, "Delete the migration era", Shared part (the deletion ledger's "Shared SQLite stores"). Every consumer runs on PostgreSQL. Live and Media hand `createConfigStore` an openvibe-sdk/db handle, and Live's analytics use `analytics/pg`. So the SQLite implementations go. This is a major version: removed exports.

**Removed:**
- The SQLite config store: `createConfigStore` now requires an openvibe-sdk/db handle and delegates to `config-pg.js`.
- `analytics/tracker.js` (the SQLite `AnalyticsTracker`), `analytics/schema.js`, `analytics/prune-cli.js`, and the SQLite retention and scrub functions. `analytics/retention` keeps `MAX_DAYS` and its backend-independent helpers.
- `better-sqlite3`.
- `test/config.test.js` and `test/analytics-cli.test.js` (SQLite-only); the SQLite halves of the privacy and retention tests.

**Moved, not changed:**
- `config-core.js` holds the helpers `config-pg.js` used to take from `config.js`'s `_internal`.
- `analytics/core.js` holds what `analytics/pg.js` inherited from the SQLite tracker (`sqlTime`, `dayOf`, `hourOf`, the request reduction).
- The PostgreSQL SQL, table names, method names and return shapes are unchanged.

**Consumers checked against this branch** (packed and installed without saving):
- Live: `site-config`, `analytics-privacy`, `network-analytics` and the analytics tests pass. `shared-pins` only fails its "node_modules matches the pin" check, as expected for a hand-installed tarball. Its `RESOLVE_ONLY` entry for `analytics/prune-cli` must go in Live's bump PR.
- Media: `tier-config`, `placement-storage-policy`, `placement-cost-tiers` and `object-tiering` pass.
- Nothing else in the estate imports `openvibe-shared/config` or the SQLite analytics.

Built by a Codex job, reviewed here. Full suite 56/56, with acorn installed for floating-promises and the HTTP sections the agent's sandbox could not run.

* CI consumer check: require analytics/pg instead of the removed analytics/prune-cli

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex d4ad41d..03a1688 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -30,7 +30,7 @@ jobs:
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
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).
security:
uses: ./.github/workflows/security.yml
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 521fd49..1de3fce 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/README.md b/README.md
iindex 394bcc5..012168d 100644
---- a/README.md
++++ b/README.md
@@@ -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.
```js
const 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.
```js
const 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, multipleOf
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
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 */ },
keep: 50, log: console, now: () => new Date(),
});
settings.get(); // defaults overlaid with the active values: frozen, in memory
settings.get('max_bitrate_kbps'); // one value; never a database read
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.apply(values, { actor, reason, merge, unset }); // propose + activate
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
@@@ -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), a
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
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
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex f95548f..1ec8a7c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/analytics/core.js b/analytics/core.js
nnew file mode 100644
iindex 0000000..0f33075
---- /dev/null
++++ b/analytics/core.js
@@@ -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 };
ddiff --git a/analytics/event.js b/analytics/event.js
iindex 786460f..6e287a4 100644
---- a/analytics/event.js
++++ b/analytics/event.js
@@@ -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);
ddiff --git a/analytics/index.js b/analytics/index.js
iindex 80447c6..98a39c8 100644
---- a/analytics/index.js
++++ b/analytics/index.js
@@@ -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,
};
ddiff --git a/analytics/pg.js b/analytics/pg.js
iindex 9ddc046..cf0ba1b 100644
---- a/analytics/pg.js
++++ b/analytics/pg.js
@@@ -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 key
this._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;
ddiff --git a/analytics/prune-cli.js b/analytics/prune-cli.js
ddeleted file mode 100644
iindex c30f3fc..0000000
---- a/analytics/prune-cli.js
++++ /dev/null
@@@ -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)
--
----apply refuses to run without --backup or --no-backup. Rollup totals are compared before and after
--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 };
ddiff --git a/analytics/retention.js b/analytics/retention.js
iindex 483cac5..e391ae9 100644
---- a/analytics/retention.js
++++ b/analytics/retention.js
@@@ -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 };
ddiff --git a/analytics/schema.js b/analytics/schema.js
ddeleted file mode 100644
iindex b1f73a0..0000000
---- a/analytics/schema.js
++++ /dev/null
@@@ -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 };
ddiff --git a/analytics/tracker.js b/analytics/tracker.js
ddeleted file mode 100644
iindex 4cc1492..0000000
---- a/analytics/tracker.js
++++ /dev/null
@@@ -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 };
ddiff --git a/config-core.js b/config-core.js
nnew file mode 100644
iindex 0000000..4d225d8
---- /dev/null
++++ b/config-core.js
@@@ -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 };
ddiff --git a/config-pg.js b/config-pg.js
iindex 2add13d..bc19394 100644
---- a/config-pg.js
++++ b/config-pg.js
@@@ -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() {
ddiff --git a/config.js b/config.js
iindex 7994256..7f1eeec 100644
---- a/config.js
++++ b/config.js
@@@ -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 };
ddiff --git a/docs/adr/0001-one-package-subpath-exports.md b/docs/adr/0001-one-package-subpath-exports.md
iindex ab49992..45cc2b9 100644
---- a/docs/adr/0001-one-package-subpath-exports.md
++++ b/docs/adr/0001-one-package-subpath-exports.md
@@@ -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
loads.
- **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
compatibility matrix between them. There is no second team or release cadence to justify
that.
@@@ -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 the
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.
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 published
third-party SDK.
- Shared starts publishing to an npm registry. Scoped packages would then cost little, and a
ddiff --git a/index.js b/index.js
iindex 14d9584..7752350 100644
---- a/index.js
++++ b/index.js
@@@ -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
ddiff --git a/package-lock.json b/package-lock.json
iindex bfb1409..a9819fd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
}
}
}
ddiff --git a/package.json b/package.json
iindex bb12612..a8c14de 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/floating-promises.js b/scripts/floating-promises.js
iindex ad1c3be..75f6e6c 100644
---- a/scripts/floating-promises.js
++++ b/scripts/floating-promises.js
@@@ -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/exec
if (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/store
return 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 handled
let 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 process
ddiff --git a/test/analytics-cli.test.js b/test/analytics-cli.test.js
ddeleted file mode 100644
iindex 7e22c0c..0000000
---- a/test/analytics-cli.test.js
++++ /dev/null
@@@ -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');
--})();
ddiff --git a/test/analytics-pg.test.js b/test/analytics-pg.test.js
iindex 21f6e2f..4c4dbcb 100644
---- a/test/analytics-pg.test.js
++++ b/test/analytics-pg.test.js
@@@ -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');
ddiff --git a/test/analytics-privacy.test.js b/test/analytics-privacy.test.js
iindex 3c7f8fb..5632301 100644
---- a/test/analytics-privacy.test.js
++++ b/test/analytics-privacy.test.js
@@@ -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');
})();
ddiff --git a/test/analytics-retention.test.js b/test/analytics-retention.test.js
iindex 3ed25c4..6f74b2e 100644
---- a/test/analytics-retention.test.js
++++ b/test/analytics-retention.test.js
@@@ -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');
ddiff --git a/test/config-pg.test.js b/test/config-pg.test.js
iindex 035fd8f..a87da48 100644
---- a/test/config-pg.test.js
++++ b/test/config-pg.test.js
@@@ -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/);
ddiff --git a/test/config.test.js b/test/config.test.js
ddeleted file mode 100644
iindex 764e664..0000000
---- a/test/config.test.js
++++ /dev/null
@@@ -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); });
ddiff --git a/test/floating-promises.test.js b/test/floating-promises.test.js
iindex a3ec63d..88db28f 100644
---- a/test/floating-promises.test.js
++++ b/test/floating-promises.test.js
@@@ -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.
ddiff --git a/test/package.test.js b/test/package.test.js
iindex 5349ac4..26de140 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -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)) {