openvibers/openvibe.tools Commits
Tools on PostgreSQL, part 2 (plan T8, phases 1 and 4; not deployed yet): the network and dev pages call the tools run API (/api/v1/tools/<id>/run) instead of /api/net and /api/dev, a signed-in person's probe is a normal quota-metered run and a signed-out visitor gets a sign-in card for probes (port, ping, latency, traceroute, mtr) while lookups stay open; the guard's day counters, salt and buckets live in Valkey (two processes share one budget; an outage test). npm test 57/57 and test:pg 57/57 (verified). The job store on openvibe-sdk/db and the legacy route deletion follow before the deploy.
Changes
@@@ -638,5 +638,12 @@ with `DATABASE_DIRECT_URL` set for the cutover; it prints a per-table count andAfter the move the apps read `DATABASE_URL` / `DATABASE_DIRECT_URL` (PostgreSQL, via PgBouncer) and`VALKEY_URL` (Valkey); `guard_salt`, `guard_day` and the minute/burst buckets live in Valkey (a throttle,never authoritative), while `guard_abuse`, the job store and the analytics tables stay in PostgreSQL.--`TOOLS_GUARD` (default `report`) and its meaning are unchanged. Until the cutover lands the apps still run--on `better-sqlite3` exactly as described above.++`TOOLS_GUARD` (default `report`) and its meaning are unchanged.++++State (plan T8 phases 1–4 done, 5–6 pending): every app already opens the one `tools` database++(`apps/_shared/db.js`, `DATABASE_URL` else an embedded PGlite database in development; `DATABASE_DIRECT_URL`++runs the migrations as owner) and the shared Valkey (`VALKEY_URL`, else the guard's counters and salt stay++in this process); `guard_abuse`, `token_revocations` and the request analytics are on PostgreSQL, and the++net and dev pages call the run API (the `/api/net/*` and `/api/dev/*` routes still answer until phase 6++removes them; a signed-in person may now run a probe under their per-person quota). The job store still++runs on `better-sqlite3` (`apps/_shared/jobs/`, plan T8 phases 2–3) — only that dependency remains.@@@ -3,7 +3,7 @@"stage": "stable","code": true,"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express, better-sqlite3; eight apps (gateway, maps, food, img, yt, audio, text, docs)",++ "runtime": "node 22, express; PostgreSQL (openvibe-sdk/db, PGlite in development) + Valkey for the guard and analytics; better-sqlite3 only for the job store (plan T8 phases 2-3 pending); eight apps (gateway, maps, food, img, yt, audio, text, docs)","port": 4001,"ports": {"gateway": 4001,nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// The one `tools` database (plan T8, decisions 3 and 6): all eight apps serve from a single++// PostgreSQL database, DATABASE_URL through PgBouncer in production. Migrations live in the++// repository's migrations/ and run as the owner (DATABASE_DIRECT_URL), or on the embedded handle in++// development. Without a DATABASE_URL outside production an embedded PGlite database serves the app,++// so what the tests run is what production runs (ADR-035).++//++// Like the rest of apps/_shared there are no dependencies of its own: the app passes its++// require('openvibe-sdk/db').createDb and require('openvibe-sdk/valkey').createValkey.++//++// openToolsDb returns { db, ready }: `db` is usable at once (its statements queue), `ready` resolves++// when the schema is applied — mount it in /api/ready and never block the boot on it.++// ═══════════════════════════════════════════════════════════════++++const fs = require('fs');++const path = require('path');++++const MIGRATIONS = path.resolve(__dirname, '..', '..', 'migrations');++++/**++ * Await `ready` before every statement, so a caller need not hold the schema promise: the guard writes++ * guard_abuse on the first refusal, and a request must never race the migrations. `tx`/`prepare` are++ * gated too; the rest of the handle (sql, stats, store, _adapter) passes through.++ */++function gate(db, ready) {++ const wait = () => ready;++ const wrapStmt = (s) => ({++ get: async (...a) => { await wait(); return s.get(...a); },++ all: async (...a) => { await wait(); return s.all(...a); },++ run: async (...a) => { await wait(); return s.run(...a); },++ });++ const out = Object.create(db);++ for (const m of ['query', 'many', 'maybe', 'one', 'value', 'exec']) out[m] = async (...a) => { await wait(); return db[m](...a); };++ out.tx = async (fn, opts) => { await wait(); return db.tx(fn, opts); };++ out.prepare = (text) => wrapStmt(db.prepare(text));++ out.ready = async () => { await wait(); return db.ready(); };++ out.afterCommit = (fn) => db.afterCommit(fn);++ out.inTransaction = () => db.inTransaction();++ out.detached = (fn) => db.detached(fn);++ out.close = () => db.close();++ return out;++}++++/**++ * @param {object} o++ * @param {Function} o.createDb require('openvibe-sdk/db').createDb++ * @param {string} [o.service] application_name and metric label ('tools-img', 'tools-gateway', …)++ * @param {object} [o.log]++ * @param {object} [o.registry] openvibe-shared/metrics registry (db_query_seconds, pool gauges)++ * @param {string} [o.pgliteDir] development database directory (default data/pglite/<service>)++ * @param {string} [o.migrationsDir]++ */++function openToolsDb({ createDb, service = 'tools', log = console, registry, pgliteDir, migrationsDir = MIGRATIONS } = {}) {++ if (typeof createDb !== 'function') throw new TypeError('openToolsDb needs createDb (require(\'openvibe-sdk/db\').createDb)');++ const url = String(process.env.DATABASE_URL || '').trim();++ if (!url) {++ if (String(process.env.NODE_ENV || '') === 'production') throw new Error('DATABASE_URL is not set: Tools serves from PostgreSQL (plan T8)');++ // A directory keeps development data across restarts; without one (tests) the database is in memory,++ // so every run starts from the empty, migrated schema.++ const dir = pgliteDir || process.env.TOOLS_PGLITE_DIR || '';++ if (dir) fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir || true, service, log, registry });++ log.warn(`[DB] ${service}: DATABASE_URL unset, ${dir ? `embedded PGlite database in ${dir}` : 'in-memory PGlite database'} (development only)`);++ const ready = db.migrate({ dir: migrationsDir, log });++ return { db: gate(db, ready), ready };++ }++ const db = createDb({ url, service, log, registry });++ const direct = String(process.env.DATABASE_DIRECT_URL || '').trim();++ let ready = Promise.resolve();++ if (direct) {++ const owner = createDb({ url: direct, service: `${service}-migrate`, max: 1, log });++ ready = owner.migrate({ dir: migrationsDir, log }).finally(() => owner.close().catch(() => {}));++ }++ return { db: gate(db, ready), ready };++}++++/**++ * The shared Valkey connection (plan T8, decision 4), confined to `ov:tools:*`. Without VALKEY_URL it++ * is null and the guard keeps its counters and salt in this process, as it did before (ADR-007 rule 4:++ * Valkey is a throttle, never authoritative).++ *++ * @param {object} o++ * @param {Function} o.createValkey require('openvibe-sdk/valkey').createValkey++ */++function openToolsValkey({ createValkey, log = console, prefix = process.env.VALKEY_PREFIX || 'ov:tools:' } = {}) {++ if (typeof createValkey !== 'function') return null;++ return createValkey({ url: process.env.VALKEY_URL, prefix, log });++}++++module.exports = { openToolsDb, openToolsValkey, MIGRATIONS };@@@ -100,7 +100,10 @@ function createGuard(o = {}) {const enforcing = mode === 'enforce';const bounds = limits.bounds(env);const table = limits.quotas(env, log);-- const store = createGuardStore({ Database: o.Database, dataDir: o.dataDir, now, log });++ // guard_abuse in PostgreSQL, guard_salt and guard_day in Valkey (plan T8, decision 4); without++ // them (tests, or a Valkey outage) the counters and the salt stay in this process.++ const store = o.store || createGuardStore({ db: o.db || null, valkey: o.valkey || null, app: appName, now, log });++ if (!o.store) void store.warm();let saltDay = null, saltValue = null;const salt = () => {@@@ -109,13 +112,15 @@ function createGuard(o = {}) {return saltValue;};-- // Network's sign-out-everywhere cutoffs, written by the gateway (./revocations.js).-- const cutoffs = o.cutoffs || require('./revocations').createCutoffReader({ Database: o.Database, file: o.revocationsFile, now });++ // Network's sign-out-everywhere cutoffs (PostgreSQL, read through a short shared cache): the gateway++ // writes them (apps/gateway/server/revocation-events.js); every app reads them here.++ const cutoffs = o.cutoffs || require('./revocations').createCutoffReader({ db: o.db || null, valkey: o.valkey || null, now, log });++ if (!o.cutoffs) void cutoffs.warm();const keys = o.keys || tokens.createKeySource({ networkUrl: o.networkUrl, networkInternalUrl: o.networkInternalUrl, files: o.publicKeyFiles || [], log });const issuer = o.issuer;const audience = env.OV_TOOLS_AUDIENCE || tokens.AUDIENCE;const callers = createCallerResolver({ getPublicKey: () => keys.get(), issuer, audience, contracts: o.contracts, salt, secureCookie: o.secureCookie });-- const quotas = createQuotas({ store, quotas: () => table, now });++ const quotas = createQuotas({ store, quotas: () => table, now, valkey: o.valkey || null, log });const targets = createTargetThrottle({ now });const ports = createPortScanCap({ ...bounds.ports, now });const sync = createSemaphore({ max: bounds.sync.concurrency, queue: bounds.sync.queue, waitMs: bounds.sync.waitMs });@@@ -203,7 +208,7 @@ function createGuard(o = {}) {// ── Quotas ───────────────────────────────────────────────async function charge(req, res, { quotaClass, cost, tool: toolId }) {const c = caller(req, res);-- const r = quotas.check(c, { quotaClass, cost }, { report: !enforcing });++ const r = await quotas.check(c, { quotaClass, cost }, { report: !enforcing });// In report mode an older limiter that ran on this request (req.rateLimit) is the one in force:// its RateLimit-* headers stay. Otherwise the guard's describe the allowance closest to running out.if (enforcing || !req.rateLimit) setHeaders(res, r);@@@ -257,7 +262,7 @@ function createGuard(o = {}) {return charge(req, res, { quotaClass: d.quotaClass, cost, tool: d.id });}if (cost > was.cost) { req._ovCharged = { ...was, cost }; return charge(req, res, { quotaClass: d.quotaClass, cost: cost - was.cost, tool: d.id }); }-- if (cost < was.cost) { quotas.refund(caller(req, res), { quotaClass: d.quotaClass, cost: was.cost - cost }); req._ovCharged = { ...was, cost }; }++ if (cost < was.cost) { await quotas.refund(caller(req, res), { quotaClass: d.quotaClass, cost: was.cost - cost }); req._ovCharged = { ...was, cost }; }return true;}@@@ -477,11 +482,10 @@ function createGuard(o = {}) {// ── Upkeep ───────────────────────────────────────────────function prune() {-- try {-- const cutoff = now() - bounds.abuseRetentionDays * DAY_MS;-- store.pruneAbuse(cutoff);-- store.pruneDays(dayOf(now() - DAY_MS));-- } catch (err) { log.error(`[Guard] ${appName}: prune failed: ${err.message}`); }++ const cutoff = now() - bounds.abuseRetentionDays * DAY_MS;++ return Promise.resolve(store.pruneAbuse(cutoff))++ .then(() => store.pruneDays(dayOf(now() - DAY_MS)))++ .catch((err) => log.error(`[Guard] ${appName}: prune failed: ${err.message}`));}prune();const pruneTimer = o.pruneIntervalMs === 0 ? null : setInterval(prune, o.pruneIntervalMs || 6 * 60 * 60 * 1000);@@@ -1,7 +1,12 @@'use strict';// ═══════════════════════════════════════════════════════════════--// Tiered quotas: a token bucket per (key, class) in memory, and a UTC-day allowance per (key, class)--// in guard.db. A run takes its descriptor's `cost` from both, all or nothing.++// Tiered quotas: a token bucket per (key, class) and a UTC-day allowance per (key, class). A run takes++// its descriptor's `cost` from both, all or nothing.++//++// The day allowance is shared across processes through the guard store (Valkey, plan T8 decision 4) and++// so are the minute/burst buckets when a Valkey connection is passed: one Lua call refills and spends++// one bucket atomically, so two hosts enforce one quota. Without Valkey (or on an outage) both legs use++// this process's own counters — a throttle that never fails open and never fails every request.//// Who is charged (keys come from the caller resolver, addresses already hashed):// anonymous its address (IPv6: its /64) at the anonymous tier@@@ -18,17 +23,77 @@const { SESSION_IP_SHARE } = require('./limits');const { dayOf, DAY_MS } = require('./store');++// Refill a bucket (tokens + elapsed × rate, capped at burst) and spend `need`, atomically. It only++// consumes when the bucket holds `need`: a refused run changes nothing. Returns "tokens:ok".++// ARGV: burst, rate (tokens/ms), need, now (ms), ttl (s).++const SPEND = `++local key = KEYS[1]++local burst = tonumber(ARGV[1])++local rate = tonumber(ARGV[2])++local need = tonumber(ARGV[3])++local now = tonumber(ARGV[4])++local ttl = tonumber(ARGV[5])++local d = redis.call('HMGET', key, 'tokens', 'at')++local tokens = (d[1] and tonumber(d[1])) or burst++local at = (d[2] and tonumber(d[2])) or now++tokens = math.min(burst, tokens + (now - at) * rate)++if tokens < need then return tostring(tokens)..':0' end++local left = tokens - need++redis.call('HSET', key, 'tokens', tostring(left), 'at', tostring(now))++redis.call('EXPIRE', key, ttl)++return tostring(left)..':1'`;++// Refill and give `need` back (a run that cost less than the up-front charge), capped at burst.++const REFUND = `++local key = KEYS[1]++local burst = tonumber(ARGV[1])++local rate = tonumber(ARGV[2])++local need = tonumber(ARGV[3])++local now = tonumber(ARGV[4])++local ttl = tonumber(ARGV[5])++local d = redis.call('HMGET', key, 'tokens', 'at')++local tokens = (d[1] and tonumber(d[1])) or burst++local at = (d[2] and tonumber(d[2])) or now++tokens = math.min(burst, tokens + (now - at) * rate)++local left = math.min(burst, tokens + need)++redis.call('HSET', key, 'tokens', tostring(left), 'at', tostring(now))++redis.call('EXPIRE', key, ttl)++return tostring(left)`;++++function createValkeyBuckets(valkey, log) {++ const c = valkey.client;++ if (typeof c.ovBucketSpend !== 'function') c.defineCommand('ovBucketSpend', { lua: SPEND });++ if (typeof c.ovBucketRefund !== 'function') c.defineCommand('ovBucketRefund', { lua: REFUND });++ let up = true;++ return {++ get down() { return !up; },++ /** { left, ok } — ok false means the bucket was short and nothing was taken. null: Valkey is down. */++ async spend(id, lim, need, t) {++ try {++ const r = String(await c.ovBucketSpend(1, valkey.key('bucket', id), String(lim.burst), String(lim.perMinute / 60_000), String(need), String(t), String(600)));++ const [left, ok] = r.split(':');++ return { left: Number(left), ok: ok === '1' };++ } catch (err) { up = false; log.warn(`[Quota] Valkey buckets unavailable (${err.message}); counting in this process`); return null; }++ },++ async refund(id, lim, need, t) {++ try { await c.ovBucketRefund(1, valkey.key('bucket', id), String(lim.burst), String(lim.perMinute / 60_000), String(need), String(t), String(600)); } catch { /* fallback ignores */ }++ },++ };++}++/*** @param {object} o-- * @param {object} o.store guard store (dayUsed, dayAdd)++ * @param {object} o.store guard store (async dayUsed, dayAdd)* @param {() => object} o.quotas the QUOTAS table (limits.quotas())* @param {() => number} [o.now]* @param {number} [o.maxBuckets]++ * @param {object} [o.valkey] openvibe-sdk/valkey connection (shared buckets); absent → in-process*/function createQuotas(o) {const now = o.now || Date.now;const buckets = new Map(); // `${cls}|${key}` → { tokens, at }const maxBuckets = o.maxBuckets || 200_000;++ const log = o.log || console;++ const shared = o.valkey ? createValkeyBuckets(o.valkey, log) : null;let lastSweep = now();function sweep(t) {@@@ -38,7 +103,7 @@ function createQuotas(o) {for (const [k, b] of buckets) if (t - b.at > 10 * 60_000) buckets.delete(k);}-- /** A bucket's tokens right now (refilled since it was last touched). */++ /** A bucket's tokens right now (refilled since it was last touched), from the process map. */function level(id, lim, t) {const b = buckets.get(id);if (!b) return lim.burst;@@@ -63,7 +128,7 @@ function createQuotas(o) {* @param {object} q { quotaClass, cost }* @param {object} [opt] { report: charge even when refused }*/-- function check(caller, { quotaClass, cost = 1 }, opt = {}) {++ async function check(caller, { quotaClass, cost = 1 }, opt = {}) {const t = now();sweep(t);const table = o.quotas();@@@ -77,14 +142,21 @@ function createQuotas(o) {const { lim } = c;if (!(lim.burst > 0) || !(lim.perMinute > 0)) continue;const need = Math.min(cost, lim.burst); // one run never costs more than a full bucket-- const tokens = level(`${quotaClass}|${c.key}`, lim, t);++ const id = `${quotaClass}|${c.key}`;const rate = lim.perMinute / 60_000;-- const minuteOk = tokens >= need;-- const used = lim.perDay > 0 ? o.store.dayUsed(day, c.key, quotaClass) : 0;++ // Shared (Valkey): refill and spend in one atomic call, and decide from its answer. Otherwise++ // (no Valkey, or an outage) decide and spend from this process's own map below.++ let tokens, minuteOk, done = false;++ if (shared && !shared.down) {++ const r = await shared.spend(id, lim, need, t);++ if (r) { tokens = r.ok ? r.left + need : r.left; minuteOk = r.ok; done = true; }++ }++ if (!done) { tokens = level(id, lim, t); minuteOk = tokens >= need; }++ const used = lim.perDay > 0 ? await o.store.dayUsed(day, c.key, quotaClass) : 0;const dayOk = !(lim.perDay > 0) || used + cost <= lim.perDay;const after = Math.max(0, tokens - need);views.push({-- c, need, tokens, rate, dayUsed: used,++ c, need, tokens, rate, dayUsed: used, done,minute: { limit: lim.burst, remaining: Math.floor(after), reset: Math.ceil((lim.burst - after) / rate / 1000) },day: lim.perDay > 0 ? { limit: lim.perDay, remaining: Math.max(0, Math.floor(lim.perDay - used - cost)), reset: nextDay } : null,});@@@ -100,8 +172,8 @@ function createQuotas(o) {}if (ok || opt.report) {for (const v of views) {-- buckets.set(`${quotaClass}|${v.c.key}`, { tokens: Math.max(0, v.tokens - v.need), at: t });-- if (v.c.lim.perDay > 0) o.store.dayAdd(day, v.c.key, quotaClass, cost);++ if (!v.done) await spend(`${quotaClass}|${v.c.key}`, v.c.lim, v.need, t, v.tokens);++ if (v.c.lim.perDay > 0) await o.store.dayAdd(day, v.c.key, quotaClass, cost);}}// The headers describe the allowance closest to running out, in units of this run's cost.@@@ -119,12 +191,23 @@ function createQuotas(o) {};}++ /** Take `need` from a bucket: shared (Valkey) when it is up, this process otherwise. */++ async function spend(id, lim, need, t, peekedTokens) {++ if (shared && !shared.down) {++ if (await shared.spend(id, lim, need, t) != null) return;++ }++ const left = Math.max(0, peekedTokens - need);++ buckets.set(id, { tokens: left, at: t });++ if (buckets.size > maxBuckets) buckets.delete(buckets.keys().next().value);++ }++/** Give tokens back (a run that turned out to cost less than was taken up front). Day units stay counted. */-- function refund(caller, { quotaClass, cost }) {++ async function refund(caller, { quotaClass, cost }) {if (!(cost > 0)) return;const t = now();for (const c of chargesFor(caller, quotaClass, o.quotas())) {const id = `${quotaClass}|${c.key}`;++ if (shared && !shared.down) { await shared.refund(id, c.lim, cost, t); continue; }if (buckets.has(id)) buckets.set(id, { tokens: Math.min(c.lim.burst, level(id, c.lim, t) + cost), at: t });}}@@@ -1,49 +1,82 @@'use strict';// Sign-out everywhere for every Tools app (roadmap WS-B task 4; Contracts 0.39.0--// network.user.token_valid_after). The gateway receives the event and keeps each person's cutoff in--// one SQLite file on the host (apps/gateway/server/revocation-events.js); every app's guard reads it--// here and treats a token issued before the cutoff as nobody signed in (Network's rule:--// iat * 1000 < valid_after). Lookups are memoised briefly per subject, so a revocation reaches every--// app within `ttlMs`. Without better-sqlite3 or before the gateway has written the file, nothing is revoked.--const fs = require('fs');--const path = require('path');++// network.user.token_valid_after). The cutoff lives in PostgreSQL (token_revocations, plan T8++// decision 4): the gateway writes it through openvibe-sdk/auth createPgRevocationStore++// (apps/gateway/server/revocation-events.js), and every app's guard reads it here.++//++// A read happens on the request path (isRevoked), so it stays synchronous against a short in-memory++// cache loaded from PostgreSQL and refreshed at most every `ttlMs`. With a Valkey connection the++// refresh is driven by a shared version counter the writer bumps, so a revocation reaches every++// process as soon as its next refresh (or the ttl elapses). Without a database (tests, or a database++// outage) nothing is revoked, exactly as before the shared store existed — never a hard failure on a++// request.++//++// const cutoffs = createCutoffReader({ db, valkey });++// cutoffs.isRevoked(claims) → boolean (from the cache)++// await cutoffs.refresh() → reloads from PostgreSQL now--// In the gateway's data directory: the one place its unit may write (ReadWritePaths); the other apps only read it.--const DEFAULT_FILE = path.resolve(__dirname, '..', '..', 'gateway', 'data', 'token-revocations.db');++const DEFAULT_TABLE = 'token_revocations';--function revocationsFile(env = process.env) { return env.TOOLS_REVOCATIONS_DB || DEFAULT_FILE; }++/**++ * @param {object} o++ * @param {object} [o.db] openvibe-sdk/db handle (token_revocations); absent → nothing revoked++ * @param {object} [o.valkey] openvibe-sdk/valkey connection; a shared refresh version++ * @param {string} [o.table='token_revocations']++ * @param {number} [o.ttlMs=15_000] how long a loaded cache is trusted before the next read refreshes it++ * @param {() => number} [o.now]++ * @param {object} [o.log]++ */++function createCutoffReader(o = {}) {++ const db = o.db || null;++ const valkey = o.valkey || null;++ const table = o.table || DEFAULT_TABLE;++ const ttlMs = o.ttlMs == null ? 15_000 : o.ttlMs;++ const now = o.now || Date.now;++ const log = o.log || console;++ const cache = new Map(); // subject → cutoff ms++ let loadedAt = 0;++ let version = null;++ let inFlight = null;--function createCutoffReader({ Database = null, file = revocationsFile(), ttlMs = 15_000, now = Date.now } = {}) {-- let stmt = null, lastTry = 0;-- const memo = new Map();-- function open() {-- if (stmt || !Database) return stmt;-- if (now() - lastTry < 10_000) return null;-- lastTry = now();-- try {-- if (!fs.existsSync(file)) return null;-- const db = new Database(file, { readonly: true, fileMustExist: true });-- db.pragma('busy_timeout = 1000');-- stmt = db.prepare('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?');-- } catch { stmt = null; }-- return stmt;-- }function cutoffFor(subject) {if (!subject) return 0;-- const hit = memo.get(subject);-- if (hit && now() - hit.at < ttlMs) return hit.ms;-- let ms = 0;-- const s = open();-- if (s) { try { const row = s.get(subject); ms = row ? Number(row.valid_after_ms) || 0 : 0; } catch { ms = hit ? hit.ms : 0; } }-- if (memo.size > 20000) memo.clear();-- memo.set(subject, { at: now(), ms });-- return ms;++ if (db && now() - loadedAt > ttlMs) void refresh(); // background; this read uses the cache++ return cache.get(subject) || 0;}function isRevoked(claims) {if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;return claims.iat * 1000 < cutoffFor(claims.subject_id);}-- return { isRevoked, cutoffFor };++++ const versionKey = () => valkey.key('token_revocations_version');++ async function refresh() {++ if (!db) return 0;++ if (inFlight) return inFlight;++ inFlight = (async () => {++ try {++ // With Valkey, an unchanged version means no writer moved a cutoff: the cache still holds.++ if (valkey) {++ const v = await valkey.client.get(versionKey());++ if (v && v === version && loadedAt) { loadedAt = now(); return cache.size; }++ version = v || version;++ }++ const rows = await db.prepare(`SELECT subject_id, valid_after_ms FROM ${table}`).all();++ cache.clear();++ for (const r of rows) cache.set(r.subject_id, Number(r.valid_after_ms) || 0);++ loadedAt = now();++ return cache.size;++ } catch (err) {++ log.warn && log.warn(`[Guard] revocations read failed: ${err.message}; keeping the last cutoffs`);++ loadedAt = now();++ return cache.size;++ } finally { inFlight = null; }++ })();++ return inFlight;++ }++ /** Load the cutoffs once at boot (the gateway's writer bumps the shared version on every write). */++ async function warm() { return refresh(); }++++ return { isRevoked, cutoffFor, refresh, warm, size: () => cache.size };}--module.exports = { createCutoffReader, revocationsFile, DEFAULT_FILE };++module.exports = { createCutoffReader, DEFAULT_TABLE };@@@ -1,152 +1,137 @@'use strict';// ═══════════════════════════════════════════════════════════════--// guard.db — the guard's own small SQLite file in each app's data directory (better-sqlite3 handle--// class passed in, like the job runtime). Three tables:++// The guard's own state (plan T8, decision 4)://--// guard_salt today's random salt (one row; the previous day's is deleted when the day turns). Every--// address the guard counts or logs is HMAC-SHA256(salt, address) — never the address.--// Kept on disk so a restart keeps today's counts; gone the next day, so older hashes--// can no longer be tied to anything (pseudonymous, ADR-021).--// guard_day quota units used per UTC day, key and quota class (the day allowances survive restarts;--// rows older than yesterday are pruned).--// guard_abuse the abuse log: time, hashed address, principal or user id, tool, reason, whether it was--// refused (enforce) or only reported; repeats of the same row within a minute add to--// `count`. Kept 30 days.++// guard_abuse PostgreSQL (migrations/0001_tools.sql): the 30-day audit trail, one row per++// minute/address/principal/tool/reason/enforced, `count` incremented on repeat. Every++// app writes it with its own `app`. Writes are off the request path (a failed write is++// logged, never thrown), and the process keeps a bounded in-memory tail for /metrics,++// the admin endpoint and the tests.++// guard_salt Valkey, key guard_salt:<day>, two-day TTL, SETNX race — all processes agree on the++// same HMAC salt without sharing a file. Memoised, loaded in the background (warm()).++// guard_day Valkey hash guard_day:<day>, field <key>|<class> → used, TTL past midnight UTC.++// A daily *throttle*: worthless a day later, so it belongs in Valkey, not PostgreSQL.//--// Without a Database class (an app that has none) the same API is kept in memory.++// Without a database or Valkey (tests, or a Valkey outage) every counter and the salt stay in this++// process, exactly what the guard did before: a throttle that never fails open for money or accounts++// and never fails every request.// ═══════════════════════════════════════════════════════════════const crypto = require('crypto');--const fs = require('fs');--const path = require('path');const DAY_MS = 24 * 60 * 60 * 1000;const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);++const SALT_TTL_S = 2 * DAY_MS / 1000;++const MAX_ABUSE = 10000;--const SCHEMA = `--CREATE TABLE IF NOT EXISTS guard_salt (-- day TEXT PRIMARY KEY,-- salt TEXT NOT NULL--);--CREATE TABLE IF NOT EXISTS guard_day (-- day TEXT NOT NULL,-- key TEXT NOT NULL,-- class TEXT NOT NULL,-- used REAL NOT NULL DEFAULT 0,-- PRIMARY KEY (day, key, class)--);--CREATE TABLE IF NOT EXISTS guard_abuse (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- at INTEGER NOT NULL,-- minute INTEGER NOT NULL,-- ip_hash TEXT NOT NULL DEFAULT '',-- principal TEXT NOT NULL DEFAULT '',-- tool TEXT NOT NULL DEFAULT '',-- reason TEXT NOT NULL,-- enforced INTEGER NOT NULL,-- count INTEGER NOT NULL DEFAULT 1--);--CREATE UNIQUE INDEX IF NOT EXISTS guard_abuse_once ON guard_abuse(minute, ip_hash, principal, tool, reason, enforced);--CREATE INDEX IF NOT EXISTS guard_abuse_at ON guard_abuse(at);--`;++/** Seconds until just after the next UTC midnight (a day counter's TTL). */++function dayTtl(t) { return Math.ceil((Math.floor(t / DAY_MS) + 1) * DAY_MS / 1000) - Math.floor(t / 1000) + 3600; }/*** @param {object} o-- * @param {Function} [o.Database] require('better-sqlite3'); omitted → in memory-- * @param {string} [o.dataDir]-- * @param {string} [o.file='guard.db']++ * @param {object} [o.db] openvibe-sdk/db handle (guard_abuse); absent → process-only++ * @param {object} [o.valkey] openvibe-sdk/valkey connection; absent → counters and salt in-process++ * @param {string} [o.app] the owning app, written in every guard_abuse row* @param {() => number} [o.now]++ * @param {object} [o.log]*/function createGuardStore(o = {}) {const now = o.now || Date.now;-- if (!o.Database || !o.dataDir) return memoryStore(now);-- try { return sqliteStore(o, now); } catch (err) {-- // An unwritable data directory must not take the app down: quotas and the log stay in memory.-- (o.log || console).error(`[Guard] ${path.join(o.dataDir, o.file || 'guard.db')} cannot be opened (${err.message}); keeping guard state in memory`);-- return memoryStore(now);++ const log = o.log || console;++ const app = o.app || '';++ const db = o.db || null;++ const valkey = o.valkey || null;++ const salts = new Map(); // day → salt++ const days = new Map(); // `${day}|${key}|${cls}` → used (process view and fallback)++ const abuse = []; // this process's bounded tail of the audit log++ let cacheDay = null;++ let saltUp = !!valkey; // flipped off for the process on a Valkey error++ let dayUp = !!valkey;++++ // ── guard_salt (Valkey) ──────────────────────────────────++ function salt(day = dayOf(now())) {++ const hit = salts.get(day);++ if (hit) return hit;++ const local = crypto.randomBytes(32).toString('hex'); // used until Valkey answers++ salts.set(day, local);++ if (saltUp) void loadSalt(day);++ return local;}--}++ async function loadSalt(day) {++ try {++ const key = valkey.key('guard_salt', day);++ let v = await valkey.client.get(key);++ if (!v) {++ await valkey.client.set(key, crypto.randomBytes(32).toString('hex'), 'NX', 'EX', SALT_TTL_S);++ v = await valkey.client.get(key);++ }++ if (v) {++ salts.set(day, v);++ for (const k of salts.keys()) if (k !== day) salts.delete(k);++ }++ } catch (err) {++ saltUp = false;++ log.warn(`[Guard] ${app}: Valkey salt unavailable (${err.message}); this process keeps its own`);++ }++ }++ /** Load (or create) today's shared salt; called once at boot. */++ async function warm() { if (valkey) await loadSalt(dayOf(now())); }--function sqliteStore(o, now) {-- fs.mkdirSync(o.dataDir, { recursive: true });-- const db = new o.Database(path.join(o.dataDir, o.file || 'guard.db'));-- db.pragma('journal_mode = WAL');-- db.pragma('synchronous = NORMAL');-- db.pragma('busy_timeout = 5000');-- db.exec(SCHEMA);-- const st = {-- saltGet: db.prepare('SELECT salt FROM guard_salt WHERE day = ?'),-- saltPut: db.prepare('INSERT OR IGNORE INTO guard_salt (day, salt) VALUES (?, ?)'),-- saltDrop: db.prepare('DELETE FROM guard_salt WHERE day <> ?'),-- dayGet: db.prepare('SELECT used FROM guard_day WHERE day = ? AND key = ? AND class = ?'),-- dayAdd: db.prepare('INSERT INTO guard_day (day, key, class, used) VALUES (?, ?, ?, ?) ON CONFLICT(day, key, class) DO UPDATE SET used = used + excluded.used'),-- dayPrune: db.prepare('DELETE FROM guard_day WHERE day < ?'),-- abuse: db.prepare(`INSERT INTO guard_abuse (at, minute, ip_hash, principal, tool, reason, enforced) VALUES (?, ?, ?, ?, ?, ?, ?)-- ON CONFLICT(minute, ip_hash, principal, tool, reason, enforced) DO UPDATE SET count = count + 1, at = excluded.at`),-- abusePrune: db.prepare('DELETE FROM guard_abuse WHERE at < ?'),-- abuseAll: db.prepare('SELECT at, ip_hash, principal, tool, reason, enforced, count FROM guard_abuse ORDER BY id'),-- };-- const cache = new Map(); // `${day}|${key}|${cls}` → used (today only)-- let cacheDay = null;-- const saltTx = db.transaction((day) => {-- st.saltPut.run(day, crypto.randomBytes(32).toString('hex'));-- st.saltDrop.run(day);-- return st.saltGet.get(day).salt;-- });-- return {-- kind: 'sqlite',-- db,-- salt(day = dayOf(now())) {-- const row = st.saltGet.get(day);-- return row ? row.salt : saltTx(day);-- },-- dayUsed(day, key, cls) {-- if (cacheDay !== day) { cache.clear(); cacheDay = day; }-- const k = `${day}|${key}|${cls}`;-- if (!cache.has(k)) { const r = st.dayGet.get(day, key, cls); cache.set(k, r ? r.used : 0); }-- return cache.get(k);-- },-- dayAdd(day, key, cls, n) {-- if (!(n > 0)) return;-- const used = this.dayUsed(day, key, cls) + n;-- cache.set(`${day}|${key}|${cls}`, used);-- st.dayAdd.run(day, key, cls, n);-- },-- pruneDays(keepFromDay) { return st.dayPrune.run(keepFromDay).changes; },-- logAbuse(r) {-- const at = r.at || now();-- st.abuse.run(at, Math.floor(at / 60_000), r.ipHash || '', r.principal || '', r.tool || '', String(r.reason), r.enforced ? 1 : 0);-- },-- pruneAbuse(beforeMs) { return st.abusePrune.run(beforeMs).changes; },-- abuseRows() { return st.abuseAll.all(); },-- close() { try { db.close(); } catch { /* already closed */ } },-- };--}++ // ── guard_day (Valkey) ───────────────────────────────────++ async function dayUsed(day, key, cls) {++ if (cacheDay !== day) { days.clear(); cacheDay = day; }++ const k = `${day}|${key}|${cls}`;++ if (dayUp) {++ try {++ const v = await valkey.client.hget(valkey.key('guard_day', day), `${key}|${cls}`);++ const n = v == null ? 0 : Number(v);++ days.set(k, n);++ return n;++ } catch (err) { dayUp = false; log.warn(`[Guard] ${app}: Valkey day counters unavailable (${err.message}); counting in this process`); }++ }++ return days.get(k) || 0;++ }++ async function dayAdd(day, key, cls, n) {++ if (!(n > 0)) return;++ const k = `${day}|${key}|${cls}`;++ days.set(k, (days.get(k) || 0) + n);++ if (!dayUp) return;++ try {++ const h = valkey.key('guard_day', day);++ await valkey.client.hincrbyfloat(h, `${key}|${cls}`, n);++ await valkey.client.expire(h, dayTtl(now()));++ } catch (err) { dayUp = false; log.warn(`[Guard] ${app}: Valkey day counters unavailable (${err.message}); counting in this process`); }++ }++++ // ── guard_abuse (PostgreSQL) ─────────────────────────────++ function logAbuse(r) {++ const at = r.at || now();++ const row = { at, minute: Math.floor(at / 60_000), ip_hash: r.ipHash || '', principal: r.principal || '', tool: r.tool || '', reason: String(r.reason), enforced: r.enforced ? 1 : 0, count: 1 };++ const same = abuse.find((x) => x.minute === row.minute && x.ip_hash === row.ip_hash && x.principal === row.principal && x.tool === row.tool && x.reason === row.reason && x.enforced === row.enforced);++ if (same) { same.count++; same.at = at; } else { abuse.push(row); if (abuse.length > MAX_ABUSE) abuse.shift(); }++ if (db) void insertAbuse(row).catch((err) => log.error(`[Guard] ${app}: abuse log write failed: ${err.message}`));++ }++ async function insertAbuse(row) {++ await db.query(++ `INSERT INTO guard_abuse (app, at, minute, ip_hash, principal, tool, reason, enforced) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)++ ON CONFLICT (app, minute, ip_hash, principal, tool, reason, enforced) DO UPDATE SET count = guard_abuse.count + 1, at = excluded.at`,++ [app, row.at, row.minute, row.ip_hash, row.principal, row.tool, row.reason, row.enforced]);++ }++ /** This process's tail of the log (sync: tests and the debug endpoint). */++ function abuseRows() { return abuse.map(({ minute, ...r }) => r); }++++ async function pruneAbuse(beforeMs) {++ for (let i = abuse.length - 1; i >= 0; i--) if (abuse[i].at < beforeMs) abuse.splice(i, 1);++ if (!db) return 0;++ const n = await db.exec('DELETE FROM guard_abuse WHERE at < $1', [beforeMs]);++ return n || 0;++ }++ // guard_day rows live in Valkey and expire on their own.++ async function pruneDays() { return 0; }++++ async function close() { /* the app owns db and valkey */ }--function memoryStore(now) {-- let salt = null;-- const days = new Map();-- const abuse = [];-- return {-- kind: 'memory',-- db: null,-- salt(day = dayOf(now())) {-- if (!salt || salt.day !== day) salt = { day, salt: crypto.randomBytes(32).toString('hex') };-- return salt.salt;-- },-- dayUsed(day, key, cls) { return days.get(`${day}|${key}|${cls}`) || 0; },-- dayAdd(day, key, cls, n) { if (n > 0) days.set(`${day}|${key}|${cls}`, this.dayUsed(day, key, cls) + n); },-- pruneDays(keepFromDay) { let n = 0; for (const k of days.keys()) if (k.slice(0, 10) < keepFromDay) { days.delete(k); n++; } return n; },-- logAbuse(r) {-- const at = r.at || now();-- const row = { at, minute: Math.floor(at / 60_000), ip_hash: r.ipHash || '', principal: r.principal || '', tool: r.tool || '', reason: String(r.reason), enforced: r.enforced ? 1 : 0, count: 1 };-- const same = abuse.find(x => x.minute === row.minute && x.ip_hash === row.ip_hash && x.principal === row.principal && x.tool === row.tool && x.reason === row.reason && x.enforced === row.enforced);-- if (same) { same.count++; same.at = at; } else { abuse.push(row); if (abuse.length > 10000) abuse.shift(); }-- },-- pruneAbuse(beforeMs) { const n = abuse.length; for (let i = abuse.length - 1; i >= 0; i--) if (abuse[i].at < beforeMs) abuse.splice(i, 1); return n - abuse.length; },-- abuseRows() { return abuse.map(({ minute, ...r }) => r); },-- close() {},-- };++ return { kind: db ? 'postgres' : 'memory', app, db, valkey, salt, warm, dayUsed, dayAdd, logAbuse, abuseRows, pruneAbuse, pruneDays, close };}module.exports = { createGuardStore, dayOf, DAY_MS };@@@ -90,8 +90,21 @@ const checks = {};},++ /** A real round trip on the one `tools` database (an openvibe-sdk/db handle, or a function++ * returning one); the detail names the store it answered from (postgresql / pglite). */++ postgres(name, db, { required = true, description } = {}) {++ return {++ name, required, description,++ check: async () => {++ const h = typeof db === 'function' ? db() : db;++ if (!h) return 'database not open';++ return h.ready();++ },++ };++ },++/** This process can write to the directory (a real create + remove). A missing directory is-- * created first, as the app itself does on first use (mkdir -p). */++ * created first, as the app itself does on first boot (mkdir -p). */writableDir(name, dir, { required = true, description } = {}) {return {name, required, description,@@@ -16,7 +16,7 @@ const { dep } = require('./deps');const express = dep('express');const cookieParser = dep('cookie-parser');const multer = dep('multer');--const Database = dep('better-sqlite3');++const Database = dep('better-sqlite3'); // the job store is still SQLite (Phases 2–3 not done)const contracts = dep('openvibe-contracts');const metrics = dep('openvibe-shared/metrics');const guardLib = require('../guard');@@@ -43,6 +43,41 @@ const svcToken = (name, extra = {}) => contracts.serviceAuth.signServiceToken({const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ov-guard-'));const quiet = { log() {}, warn() {}, error() {} };++// A stand-in for openvibe-sdk/valkey: the same key()/client shape over one Map, so two guards can++// share "one Valkey" in-process. The bucket's two Lua commands are emulated in JS (the real ones are++// atomic; a single-threaded test has no race to lose).++function fakeValkey({ down = false } = {}) {++ const store = new Map();++ const guard = () => { if (down) throw new Error('valkey down'); };++ const client = {++ async get(k) { guard(); return store.has(k) ? store.get(k) : null; },++ async set(k, v) { guard(); if (store.has(k)) return null; store.set(k, String(v)); return 'OK'; },++ async hget(k, f) { guard(); const h = store.get(k); return h && h[f] != null ? h[f] : null; },++ async hincrbyfloat(k, f, n) { guard(); const h = store.get(k) || {}; h[f] = String(Number(h[f] || 0) + Number(n)); store.set(k, h); return h[f]; },++ async expire() { guard(); return 1; },++ async incr(k) { guard(); const v = Number(store.get(k) || 0) + 1; store.set(k, String(v)); return v; },++ defineCommand(name) {++ client[name] = async (_numKeys, key, burst, rate, need, now) => {++ guard();++ const h = store.get(key) || {};++ let tokens = h.tokens != null ? Number(h.tokens) : Number(burst);++ const at = h.at != null ? Number(h.at) : Number(now);++ tokens = Math.min(Number(burst), tokens + (Number(now) - at) * Number(rate));++ if (name === 'ovBucketRefund') { // give back, capped at burst++ const left = Math.min(Number(burst), tokens + Number(need));++ store.set(key, { tokens: String(left), at: String(now) });++ return String(left);++ }++ if (tokens < Number(need)) return `${tokens}:0`; // short: take nothing++ const left = tokens - Number(need);++ store.set(key, { tokens: String(left), at: String(now) });++ return `${left}:1`;++ };++ },++ };++ return { client, key: (...p) => p.join(':'), ready: async () => (down ? { ok: false, error: 'down' } : { ok: true }), close: async () => {} };++}++// Pure-data descriptors of the real apps (nothing loads sharp or ffmpeg).const IMG = require('../../img/server/descriptors').SPECS;const AUDIO = require('../../audio/server/descriptors').SPECS;@@@ -50,7 +85,7 @@ const NET = require('../../gateway/server/net/descriptors').SPECS;function makeGuard(o = {}) {return createGuard({-- app: o.app || 'test', dataDir: o.dataDir, Database: o.dataDir ? Database : undefined, contracts,++ app: o.app || 'test', db: o.db, valkey: o.valkey, contracts,specs: o.specs || [...IMG, ...AUDIO, ...NET], issuer: ISSUER,keys: { get: () => publicKey, ensure: async () => publicKey },env: { TOOLS_GUARD: o.mode || 'report', ...(o.env || {}) }, log: quiet, now: o.now, pruneIntervalMs: 0,@@@ -140,28 +175,30 @@ async function serve(app) {assert.strictEqual(cost('png'), 5);assert.strictEqual(cost('avif'), 8, 'AVIF encoding weighs more');assert.strictEqual(cost('merge'), 20);-- // tools-job anonymous: a bucket of 30 refilled at 60 a minute.-- for (let i = 0; i < 3; i++) assert.ok(q.check(anon, { quotaClass: 'tools-job', cost: cost('avif') }).ok, `avif ${i + 1}`);-- let r = q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });++ // tools-job anonymous: a bucket of 30 refilled at 60 a minute. check() is async since the day++ // allowance and the minute/burst buckets became shared (Valkey, plan T8 decision 4).++ for (let i = 0; i < 3; i++) assert.ok((await q.check(anon, { quotaClass: 'tools-job', cost: cost('avif') })).ok, `avif ${i + 1}`);++ let r = await q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });assert.ok(r.ok, 'png: 24 + 5 = 29 of 30');assert.strictEqual(r.limit, 30);assert.strictEqual(r.remaining, 1);-- r = q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });++ r = await q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });assert.strictEqual(r.ok, false, 'the next png does not fit');assert.strictEqual(r.binding, 'minute');assert.strictEqual(r.retryAfter, 4, 'four more units at one a second');clock += 4000;-- assert.ok(q.check(anon, { quotaClass: 'tools-job', cost: cost('png') }).ok, 'refilled');++ assert.ok((await q.check(anon, { quotaClass: 'tools-job', cost: cost('png') })).ok, 'refilled');// Tiers: a signed-in person has four times the bucket, a service forty.const user = { tier: 'user', key: 'user:x', ipKey: 'ip:a' };-- let n = 0; while (q.check(user, { quotaClass: 'tools-job', cost: 5 }).ok) n++;++ let n = 0; while ((await q.check(user, { quotaClass: 'tools-job', cost: 5 })).ok) n++;assert.strictEqual(n, 24, 'user burst 120 / 5');// Sessions from one address share SESSION_IP_SHARE × one session's allowance: dropping the cookie is no reset.const sessions = [1, 2, 3, 4].map(i => ({ tier: 'session', key: `session:${i}`, ipKey: 'ip:nat' }));-- const spent = sessions.map(sess => { let k = 0; while (q.check(sess, { quotaClass: 'tools-job', cost: 5 }).ok) k++; return k; });++ const spent = [];++ for (const sess of sessions) { let k = 0; while ((await q.check(sess, { quotaClass: 'tools-job', cost: 5 })).ok) k++; spent.push(k); }assert.deepStrictEqual(spent, [9, 9, 9, 0], 'three sessions use the address allowance, a fourth cookie gets nothing more');// A run costing more than the whole bucket still runs when the bucket is full.-- assert.ok(q.check({ tier: 'anonymous', key: 'ip:big', ipKey: 'ip:big' }, { quotaClass: 'tools-download', cost: 500 }).ok);++ assert.ok((await q.check({ tier: 'anonymous', key: 'ip:big', ipKey: 'ip:big' }, { quotaClass: 'tools-download', cost: 500 })).ok);}// ── Report vs enforce; headers and problem+json; the metric; the challenge hook ──@@@ -237,32 +274,52 @@ async function serve(app) {} finally { await s.close(); g.close(); }}-- // ── Day allowances survive a restart (guard.db) ──++ // ── One Valkey, two processes: one shared day allowance and bucket (plan T8 decision 4) ──++ // (Replaces the old "day allowances survive a restart" test: the allowance no longer lives in a++ // per-app guard.db, it lives in Valkey, so the invariant to test is two processes against one store.){-- const dataDir = tmp();-- const env = { TOOLS_GUARD: 'enforce', TOOLS_GUARD_LIMITS: JSON.stringify({ 'tools-fetch': { anonymous: { perMinute: 600, burst: 600, perDay: 10 } } }) };++ const valkey = fakeValkey();++ const env = { TOOLS_GUARD: 'enforce', TOOLS_GUARD_LIMITS: JSON.stringify({++ 'tools-fetch': { anonymous: { perMinute: 600, burst: 600, perDay: 10 } },++ 'tools-tiny': { anonymous: { perMinute: 60, burst: 4, perDay: 1000 } },++ }) };const req = { ip: '198.51.100.20', headers: {}, cookies: {} };-- let g = makeGuard({ dataDir, env });-- const caller1 = g.resolveCaller(req);-- assert.ok(g.quotas.check(caller1, { quotaClass: 'tools-fetch', cost: 4 }).ok);-- assert.ok(g.quotas.check(caller1, { quotaClass: 'tools-fetch', cost: 4 }).ok);-- g.close();-- g = makeGuard({ dataDir, env });-- const caller2 = g.resolveCaller(req);-- assert.strictEqual(caller2.ipKey, caller1.ipKey, "today's salt is kept, so the same address is the same key after a restart");-- const r = g.quotas.check(caller2, { quotaClass: 'tools-fetch', cost: 4 });-- assert.strictEqual(r.ok, false, '8 of 10 used before the restart: 4 more do not fit');++ const a = makeGuard({ valkey, env });++ const b = makeGuard({ valkey, env });++ await a.store.warm(); await b.store.warm();++ const ca = a.resolveCaller(req);++ const cb = b.resolveCaller(req);++ assert.strictEqual(cb.ipKey, ca.ipKey, "one salt in Valkey: the same address is the same key in both processes");++ assert.ok((await a.quotas.check(ca, { quotaClass: 'tools-fetch', cost: 4 })).ok);++ assert.ok((await a.quotas.check(ca, { quotaClass: 'tools-fetch', cost: 4 })).ok, 'process A has spent 8 of the day allowance');++ const r = await b.quotas.check(cb, { quotaClass: 'tools-fetch', cost: 4 });++ assert.strictEqual(r.ok, false, "process B sees A's spend: 4 more do not fit");assert.strictEqual(r.binding, 'day');-- assert.ok(g.quotas.check(caller2, { quotaClass: 'tools-fetch', cost: 2 }).ok, 'the last 2 still do');++ assert.ok((await b.quotas.check(cb, { quotaClass: 'tools-fetch', cost: 2 })).ok, 'the last 2 still do');++ // The minute/burst bucket is shared too: A drains it, B is refused on the minute leg.++ for (let i = 0; i < 4; i++) assert.ok((await a.quotas.check(ca, { quotaClass: 'tools-tiny', cost: 1 })).ok, `tiny ${i + 1}`);++ const rm = await b.quotas.check(cb, { quotaClass: 'tools-tiny', cost: 1 });++ assert.strictEqual(rm.ok, false, "process B sees A's bucket: the minute leg is shared");++ assert.strictEqual(rm.binding, 'minute');++ a.close(); b.close();++ }++++ // ── A Valkey outage degrades to this process's own counters: a throttle, never a failure ──++ {++ const valkey = fakeValkey({ down: true });++ const env = { TOOLS_GUARD: 'enforce', TOOLS_GUARD_LIMITS: JSON.stringify({ 'tools-fetch': { anonymous: { perMinute: 600, burst: 600, perDay: 2 } } }) };++ const g = makeGuard({ valkey, env });++ const c = g.resolveCaller({ ip: '198.51.100.30', headers: {}, cookies: {} });++ let ok = 0;++ for (let i = 0; i < 6; i++) if ((await g.quotas.check(c, { quotaClass: 'tools-fetch', cost: 1 })).ok) ok++;++ assert.strictEqual(ok, 2, 'the day allowance still holds in this process: the guard never fails open and never fails every request');g.close();-- fs.rmSync(dataDir, { recursive: true, force: true });}// ── The abuse log: no raw address anywhere, 30 days ──{-- const dataDir = tmp();let clock = now0;-- const g = makeGuard({ dataDir, now: () => clock, mode: 'enforce' });++ const g = makeGuard({ now: () => clock, mode: 'enforce' });const RAW = '203.0.113.201';const req = { ip: RAW, headers: {}, cookies: {} };const res = { headersSent: false, statusCode: 200, setHeader() {}, end() {} };@@@ -274,15 +331,12 @@ async function serve(app) {assert.strictEqual(rows[0].count, 3, 'repeats within a minute are one row with a count');assert.match(rows[0].ip_hash, /^[0-9a-f]{32}$/);assert.notStrictEqual(rows[0].ip_hash, rows[1].ip_hash, 'the salt rotates daily: yesterday\'s hash no longer matches');-- g.store.db.pragma('wal_checkpoint(TRUNCATE)');-- for (const f of fs.readdirSync(dataDir)) assert.ok(!fs.readFileSync(path.join(dataDir, f)).includes(RAW), `${f} holds no raw address`);-- assert.strictEqual(g.store.db.prepare('SELECT COUNT(*) AS n FROM guard_salt').get().n, 1, 'only today\'s salt is kept');++ assert.ok(!JSON.stringify(rows).includes(RAW), 'the log keeps no raw address');clock += 6 * 24 * 3600 * 1000; // the first row is now 31 days old, the second 6-- g.prune();++ await g.prune();rows = g.store.abuseRows();assert.deepStrictEqual(rows.map(r => r.reason), ['sniff'], 'rows older than 30 days are pruned');g.close();-- fs.rmSync(dataDir, { recursive: true, force: true });}// ── Upload sniffing: the bytes, not the name or the declared type ──@@@ -514,5 +568,5 @@ async function serve(app) {system.stop(); db.close(); fs.rmSync(dataDir, { recursive: true, force: true });}-- console.log('guard: callers (/64, one loopback hop, services by token, aud-checked users, sessions, sandbox), bucket math + cost, session sharing, report vs enforce, headers + problem+json, day counters across restarts, sniffing, target throttle, port cap, abuse log + prune + metric, semaphore, job bounds (queue, disk, per address), challenge hook, Origin check (CSRF): all checks passed');++ console.log('guard: callers (/64, one loopback hop, services by token, aud-checked users, sessions, sandbox), bucket math + cost, session sharing, report vs enforce, headers + problem+json, two processes one Valkey quota + outage fallback, sniffing, target throttle, port cap, abuse log + prune + metric, semaphore, job bounds (queue, disk, per address), challenge hook, Origin check (CSRF): all checks passed');})().catch((err) => { console.error(err); process.exit(1); });@@@ -239,10 +239,11 @@ function createRunApi(o) {}return c;}-- if (probe) {++ // A signed-in person may run probes through the run API under their per-person quota (plan T8, Phase++ // 1: the net pages and dev.html call the run API with the session token); anonymous stays 401.++ if (probe && c.kind !== 'user') {guard.record(req, c, { reason: 'capability', tool: d.id, enforced: true });-- if (c.kind === 'user') throw new Refusal(403, 'capability.denied', `Network probes run through the API only for apps and services holding ${cap}; people use the tool's page.`);-- throw new Refusal(401, 'token.missing', `${d.id} runs through the API only with a token holding ${cap}; people use the tool's page.`);++ throw new Refusal(401, 'token.missing', `${d.id} runs through the API only with a token holding ${cap}, or a signed-in person on its page.`);}if (d.auth && d.auth.anonymous === false && c.kind === 'anonymous') {guard.record(req, c, { reason: 'session', tool: d.id, enforced: true });@@@ -9,6 +9,7 @@"version": "1.0.0","license": "MIT","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -17,14 +18,28 @@"express-rate-limit": "^7.5.0","fluent-ffmpeg": "^2.1.3","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "pg": "^8.23.0","uuid": "^11.1.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -268,6 +283,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/concat-stream": {"version": "1.6.2","resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",@@@ -388,6 +412,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -838,6 +871,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -914,12 +990,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1227,6 +1315,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1363,6 +1579,27 @@"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==","license": "MIT"},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1578,6 +1815,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -11,8 +11,11 @@"license": "MIT","openvibeToolsShared": "^1.2.0","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0","cors": "^2.8.5","dotenv": "^16.4.7","express": "^4.21.2",@@@ -36,13 +36,8 @@ const sdk = require('openvibe-sdk');// ── Analytics ────────────────────────────────────────────────const Database = require('better-sqlite3');--const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');--const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');--fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });--const analyticsDb = new Database(analyticsDbPath);--analyticsDb.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio', { retention: { days: 30 } });// ── Guard (apps/_shared/guard) ───────────────────────────────// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else@@@ -50,8 +45,12 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio', { retentio// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse. Every audio// tool needs a browser session, a sign-in or a token (descriptor auth.anonymous false).const SPECS = require('./descriptors').SPECS;++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-audio' });++const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-audio', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });const guard = createGuard({-- app: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, specs: SPECS,++ app: 'audio', db: toolsDb.db, valkey: toolsValkey, contracts, specs: SPECS,issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,});const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);@@@ -66,16 +65,19 @@ const release = require('../../_shared/release').toolsRelease('audio', require);// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real// dependencies (roadmap Track O). First, so the HTTP metrics see every request.const { observe, checks: ready, which } = require('../../_shared/observe');++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools-audio', release: release.release,checks: [++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, jobs, analytics, revocations)' }),ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),ready.jobRuntime('job_runtime', () => jobs),ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),-- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),...jobsRuntime.readyChecks(() => jobs),ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'every audio tool runs ffmpeg; without it jobs and conversions fail' }),@@@ -349,12 +351,12 @@ app.get('/api/preview/:id', (req, res) => {// ── Internal Analytics API ────────────────────────────────────const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });--app.get('/api/internal/analytics', internalAccess, (req, res) => {-- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }++app.get('/api/internal/analytics', internalAccess, async (req, res) => {++ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});--app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {-- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }++app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {++ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -406,8 +408,9 @@ require('../../_shared/graceful').gracefulStop({close: [() => require('../../_shared/usage').stopRecorder(800),() => analytics.destroy(),-- () => analyticsDb.close(),() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),],});@@@ -9,6 +9,7 @@"version": "1.0.0","license": "MIT","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -16,16 +17,24 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1","pdf-lib": "^1.17.1",++ "pg": "^8.23.0","sharp": "^0.33.5","uuid": "^11.1.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },"node_modules/@img/sharp-libvips-linux-x64": {"version": "1.0.4","resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",@@@ -64,6 +73,12 @@"@img/sharp-libvips-linux-x64": "1.0.4"}},++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/@pdf-lib/standard-fonts": {"version": "1.0.0","resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",@@@ -320,6 +335,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/color": {"version": "4.2.3","resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",@@@ -481,6 +505,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -917,6 +950,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -993,12 +1069,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1324,6 +1412,134 @@"tslib": "^1.11.1"}},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1460,6 +1676,27 @@"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==","license": "MIT"},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1723,6 +1960,21 @@"is-arrayish": "^0.3.1"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -11,8 +11,11 @@"license": "MIT","openvibeToolsShared": "^1.2.0","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0","cors": "^2.8.5","dotenv": "^16.4.7","express": "^4.21.2",@@@ -36,13 +36,8 @@ const sdk = require('openvibe-sdk');// ── Analytics ────────────────────────────────────────────────const Database = require('better-sqlite3');--const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');--const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');--fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });--const analyticsDb = new Database(analyticsDbPath);--analyticsDb.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs', { retention: { days: 30 } });// ── Guard (apps/_shared/guard) ───────────────────────────────// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else@@@ -50,8 +45,12 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs', { retention// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse. Every PDF// tool needs a browser session, a sign-in or a token (descriptor auth.anonymous false).const SPECS = require('./descriptors').SPECS;++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-docs' });++const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-docs', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });const guard = createGuard({-- app: 'docs', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, specs: SPECS,++ app: 'docs', db: toolsDb.db, valkey: toolsValkey, contracts, specs: SPECS,issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,});const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);@@@ -83,16 +82,19 @@ const release = require('../../_shared/release').toolsRelease('docs', require);// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real// dependencies (roadmap Track O). First, so the HTTP metrics see every request.const { observe, checks: ready } = require('../../_shared/observe');++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools-docs', release: release.release,checks: [++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, jobs, analytics, revocations)' }),ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),ready.jobRuntime('job_runtime', () => jobs),ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),-- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),...BINARIES.map(b => b.readyCheck(b.name === 'qpdf' ? 'Protect and Unlock PDF (package qpdf); the other tools work without it' : 'PDF to image (package poppler-utils); the other tools work without it')),...jobsRuntime.readyChecks(() => jobs),@@@ -369,12 +371,12 @@ app.get('/api/download/:id', (req, res) => {// ── Internal Analytics API ────────────────────────────────────const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });--app.get('/api/internal/analytics', internalAccess, (req, res) => {-- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }++app.get('/api/internal/analytics', internalAccess, async (req, res) => {++ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});--app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {-- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }++app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {++ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -430,9 +432,10 @@ require('../../_shared/graceful').gracefulStop({close: [() => require('../../_shared/usage').stopRecorder(800),() => analytics.destroy(),-- () => analyticsDb.close(),() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them() => pool.close(),() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),],});@@@ -8,6 +8,7 @@"name": "openvibe-food","version": "1.0.0","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -15,10 +16,24 @@"express": "^4.18.2","express-rate-limit": "^7.1.5","helmet": "^7.1.0",++ "iovalkey": "^0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "pg": "^8.23.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -187,6 +202,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -286,6 +310,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -697,6 +730,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -755,12 +831,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1021,6 +1109,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1144,6 +1360,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -1350,6 +1587,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1477,6 +1729,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -14,7 +14,10 @@"cors": "^2.8.5","dotenv": "^16.3.1","express": "^4.18.2",++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0","express-rate-limit": "^7.1.5","helmet": "^7.1.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",@@@ -13,26 +13,24 @@ const fs = require('fs');const cookieParser = require('cookie-parser');// ── Analytics ────────────────────────────────────────────────--const Database = require('better-sqlite3');--const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');const { hostGuard, stampedPage } = require('../../_shared/host-role');const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');const { createGuard, TRUST_PROXY } = require('../../_shared/guard');--const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');--fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });--const analyticsDb = new Database(analyticsDbPath);--analyticsDb.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-food', { retention: { days: 30 } });// ── Guard (apps/_shared/guard) ───────────────────────────────// Who is asking (Network sign-in with aud openvibe.tools, else the address), the tools-api quota and// the abuse log (data/guard.db). The data lookups are counted by maps (tools-map), which gets the// visitor's address from here. TOOLS_GUARD=report (default) records what it would refuse.const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-food' });++const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-food', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });const guard = createGuard({-- app: 'food', dataDir: path.join(__dirname, '..', 'data'), Database, specs: require('../../maps/server/descriptors').SPECS.filter(s => s.id === 'food'),++ app: 'food', db: toolsDb.db, valkey: toolsValkey, specs: require('../../maps/server/descriptors').SPECS.filter(s => s.id === 'food'),issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),});@@@ -46,13 +44,16 @@ const release = require('../../_shared/release').toolsRelease('food', require);// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real// dependencies (roadmap Track O). First, so the HTTP metrics see every request.const { observe, checks: ready } = require('../../_shared/observe');++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools-food', release: release.release,checks: [// Every food API answer comes from the maps satellite; without it this server only has its page.++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),ready.upstream('maps', `${MAPS_API}/api/ready`, { required: true, cacheMs: 5000, description: 'food APIs are proxied to the maps satellite' }),-- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only' }),],});guard.attachMetrics(obs.registry);@@@ -146,12 +147,12 @@ app.get('/api/geocode', proxyToMaps('/api/geocode'));// ── Internal Analytics API ────────────────────────────────────const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });--app.get('/api/internal/analytics', internalAccess, (req, res) => {-- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }++app.get('/api/internal/analytics', internalAccess, async (req, res) => {++ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});--app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {-- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }++app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {++ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -169,7 +170,8 @@ require('../../_shared/graceful').gracefulStop({name: 'Food.OpenVibe', server,close: [() => analytics.destroy(),-- () => analyticsDb.close(),() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),],});@@@ -9,6 +9,7 @@"version": "2.0.0","license": "MIT","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.10.0","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -16,12 +17,26 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "pg": "^8.23.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -223,6 +238,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -322,6 +346,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -758,6 +791,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -822,12 +898,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1104,6 +1192,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1223,6 +1439,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1438,6 +1675,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1565,6 +1817,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -11,8 +11,11 @@"license": "MIT","openvibeToolsShared": "^1.2.0","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.10.0","cookie-parser": "^1.4.7",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0","cors": "^2.8.5","dotenv": "^16.4.7","express": "^4.21.2",@@@ -399,7 +399,10 @@ P.color=(s)=>{};P.opengraph=async(s)=>{-- const r=await fetch(API+'/opengraph?url='+encodeURIComponent(s.trim()));const d=await r.json();if(!d.ok)throw new Error(d.error||'Failed to fetch');++ // The tool's run API (plan T8, Phase 1), with the same tags/seo the old /api/dev/opengraph answered.++ const r=await fetch('/api/v1/tools/opengraph/run',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({input:{url:s.trim()}})});++ const b=await r.json();if(b.state!=='succeeded'||!b.result)throw new Error((b.error&&(b.error.detail||b.error.title))||b.detail||'Failed to fetch');++ const d={ok:true,...b.result.data};const og=d.tags||{};let u;try{u=new URL(s.trim()).hostname}catch{u=''}return{html:'<div class="og-card">'+(og['og:image']?'<img src="'+H(og['og:image'])+'" onerror="this.style.display=\'none\'">':'')+'<div class="og-body"><div class="og-dm">'+H(og['og:site_name']||u)+'</div><div class="og-tt">'+H(og['og:title']||og.title||'No title')+'</div><div class="og-ds">'+H(og['og:description']||og.description||'No description')+'</div></div></div>'+'<div style="margin-top:14px"><div class="jl">All Meta Tags</div>'+Object.entries(og).map(([k,v])=>'<div><span style="color:var(--ac)">'+H(k)+'</span>: '+H(String(v))+'</div>').join('')+'</div>'@@@ -215,6 +215,15 @@border-radius: var(--radius-sm); color: var(--red); font-size: 14px;}++ /* A probe asked for by a signed-out visitor: probes reach other machines, so they need an account. */++ .signin-card { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }++ .signin-card > i { font-size: 22px; color: var(--cyan); flex: none; }++ .signin-card .signin-text { flex: 1 1 240px; min-width: 0; }++ .signin-card .signin-text b { display: block; font-size: 15px; margin-bottom: 4px; color: var(--text-primary); }++ .signin-card .signin-text p { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--text-secondary, #b0b0b8); }++ .signin-card .btn-go { padding: 10px 22px; border-radius: 10px; font-weight: 600; font-size: 14px; background: var(--cyan); color: #fff; white-space: nowrap; }++ .signin-card .btn-go:hover { background: #0891b2; color: #fff; }++/* A tool this server cannot run */.tool-unavailable p { font-size: 14px; color: var(--text-secondary, #b0b0b8); margin: 8px 0 12px; line-height: 1.5; }@@@ -632,10 +641,10 @@// ═══════════════════════════════════════════════════════════// Net.OpenVibe — Client-Side App// Detects subdomain to determine active tool, renders UI,-- // calls /api/net/* endpoints on openvibe.tools.++ // calls the tools run API (/api/v1/tools/<id>/run) on openvibe.tools.// ═══════════════════════════════════════════════════════════-- const API_BASE = 'https://openvibe.tools/api/net';++ const API_BASE = '/api/v1/tools';// ── Tool definitions (mirrors server/net/config.js: endpoint and status must agree, which// test/registry-consistency.test.js checks) ──────@@@ -909,6 +918,12 @@location.href = url;}++ // The message of a run that did not succeed (a refusal problem, or the tool's own error).++ function runError(body) {++ const e = body && body.error;++ return (e && (e.detail || e.title || e.message)) || (body && (body.detail || body.title)) || 'Lookup failed';++ }++// ── Run the active tool ──────────────────────────────────async function runTool() {const input = document.getElementById('search-input');@@@ -928,49 +943,46 @@resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…</p></div>`;try {-- let url = `${API_BASE}${tool.endpoint}`;-- const params = new URLSearchParams();-- if (target) params.set('target', target);++ // The tool's run API (plan T8, Phase 1): the same handlers the old /api/net routes called,++ // in the run response shape. The request is the route's query as an `input` object.++ const input = {};++ if (target) input.target = target;// DNS type overrides-- if (tool.defaultTypes) params.set('types', tool.defaultTypes);++ if (tool.defaultTypes) input.types = tool.defaultTypes.split(',');if (tool.dnsTypes) {const sel = document.getElementById('dns-types');if (sel) {const selected = [...sel.selectedOptions].map(o => o.value);-- if (selected.length) params.set('types', selected.join(','));++ if (selected.length) input.types = selected;}}// DKIM special: query selector._domainkey.domainif (activeTool === 'dkim' && target) {const selector = prompt('DKIM selector (e.g., "default", "google", "s1"):', 'default') || 'default';-- params.set('target', `${selector}._domainkey.${target}`);++ input.target = `${selector}._domainkey.${target}`;}// DMARC: prepend _dmarc.-- if (activeTool === 'dmarc' && target) {-- params.set('target', `_dmarc.${target}`);-- }++ if (activeTool === 'dmarc' && target) input.target = `_dmarc.${target}`;// SPF: just TXT-- if (activeTool === 'spf') params.set('types', 'TXT');-- if (tool.count) params.set('count', String(tool.count));-- if (tool.propTypes) params.set('type', document.getElementById('prop-type').value);-- if (tool.smtpPorts) params.set('port', document.getElementById('smtp-port').value);++ if (activeTool === 'spf') input.types = ['TXT'];++ if (tool.count) input.count = tool.count;++ if (tool.propTypes) input.type = document.getElementById('prop-type').value;++ if (tool.smtpPorts) input.port = parseInt(document.getElementById('smtp-port').value, 10);if (tool.robotsTest) {const p = document.getElementById('robots-path').value.trim();-- if (p) { params.set('path', p); params.set('ua', document.getElementById('robots-ua').value); }++ if (p) { input.path = p; input.ua = document.getElementById('robots-ua').value; }}-- const qs = params.toString();-- if (qs) url += (url.includes('?') ? '&' : '?') + qs;--const token = localStorage.getItem('ov_token') || getCookie('ov_token');-- const headers = {};++ const headers = { 'Content-Type': 'application/json' };if (token) headers['Authorization'] = `Bearer ${token}`;-- const r = await fetch(url, { headers });-- const data = await r.json();---- if (!data.ok) throw new Error(data.error || 'Lookup failed');++ const r = await fetch(`${API_BASE}/${activeTool}/run`, { method: 'POST', headers, credentials: 'same-origin', body: JSON.stringify({ input }) });++ if (r.status === 401) return renderSignIn(resultsEl);++ const body = await r.json();++ if (body.state !== 'succeeded' || !body.result) throw new Error(runError(body));++ const data = { ok: true, ...body.result.data };renderResults(data);} catch (err) {@@@ -978,6 +990,13 @@}}++ // A probe (port, ping, latency, traceroute, mtr) sends traffic to the target, so the run API asks for an account++ // (401); an expired sign-in answers 401 too, and the same card signs the visitor back in.++ function renderSignIn(el) {++ const next = encodeURIComponent(location.href);++ el.innerHTML = `<div class="card signin-card"><i class="fa-solid fa-lock" aria-hidden="true"></i><div class="signin-text"><b>Sign in to run ${esc(tool.name)}</b><p>Probes like ping, port checks and traceroute send traffic to the target itself, so they run under an OpenVibe account. It is free, and lookups like DNS still work without one.</p></div><a class="btn-go" href="https://openvibe.tools/auth/login?next=${next}">Sign in</a></div>`;++ }++// ── Render results based on tool type ────────────────────function renderResults(data) {const el = document.getElementById('results');@@@ -1029,12 +1048,13 @@async function fetchAndAppendIPInfo(el, ip) {try {const token = localStorage.getItem('ov_token') || getCookie('ov_token');-- const headers = {};++ const headers = { 'Content-Type': 'application/json' };if (token) headers['Authorization'] = `Bearer ${token}`;-- const r = await fetch(`${API_BASE}/ip?target=${encodeURIComponent(ip)}`, { headers });-- const data = await r.json();-- if (data.ok) {-- el.innerHTML += renderIPCard(data);++ const r = await fetch(`${API_BASE}/ip/run`, { method: 'POST', headers, credentials: 'same-origin', body: JSON.stringify({ input: { target: ip } }) });++ const body = await r.json();++ if (body.state === 'succeeded' && body.result) {++ const data = { ok: true, ...body.result.data };++ if (data.ok) el.innerHTML += renderIPCard(data);}} catch {}}@@@ -56,13 +56,19 @@ let auth = null; // created below; the key check reads it at request time// the address), the tools-api quota, the net and dev tools' quotas by descriptor, the per-target// throttle, the port-scan cap, webhook bin caps and the abuse log (data/guard.db). The Network key is// the OAuth client's (loaded and retried by it). TOOLS_GUARD=report (default) records what it would refuse.++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-gateway' });++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });++require('./revocation-events').configure({ db: toolsDb.db, valkey: toolsValkey });const guard = createGuard({-- app: 'gateway', dataDir: require('path').resolve(__dirname, '..', process.env.DATA_DIR || 'data'),-- Database: require('better-sqlite3'), contracts: require('openvibe-contracts'),++ app: 'gateway', db: toolsDb.db, valkey: toolsValkey, contracts: require('openvibe-contracts'),specs: [...require('./net/descriptors').SPECS, ...require('./dev/descriptors').SPECS],issuer: config.networkUrl,keys: { get: () => (auth && auth.client.publicKey) || null, ensure: () => (auth ? auth.ensureKey() : Promise.resolve(null)) },});++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools', release: release.release, mountReady: false,@@@ -78,6 +84,7 @@ const obs = observe({name: 'catalog', required: true, description: 'the tool catalog every apex page, host route and proxy decision is built from',check: () => { const n = registry.get().tools.length; return n > 0 ? { ok: true, detail: { tools: n } } : 'catalog is empty'; },},++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, revocations)' }),ready.networkKey('network_key', () => auth && auth.client.publicKey, { description: 'verifies signed-in visitors offline; signed-out use works without it' }),{name: 'service_directory', required: false, description: 'other services\' origins from the Network registry (a built-in fallback list is used without it)',@@@ -542,6 +549,8 @@ require('../../_shared/graceful').gracefulStop({() => require('../../_shared/usage').stopRecorder(800),() => runApi.pool.close(),() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),() => require('./revocation-events').close(),],});@@@ -3,45 +3,41 @@* Sign-out everywhere reaches OpenVibe.Tools (roadmap WS-B task 4; Contracts 0.39.0* network.user.token_valid_after). The gateway is the one consumer: POST /internal/events (the* endpoint of its Events subscription, TOOLS_EVENTS_SECRET, signature v2 only, never through the-- * proxy) writes each person's cutoff into the shared file every app's guard reads-- * (apps/_shared/guard/revocations.js). The subscription is created at boot when EVENTS_URL, the-- * service secret and TOOLS_EVENTS_SECRET are set (grant tools events.subscription.manage).++ * proxy) writes each person's cutoff into PostgreSQL through openvibe-sdk/auth createPgRevocationStore++ * (the `token_revocations` table of the one `tools` database, migrations/0003); every app's guard++ * reads it (apps/_shared/guard/revocations.js). The subscription is created at boot when EVENTS_URL,++ * the service secret and TOOLS_EVENTS_SECRET are set (grant tools events.subscription.manage).++ *++ * The gateway owns the write, so it also bumps the shared Valkey version the readers watch: with it,++ * a revocation reaches every app on their next read rather than after their cache ttl.*/--const fs = require('fs');--const path = require('path');const express = require('express');--const { revocationsFile } = require('../../_shared/guard/revocations');++const { createPgRevocationStore } = require('openvibe-sdk/auth');const TOPIC = 'network.user.token_valid_after';let store = null;let storeDb = null;--function cutoffs() {++let storeValkey = null;++++/** The gateway calls this once, at boot, with the one `tools` database and the shared Valkey. */++function configure({ db = null, valkey = null } = {}) { storeDb = db; storeValkey = valkey; store = null; }++++async function cutoffs() {if (store) return store;-- const Database = require('better-sqlite3');-- const file = revocationsFile();-- fs.mkdirSync(path.dirname(file), { recursive: true });-- const db = new Database(file);-- storeDb = db;-- // A rollback journal, not WAL: the other apps open the file read-only under ProtectSystem=strict and could not-- // create a WAL's -shm file. Writes are one row per sign-out-everywhere.-- db.pragma('journal_mode = DELETE');-- db.pragma('busy_timeout = 2000');-- store = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });++ if (!storeDb) throw new Error('revocation-events: configure({ db }) was not called');++ const s = createPgRevocationStore(storeDb, { table: 'token_revocations' });++ await s.load();++ store = s;return store;}--/** Close the cutoff store (graceful stop); the next request opens it again. */--function close() {-- const db = storeDb;-- store = null;-- storeDb = null;-- if (db) try { db.close(); } catch { /* already closed */ }--}++/** Close the cutoff store (graceful stop); the next request loads it again. */++function close() { store = null; }const secrets = () => String(process.env.TOOLS_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 32);const stats = { received: 0, revoked: 0, refused: 0 };function handler() {const { parseDelivery } = require('openvibe-sdk/events');-- return [express.raw({ type: () => true, limit: '256kb' }), (req, res) => {++ return [express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return res.status(404).json({ error: 'Not found' });const keys = secrets();if (!keys.length) return res.status(503).json({ error: 'TOOLS_EVENTS_SECRET is not set' });@@@ -50,8 +46,11 @@ function handler() {for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }stats.received++;-- const outcome = cutoffs().apply(d.event);-- if (outcome === 'revoked') stats.revoked++;++ const outcome = await (await cutoffs()).apply(d.event);++ if (outcome === 'revoked') {++ stats.revoked++;++ if (storeValkey) { try { await storeValkey.client.incr(storeValkey.key('token_revocations_version')); } catch { /* the readers fall back to their ttl */ } }++ }res.json({ event_id: d.event.event_id || null, outcome });}];}@@@ -80,4 +79,4 @@ async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = coreturn 'created';}--module.exports = { handler, ensureSubscription, close, stats, TOPIC, _cutoffs: cutoffs };++module.exports = { handler, ensureSubscription, close, configure, stats, TOPIC, _cutoffs: cutoffs };@@@ -1,24 +1,21 @@'use strict';// Sign-out everywhere across Tools (WS-B task 4): the gateway's POST /internal/events (signature v2,--// never through the proxy) writes the person's cutoff into the shared file; any app's guard reader then--// refuses their older tokens and accepts newer ones; forged, foreign and proxied deliveries change nothing.++// never through the proxy) writes the person's cutoff into PostgreSQL through openvibe-sdk/auth++// createPgRevocationStore; any app's guard reader then refuses their older tokens and accepts newer++// ones; forged, foreign and proxied deliveries change nothing.++//++// The shared file of the SQLite design is gone (plan T8 decision 4): writer and reader share one++// openvibe-sdk/db handle here (one PGlite database), which is what DATABASE_URL gives production.const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');const http = require('http');--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tools-revoke-'));--process.env.TOOLS_REVOCATIONS_DB = path.join(tmp, 'shared', 'token-revocations.db');process.env.TOOLS_EVENTS_SECRET = 't'.repeat(40);const express = require('express');--const Database = require('better-sqlite3');++const { createDb } = require('openvibe-sdk/db');++const { revocationSchema } = require('openvibe-sdk/auth');const { signDeliveryHeaders } = require('openvibe-sdk/events');const events = require('../server/revocation-events');--const { createCutoffReader, DEFAULT_FILE } = require('../../_shared/guard/revocations');----// The default lives where the gateway's unit may write (ReadWritePaths=apps/gateway/data).--assert.strictEqual(DEFAULT_FILE, path.resolve(__dirname, '..', 'data', 'token-revocations.db'));++const { createCutoffReader } = require('../../_shared/guard/revocations');const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';const at = Date.parse('2026-09-25T12:00:00Z');@@@ -26,6 +23,10 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_tpayload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'password_changed' }, ...over });(async () => {++ const db = createDb({ pglite: true, service: 'revocations-test' });++ await db.exec(revocationSchema('token_revocations'));++ events.configure({ db });++const app = express();app.post('/internal/events', ...events.handler());const srv = http.createServer(app);@@@ -36,25 +37,25 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_treturn fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));};let clock = 1_000_000;-- const reader = createCutoffReader({ Database, file: process.env.TOOLS_REVOCATIONS_DB, ttlMs: 5000, now: () => clock });++ const reader = createCutoffReader({ db, ttlMs: 5000, now: () => clock });try {const old = { subject_id: SUBJECT, iat: at / 1000 - 30 };-- assert.strictEqual(reader.isRevoked(old), false, 'no file yet: nothing revoked');++ await reader.refresh();++ assert.strictEqual(reader.isRevoked(old), false, 'no cutoff yet: nothing revoked');assert.strictEqual((await post(ev(), { secret: 'y'.repeat(40) })).status, 401);assert.strictEqual((await post(ev(), { headers: { 'cf-connecting-ip': '1.2.3.4' } })).status, 404);assert.strictEqual((await post(ev({ source: 'live' }))).body.outcome, 'ignored:source');assert.strictEqual((await post(ev())).body.outcome, 'revoked');-- assert.ok(fs.existsSync(process.env.TOOLS_REVOCATIONS_DB), 'the shared file exists');-- clock += 20_000; // past the reader's retry pause and memo++ clock += 20_000; // past the reader's cache ttl++ await reader.refresh();assert.strictEqual(reader.isRevoked(old), true, 'another app sees the cutoff');assert.strictEqual(reader.isRevoked({ subject_id: SUBJECT, iat: at / 1000 + 1 }), false);assert.strictEqual(reader.isRevoked({ subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4', iat: 1 }), false);assert.strictEqual((await post(ev())).body.outcome, 'unchanged');-- const guardSrc = fs.readFileSync(path.join(__dirname, '../../_shared/guard/index.js'), 'utf8');-- assert.ok(/if \(claims && !cutoffs\.isRevoked\(claims\)\)/.test(guardSrc), 'every app\'s guard checks it');} finally {srv.close();-- fs.rmSync(tmp, { recursive: true, force: true });++ await db.close();++ events.close();}console.log('tools revocations: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });@@@ -129,9 +129,13 @@ function checkProblem(r, status, code, where) {checkRun(r.body, 'headers refused');assert.deepStrictEqual([r.body.state, r.body.error.status, r.body.error.code], ['failed', 403, 'tools.net.target_not_public']);-- // ── Probes: a token with tools.net.probe, nobody else ──++ // ── Probes: a token with tools.net.probe, a signed-in person under their quota, nobody else ──checkProblem(await run('ping', { input: { target: '127.0.0.1' } }), 401, 'token.missing', 'probe, anonymous');-- checkProblem(await run('ping', { input: { target: '127.0.0.1' } }, { headers: { Authorization: `Bearer ${net.user()}` } }), 403, 'capability.denied', 'probe, a person');++ // Plan T8, Phase 1: the net pages call the run API with the session token, so a signed-in person++ // may run a probe (under their per-person quota); the SSRF guard still keeps it off loopback.++ r = await run('ping', { input: { target: '127.0.0.1' } }, { headers: { Authorization: `Bearer ${net.user()}` } });++ checkRun(r.body, 'probe, a person');++ assert.deepStrictEqual([r.body.state, r.body.error.code], ['failed', 'tools.net.target_not_public'], 'a person runs a probe: it ran and the SSRF guard refused loopback');checkProblem(await run('ping', { input: { target: '127.0.0.1' } }, { headers: { Authorization: `Bearer ${net.service(['tools.tool.run'])}` } }), 403, 'capability.denied', 'probe, runner token');r = await run('ping', { input: { target: '127.0.0.1', count: 1 } }, { headers: { Authorization: `Bearer ${net.service(['tools.net.probe'], { name: 'prober' })}` } });checkRun(r.body, 'ping with tools.net.probe');@@@ -9,6 +9,7 @@"version": "1.0.0","license": "MIT","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -16,16 +17,24 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "pg": "^8.23.0","sharp": "^0.33.5","to-ico": "^1.1.5","uuid": "^11.1.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },"node_modules/@img/sharp-libvips-linux-x64": {"version": "1.0.4","resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",@@@ -64,6 +73,12 @@"@img/sharp-libvips-linux-x64": "1.0.4"}},++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -442,6 +457,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/color": {"version": "4.2.3","resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",@@@ -636,6 +660,15 @@"node": ">=0.4.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -1253,6 +1286,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ip-regex": {"version": "1.0.3","resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-1.0.3.tgz",@@@ -1466,12 +1542,24 @@"xtend": "^4.0.0"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1865,6 +1953,95 @@"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },"node_modules/phin": {"version": "3.7.1","resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz",@@@ -1920,6 +2097,45 @@"node": ">=4.0.0"}},++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -2095,6 +2311,27 @@"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==","license": "MIT"},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/request": {"version": "2.88.2","resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",@@@ -2435,6 +2672,15 @@"is-arrayish": "^0.3.1"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },"node_modules/sshpk": {"version": "1.18.0","resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",@@@ -2460,6 +2706,12 @@"node": ">=0.10.0"}},++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -11,8 +11,11 @@"license": "MIT","openvibeToolsShared": "^1.2.0","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0","cors": "^2.8.5","dotenv": "^16.4.7","express": "^4.21.2",@@@ -36,21 +36,21 @@ const sdk = require('openvibe-sdk');// ── Analytics ────────────────────────────────────────────────const Database = require('better-sqlite3');--const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');--const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');--fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });--const analyticsDb = new Database(analyticsDbPath);--analyticsDb.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img', { retention: { days: 30 } });// ── Guard (apps/_shared/guard) ───────────────────────────────// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else// the address), tiered quotas by each tool's descriptor, upload sniffing, the sync semaphore and the// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.const SPECS = require('./descriptors').SPECS;++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4): guard_abuse in++// PostgreSQL, guard_salt/guard_day and the quota buckets in Valkey. `ready` resolves when the schema is in.++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-img' });++const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-img', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });const guard = createGuard({-- app: 'img', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, specs: SPECS,++ app: 'img', db: toolsDb.db, valkey: toolsValkey, contracts, specs: SPECS,issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,});const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);@@@ -89,16 +89,19 @@ const release = require('../../_shared/release').toolsRelease('img', require);// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real// dependencies (roadmap Track O). First, so the HTTP metrics see every request.const { observe, checks: ready } = require('../../_shared/observe');++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools-img', release: release.release,checks: [++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, jobs, analytics, revocations)' }),ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),ready.jobRuntime('job_runtime', () => jobs),ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),-- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),...jobsRuntime.readyChecks(() => jobs),codec.heif.readyCheck('HEIC (iPhone) photos: heif-dec or heif-convert with an HEVC decoder plugin (libheif-examples + libheif-plugin-libde265); other formats work without it'),@@@ -311,12 +314,12 @@ app.get('/api/download/:id', (req, res) => {// ── Internal Analytics API ────────────────────────────────────const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });--app.get('/api/internal/analytics', internalAccess, (req, res) => {-- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }++app.get('/api/internal/analytics', internalAccess, async (req, res) => {++ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});--app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {-- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }++app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {++ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -370,9 +373,10 @@ require('../../_shared/graceful').gracefulStop({close: [() => require('../../_shared/usage').stopRecorder(800),() => analytics.destroy(),-- () => analyticsDb.close(),() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them() => pool.close(),() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),],});@@@ -71,7 +71,10 @@ const { startApp } = require('../../_shared/test/spawn');assert.match(metrics, /tools_guard_refused_total\{reason="sniff",tool="convert"\} [12]/);assert.match(metrics, /tools_guard_sync\{kind="limit"\} 2/);assert.match(metrics, /tools_guard_enforcing 0/);-- assert.ok(fs.existsSync(path.join(data, 'guard.db')), 'guard.db in the data directory');++ // The guard no longer keeps a per-app guard.db: guard_abuse is in the one tools database and++ // guard_salt/guard_day in Valkey (plan T8 decision 4), reported by the tools_db readiness check.++ const ready = await (await fetch(`${app.base}/api/ready`)).json();++ assert.strictEqual(ready.checks.tools_db.status, 'ok', 'the guard serves from the one tools database');} finally {await app.kill();fs.rmSync(data, { recursive: true, force: true });@@@ -9,6 +9,7 @@"version": "1.0.0","license": "MIT","dependencies": {++ "@electric-sql/pglite": "^0.5.8","axios": "^1.7.0","better-sqlite3": "^11.0.0","cheerio": "^1.0.0-rc.12",@@@ -18,11 +19,25 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","node-cache": "^5.1.2","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "pg": "^8.23.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -313,6 +328,15 @@"node": ">=0.8"}},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/combined-stream": {"version": "1.0.8","resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",@@@ -461,6 +485,15 @@"node": ">=0.4.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -1088,6 +1121,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -1146,12 +1222,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1485,6 +1573,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1625,6 +1841,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -1831,6 +2068,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1989,6 +2241,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -12,6 +12,9 @@"dependencies": {"axios": "^1.7.0","better-sqlite3": "^11.0.0",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0",++ "@electric-sql/pglite": "^0.5.8","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1","cheerio": "^1.0.0-rc.12",@@@ -18,27 +18,25 @@ const cookieParser = require('cookie-parser');const config = require('./config');// ── Analytics ────────────────────────────────────────────────--const Database = require('better-sqlite3');--const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');const { hostGuard, stampedPage } = require('../../_shared/host-role');const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');const { createGuard, TRUST_PROXY } = require('../../_shared/guard');const { createPacer, Busy } = require('../../_shared/guard/semaphore');--const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');--fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });--const analyticsDb = new Database(analyticsDbPath);--analyticsDb.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps', { retention: { days: 30 } });// ── Guard (apps/_shared/guard) ───────────────────────────────// Who is asking (Network sign-in with aud openvibe.tools, else the address — the food app forwards its// visitor's), the tools-map quota on the data routes (descriptor maps: cost 2 a call) and the abuse log// (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-maps' });++const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-maps', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });const guard = createGuard({-- app: 'maps', dataDir: path.join(__dirname, '..', 'data'), Database, specs: require('./descriptors').SPECS,++ app: 'maps', db: toolsDb.db, valkey: toolsValkey, specs: require('./descriptors').SPECS,issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),});@@@ -49,11 +47,14 @@ const release = require('../../_shared/release').toolsRelease('maps', require);// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real// dependencies (roadmap Track O). First, so the HTTP metrics see every request.const { observe, checks: ready } = require('../../_shared/observe');++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools-maps', release: release.release,checks: [-- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only' }),++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),],// The public data sources maps queries (OSM and others) are not probed here: each request says when one fails.});@@@ -476,12 +477,12 @@ app.get('/api/meal-plan', (req, res) => {// ── Internal Analytics API ────────────────────────────────────const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });--app.get('/api/internal/analytics', internalAccess, (req, res) => {-- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }++app.get('/api/internal/analytics', internalAccess, async (req, res) => {++ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});--app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {-- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }++app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {++ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -504,7 +505,8 @@ require('../../_shared/graceful').gracefulStop({close: [() => require('../../_shared/usage').stopRecorder(800),() => analytics.destroy(),-- () => analyticsDb.close(),() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),],});@@@ -9,6 +9,7 @@"version": "1.0.0","license": "MIT","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.6","cors": "^2.8.5",@@@ -16,10 +17,24 @@"express": "^4.18.2","express-rate-limit": "^7.1.5","helmet": "^7.1.0",++ "iovalkey": "^0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "pg": "^8.23.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -188,6 +203,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -287,6 +311,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -698,6 +731,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -756,12 +832,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1022,6 +1110,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1141,6 +1357,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -1347,6 +1584,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1474,6 +1726,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -15,6 +15,9 @@"cors": "^2.8.5","cookie-parser": "^1.4.6","better-sqlite3": "^11.0.0",++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0","express-rate-limit": "^7.1.5","dotenv": "^16.3.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",@@@ -11,25 +11,23 @@ const fs = require('fs');const config = require('./config');// ── Analytics ────────────────────────────────────────────────--const Database = require('better-sqlite3');--const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');const { hostGuard } = require('../../_shared/host-role');const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');const { createGuard, TRUST_PROXY } = require('../../_shared/guard');--const analyticsDbPath = path.resolve(__dirname, '..', process.env.DATA_DIR || 'data', 'analytics.db');--fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });--const analyticsDb = new Database(analyticsDbPath);--analyticsDb.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text', { retention: { days: 30 } });// ── Guard (apps/_shared/guard) ───────────────────────────────// Who is asking (Network sign-in with aud openvibe.tools, else the address), the tools-api quota on// /api/ and the abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-text' });++const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-text', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });const guard = createGuard({-- app: 'text', dataDir: path.resolve(__dirname, '..', process.env.DATA_DIR || 'data'), Database, specs: require('./descriptors').SPECS,++ app: 'text', db: toolsDb.db, valkey: toolsValkey, specs: require('./descriptors').SPECS,issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),});@@@ -40,11 +38,14 @@ const release = require('../../_shared/release').toolsRelease('text', require);// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real// dependencies (roadmap Track O). First, so the HTTP metrics see every request.const { observe, checks: ready } = require('../../_shared/observe');++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools-text', release: release.release,checks: [-- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; the text tools run in the browser and on this process' }),++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),],});guard.attachMetrics(obs.registry);@@@ -118,12 +119,12 @@ app.use(hostGuard({ knows: (h) => Object.prototype.hasOwnProperty.call(HOSTNAME_// ── Internal Analytics API ────────────────────────────────────const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });--app.get('/api/internal/analytics', internalAccess, (req, res) => {-- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }++app.get('/api/internal/analytics', internalAccess, async (req, res) => {++ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});--app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {-- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }++app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {++ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -187,7 +188,8 @@ require('../../_shared/graceful').gracefulStop({close: [() => require('../../_shared/usage').stopRecorder(800),() => analytics.destroy(),-- () => analyticsDb.close(),() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),],});@@@ -9,6 +9,7 @@"version": "1.0.0","license": "MIT","dependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.0.0","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -16,12 +17,26 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "pg": "^8.23.0","uuid": "^11.1.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","license": "MIT",@@@ -164,6 +179,15 @@"version": "1.1.4","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","license": "MIT",@@@ -245,6 +269,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","license": "MIT",@@@ -589,6 +622,49 @@"version": "1.3.8","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","license": "MIT",@@@ -637,10 +713,22 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","license": "MIT"@@@ -849,6 +937,134 @@"version": "0.1.13","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","license": "MIT",@@@ -955,6 +1171,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","funding": [@@@ -1135,6 +1372,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","license": "MIT",@@@ -1247,6 +1499,15 @@"node_modules/wrappy": {"version": "1.0.2","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -18,6 +18,9 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","better-sqlite3": "^11.0.0",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0",++ "@electric-sql/pglite": "^0.5.8","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1","jsonwebtoken": "^9.0.2",@@@ -22,21 +22,19 @@ const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/locconst { createGuard, TRUST_PROXY } = require('../../_shared/guard');// ── Analytics ────────────────────────────────────────────────--const Database = require('better-sqlite3');--const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded++const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');--const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');--fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });--const analyticsDb = new Database(analyticsDbPath);--analyticsDb.pragma('journal_mode = WAL');--const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt', { retention: { days: 30 } });// ── Guard (apps/_shared/guard) ───────────────────────────────// Who is asking (Network sign-in with aud openvibe.tools, the browser session, else the address),// the tools-download quota (a download costs 50, descriptor yt) and the abuse log (data/guard.db).// TOOLS_GUARD=report (default) records what it would refuse; the older limiters below stay until enforce.++// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).++const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-yt' });++const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-yt', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)++const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });const guard = createGuard({-- app: 'yt', dataDir: path.resolve(__dirname, '..', config.dataDir || 'data'), Database, specs: require('./descriptors').SPECS,++ app: 'yt', db: toolsDb.db, valkey: toolsValkey, specs: require('./descriptors').SPECS,issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,});@@@ -46,12 +44,15 @@ const release = require('../../_shared/release').toolsRelease('yt', require);// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real// dependencies (roadmap Track O). First, so the HTTP metrics see every request.const { observe, checks: ready, which } = require('../../_shared/observe');++// Serve only once the schema is in: the first PGlite migration must not run inside a request (it++// would block the event loop and time out a satellite's readiness check).++app.use((req, res, next) => toolsDb.ready.then(() => next(), next));const obs = observe({app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),service: 'tools-yt', release: release.release,checks: [++ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),ready.writableDir('downloads_dir', path.resolve(config.downloadsDir), { description: 'downloads are written here before they are served' }),-- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; downloads work without it' }),ready.binary('yt_dlp', config.ytdlpPath, { description: 'every download runs yt-dlp' }),ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'merging video+audio and audio conversion' }),...(process.env.YT_COOKIES_FILE ? [ready.readableFile('yt_cookies', process.env.YT_COOKIES_FILE, { description: 'YT_COOKIES_FILE is set; yt-dlp is given these cookies' })] : []),@@@ -284,12 +285,12 @@ app.get('/api/download/:id', (req, res) => {// ── Internal Analytics API ────────────────────────────────────const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });--app.get('/api/internal/analytics', internalAccess, (req, res) => {-- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }++app.get('/api/internal/analytics', internalAccess, async (req, res) => {++ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});--app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {-- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }++app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {++ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -341,7 +342,8 @@ require('../../_shared/graceful').gracefulStop({close: [() => require('../../_shared/usage').stopRecorder(800),() => analytics.destroy(),-- () => analyticsDb.close(),() => guard.close(),++ () => toolsDb.db.close(),++ () => toolsValkey && toolsValkey.close(),],});nnew file mode 100644@@@ -0,0 +1,11 @@++-- phase: expand++-- token_revocations (plan T8, decision 4): the sign-out-everywhere cutoffs. The gateway writes them++-- through openvibe-sdk/auth createPgRevocationStore and every app's guard reads them; the PostgreSQL++-- store needs the table to exist (revocationSchema() in the SDK is the same DDL), so it lives here.++-- 0001's note that the SDK creates it at boot is corrected by this file.++CREATE TABLE IF NOT EXISTS token_revocations (++ subject_id text COLLATE "C" PRIMARY KEY,++ valid_after_ms bigint NOT NULL,++ reason text,++ updated_at bigint NOT NULL++);@@@ -5,6 +5,7 @@"description": "OpenVibe.Tools \u2014 the tools suite on openvibe.tools and *.openvibe.tools","scripts": {"test": "node test/run.js",++ "test:pg": "DATABASE_URL=\"$OV_TEST_PG_URL\" DATABASE_DIRECT_URL=\"$OV_TEST_PG_DIRECT_URL\" VALKEY_URL=\"$OV_TEST_VALKEY_URL\" node test/run.js","install:all": "npm --prefix apps/gateway install && npm --prefix apps/maps install && npm --prefix apps/food install && npm --prefix apps/img install && npm --prefix apps/yt install && npm --prefix apps/audio install && npm --prefix apps/text install && npm --prefix apps/docs install","start:gateway": "npm --prefix apps/gateway start","start:maps": "npm --prefix apps/maps start",@@@ -42,9 +42,45 @@ function targets(args) {}const USAGE_APPS = ` --app <name> only this app (repeatable); default: every app that has data/analytics.db-- (an app's DATA_DIR other than data/ needs --db)`;++ (an app's DATA_DIR other than data/ needs --db)++ --pg prune the one tools database (DATABASE_URL) with openvibe-shared/analytics/pg instead++ of the apps' analytics.db files; --days <n> and --apply apply here too`;++++/** The tracker's service names in the one tools database (plan T8). */++const SERVICES = ['openvibe-gateway', 'openvibe-maps', 'openvibe-food', 'openvibe-img', 'openvibe-yt', 'openvibe-audio', 'openvibe-text', 'openvibe-docs'];++++/**++ * The PostgreSQL branch (plan T8, decision 5): after the cutover the raw analytics events are rows in the++ * one tools database, not SQLite files. Prunes them with openvibe-shared/analytics/pg (the rollups stay);++ * without --apply it counts only, as the SQLite dry run does. The backup/scrub/VACUUM options are for the++ * pre-cutover files and do not apply here.++ */++async function pgMain(argv, log) {++ const i = argv.indexOf('--days');++ const raw = i >= 0 ? parseInt(argv[i + 1], 10) : 30;++ const days = Number.isFinite(raw) ? Math.min(Math.max(raw, 1), 3650) : 30;++ const apply = argv.includes('--apply');++ const { createDb } = fromApps('openvibe-sdk/db');++ const { pruneRawEventsPg } = fromApps('openvibe-shared/analytics/pg');++ const db = createDb({ url: process.env.DATABASE_URL, service: 'tools-analytics-prune', max: 1, log });++ try {++ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 19).replace('T', ' ');++ const rows = await db.query('SELECT service, COUNT(*)::bigint AS n FROM analytics_events WHERE created_at < $1 GROUP BY service ORDER BY service', [cutoff]);++ for (const r of rows) log(`${r.service}: ${r.n} raw events older than ${days} days`);++ if (!rows.length) log(`nothing older than ${days} days`);++ if (apply) {++ const r = await pruneRawEventsPg(db, { days });++ log(`removed ${r.removed} raw events, plus visitor days and day salts, older than ${r.cutoff}`);++ } else {++ log('dry run: nothing changed (pass --apply)');++ }++ } finally {++ await db.close();++ }++}function main(argv, log) {++ if (argv.includes('--pg') || (process.env.DATABASE_URL && !argv.includes('--db'))) return pgMain(argv, log);const cli = fromApps('openvibe-shared/analytics/prune-cli');return cli.main(argv, {Database: loadSqlite(),@@@ -57,4 +93,4 @@ function main(argv, log) {if (require.main === module) fromApps('openvibe-shared/analytics/prune-cli').run(main);--module.exports = { main, targets, loadSqlite };++module.exports = { main, pgMain, targets, loadSqlite, SERVICES };
@@ -638,5 +638,12 @@ with `DATABASE_DIRECT_URL` set for the cutover; it prints a per-table count and
@@ -638,5 +638,12 @@ with `DATABASE_DIRECT_URL` set for the cutover; it prints a per-table count and
After the move the apps read `DATABASE_URL` / `DATABASE_DIRECT_URL` (PostgreSQL, via PgBouncer) and
After the move the apps read `DATABASE_URL` / `DATABASE_DIRECT_URL` (PostgreSQL, via PgBouncer) and
`VALKEY_URL` (Valkey); `guard_salt`, `guard_day` and the minute/burst buckets live in Valkey (a throttle,
`VALKEY_URL` (Valkey); `guard_salt`, `guard_day` and the minute/burst buckets live in Valkey (a throttle,
never authoritative), while `guard_abuse`, the job store and the analytics tables stay in PostgreSQL.
never authoritative), while `guard_abuse`, the job store and the analytics tables stay in PostgreSQL.
-`TOOLS_GUARD` (default `report`) and its meaning are unchanged. Until the cutover lands the apps still run
-on `better-sqlite3` exactly as described above.
+`TOOLS_GUARD` (default `report`) and its meaning are unchanged.
+
+State (plan T8 phases 1–4 done, 5–6 pending): every app already opens the one `tools` database
+(`apps/_shared/db.js`, `DATABASE_URL` else an embedded PGlite database in development; `DATABASE_DIRECT_URL`
+runs the migrations as owner) and the shared Valkey (`VALKEY_URL`, else the guard's counters and salt stay
+in this process); `guard_abuse`, `token_revocations` and the request analytics are on PostgreSQL, and the
+net and dev pages call the run API (the `/api/net/*` and `/api/dev/*` routes still answer until phase 6
+removes them; a signed-in person may now run a probe under their per-person quota). The job store still
+runs on `better-sqlite3` (`apps/_shared/jobs/`, plan T8 phases 2–3) — only that dependency remains.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "stable",
"stage": "stable",
"code": true,
"code": true,
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express, better-sqlite3; eight apps (gateway, maps, food, img, yt, audio, text, docs)",
+ "runtime": "node 22, express; PostgreSQL (openvibe-sdk/db, PGlite in development) + Valkey for the guard and analytics; better-sqlite3 only for the job store (plan T8 phases 2-3 pending); eight apps (gateway, maps, food, img, yt, audio, text, docs)",
"port": 4001,
"port": 4001,
"ports": {
"ports": {
"gateway": 4001,
"gateway": 4001,
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// The one `tools` database (plan T8, decisions 3 and 6): all eight apps serve from a single
+// PostgreSQL database, DATABASE_URL through PgBouncer in production. Migrations live in the
+// repository's migrations/ and run as the owner (DATABASE_DIRECT_URL), or on the embedded handle in
+// development. Without a DATABASE_URL outside production an embedded PGlite database serves the app,
+// so what the tests run is what production runs (ADR-035).
+//
+// Like the rest of apps/_shared there are no dependencies of its own: the app passes its
+// require('openvibe-sdk/db').createDb and require('openvibe-sdk/valkey').createValkey.
+//
+// openToolsDb returns { db, ready }: `db` is usable at once (its statements queue), `ready` resolves
+// when the schema is applied — mount it in /api/ready and never block the boot on it.
+// ═══════════════════════════════════════════════════════════════
+
+const fs = require('fs');
+const path = require('path');
+
+const MIGRATIONS = path.resolve(__dirname, '..', '..', 'migrations');
+
+/**
+ * Await `ready` before every statement, so a caller need not hold the schema promise: the guard writes
+ * guard_abuse on the first refusal, and a request must never race the migrations. `tx`/`prepare` are
+ * gated too; the rest of the handle (sql, stats, store, _adapter) passes through.
+ */
+function gate(db, ready) {
+ const wait = () => ready;
+ const wrapStmt = (s) => ({
+ get: async (...a) => { await wait(); return s.get(...a); },
+ all: async (...a) => { await wait(); return s.all(...a); },
+ run: async (...a) => { await wait(); return s.run(...a); },
+ });
+ const out = Object.create(db);
+ for (const m of ['query', 'many', 'maybe', 'one', 'value', 'exec']) out[m] = async (...a) => { await wait(); return db[m](...a); };
+ out.tx = async (fn, opts) => { await wait(); return db.tx(fn, opts); };
+ out.prepare = (text) => wrapStmt(db.prepare(text));
+ out.ready = async () => { await wait(); return db.ready(); };
+ out.afterCommit = (fn) => db.afterCommit(fn);
+ out.inTransaction = () => db.inTransaction();
+ out.detached = (fn) => db.detached(fn);
+ out.close = () => db.close();
+ return out;
+}
+
+/**
+ * @param {object} o
+ * @param {Function} o.createDb require('openvibe-sdk/db').createDb
+ * @param {string} [o.service] application_name and metric label ('tools-img', 'tools-gateway', …)
+ * @param {object} [o.log]
+ * @param {object} [o.registry] openvibe-shared/metrics registry (db_query_seconds, pool gauges)
+ * @param {string} [o.pgliteDir] development database directory (default data/pglite/<service>)
+ * @param {string} [o.migrationsDir]
+ */
+function openToolsDb({ createDb, service = 'tools', log = console, registry, pgliteDir, migrationsDir = MIGRATIONS } = {}) {
+ if (typeof createDb !== 'function') throw new TypeError('openToolsDb needs createDb (require(\'openvibe-sdk/db\').createDb)');
+ const url = String(process.env.DATABASE_URL || '').trim();
+ if (!url) {
+ if (String(process.env.NODE_ENV || '') === 'production') throw new Error('DATABASE_URL is not set: Tools serves from PostgreSQL (plan T8)');
+ // A directory keeps development data across restarts; without one (tests) the database is in memory,
+ // so every run starts from the empty, migrated schema.
+ const dir = pgliteDir || process.env.TOOLS_PGLITE_DIR || '';
+ if (dir) fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir || true, service, log, registry });
+ log.warn(`[DB] ${service}: DATABASE_URL unset, ${dir ? `embedded PGlite database in ${dir}` : 'in-memory PGlite database'} (development only)`);
+ const ready = db.migrate({ dir: migrationsDir, log });
+ return { db: gate(db, ready), ready };
+ }
+ const db = createDb({ url, service, log, registry });
+ const direct = String(process.env.DATABASE_DIRECT_URL || '').trim();
+ let ready = Promise.resolve();
+ if (direct) {
+ const owner = createDb({ url: direct, service: `${service}-migrate`, max: 1, log });
+ ready = owner.migrate({ dir: migrationsDir, log }).finally(() => owner.close().catch(() => {}));
+ }
+ return { db: gate(db, ready), ready };
+}
+
+/**
+ * The shared Valkey connection (plan T8, decision 4), confined to `ov:tools:*`. Without VALKEY_URL it
+ * is null and the guard keeps its counters and salt in this process, as it did before (ADR-007 rule 4:
+ * Valkey is a throttle, never authoritative).
+ *
+ * @param {object} o
+ * @param {Function} o.createValkey require('openvibe-sdk/valkey').createValkey
+ */
+function openToolsValkey({ createValkey, log = console, prefix = process.env.VALKEY_PREFIX || 'ov:tools:' } = {}) {
+ if (typeof createValkey !== 'function') return null;
+ return createValkey({ url: process.env.VALKEY_URL, prefix, log });
+}
+
+module.exports = { openToolsDb, openToolsValkey, MIGRATIONS };
@@ -100,7 +100,10 @@ function createGuard(o = {}) {
@@ -100,7 +100,10 @@ function createGuard(o = {}) {
const enforcing = mode === 'enforce';
const enforcing = mode === 'enforce';
const bounds = limits.bounds(env);
const bounds = limits.bounds(env);
const table = limits.quotas(env, log);
const table = limits.quotas(env, log);
- const store = createGuardStore({ Database: o.Database, dataDir: o.dataDir, now, log });
+ // guard_abuse in PostgreSQL, guard_salt and guard_day in Valkey (plan T8, decision 4); without
+ // them (tests, or a Valkey outage) the counters and the salt stay in this process.
+ const store = o.store || createGuardStore({ db: o.db || null, valkey: o.valkey || null, app: appName, now, log });
+ if (!o.store) void store.warm();
let saltDay = null, saltValue = null;
let saltDay = null, saltValue = null;
const salt = () => {
const salt = () => {
@@ -109,13 +112,15 @@ function createGuard(o = {}) {
@@ -109,13 +112,15 @@ function createGuard(o = {}) {
return saltValue;
return saltValue;
};
};
- // Network's sign-out-everywhere cutoffs, written by the gateway (./revocations.js).
- const cutoffs = o.cutoffs || require('./revocations').createCutoffReader({ Database: o.Database, file: o.revocationsFile, now });
+ // Network's sign-out-everywhere cutoffs (PostgreSQL, read through a short shared cache): the gateway
+ // writes them (apps/gateway/server/revocation-events.js); every app reads them here.
+ const cutoffs = o.cutoffs || require('./revocations').createCutoffReader({ db: o.db || null, valkey: o.valkey || null, now, log });
+ if (!o.cutoffs) void cutoffs.warm();
const keys = o.keys || tokens.createKeySource({ networkUrl: o.networkUrl, networkInternalUrl: o.networkInternalUrl, files: o.publicKeyFiles || [], log });
const keys = o.keys || tokens.createKeySource({ networkUrl: o.networkUrl, networkInternalUrl: o.networkInternalUrl, files: o.publicKeyFiles || [], log });
const issuer = o.issuer;
const issuer = o.issuer;
const audience = env.OV_TOOLS_AUDIENCE || tokens.AUDIENCE;
const audience = env.OV_TOOLS_AUDIENCE || tokens.AUDIENCE;
const callers = createCallerResolver({ getPublicKey: () => keys.get(), issuer, audience, contracts: o.contracts, salt, secureCookie: o.secureCookie });
const callers = createCallerResolver({ getPublicKey: () => keys.get(), issuer, audience, contracts: o.contracts, salt, secureCookie: o.secureCookie });
- const quotas = createQuotas({ store, quotas: () => table, now });
+ const quotas = createQuotas({ store, quotas: () => table, now, valkey: o.valkey || null, log });
const targets = createTargetThrottle({ now });
const targets = createTargetThrottle({ now });
const ports = createPortScanCap({ ...bounds.ports, now });
const ports = createPortScanCap({ ...bounds.ports, now });
const sync = createSemaphore({ max: bounds.sync.concurrency, queue: bounds.sync.queue, waitMs: bounds.sync.waitMs });
const sync = createSemaphore({ max: bounds.sync.concurrency, queue: bounds.sync.queue, waitMs: bounds.sync.waitMs });
@@ -203,7 +208,7 @@ function createGuard(o = {}) {
@@ -203,7 +208,7 @@ function createGuard(o = {}) {
// ── Quotas ───────────────────────────────────────────────
// ── Quotas ───────────────────────────────────────────────
async function charge(req, res, { quotaClass, cost, tool: toolId }) {
async function charge(req, res, { quotaClass, cost, tool: toolId }) {
const c = caller(req, res);
const c = caller(req, res);
- const r = quotas.check(c, { quotaClass, cost }, { report: !enforcing });
+ const r = await quotas.check(c, { quotaClass, cost }, { report: !enforcing });
// In report mode an older limiter that ran on this request (req.rateLimit) is the one in force:
// In report mode an older limiter that ran on this request (req.rateLimit) is the one in force:
// its RateLimit-* headers stay. Otherwise the guard's describe the allowance closest to running out.
// its RateLimit-* headers stay. Otherwise the guard's describe the allowance closest to running out.
if (enforcing || !req.rateLimit) setHeaders(res, r);
if (enforcing || !req.rateLimit) setHeaders(res, r);
@@ -257,7 +262,7 @@ function createGuard(o = {}) {
@@ -257,7 +262,7 @@ function createGuard(o = {}) {
return charge(req, res, { quotaClass: d.quotaClass, cost, tool: d.id });
return charge(req, res, { quotaClass: d.quotaClass, cost, tool: d.id });
}
}
if (cost > was.cost) { req._ovCharged = { ...was, cost }; return charge(req, res, { quotaClass: d.quotaClass, cost: cost - was.cost, tool: d.id }); }
if (cost > was.cost) { req._ovCharged = { ...was, cost }; return charge(req, res, { quotaClass: d.quotaClass, cost: cost - was.cost, tool: d.id }); }
- if (cost < was.cost) { quotas.refund(caller(req, res), { quotaClass: d.quotaClass, cost: was.cost - cost }); req._ovCharged = { ...was, cost }; }
+ if (cost < was.cost) { await quotas.refund(caller(req, res), { quotaClass: d.quotaClass, cost: was.cost - cost }); req._ovCharged = { ...was, cost }; }
return true;
return true;
}
}
@@ -477,11 +482,10 @@ function createGuard(o = {}) {
@@ -477,11 +482,10 @@ function createGuard(o = {}) {
// ── Upkeep ───────────────────────────────────────────────
// ── Upkeep ───────────────────────────────────────────────
function prune() {
function prune() {
- try {
- const cutoff = now() - bounds.abuseRetentionDays * DAY_MS;
- store.pruneAbuse(cutoff);
- store.pruneDays(dayOf(now() - DAY_MS));
- } catch (err) { log.error(`[Guard] ${appName}: prune failed: ${err.message}`); }
+ const cutoff = now() - bounds.abuseRetentionDays * DAY_MS;
+ return Promise.resolve(store.pruneAbuse(cutoff))
+ .then(() => store.pruneDays(dayOf(now() - DAY_MS)))
+ .catch((err) => log.error(`[Guard] ${appName}: prune failed: ${err.message}`));
}
}
prune();
prune();
const pruneTimer = o.pruneIntervalMs === 0 ? null : setInterval(prune, o.pruneIntervalMs || 6 * 60 * 60 * 1000);
const pruneTimer = o.pruneIntervalMs === 0 ? null : setInterval(prune, o.pruneIntervalMs || 6 * 60 * 60 * 1000);
@@ -1,7 +1,12 @@
@@ -1,7 +1,12 @@
'use strict';
'use strict';
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// Tiered quotas: a token bucket per (key, class) in memory, and a UTC-day allowance per (key, class)
-// in guard.db. A run takes its descriptor's `cost` from both, all or nothing.
+// Tiered quotas: a token bucket per (key, class) and a UTC-day allowance per (key, class). A run takes
+// its descriptor's `cost` from both, all or nothing.
+//
+// The day allowance is shared across processes through the guard store (Valkey, plan T8 decision 4) and
+// so are the minute/burst buckets when a Valkey connection is passed: one Lua call refills and spends
+// one bucket atomically, so two hosts enforce one quota. Without Valkey (or on an outage) both legs use
+// this process's own counters — a throttle that never fails open and never fails every request.
//
//
// Who is charged (keys come from the caller resolver, addresses already hashed):
// Who is charged (keys come from the caller resolver, addresses already hashed):
// anonymous its address (IPv6: its /64) at the anonymous tier
// anonymous its address (IPv6: its /64) at the anonymous tier
@@ -18,17 +23,77 @@
@@ -18,17 +23,77 @@
const { SESSION_IP_SHARE } = require('./limits');
const { SESSION_IP_SHARE } = require('./limits');
const { dayOf, DAY_MS } = require('./store');
const { dayOf, DAY_MS } = require('./store');
+// Refill a bucket (tokens + elapsed × rate, capped at burst) and spend `need`, atomically. It only
+// consumes when the bucket holds `need`: a refused run changes nothing. Returns "tokens:ok".
+// ARGV: burst, rate (tokens/ms), need, now (ms), ttl (s).
+const SPEND = `
+local key = KEYS[1]
+local burst = tonumber(ARGV[1])
+local rate = tonumber(ARGV[2])
+local need = tonumber(ARGV[3])
+local now = tonumber(ARGV[4])
+local ttl = tonumber(ARGV[5])
+local d = redis.call('HMGET', key, 'tokens', 'at')
+local tokens = (d[1] and tonumber(d[1])) or burst
+local at = (d[2] and tonumber(d[2])) or now
+tokens = math.min(burst, tokens + (now - at) * rate)
+if tokens < need then return tostring(tokens)..':0' end
+local left = tokens - need
+redis.call('HSET', key, 'tokens', tostring(left), 'at', tostring(now))
+redis.call('EXPIRE', key, ttl)
+return tostring(left)..':1'`;
+// Refill and give `need` back (a run that cost less than the up-front charge), capped at burst.
+const REFUND = `
+local key = KEYS[1]
+local burst = tonumber(ARGV[1])
+local rate = tonumber(ARGV[2])
+local need = tonumber(ARGV[3])
+local now = tonumber(ARGV[4])
+local ttl = tonumber(ARGV[5])
+local d = redis.call('HMGET', key, 'tokens', 'at')
+local tokens = (d[1] and tonumber(d[1])) or burst
+local at = (d[2] and tonumber(d[2])) or now
+tokens = math.min(burst, tokens + (now - at) * rate)
+local left = math.min(burst, tokens + need)
+redis.call('HSET', key, 'tokens', tostring(left), 'at', tostring(now))
+redis.call('EXPIRE', key, ttl)
+return tostring(left)`;
+
+function createValkeyBuckets(valkey, log) {
+ const c = valkey.client;
+ if (typeof c.ovBucketSpend !== 'function') c.defineCommand('ovBucketSpend', { lua: SPEND });
+ if (typeof c.ovBucketRefund !== 'function') c.defineCommand('ovBucketRefund', { lua: REFUND });
+ let up = true;
+ return {
+ get down() { return !up; },
+ /** { left, ok } — ok false means the bucket was short and nothing was taken. null: Valkey is down. */
+ async spend(id, lim, need, t) {
+ try {
+ const r = String(await c.ovBucketSpend(1, valkey.key('bucket', id), String(lim.burst), String(lim.perMinute / 60_000), String(need), String(t), String(600)));
+ const [left, ok] = r.split(':');
+ return { left: Number(left), ok: ok === '1' };
+ } catch (err) { up = false; log.warn(`[Quota] Valkey buckets unavailable (${err.message}); counting in this process`); return null; }
+ },
+ async refund(id, lim, need, t) {
+ try { await c.ovBucketRefund(1, valkey.key('bucket', id), String(lim.burst), String(lim.perMinute / 60_000), String(need), String(t), String(600)); } catch { /* fallback ignores */ }
+ },
+ };
+}
+
/**
/**
* @param {object} o
* @param {object} o
- * @param {object} o.store guard store (dayUsed, dayAdd)
+ * @param {object} o.store guard store (async dayUsed, dayAdd)
* @param {() => object} o.quotas the QUOTAS table (limits.quotas())
* @param {() => object} o.quotas the QUOTAS table (limits.quotas())
* @param {() => number} [o.now]
* @param {() => number} [o.now]
* @param {number} [o.maxBuckets]
* @param {number} [o.maxBuckets]
+ * @param {object} [o.valkey] openvibe-sdk/valkey connection (shared buckets); absent → in-process
*/
*/
function createQuotas(o) {
function createQuotas(o) {
const now = o.now || Date.now;
const now = o.now || Date.now;
const buckets = new Map(); // `${cls}|${key}` → { tokens, at }
const buckets = new Map(); // `${cls}|${key}` → { tokens, at }
const maxBuckets = o.maxBuckets || 200_000;
const maxBuckets = o.maxBuckets || 200_000;
+ const log = o.log || console;
+ const shared = o.valkey ? createValkeyBuckets(o.valkey, log) : null;
let lastSweep = now();
let lastSweep = now();
function sweep(t) {
function sweep(t) {
@@ -38,7 +103,7 @@ function createQuotas(o) {
@@ -38,7 +103,7 @@ function createQuotas(o) {
for (const [k, b] of buckets) if (t - b.at > 10 * 60_000) buckets.delete(k);
for (const [k, b] of buckets) if (t - b.at > 10 * 60_000) buckets.delete(k);
}
}
- /** A bucket's tokens right now (refilled since it was last touched). */
+ /** A bucket's tokens right now (refilled since it was last touched), from the process map. */
function level(id, lim, t) {
function level(id, lim, t) {
const b = buckets.get(id);
const b = buckets.get(id);
if (!b) return lim.burst;
if (!b) return lim.burst;
@@ -63,7 +128,7 @@ function createQuotas(o) {
@@ -63,7 +128,7 @@ function createQuotas(o) {
* @param {object} q { quotaClass, cost }
* @param {object} q { quotaClass, cost }
* @param {object} [opt] { report: charge even when refused }
* @param {object} [opt] { report: charge even when refused }
*/
*/
- function check(caller, { quotaClass, cost = 1 }, opt = {}) {
+ async function check(caller, { quotaClass, cost = 1 }, opt = {}) {
const t = now();
const t = now();
sweep(t);
sweep(t);
const table = o.quotas();
const table = o.quotas();
@@ -77,14 +142,21 @@ function createQuotas(o) {
@@ -77,14 +142,21 @@ function createQuotas(o) {
const { lim } = c;
const { lim } = c;
if (!(lim.burst > 0) || !(lim.perMinute > 0)) continue;
if (!(lim.burst > 0) || !(lim.perMinute > 0)) continue;
const need = Math.min(cost, lim.burst); // one run never costs more than a full bucket
const need = Math.min(cost, lim.burst); // one run never costs more than a full bucket
- const tokens = level(`${quotaClass}|${c.key}`, lim, t);
+ const id = `${quotaClass}|${c.key}`;
const rate = lim.perMinute / 60_000;
const rate = lim.perMinute / 60_000;
- const minuteOk = tokens >= need;
- const used = lim.perDay > 0 ? o.store.dayUsed(day, c.key, quotaClass) : 0;
+ // Shared (Valkey): refill and spend in one atomic call, and decide from its answer. Otherwise
+ // (no Valkey, or an outage) decide and spend from this process's own map below.
+ let tokens, minuteOk, done = false;
+ if (shared && !shared.down) {
+ const r = await shared.spend(id, lim, need, t);
+ if (r) { tokens = r.ok ? r.left + need : r.left; minuteOk = r.ok; done = true; }
+ }
+ if (!done) { tokens = level(id, lim, t); minuteOk = tokens >= need; }
+ const used = lim.perDay > 0 ? await o.store.dayUsed(day, c.key, quotaClass) : 0;
const dayOk = !(lim.perDay > 0) || used + cost <= lim.perDay;
const dayOk = !(lim.perDay > 0) || used + cost <= lim.perDay;
const after = Math.max(0, tokens - need);
const after = Math.max(0, tokens - need);
views.push({
views.push({
- c, need, tokens, rate, dayUsed: used,
+ c, need, tokens, rate, dayUsed: used, done,
minute: { limit: lim.burst, remaining: Math.floor(after), reset: Math.ceil((lim.burst - after) / rate / 1000) },
minute: { limit: lim.burst, remaining: Math.floor(after), reset: Math.ceil((lim.burst - after) / rate / 1000) },
day: lim.perDay > 0 ? { limit: lim.perDay, remaining: Math.max(0, Math.floor(lim.perDay - used - cost)), reset: nextDay } : null,
day: lim.perDay > 0 ? { limit: lim.perDay, remaining: Math.max(0, Math.floor(lim.perDay - used - cost)), reset: nextDay } : null,
});
});
@@ -100,8 +172,8 @@ function createQuotas(o) {
@@ -100,8 +172,8 @@ function createQuotas(o) {
}
}
if (ok || opt.report) {
if (ok || opt.report) {
for (const v of views) {
for (const v of views) {
- buckets.set(`${quotaClass}|${v.c.key}`, { tokens: Math.max(0, v.tokens - v.need), at: t });
- if (v.c.lim.perDay > 0) o.store.dayAdd(day, v.c.key, quotaClass, cost);
+ if (!v.done) await spend(`${quotaClass}|${v.c.key}`, v.c.lim, v.need, t, v.tokens);
+ if (v.c.lim.perDay > 0) await o.store.dayAdd(day, v.c.key, quotaClass, cost);
}
}
}
}
// The headers describe the allowance closest to running out, in units of this run's cost.
// The headers describe the allowance closest to running out, in units of this run's cost.
@@ -119,12 +191,23 @@ function createQuotas(o) {
@@ -119,12 +191,23 @@ function createQuotas(o) {
};
};
}
}
+ /** Take `need` from a bucket: shared (Valkey) when it is up, this process otherwise. */
+ async function spend(id, lim, need, t, peekedTokens) {
+ if (shared && !shared.down) {
+ if (await shared.spend(id, lim, need, t) != null) return;
+ }
+ const left = Math.max(0, peekedTokens - need);
+ buckets.set(id, { tokens: left, at: t });
+ if (buckets.size > maxBuckets) buckets.delete(buckets.keys().next().value);
+ }
+
/** Give tokens back (a run that turned out to cost less than was taken up front). Day units stay counted. */
/** Give tokens back (a run that turned out to cost less than was taken up front). Day units stay counted. */
- function refund(caller, { quotaClass, cost }) {
+ async function refund(caller, { quotaClass, cost }) {
if (!(cost > 0)) return;
if (!(cost > 0)) return;
const t = now();
const t = now();
for (const c of chargesFor(caller, quotaClass, o.quotas())) {
for (const c of chargesFor(caller, quotaClass, o.quotas())) {
const id = `${quotaClass}|${c.key}`;
const id = `${quotaClass}|${c.key}`;
+ if (shared && !shared.down) { await shared.refund(id, c.lim, cost, t); continue; }
if (buckets.has(id)) buckets.set(id, { tokens: Math.min(c.lim.burst, level(id, c.lim, t) + cost), at: t });
if (buckets.has(id)) buckets.set(id, { tokens: Math.min(c.lim.burst, level(id, c.lim, t) + cost), at: t });
}
}
}
}
@@ -1,49 +1,82 @@
@@ -1,49 +1,82 @@
'use strict';
'use strict';
// Sign-out everywhere for every Tools app (roadmap WS-B task 4; Contracts 0.39.0
// Sign-out everywhere for every Tools app (roadmap WS-B task 4; Contracts 0.39.0
-// network.user.token_valid_after). The gateway receives the event and keeps each person's cutoff in
-// one SQLite file on the host (apps/gateway/server/revocation-events.js); every app's guard reads it
-// here and treats a token issued before the cutoff as nobody signed in (Network's rule:
-// iat * 1000 < valid_after). Lookups are memoised briefly per subject, so a revocation reaches every
-// app within `ttlMs`. Without better-sqlite3 or before the gateway has written the file, nothing is revoked.
-const fs = require('fs');
-const path = require('path');
+// network.user.token_valid_after). The cutoff lives in PostgreSQL (token_revocations, plan T8
+// decision 4): the gateway writes it through openvibe-sdk/auth createPgRevocationStore
+// (apps/gateway/server/revocation-events.js), and every app's guard reads it here.
+//
+// A read happens on the request path (isRevoked), so it stays synchronous against a short in-memory
+// cache loaded from PostgreSQL and refreshed at most every `ttlMs`. With a Valkey connection the
+// refresh is driven by a shared version counter the writer bumps, so a revocation reaches every
+// process as soon as its next refresh (or the ttl elapses). Without a database (tests, or a database
+// outage) nothing is revoked, exactly as before the shared store existed — never a hard failure on a
+// request.
+//
+// const cutoffs = createCutoffReader({ db, valkey });
+// cutoffs.isRevoked(claims) → boolean (from the cache)
+// await cutoffs.refresh() → reloads from PostgreSQL now
-// In the gateway's data directory: the one place its unit may write (ReadWritePaths); the other apps only read it.
-const DEFAULT_FILE = path.resolve(__dirname, '..', '..', 'gateway', 'data', 'token-revocations.db');
+const DEFAULT_TABLE = 'token_revocations';
-function revocationsFile(env = process.env) { return env.TOOLS_REVOCATIONS_DB || DEFAULT_FILE; }
+/**
+ * @param {object} o
+ * @param {object} [o.db] openvibe-sdk/db handle (token_revocations); absent → nothing revoked
+ * @param {object} [o.valkey] openvibe-sdk/valkey connection; a shared refresh version
+ * @param {string} [o.table='token_revocations']
+ * @param {number} [o.ttlMs=15_000] how long a loaded cache is trusted before the next read refreshes it
+ * @param {() => number} [o.now]
+ * @param {object} [o.log]
+ */
+function createCutoffReader(o = {}) {
+ const db = o.db || null;
+ const valkey = o.valkey || null;
+ const table = o.table || DEFAULT_TABLE;
+ const ttlMs = o.ttlMs == null ? 15_000 : o.ttlMs;
+ const now = o.now || Date.now;
+ const log = o.log || console;
+ const cache = new Map(); // subject → cutoff ms
+ let loadedAt = 0;
+ let version = null;
+ let inFlight = null;
-function createCutoffReader({ Database = null, file = revocationsFile(), ttlMs = 15_000, now = Date.now } = {}) {
- let stmt = null, lastTry = 0;
- const memo = new Map();
- function open() {
- if (stmt || !Database) return stmt;
- if (now() - lastTry < 10_000) return null;
- lastTry = now();
- try {
- if (!fs.existsSync(file)) return null;
- const db = new Database(file, { readonly: true, fileMustExist: true });
- db.pragma('busy_timeout = 1000');
- stmt = db.prepare('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?');
- } catch { stmt = null; }
- return stmt;
- }
function cutoffFor(subject) {
function cutoffFor(subject) {
if (!subject) return 0;
if (!subject) return 0;
- const hit = memo.get(subject);
- if (hit && now() - hit.at < ttlMs) return hit.ms;
- let ms = 0;
- const s = open();
- if (s) { try { const row = s.get(subject); ms = row ? Number(row.valid_after_ms) || 0 : 0; } catch { ms = hit ? hit.ms : 0; } }
- if (memo.size > 20000) memo.clear();
- memo.set(subject, { at: now(), ms });
- return ms;
+ if (db && now() - loadedAt > ttlMs) void refresh(); // background; this read uses the cache
+ return cache.get(subject) || 0;
}
}
function isRevoked(claims) {
function isRevoked(claims) {
if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;
if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;
return claims.iat * 1000 < cutoffFor(claims.subject_id);
return claims.iat * 1000 < cutoffFor(claims.subject_id);
}
}
- return { isRevoked, cutoffFor };
+
+ const versionKey = () => valkey.key('token_revocations_version');
+ async function refresh() {
+ if (!db) return 0;
+ if (inFlight) return inFlight;
+ inFlight = (async () => {
+ try {
+ // With Valkey, an unchanged version means no writer moved a cutoff: the cache still holds.
+ if (valkey) {
+ const v = await valkey.client.get(versionKey());
+ if (v && v === version && loadedAt) { loadedAt = now(); return cache.size; }
+ version = v || version;
+ }
+ const rows = await db.prepare(`SELECT subject_id, valid_after_ms FROM ${table}`).all();
+ cache.clear();
+ for (const r of rows) cache.set(r.subject_id, Number(r.valid_after_ms) || 0);
+ loadedAt = now();
+ return cache.size;
+ } catch (err) {
+ log.warn && log.warn(`[Guard] revocations read failed: ${err.message}; keeping the last cutoffs`);
+ loadedAt = now();
+ return cache.size;
+ } finally { inFlight = null; }
+ })();
+ return inFlight;
+ }
+ /** Load the cutoffs once at boot (the gateway's writer bumps the shared version on every write). */
+ async function warm() { return refresh(); }
+
+ return { isRevoked, cutoffFor, refresh, warm, size: () => cache.size };
}
}
-module.exports = { createCutoffReader, revocationsFile, DEFAULT_FILE };
+module.exports = { createCutoffReader, DEFAULT_TABLE };
@@ -1,152 +1,137 @@
@@ -1,152 +1,137 @@
'use strict';
'use strict';
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// guard.db — the guard's own small SQLite file in each app's data directory (better-sqlite3 handle
-// class passed in, like the job runtime). Three tables:
+// The guard's own state (plan T8, decision 4):
//
//
-// guard_salt today's random salt (one row; the previous day's is deleted when the day turns). Every
-// address the guard counts or logs is HMAC-SHA256(salt, address) — never the address.
-// Kept on disk so a restart keeps today's counts; gone the next day, so older hashes
-// can no longer be tied to anything (pseudonymous, ADR-021).
-// guard_day quota units used per UTC day, key and quota class (the day allowances survive restarts;
-// rows older than yesterday are pruned).
-// guard_abuse the abuse log: time, hashed address, principal or user id, tool, reason, whether it was
-// refused (enforce) or only reported; repeats of the same row within a minute add to
-// `count`. Kept 30 days.
+// guard_abuse PostgreSQL (migrations/0001_tools.sql): the 30-day audit trail, one row per
+// minute/address/principal/tool/reason/enforced, `count` incremented on repeat. Every
+// app writes it with its own `app`. Writes are off the request path (a failed write is
+// logged, never thrown), and the process keeps a bounded in-memory tail for /metrics,
+// the admin endpoint and the tests.
+// guard_salt Valkey, key guard_salt:<day>, two-day TTL, SETNX race — all processes agree on the
+// same HMAC salt without sharing a file. Memoised, loaded in the background (warm()).
+// guard_day Valkey hash guard_day:<day>, field <key>|<class> → used, TTL past midnight UTC.
+// A daily *throttle*: worthless a day later, so it belongs in Valkey, not PostgreSQL.
//
//
-// Without a Database class (an app that has none) the same API is kept in memory.
+// Without a database or Valkey (tests, or a Valkey outage) every counter and the salt stay in this
+// process, exactly what the guard did before: a throttle that never fails open for money or accounts
+// and never fails every request.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const crypto = require('crypto');
const crypto = require('crypto');
-const fs = require('fs');
-const path = require('path');
const DAY_MS = 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
+const SALT_TTL_S = 2 * DAY_MS / 1000;
+const MAX_ABUSE = 10000;
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS guard_salt (
- day TEXT PRIMARY KEY,
- salt TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS guard_day (
- day TEXT NOT NULL,
- key TEXT NOT NULL,
- class TEXT NOT NULL,
- used REAL NOT NULL DEFAULT 0,
- PRIMARY KEY (day, key, class)
-);
-CREATE TABLE IF NOT EXISTS guard_abuse (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- at INTEGER NOT NULL,
- minute INTEGER NOT NULL,
- ip_hash TEXT NOT NULL DEFAULT '',
- principal TEXT NOT NULL DEFAULT '',
- tool TEXT NOT NULL DEFAULT '',
- reason TEXT NOT NULL,
- enforced INTEGER NOT NULL,
- count INTEGER NOT NULL DEFAULT 1
-);
-CREATE UNIQUE INDEX IF NOT EXISTS guard_abuse_once ON guard_abuse(minute, ip_hash, principal, tool, reason, enforced);
-CREATE INDEX IF NOT EXISTS guard_abuse_at ON guard_abuse(at);
-`;
+/** Seconds until just after the next UTC midnight (a day counter's TTL). */
+function dayTtl(t) { return Math.ceil((Math.floor(t / DAY_MS) + 1) * DAY_MS / 1000) - Math.floor(t / 1000) + 3600; }
/**
/**
* @param {object} o
* @param {object} o
- * @param {Function} [o.Database] require('better-sqlite3'); omitted → in memory
- * @param {string} [o.dataDir]
- * @param {string} [o.file='guard.db']
+ * @param {object} [o.db] openvibe-sdk/db handle (guard_abuse); absent → process-only
+ * @param {object} [o.valkey] openvibe-sdk/valkey connection; absent → counters and salt in-process
+ * @param {string} [o.app] the owning app, written in every guard_abuse row
* @param {() => number} [o.now]
* @param {() => number} [o.now]
+ * @param {object} [o.log]
*/
*/
function createGuardStore(o = {}) {
function createGuardStore(o = {}) {
const now = o.now || Date.now;
const now = o.now || Date.now;
- if (!o.Database || !o.dataDir) return memoryStore(now);
- try { return sqliteStore(o, now); } catch (err) {
- // An unwritable data directory must not take the app down: quotas and the log stay in memory.
- (o.log || console).error(`[Guard] ${path.join(o.dataDir, o.file || 'guard.db')} cannot be opened (${err.message}); keeping guard state in memory`);
- return memoryStore(now);
+ const log = o.log || console;
+ const app = o.app || '';
+ const db = o.db || null;
+ const valkey = o.valkey || null;
+ const salts = new Map(); // day → salt
+ const days = new Map(); // `${day}|${key}|${cls}` → used (process view and fallback)
+ const abuse = []; // this process's bounded tail of the audit log
+ let cacheDay = null;
+ let saltUp = !!valkey; // flipped off for the process on a Valkey error
+ let dayUp = !!valkey;
+
+ // ── guard_salt (Valkey) ──────────────────────────────────
+ function salt(day = dayOf(now())) {
+ const hit = salts.get(day);
+ if (hit) return hit;
+ const local = crypto.randomBytes(32).toString('hex'); // used until Valkey answers
+ salts.set(day, local);
+ if (saltUp) void loadSalt(day);
+ return local;
}
}
-}
+ async function loadSalt(day) {
+ try {
+ const key = valkey.key('guard_salt', day);
+ let v = await valkey.client.get(key);
+ if (!v) {
+ await valkey.client.set(key, crypto.randomBytes(32).toString('hex'), 'NX', 'EX', SALT_TTL_S);
+ v = await valkey.client.get(key);
+ }
+ if (v) {
+ salts.set(day, v);
+ for (const k of salts.keys()) if (k !== day) salts.delete(k);
+ }
+ } catch (err) {
+ saltUp = false;
+ log.warn(`[Guard] ${app}: Valkey salt unavailable (${err.message}); this process keeps its own`);
+ }
+ }
+ /** Load (or create) today's shared salt; called once at boot. */
+ async function warm() { if (valkey) await loadSalt(dayOf(now())); }
-function sqliteStore(o, now) {
- fs.mkdirSync(o.dataDir, { recursive: true });
- const db = new o.Database(path.join(o.dataDir, o.file || 'guard.db'));
- db.pragma('journal_mode = WAL');
- db.pragma('synchronous = NORMAL');
- db.pragma('busy_timeout = 5000');
- db.exec(SCHEMA);
- const st = {
- saltGet: db.prepare('SELECT salt FROM guard_salt WHERE day = ?'),
- saltPut: db.prepare('INSERT OR IGNORE INTO guard_salt (day, salt) VALUES (?, ?)'),
- saltDrop: db.prepare('DELETE FROM guard_salt WHERE day <> ?'),
- dayGet: db.prepare('SELECT used FROM guard_day WHERE day = ? AND key = ? AND class = ?'),
- dayAdd: db.prepare('INSERT INTO guard_day (day, key, class, used) VALUES (?, ?, ?, ?) ON CONFLICT(day, key, class) DO UPDATE SET used = used + excluded.used'),
- dayPrune: db.prepare('DELETE FROM guard_day WHERE day < ?'),
- abuse: db.prepare(`INSERT INTO guard_abuse (at, minute, ip_hash, principal, tool, reason, enforced) VALUES (?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(minute, ip_hash, principal, tool, reason, enforced) DO UPDATE SET count = count + 1, at = excluded.at`),
- abusePrune: db.prepare('DELETE FROM guard_abuse WHERE at < ?'),
- abuseAll: db.prepare('SELECT at, ip_hash, principal, tool, reason, enforced, count FROM guard_abuse ORDER BY id'),
- };
- const cache = new Map(); // `${day}|${key}|${cls}` → used (today only)
- let cacheDay = null;
- const saltTx = db.transaction((day) => {
- st.saltPut.run(day, crypto.randomBytes(32).toString('hex'));
- st.saltDrop.run(day);
- return st.saltGet.get(day).salt;
- });
- return {
- kind: 'sqlite',
- db,
- salt(day = dayOf(now())) {
- const row = st.saltGet.get(day);
- return row ? row.salt : saltTx(day);
- },
- dayUsed(day, key, cls) {
- if (cacheDay !== day) { cache.clear(); cacheDay = day; }
- const k = `${day}|${key}|${cls}`;
- if (!cache.has(k)) { const r = st.dayGet.get(day, key, cls); cache.set(k, r ? r.used : 0); }
- return cache.get(k);
- },
- dayAdd(day, key, cls, n) {
- if (!(n > 0)) return;
- const used = this.dayUsed(day, key, cls) + n;
- cache.set(`${day}|${key}|${cls}`, used);
- st.dayAdd.run(day, key, cls, n);
- },
- pruneDays(keepFromDay) { return st.dayPrune.run(keepFromDay).changes; },
- logAbuse(r) {
- const at = r.at || now();
- st.abuse.run(at, Math.floor(at / 60_000), r.ipHash || '', r.principal || '', r.tool || '', String(r.reason), r.enforced ? 1 : 0);
- },
- pruneAbuse(beforeMs) { return st.abusePrune.run(beforeMs).changes; },
- abuseRows() { return st.abuseAll.all(); },
- close() { try { db.close(); } catch { /* already closed */ } },
- };
-}
+ // ── guard_day (Valkey) ───────────────────────────────────
+ async function dayUsed(day, key, cls) {
+ if (cacheDay !== day) { days.clear(); cacheDay = day; }
+ const k = `${day}|${key}|${cls}`;
+ if (dayUp) {
+ try {
+ const v = await valkey.client.hget(valkey.key('guard_day', day), `${key}|${cls}`);
+ const n = v == null ? 0 : Number(v);
+ days.set(k, n);
+ return n;
+ } catch (err) { dayUp = false; log.warn(`[Guard] ${app}: Valkey day counters unavailable (${err.message}); counting in this process`); }
+ }
+ return days.get(k) || 0;
+ }
+ async function dayAdd(day, key, cls, n) {
+ if (!(n > 0)) return;
+ const k = `${day}|${key}|${cls}`;
+ days.set(k, (days.get(k) || 0) + n);
+ if (!dayUp) return;
+ try {
+ const h = valkey.key('guard_day', day);
+ await valkey.client.hincrbyfloat(h, `${key}|${cls}`, n);
+ await valkey.client.expire(h, dayTtl(now()));
+ } catch (err) { dayUp = false; log.warn(`[Guard] ${app}: Valkey day counters unavailable (${err.message}); counting in this process`); }
+ }
+
+ // ── guard_abuse (PostgreSQL) ─────────────────────────────
+ function logAbuse(r) {
+ const at = r.at || now();
+ const row = { at, minute: Math.floor(at / 60_000), ip_hash: r.ipHash || '', principal: r.principal || '', tool: r.tool || '', reason: String(r.reason), enforced: r.enforced ? 1 : 0, count: 1 };
+ const same = abuse.find((x) => x.minute === row.minute && x.ip_hash === row.ip_hash && x.principal === row.principal && x.tool === row.tool && x.reason === row.reason && x.enforced === row.enforced);
+ if (same) { same.count++; same.at = at; } else { abuse.push(row); if (abuse.length > MAX_ABUSE) abuse.shift(); }
+ if (db) void insertAbuse(row).catch((err) => log.error(`[Guard] ${app}: abuse log write failed: ${err.message}`));
+ }
+ async function insertAbuse(row) {
+ await db.query(
+ `INSERT INTO guard_abuse (app, at, minute, ip_hash, principal, tool, reason, enforced) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (app, minute, ip_hash, principal, tool, reason, enforced) DO UPDATE SET count = guard_abuse.count + 1, at = excluded.at`,
+ [app, row.at, row.minute, row.ip_hash, row.principal, row.tool, row.reason, row.enforced]);
+ }
+ /** This process's tail of the log (sync: tests and the debug endpoint). */
+ function abuseRows() { return abuse.map(({ minute, ...r }) => r); }
+
+ async function pruneAbuse(beforeMs) {
+ for (let i = abuse.length - 1; i >= 0; i--) if (abuse[i].at < beforeMs) abuse.splice(i, 1);
+ if (!db) return 0;
+ const n = await db.exec('DELETE FROM guard_abuse WHERE at < $1', [beforeMs]);
+ return n || 0;
+ }
+ // guard_day rows live in Valkey and expire on their own.
+ async function pruneDays() { return 0; }
+
+ async function close() { /* the app owns db and valkey */ }
-function memoryStore(now) {
- let salt = null;
- const days = new Map();
- const abuse = [];
- return {
- kind: 'memory',
- db: null,
- salt(day = dayOf(now())) {
- if (!salt || salt.day !== day) salt = { day, salt: crypto.randomBytes(32).toString('hex') };
- return salt.salt;
- },
- dayUsed(day, key, cls) { return days.get(`${day}|${key}|${cls}`) || 0; },
- dayAdd(day, key, cls, n) { if (n > 0) days.set(`${day}|${key}|${cls}`, this.dayUsed(day, key, cls) + n); },
- pruneDays(keepFromDay) { let n = 0; for (const k of days.keys()) if (k.slice(0, 10) < keepFromDay) { days.delete(k); n++; } return n; },
- logAbuse(r) {
- const at = r.at || now();
- const row = { at, minute: Math.floor(at / 60_000), ip_hash: r.ipHash || '', principal: r.principal || '', tool: r.tool || '', reason: String(r.reason), enforced: r.enforced ? 1 : 0, count: 1 };
- const same = abuse.find(x => x.minute === row.minute && x.ip_hash === row.ip_hash && x.principal === row.principal && x.tool === row.tool && x.reason === row.reason && x.enforced === row.enforced);
- if (same) { same.count++; same.at = at; } else { abuse.push(row); if (abuse.length > 10000) abuse.shift(); }
- },
- pruneAbuse(beforeMs) { const n = abuse.length; for (let i = abuse.length - 1; i >= 0; i--) if (abuse[i].at < beforeMs) abuse.splice(i, 1); return n - abuse.length; },
- abuseRows() { return abuse.map(({ minute, ...r }) => r); },
- close() {},
- };
+ return { kind: db ? 'postgres' : 'memory', app, db, valkey, salt, warm, dayUsed, dayAdd, logAbuse, abuseRows, pruneAbuse, pruneDays, close };
}
}
module.exports = { createGuardStore, dayOf, DAY_MS };
module.exports = { createGuardStore, dayOf, DAY_MS };
@@ -90,8 +90,21 @@ const checks = {
@@ -90,8 +90,21 @@ const checks = {
};
};
},
},
+ /** A real round trip on the one `tools` database (an openvibe-sdk/db handle, or a function
+ * returning one); the detail names the store it answered from (postgresql / pglite). */
+ postgres(name, db, { required = true, description } = {}) {
+ return {
+ name, required, description,
+ check: async () => {
+ const h = typeof db === 'function' ? db() : db;
+ if (!h) return 'database not open';
+ return h.ready();
+ },
+ };
+ },
+
/** This process can write to the directory (a real create + remove). A missing directory is
/** This process can write to the directory (a real create + remove). A missing directory is
- * created first, as the app itself does on first use (mkdir -p). */
+ * created first, as the app itself does on first boot (mkdir -p). */
writableDir(name, dir, { required = true, description } = {}) {
writableDir(name, dir, { required = true, description } = {}) {
return {
return {
name, required, description,
name, required, description,
@@ -16,7 +16,7 @@ const { dep } = require('./deps');
@@ -16,7 +16,7 @@ const { dep } = require('./deps');
const express = dep('express');
const express = dep('express');
const cookieParser = dep('cookie-parser');
const cookieParser = dep('cookie-parser');
const multer = dep('multer');
const multer = dep('multer');
-const Database = dep('better-sqlite3');
+const Database = dep('better-sqlite3'); // the job store is still SQLite (Phases 2–3 not done)
const contracts = dep('openvibe-contracts');
const contracts = dep('openvibe-contracts');
const metrics = dep('openvibe-shared/metrics');
const metrics = dep('openvibe-shared/metrics');
const guardLib = require('../guard');
const guardLib = require('../guard');
@@ -43,6 +43,41 @@ const svcToken = (name, extra = {}) => contracts.serviceAuth.signServiceToken({
@@ -43,6 +43,41 @@ const svcToken = (name, extra = {}) => contracts.serviceAuth.signServiceToken({
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ov-guard-'));
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ov-guard-'));
const quiet = { log() {}, warn() {}, error() {} };
const quiet = { log() {}, warn() {}, error() {} };
+// A stand-in for openvibe-sdk/valkey: the same key()/client shape over one Map, so two guards can
+// share "one Valkey" in-process. The bucket's two Lua commands are emulated in JS (the real ones are
+// atomic; a single-threaded test has no race to lose).
+function fakeValkey({ down = false } = {}) {
+ const store = new Map();
+ const guard = () => { if (down) throw new Error('valkey down'); };
+ const client = {
+ async get(k) { guard(); return store.has(k) ? store.get(k) : null; },
+ async set(k, v) { guard(); if (store.has(k)) return null; store.set(k, String(v)); return 'OK'; },
+ async hget(k, f) { guard(); const h = store.get(k); return h && h[f] != null ? h[f] : null; },
+ async hincrbyfloat(k, f, n) { guard(); const h = store.get(k) || {}; h[f] = String(Number(h[f] || 0) + Number(n)); store.set(k, h); return h[f]; },
+ async expire() { guard(); return 1; },
+ async incr(k) { guard(); const v = Number(store.get(k) || 0) + 1; store.set(k, String(v)); return v; },
+ defineCommand(name) {
+ client[name] = async (_numKeys, key, burst, rate, need, now) => {
+ guard();
+ const h = store.get(key) || {};
+ let tokens = h.tokens != null ? Number(h.tokens) : Number(burst);
+ const at = h.at != null ? Number(h.at) : Number(now);
+ tokens = Math.min(Number(burst), tokens + (Number(now) - at) * Number(rate));
+ if (name === 'ovBucketRefund') { // give back, capped at burst
+ const left = Math.min(Number(burst), tokens + Number(need));
+ store.set(key, { tokens: String(left), at: String(now) });
+ return String(left);
+ }
+ if (tokens < Number(need)) return `${tokens}:0`; // short: take nothing
+ const left = tokens - Number(need);
+ store.set(key, { tokens: String(left), at: String(now) });
+ return `${left}:1`;
+ };
+ },
+ };
+ return { client, key: (...p) => p.join(':'), ready: async () => (down ? { ok: false, error: 'down' } : { ok: true }), close: async () => {} };
+}
+
// Pure-data descriptors of the real apps (nothing loads sharp or ffmpeg).
// Pure-data descriptors of the real apps (nothing loads sharp or ffmpeg).
const IMG = require('../../img/server/descriptors').SPECS;
const IMG = require('../../img/server/descriptors').SPECS;
const AUDIO = require('../../audio/server/descriptors').SPECS;
const AUDIO = require('../../audio/server/descriptors').SPECS;
@@ -50,7 +85,7 @@ const NET = require('../../gateway/server/net/descriptors').SPECS;
@@ -50,7 +85,7 @@ const NET = require('../../gateway/server/net/descriptors').SPECS;
function makeGuard(o = {}) {
function makeGuard(o = {}) {
return createGuard({
return createGuard({
- app: o.app || 'test', dataDir: o.dataDir, Database: o.dataDir ? Database : undefined, contracts,
+ app: o.app || 'test', db: o.db, valkey: o.valkey, contracts,
specs: o.specs || [...IMG, ...AUDIO, ...NET], issuer: ISSUER,
specs: o.specs || [...IMG, ...AUDIO, ...NET], issuer: ISSUER,
keys: { get: () => publicKey, ensure: async () => publicKey },
keys: { get: () => publicKey, ensure: async () => publicKey },
env: { TOOLS_GUARD: o.mode || 'report', ...(o.env || {}) }, log: quiet, now: o.now, pruneIntervalMs: 0,
env: { TOOLS_GUARD: o.mode || 'report', ...(o.env || {}) }, log: quiet, now: o.now, pruneIntervalMs: 0,
@@ -140,28 +175,30 @@ async function serve(app) {
@@ -140,28 +175,30 @@ async function serve(app) {
assert.strictEqual(cost('png'), 5);
assert.strictEqual(cost('png'), 5);
assert.strictEqual(cost('avif'), 8, 'AVIF encoding weighs more');
assert.strictEqual(cost('avif'), 8, 'AVIF encoding weighs more');
assert.strictEqual(cost('merge'), 20);
assert.strictEqual(cost('merge'), 20);
- // tools-job anonymous: a bucket of 30 refilled at 60 a minute.
- for (let i = 0; i < 3; i++) assert.ok(q.check(anon, { quotaClass: 'tools-job', cost: cost('avif') }).ok, `avif ${i + 1}`);
- let r = q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });
+ // tools-job anonymous: a bucket of 30 refilled at 60 a minute. check() is async since the day
+ // allowance and the minute/burst buckets became shared (Valkey, plan T8 decision 4).
+ for (let i = 0; i < 3; i++) assert.ok((await q.check(anon, { quotaClass: 'tools-job', cost: cost('avif') })).ok, `avif ${i + 1}`);
+ let r = await q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });
assert.ok(r.ok, 'png: 24 + 5 = 29 of 30');
assert.ok(r.ok, 'png: 24 + 5 = 29 of 30');
assert.strictEqual(r.limit, 30);
assert.strictEqual(r.limit, 30);
assert.strictEqual(r.remaining, 1);
assert.strictEqual(r.remaining, 1);
- r = q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });
+ r = await q.check(anon, { quotaClass: 'tools-job', cost: cost('png') });
assert.strictEqual(r.ok, false, 'the next png does not fit');
assert.strictEqual(r.ok, false, 'the next png does not fit');
assert.strictEqual(r.binding, 'minute');
assert.strictEqual(r.binding, 'minute');
assert.strictEqual(r.retryAfter, 4, 'four more units at one a second');
assert.strictEqual(r.retryAfter, 4, 'four more units at one a second');
clock += 4000;
clock += 4000;
- assert.ok(q.check(anon, { quotaClass: 'tools-job', cost: cost('png') }).ok, 'refilled');
+ assert.ok((await q.check(anon, { quotaClass: 'tools-job', cost: cost('png') })).ok, 'refilled');
// Tiers: a signed-in person has four times the bucket, a service forty.
// Tiers: a signed-in person has four times the bucket, a service forty.
const user = { tier: 'user', key: 'user:x', ipKey: 'ip:a' };
const user = { tier: 'user', key: 'user:x', ipKey: 'ip:a' };
- let n = 0; while (q.check(user, { quotaClass: 'tools-job', cost: 5 }).ok) n++;
+ let n = 0; while ((await q.check(user, { quotaClass: 'tools-job', cost: 5 })).ok) n++;
assert.strictEqual(n, 24, 'user burst 120 / 5');
assert.strictEqual(n, 24, 'user burst 120 / 5');
// Sessions from one address share SESSION_IP_SHARE × one session's allowance: dropping the cookie is no reset.
// Sessions from one address share SESSION_IP_SHARE × one session's allowance: dropping the cookie is no reset.
const sessions = [1, 2, 3, 4].map(i => ({ tier: 'session', key: `session:${i}`, ipKey: 'ip:nat' }));
const sessions = [1, 2, 3, 4].map(i => ({ tier: 'session', key: `session:${i}`, ipKey: 'ip:nat' }));
- const spent = sessions.map(sess => { let k = 0; while (q.check(sess, { quotaClass: 'tools-job', cost: 5 }).ok) k++; return k; });
+ const spent = [];
+ for (const sess of sessions) { let k = 0; while ((await q.check(sess, { quotaClass: 'tools-job', cost: 5 })).ok) k++; spent.push(k); }
assert.deepStrictEqual(spent, [9, 9, 9, 0], 'three sessions use the address allowance, a fourth cookie gets nothing more');
assert.deepStrictEqual(spent, [9, 9, 9, 0], 'three sessions use the address allowance, a fourth cookie gets nothing more');
// A run costing more than the whole bucket still runs when the bucket is full.
// A run costing more than the whole bucket still runs when the bucket is full.
- assert.ok(q.check({ tier: 'anonymous', key: 'ip:big', ipKey: 'ip:big' }, { quotaClass: 'tools-download', cost: 500 }).ok);
+ assert.ok((await q.check({ tier: 'anonymous', key: 'ip:big', ipKey: 'ip:big' }, { quotaClass: 'tools-download', cost: 500 })).ok);
}
}
// ── Report vs enforce; headers and problem+json; the metric; the challenge hook ──
// ── Report vs enforce; headers and problem+json; the metric; the challenge hook ──
@@ -237,32 +274,52 @@ async function serve(app) {
@@ -237,32 +274,52 @@ async function serve(app) {
} finally { await s.close(); g.close(); }
} finally { await s.close(); g.close(); }
}
}
- // ── Day allowances survive a restart (guard.db) ──
+ // ── One Valkey, two processes: one shared day allowance and bucket (plan T8 decision 4) ──
+ // (Replaces the old "day allowances survive a restart" test: the allowance no longer lives in a
+ // per-app guard.db, it lives in Valkey, so the invariant to test is two processes against one store.)
{
{
- const dataDir = tmp();
- const env = { TOOLS_GUARD: 'enforce', TOOLS_GUARD_LIMITS: JSON.stringify({ 'tools-fetch': { anonymous: { perMinute: 600, burst: 600, perDay: 10 } } }) };
+ const valkey = fakeValkey();
+ const env = { TOOLS_GUARD: 'enforce', TOOLS_GUARD_LIMITS: JSON.stringify({
+ 'tools-fetch': { anonymous: { perMinute: 600, burst: 600, perDay: 10 } },
+ 'tools-tiny': { anonymous: { perMinute: 60, burst: 4, perDay: 1000 } },
+ }) };
const req = { ip: '198.51.100.20', headers: {}, cookies: {} };
const req = { ip: '198.51.100.20', headers: {}, cookies: {} };
- let g = makeGuard({ dataDir, env });
- const caller1 = g.resolveCaller(req);
- assert.ok(g.quotas.check(caller1, { quotaClass: 'tools-fetch', cost: 4 }).ok);
- assert.ok(g.quotas.check(caller1, { quotaClass: 'tools-fetch', cost: 4 }).ok);
- g.close();
- g = makeGuard({ dataDir, env });
- const caller2 = g.resolveCaller(req);
- assert.strictEqual(caller2.ipKey, caller1.ipKey, "today's salt is kept, so the same address is the same key after a restart");
- const r = g.quotas.check(caller2, { quotaClass: 'tools-fetch', cost: 4 });
- assert.strictEqual(r.ok, false, '8 of 10 used before the restart: 4 more do not fit');
+ const a = makeGuard({ valkey, env });
+ const b = makeGuard({ valkey, env });
+ await a.store.warm(); await b.store.warm();
+ const ca = a.resolveCaller(req);
+ const cb = b.resolveCaller(req);
+ assert.strictEqual(cb.ipKey, ca.ipKey, "one salt in Valkey: the same address is the same key in both processes");
+ assert.ok((await a.quotas.check(ca, { quotaClass: 'tools-fetch', cost: 4 })).ok);
+ assert.ok((await a.quotas.check(ca, { quotaClass: 'tools-fetch', cost: 4 })).ok, 'process A has spent 8 of the day allowance');
+ const r = await b.quotas.check(cb, { quotaClass: 'tools-fetch', cost: 4 });
+ assert.strictEqual(r.ok, false, "process B sees A's spend: 4 more do not fit");
assert.strictEqual(r.binding, 'day');
assert.strictEqual(r.binding, 'day');
- assert.ok(g.quotas.check(caller2, { quotaClass: 'tools-fetch', cost: 2 }).ok, 'the last 2 still do');
+ assert.ok((await b.quotas.check(cb, { quotaClass: 'tools-fetch', cost: 2 })).ok, 'the last 2 still do');
+ // The minute/burst bucket is shared too: A drains it, B is refused on the minute leg.
+ for (let i = 0; i < 4; i++) assert.ok((await a.quotas.check(ca, { quotaClass: 'tools-tiny', cost: 1 })).ok, `tiny ${i + 1}`);
+ const rm = await b.quotas.check(cb, { quotaClass: 'tools-tiny', cost: 1 });
+ assert.strictEqual(rm.ok, false, "process B sees A's bucket: the minute leg is shared");
+ assert.strictEqual(rm.binding, 'minute');
+ a.close(); b.close();
+ }
+
+ // ── A Valkey outage degrades to this process's own counters: a throttle, never a failure ──
+ {
+ const valkey = fakeValkey({ down: true });
+ const env = { TOOLS_GUARD: 'enforce', TOOLS_GUARD_LIMITS: JSON.stringify({ 'tools-fetch': { anonymous: { perMinute: 600, burst: 600, perDay: 2 } } }) };
+ const g = makeGuard({ valkey, env });
+ const c = g.resolveCaller({ ip: '198.51.100.30', headers: {}, cookies: {} });
+ let ok = 0;
+ for (let i = 0; i < 6; i++) if ((await g.quotas.check(c, { quotaClass: 'tools-fetch', cost: 1 })).ok) ok++;
+ assert.strictEqual(ok, 2, 'the day allowance still holds in this process: the guard never fails open and never fails every request');
g.close();
g.close();
- fs.rmSync(dataDir, { recursive: true, force: true });
}
}
// ── The abuse log: no raw address anywhere, 30 days ──
// ── The abuse log: no raw address anywhere, 30 days ──
{
{
- const dataDir = tmp();
let clock = now0;
let clock = now0;
- const g = makeGuard({ dataDir, now: () => clock, mode: 'enforce' });
+ const g = makeGuard({ now: () => clock, mode: 'enforce' });
const RAW = '203.0.113.201';
const RAW = '203.0.113.201';
const req = { ip: RAW, headers: {}, cookies: {} };
const req = { ip: RAW, headers: {}, cookies: {} };
const res = { headersSent: false, statusCode: 200, setHeader() {}, end() {} };
const res = { headersSent: false, statusCode: 200, setHeader() {}, end() {} };
@@ -274,15 +331,12 @@ async function serve(app) {
@@ -274,15 +331,12 @@ async function serve(app) {
assert.strictEqual(rows[0].count, 3, 'repeats within a minute are one row with a count');
assert.strictEqual(rows[0].count, 3, 'repeats within a minute are one row with a count');
assert.match(rows[0].ip_hash, /^[0-9a-f]{32}$/);
assert.match(rows[0].ip_hash, /^[0-9a-f]{32}$/);
assert.notStrictEqual(rows[0].ip_hash, rows[1].ip_hash, 'the salt rotates daily: yesterday\'s hash no longer matches');
assert.notStrictEqual(rows[0].ip_hash, rows[1].ip_hash, 'the salt rotates daily: yesterday\'s hash no longer matches');
- g.store.db.pragma('wal_checkpoint(TRUNCATE)');
- for (const f of fs.readdirSync(dataDir)) assert.ok(!fs.readFileSync(path.join(dataDir, f)).includes(RAW), `${f} holds no raw address`);
- assert.strictEqual(g.store.db.prepare('SELECT COUNT(*) AS n FROM guard_salt').get().n, 1, 'only today\'s salt is kept');
+ assert.ok(!JSON.stringify(rows).includes(RAW), 'the log keeps no raw address');
clock += 6 * 24 * 3600 * 1000; // the first row is now 31 days old, the second 6
clock += 6 * 24 * 3600 * 1000; // the first row is now 31 days old, the second 6
- g.prune();
+ await g.prune();
rows = g.store.abuseRows();
rows = g.store.abuseRows();
assert.deepStrictEqual(rows.map(r => r.reason), ['sniff'], 'rows older than 30 days are pruned');
assert.deepStrictEqual(rows.map(r => r.reason), ['sniff'], 'rows older than 30 days are pruned');
g.close();
g.close();
- fs.rmSync(dataDir, { recursive: true, force: true });
}
}
// ── Upload sniffing: the bytes, not the name or the declared type ──
// ── Upload sniffing: the bytes, not the name or the declared type ──
@@ -514,5 +568,5 @@ async function serve(app) {
@@ -514,5 +568,5 @@ async function serve(app) {
system.stop(); db.close(); fs.rmSync(dataDir, { recursive: true, force: true });
system.stop(); db.close(); fs.rmSync(dataDir, { recursive: true, force: true });
}
}
- console.log('guard: callers (/64, one loopback hop, services by token, aud-checked users, sessions, sandbox), bucket math + cost, session sharing, report vs enforce, headers + problem+json, day counters across restarts, sniffing, target throttle, port cap, abuse log + prune + metric, semaphore, job bounds (queue, disk, per address), challenge hook, Origin check (CSRF): all checks passed');
+ console.log('guard: callers (/64, one loopback hop, services by token, aud-checked users, sessions, sandbox), bucket math + cost, session sharing, report vs enforce, headers + problem+json, two processes one Valkey quota + outage fallback, sniffing, target throttle, port cap, abuse log + prune + metric, semaphore, job bounds (queue, disk, per address), challenge hook, Origin check (CSRF): all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -239,10 +239,11 @@ function createRunApi(o) {
@@ -239,10 +239,11 @@ function createRunApi(o) {
}
}
return c;
return c;
}
}
- if (probe) {
+ // A signed-in person may run probes through the run API under their per-person quota (plan T8, Phase
+ // 1: the net pages and dev.html call the run API with the session token); anonymous stays 401.
+ if (probe && c.kind !== 'user') {
guard.record(req, c, { reason: 'capability', tool: d.id, enforced: true });
guard.record(req, c, { reason: 'capability', tool: d.id, enforced: true });
- if (c.kind === 'user') throw new Refusal(403, 'capability.denied', `Network probes run through the API only for apps and services holding ${cap}; people use the tool's page.`);
- throw new Refusal(401, 'token.missing', `${d.id} runs through the API only with a token holding ${cap}; people use the tool's page.`);
+ throw new Refusal(401, 'token.missing', `${d.id} runs through the API only with a token holding ${cap}, or a signed-in person on its page.`);
}
}
if (d.auth && d.auth.anonymous === false && c.kind === 'anonymous') {
if (d.auth && d.auth.anonymous === false && c.kind === 'anonymous') {
guard.record(req, c, { reason: 'session', tool: d.id, enforced: true });
guard.record(req, c, { reason: 'session', tool: d.id, enforced: true });
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -17,14 +18,28 @@
@@ -17,14 +18,28 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"fluent-ffmpeg": "^2.1.3",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "pg": "^8.23.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -268,6 +283,15 @@
@@ -268,6 +283,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/concat-stream": {
"node_modules/concat-stream": {
"version": "1.6.2",
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@ -388,6 +412,15 @@
@@ -388,6 +412,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -838,6 +871,49 @@
@@ -838,6 +871,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -914,12 +990,24 @@
@@ -914,12 +990,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1227,6 +1315,134 @@
@@ -1227,6 +1315,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1363,6 +1579,27 @@
@@ -1363,6 +1579,27 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1578,6 +1815,21 @@
@@ -1578,6 +1815,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11,8 +11,11 @@
@@ -11,8 +11,11 @@
"license": "MIT",
"license": "MIT",
"openvibeToolsShared": "^1.2.0",
"openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
"cors": "^2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
@@ -36,13 +36,8 @@ const sdk = require('openvibe-sdk');
@@ -36,13 +36,8 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
-const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
-const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
-fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
-const analyticsDb = new Database(analyticsDbPath);
-analyticsDb.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio', { retention: { days: 30 } });
// ── Guard (apps/_shared/guard) ───────────────────────────────
// ── Guard (apps/_shared/guard) ───────────────────────────────
// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else
// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else
@@ -50,8 +45,12 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio', { retentio
@@ -50,8 +45,12 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio', { retentio
// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse. Every audio
// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse. Every audio
// tool needs a browser session, a sign-in or a token (descriptor auth.anonymous false).
// tool needs a browser session, a sign-in or a token (descriptor auth.anonymous false).
const SPECS = require('./descriptors').SPECS;
const SPECS = require('./descriptors').SPECS;
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-audio' });
+const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-audio', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, specs: SPECS,
+ app: 'audio', db: toolsDb.db, valkey: toolsValkey, contracts, specs: SPECS,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
});
});
const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);
const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);
@@ -66,16 +65,19 @@ const release = require('../../_shared/release').toolsRelease('audio', require);
@@ -66,16 +65,19 @@ const release = require('../../_shared/release').toolsRelease('audio', require);
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
const { observe, checks: ready, which } = require('../../_shared/observe');
const { observe, checks: ready, which } = require('../../_shared/observe');
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools-audio', release: release.release,
service: 'tools-audio', release: release.release,
checks: [
checks: [
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, jobs, analytics, revocations)' }),
ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
ready.jobRuntime('job_runtime', () => jobs),
ready.jobRuntime('job_runtime', () => jobs),
ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
...jobsRuntime.readyChecks(() => jobs),
...jobsRuntime.readyChecks(() => jobs),
ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'every audio tool runs ffmpeg; without it jobs and conversions fail' }),
ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'every audio tool runs ffmpeg; without it jobs and conversions fail' }),
@@ -349,12 +351,12 @@ app.get('/api/preview/:id', (req, res) => {
@@ -349,12 +351,12 @@ app.get('/api/preview/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
-app.get('/api/internal/analytics', internalAccess, (req, res) => {
- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
+app.get('/api/internal/analytics', internalAccess, async (req, res) => {
+ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
-app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
+app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
+ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -406,8 +408,9 @@ require('../../_shared/graceful').gracefulStop({
@@ -406,8 +408,9 @@ require('../../_shared/graceful').gracefulStop({
close: [
close: [
() => require('../../_shared/usage').stopRecorder(800),
() => require('../../_shared/usage').stopRecorder(800),
() => analytics.destroy(),
() => analytics.destroy(),
- () => analyticsDb.close(),
() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
],
],
});
});
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -16,16 +17,24 @@
@@ -16,16 +17,24 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
+ "pg": "^8.23.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
"node_modules/@img/sharp-libvips-linux-x64": {
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
@@ -64,6 +73,12 @@
@@ -64,6 +73,12 @@
"@img/sharp-libvips-linux-x64": "1.0.4"
"@img/sharp-libvips-linux-x64": "1.0.4"
}
}
},
},
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/@pdf-lib/standard-fonts": {
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
@@ -320,6 +335,15 @@
@@ -320,6 +335,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/color": {
"node_modules/color": {
"version": "4.2.3",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -481,6 +505,15 @@
@@ -481,6 +505,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -917,6 +950,49 @@
@@ -917,6 +950,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -993,12 +1069,24 @@
@@ -993,12 +1069,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1324,6 +1412,134 @@
@@ -1324,6 +1412,134 @@
"tslib": "^1.11.1"
"tslib": "^1.11.1"
}
}
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1460,6 +1676,27 @@
@@ -1460,6 +1676,27 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1723,6 +1960,21 @@
@@ -1723,6 +1960,21 @@
"is-arrayish": "^0.3.1"
"is-arrayish": "^0.3.1"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11,8 +11,11 @@
@@ -11,8 +11,11 @@
"license": "MIT",
"license": "MIT",
"openvibeToolsShared": "^1.2.0",
"openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
"cors": "^2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
@@ -36,13 +36,8 @@ const sdk = require('openvibe-sdk');
@@ -36,13 +36,8 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
-const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
-const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
-fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
-const analyticsDb = new Database(analyticsDbPath);
-analyticsDb.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs', { retention: { days: 30 } });
// ── Guard (apps/_shared/guard) ───────────────────────────────
// ── Guard (apps/_shared/guard) ───────────────────────────────
// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else
// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else
@@ -50,8 +45,12 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs', { retention
@@ -50,8 +45,12 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs', { retention
// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse. Every PDF
// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse. Every PDF
// tool needs a browser session, a sign-in or a token (descriptor auth.anonymous false).
// tool needs a browser session, a sign-in or a token (descriptor auth.anonymous false).
const SPECS = require('./descriptors').SPECS;
const SPECS = require('./descriptors').SPECS;
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-docs' });
+const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-docs', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'docs', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, specs: SPECS,
+ app: 'docs', db: toolsDb.db, valkey: toolsValkey, contracts, specs: SPECS,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
});
});
const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);
const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);
@@ -83,16 +82,19 @@ const release = require('../../_shared/release').toolsRelease('docs', require);
@@ -83,16 +82,19 @@ const release = require('../../_shared/release').toolsRelease('docs', require);
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
const { observe, checks: ready } = require('../../_shared/observe');
const { observe, checks: ready } = require('../../_shared/observe');
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools-docs', release: release.release,
service: 'tools-docs', release: release.release,
checks: [
checks: [
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, jobs, analytics, revocations)' }),
ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
ready.jobRuntime('job_runtime', () => jobs),
ready.jobRuntime('job_runtime', () => jobs),
ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
...BINARIES.map(b => b.readyCheck(b.name === 'qpdf' ? 'Protect and Unlock PDF (package qpdf); the other tools work without it' : 'PDF to image (package poppler-utils); the other tools work without it')),
...BINARIES.map(b => b.readyCheck(b.name === 'qpdf' ? 'Protect and Unlock PDF (package qpdf); the other tools work without it' : 'PDF to image (package poppler-utils); the other tools work without it')),
...jobsRuntime.readyChecks(() => jobs),
...jobsRuntime.readyChecks(() => jobs),
@@ -369,12 +371,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -369,12 +371,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
-app.get('/api/internal/analytics', internalAccess, (req, res) => {
- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
+app.get('/api/internal/analytics', internalAccess, async (req, res) => {
+ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
-app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
+app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
+ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -430,9 +432,10 @@ require('../../_shared/graceful').gracefulStop({
@@ -430,9 +432,10 @@ require('../../_shared/graceful').gracefulStop({
close: [
close: [
() => require('../../_shared/usage').stopRecorder(800),
() => require('../../_shared/usage').stopRecorder(800),
() => analytics.destroy(),
() => analytics.destroy(),
- () => analyticsDb.close(),
() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
() => pool.close(),
() => pool.close(),
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
],
],
});
});
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
"name": "openvibe-food",
"name": "openvibe-food",
"version": "1.0.0",
"version": "1.0.0",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -15,10 +16,24 @@
@@ -15,10 +16,24 @@
"express": "^4.18.2",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
+ "iovalkey": "^0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "pg": "^8.23.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -187,6 +202,15 @@
@@ -187,6 +202,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -286,6 +310,15 @@
@@ -286,6 +310,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -697,6 +730,49 @@
@@ -697,6 +730,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -755,12 +831,24 @@
@@ -755,12 +831,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1021,6 +1109,134 @@
@@ -1021,6 +1109,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1144,6 +1360,27 @@
@@ -1144,6 +1360,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1350,6 +1587,21 @@
@@ -1350,6 +1587,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1477,6 +1729,15 @@
@@ -1477,6 +1729,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -14,7 +14,10 @@
@@ -14,7 +14,10 @@
"cors": "^2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express": "^4.18.2",
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
@@ -13,26 +13,24 @@ const fs = require('fs');
@@ -13,26 +13,24 @@ const fs = require('fs');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
-const Database = require('better-sqlite3');
-const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
const { hostGuard, stampedPage } = require('../../_shared/host-role');
const { hostGuard, stampedPage } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
-const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
-fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
-const analyticsDb = new Database(analyticsDbPath);
-analyticsDb.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-food', { retention: { days: 30 } });
// ── Guard (apps/_shared/guard) ───────────────────────────────
// ── Guard (apps/_shared/guard) ───────────────────────────────
// Who is asking (Network sign-in with aud openvibe.tools, else the address), the tools-api quota and
// Who is asking (Network sign-in with aud openvibe.tools, else the address), the tools-api quota and
// the abuse log (data/guard.db). The data lookups are counted by maps (tools-map), which gets the
// the abuse log (data/guard.db). The data lookups are counted by maps (tools-map), which gets the
// visitor's address from here. TOOLS_GUARD=report (default) records what it would refuse.
// visitor's address from here. TOOLS_GUARD=report (default) records what it would refuse.
const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';
const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-food' });
+const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-food', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'food', dataDir: path.join(__dirname, '..', 'data'), Database, specs: require('../../maps/server/descriptors').SPECS.filter(s => s.id === 'food'),
+ app: 'food', db: toolsDb.db, valkey: toolsValkey, specs: require('../../maps/server/descriptors').SPECS.filter(s => s.id === 'food'),
issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),
publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),
});
});
@@ -46,13 +44,16 @@ const release = require('../../_shared/release').toolsRelease('food', require);
@@ -46,13 +44,16 @@ const release = require('../../_shared/release').toolsRelease('food', require);
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
const { observe, checks: ready } = require('../../_shared/observe');
const { observe, checks: ready } = require('../../_shared/observe');
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools-food', release: release.release,
service: 'tools-food', release: release.release,
checks: [
checks: [
// Every food API answer comes from the maps satellite; without it this server only has its page.
// Every food API answer comes from the maps satellite; without it this server only has its page.
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),
ready.upstream('maps', `${MAPS_API}/api/ready`, { required: true, cacheMs: 5000, description: 'food APIs are proxied to the maps satellite' }),
ready.upstream('maps', `${MAPS_API}/api/ready`, { required: true, cacheMs: 5000, description: 'food APIs are proxied to the maps satellite' }),
- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only' }),
],
],
});
});
guard.attachMetrics(obs.registry);
guard.attachMetrics(obs.registry);
@@ -146,12 +147,12 @@ app.get('/api/geocode', proxyToMaps('/api/geocode'));
@@ -146,12 +147,12 @@ app.get('/api/geocode', proxyToMaps('/api/geocode'));
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
-app.get('/api/internal/analytics', internalAccess, (req, res) => {
- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
+app.get('/api/internal/analytics', internalAccess, async (req, res) => {
+ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
-app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
+app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
+ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -169,7 +170,8 @@ require('../../_shared/graceful').gracefulStop({
@@ -169,7 +170,8 @@ require('../../_shared/graceful').gracefulStop({
name: 'Food.OpenVibe', server,
name: 'Food.OpenVibe', server,
close: [
close: [
() => analytics.destroy(),
() => analytics.destroy(),
- () => analyticsDb.close(),
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
],
],
});
});
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "2.0.0",
"version": "2.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -16,12 +17,26 @@
@@ -16,12 +17,26 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "pg": "^8.23.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -223,6 +238,15 @@
@@ -223,6 +238,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -322,6 +346,15 @@
@@ -322,6 +346,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -758,6 +791,49 @@
@@ -758,6 +791,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -822,12 +898,24 @@
@@ -822,12 +898,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1104,6 +1192,134 @@
@@ -1104,6 +1192,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1223,6 +1439,27 @@
@@ -1223,6 +1439,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1438,6 +1675,21 @@
@@ -1438,6 +1675,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1565,6 +1817,15 @@
@@ -1565,6 +1817,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -11,8 +11,11 @@
@@ -11,8 +11,11 @@
"license": "MIT",
"license": "MIT",
"openvibeToolsShared": "^1.2.0",
"openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
"cors": "^2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
@@ -399,7 +399,10 @@ P.color=(s)=>{
@@ -399,7 +399,10 @@ P.color=(s)=>{
};
};
P.opengraph=async(s)=>{
P.opengraph=async(s)=>{
- const r=await fetch(API+'/opengraph?url='+encodeURIComponent(s.trim()));const d=await r.json();if(!d.ok)throw new Error(d.error||'Failed to fetch');
+ // The tool's run API (plan T8, Phase 1), with the same tags/seo the old /api/dev/opengraph answered.
+ const r=await fetch('/api/v1/tools/opengraph/run',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({input:{url:s.trim()}})});
+ const b=await r.json();if(b.state!=='succeeded'||!b.result)throw new Error((b.error&&(b.error.detail||b.error.title))||b.detail||'Failed to fetch');
+ const d={ok:true,...b.result.data};
const og=d.tags||{};let u;try{u=new URL(s.trim()).hostname}catch{u=''}
const og=d.tags||{};let u;try{u=new URL(s.trim()).hostname}catch{u=''}
return{html:'<div class="og-card">'+(og['og:image']?'<img src="'+H(og['og:image'])+'" onerror="this.style.display=\'none\'">':'')+'<div class="og-body"><div class="og-dm">'+H(og['og:site_name']||u)+'</div><div class="og-tt">'+H(og['og:title']||og.title||'No title')+'</div><div class="og-ds">'+H(og['og:description']||og.description||'No description')+'</div></div></div>'
return{html:'<div class="og-card">'+(og['og:image']?'<img src="'+H(og['og:image'])+'" onerror="this.style.display=\'none\'">':'')+'<div class="og-body"><div class="og-dm">'+H(og['og:site_name']||u)+'</div><div class="og-tt">'+H(og['og:title']||og.title||'No title')+'</div><div class="og-ds">'+H(og['og:description']||og.description||'No description')+'</div></div></div>'
+'<div style="margin-top:14px"><div class="jl">All Meta Tags</div>'+Object.entries(og).map(([k,v])=>'<div><span style="color:var(--ac)">'+H(k)+'</span>: '+H(String(v))+'</div>').join('')+'</div>'
+'<div style="margin-top:14px"><div class="jl">All Meta Tags</div>'+Object.entries(og).map(([k,v])=>'<div><span style="color:var(--ac)">'+H(k)+'</span>: '+H(String(v))+'</div>').join('')+'</div>'
@@ -215,6 +215,15 @@
@@ -215,6 +215,15 @@
border-radius: var(--radius-sm); color: var(--red); font-size: 14px;
border-radius: var(--radius-sm); color: var(--red); font-size: 14px;
}
}
+ /* A probe asked for by a signed-out visitor: probes reach other machines, so they need an account. */
+ .signin-card { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
+ .signin-card > i { font-size: 22px; color: var(--cyan); flex: none; }
+ .signin-card .signin-text { flex: 1 1 240px; min-width: 0; }
+ .signin-card .signin-text b { display: block; font-size: 15px; margin-bottom: 4px; color: var(--text-primary); }
+ .signin-card .signin-text p { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--text-secondary, #b0b0b8); }
+ .signin-card .btn-go { padding: 10px 22px; border-radius: 10px; font-weight: 600; font-size: 14px; background: var(--cyan); color: #fff; white-space: nowrap; }
+ .signin-card .btn-go:hover { background: #0891b2; color: #fff; }
+
/* A tool this server cannot run */
/* A tool this server cannot run */
.tool-unavailable p { font-size: 14px; color: var(--text-secondary, #b0b0b8); margin: 8px 0 12px; line-height: 1.5; }
.tool-unavailable p { font-size: 14px; color: var(--text-secondary, #b0b0b8); margin: 8px 0 12px; line-height: 1.5; }
@@ -632,10 +641,10 @@
@@ -632,10 +641,10 @@
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
// Net.OpenVibe — Client-Side App
// Net.OpenVibe — Client-Side App
// Detects subdomain to determine active tool, renders UI,
// Detects subdomain to determine active tool, renders UI,
- // calls /api/net/* endpoints on openvibe.tools.
+ // calls the tools run API (/api/v1/tools/<id>/run) on openvibe.tools.
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
- const API_BASE = 'https://openvibe.tools/api/net';
+ const API_BASE = '/api/v1/tools';
// ── Tool definitions (mirrors server/net/config.js: endpoint and status must agree, which
// ── Tool definitions (mirrors server/net/config.js: endpoint and status must agree, which
// test/registry-consistency.test.js checks) ──────
// test/registry-consistency.test.js checks) ──────
@@ -909,6 +918,12 @@
@@ -909,6 +918,12 @@
location.href = url;
location.href = url;
}
}
+ // The message of a run that did not succeed (a refusal problem, or the tool's own error).
+ function runError(body) {
+ const e = body && body.error;
+ return (e && (e.detail || e.title || e.message)) || (body && (body.detail || body.title)) || 'Lookup failed';
+ }
+
// ── Run the active tool ──────────────────────────────────
// ── Run the active tool ──────────────────────────────────
async function runTool() {
async function runTool() {
const input = document.getElementById('search-input');
const input = document.getElementById('search-input');
@@ -928,49 +943,46 @@
@@ -928,49 +943,46 @@
resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…</p></div>`;
resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…</p></div>`;
try {
try {
- let url = `${API_BASE}${tool.endpoint}`;
- const params = new URLSearchParams();
- if (target) params.set('target', target);
+ // The tool's run API (plan T8, Phase 1): the same handlers the old /api/net routes called,
+ // in the run response shape. The request is the route's query as an `input` object.
+ const input = {};
+ if (target) input.target = target;
// DNS type overrides
// DNS type overrides
- if (tool.defaultTypes) params.set('types', tool.defaultTypes);
+ if (tool.defaultTypes) input.types = tool.defaultTypes.split(',');
if (tool.dnsTypes) {
if (tool.dnsTypes) {
const sel = document.getElementById('dns-types');
const sel = document.getElementById('dns-types');
if (sel) {
if (sel) {
const selected = [...sel.selectedOptions].map(o => o.value);
const selected = [...sel.selectedOptions].map(o => o.value);
- if (selected.length) params.set('types', selected.join(','));
+ if (selected.length) input.types = selected;
}
}
}
}
// DKIM special: query selector._domainkey.domain
// DKIM special: query selector._domainkey.domain
if (activeTool === 'dkim' && target) {
if (activeTool === 'dkim' && target) {
const selector = prompt('DKIM selector (e.g., "default", "google", "s1"):', 'default') || 'default';
const selector = prompt('DKIM selector (e.g., "default", "google", "s1"):', 'default') || 'default';
- params.set('target', `${selector}._domainkey.${target}`);
+ input.target = `${selector}._domainkey.${target}`;
}
}
// DMARC: prepend _dmarc.
// DMARC: prepend _dmarc.
- if (activeTool === 'dmarc' && target) {
- params.set('target', `_dmarc.${target}`);
- }
+ if (activeTool === 'dmarc' && target) input.target = `_dmarc.${target}`;
// SPF: just TXT
// SPF: just TXT
- if (activeTool === 'spf') params.set('types', 'TXT');
- if (tool.count) params.set('count', String(tool.count));
- if (tool.propTypes) params.set('type', document.getElementById('prop-type').value);
- if (tool.smtpPorts) params.set('port', document.getElementById('smtp-port').value);
+ if (activeTool === 'spf') input.types = ['TXT'];
+ if (tool.count) input.count = tool.count;
+ if (tool.propTypes) input.type = document.getElementById('prop-type').value;
+ if (tool.smtpPorts) input.port = parseInt(document.getElementById('smtp-port').value, 10);
if (tool.robotsTest) {
if (tool.robotsTest) {
const p = document.getElementById('robots-path').value.trim();
const p = document.getElementById('robots-path').value.trim();
- if (p) { params.set('path', p); params.set('ua', document.getElementById('robots-ua').value); }
+ if (p) { input.path = p; input.ua = document.getElementById('robots-ua').value; }
}
}
- const qs = params.toString();
- if (qs) url += (url.includes('?') ? '&' : '?') + qs;
-
const token = localStorage.getItem('ov_token') || getCookie('ov_token');
const token = localStorage.getItem('ov_token') || getCookie('ov_token');
- const headers = {};
+ const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
if (token) headers['Authorization'] = `Bearer ${token}`;
- const r = await fetch(url, { headers });
- const data = await r.json();
-
- if (!data.ok) throw new Error(data.error || 'Lookup failed');
+ const r = await fetch(`${API_BASE}/${activeTool}/run`, { method: 'POST', headers, credentials: 'same-origin', body: JSON.stringify({ input }) });
+ if (r.status === 401) return renderSignIn(resultsEl);
+ const body = await r.json();
+ if (body.state !== 'succeeded' || !body.result) throw new Error(runError(body));
+ const data = { ok: true, ...body.result.data };
renderResults(data);
renderResults(data);
} catch (err) {
} catch (err) {
@@ -978,6 +990,13 @@
@@ -978,6 +990,13 @@
}
}
}
}
+ // A probe (port, ping, latency, traceroute, mtr) sends traffic to the target, so the run API asks for an account
+ // (401); an expired sign-in answers 401 too, and the same card signs the visitor back in.
+ function renderSignIn(el) {
+ const next = encodeURIComponent(location.href);
+ el.innerHTML = `<div class="card signin-card"><i class="fa-solid fa-lock" aria-hidden="true"></i><div class="signin-text"><b>Sign in to run ${esc(tool.name)}</b><p>Probes like ping, port checks and traceroute send traffic to the target itself, so they run under an OpenVibe account. It is free, and lookups like DNS still work without one.</p></div><a class="btn-go" href="https://openvibe.tools/auth/login?next=${next}">Sign in</a></div>`;
+ }
+
// ── Render results based on tool type ────────────────────
// ── Render results based on tool type ────────────────────
function renderResults(data) {
function renderResults(data) {
const el = document.getElementById('results');
const el = document.getElementById('results');
@@ -1029,12 +1048,13 @@
@@ -1029,12 +1048,13 @@
async function fetchAndAppendIPInfo(el, ip) {
async function fetchAndAppendIPInfo(el, ip) {
try {
try {
const token = localStorage.getItem('ov_token') || getCookie('ov_token');
const token = localStorage.getItem('ov_token') || getCookie('ov_token');
- const headers = {};
+ const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
if (token) headers['Authorization'] = `Bearer ${token}`;
- const r = await fetch(`${API_BASE}/ip?target=${encodeURIComponent(ip)}`, { headers });
- const data = await r.json();
- if (data.ok) {
- el.innerHTML += renderIPCard(data);
+ const r = await fetch(`${API_BASE}/ip/run`, { method: 'POST', headers, credentials: 'same-origin', body: JSON.stringify({ input: { target: ip } }) });
+ const body = await r.json();
+ if (body.state === 'succeeded' && body.result) {
+ const data = { ok: true, ...body.result.data };
+ if (data.ok) el.innerHTML += renderIPCard(data);
}
}
} catch {}
} catch {}
}
}
@@ -56,13 +56,19 @@ let auth = null; // created below; the key check reads it at request time
@@ -56,13 +56,19 @@ let auth = null; // created below; the key check reads it at request time
// the address), the tools-api quota, the net and dev tools' quotas by descriptor, the per-target
// the address), the tools-api quota, the net and dev tools' quotas by descriptor, the per-target
// throttle, the port-scan cap, webhook bin caps and the abuse log (data/guard.db). The Network key is
// throttle, the port-scan cap, webhook bin caps and the abuse log (data/guard.db). The Network key is
// the OAuth client's (loaded and retried by it). TOOLS_GUARD=report (default) records what it would refuse.
// the OAuth client's (loaded and retried by it). TOOLS_GUARD=report (default) records what it would refuse.
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-gateway' });
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
+require('./revocation-events').configure({ db: toolsDb.db, valkey: toolsValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'gateway', dataDir: require('path').resolve(__dirname, '..', process.env.DATA_DIR || 'data'),
- Database: require('better-sqlite3'), contracts: require('openvibe-contracts'),
+ app: 'gateway', db: toolsDb.db, valkey: toolsValkey, contracts: require('openvibe-contracts'),
specs: [...require('./net/descriptors').SPECS, ...require('./dev/descriptors').SPECS],
specs: [...require('./net/descriptors').SPECS, ...require('./dev/descriptors').SPECS],
issuer: config.networkUrl,
issuer: config.networkUrl,
keys: { get: () => (auth && auth.client.publicKey) || null, ensure: () => (auth ? auth.ensureKey() : Promise.resolve(null)) },
keys: { get: () => (auth && auth.client.publicKey) || null, ensure: () => (auth ? auth.ensureKey() : Promise.resolve(null)) },
});
});
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools', release: release.release, mountReady: false,
service: 'tools', release: release.release, mountReady: false,
@@ -78,6 +84,7 @@ const obs = observe({
@@ -78,6 +84,7 @@ const obs = observe({
name: 'catalog', required: true, description: 'the tool catalog every apex page, host route and proxy decision is built from',
name: 'catalog', required: true, description: 'the tool catalog every apex page, host route and proxy decision is built from',
check: () => { const n = registry.get().tools.length; return n > 0 ? { ok: true, detail: { tools: n } } : 'catalog is empty'; },
check: () => { const n = registry.get().tools.length; return n > 0 ? { ok: true, detail: { tools: n } } : 'catalog is empty'; },
},
},
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, revocations)' }),
ready.networkKey('network_key', () => auth && auth.client.publicKey, { description: 'verifies signed-in visitors offline; signed-out use works without it' }),
ready.networkKey('network_key', () => auth && auth.client.publicKey, { description: 'verifies signed-in visitors offline; signed-out use works without it' }),
{
{
name: 'service_directory', required: false, description: 'other services\' origins from the Network registry (a built-in fallback list is used without it)',
name: 'service_directory', required: false, description: 'other services\' origins from the Network registry (a built-in fallback list is used without it)',
@@ -542,6 +549,8 @@ require('../../_shared/graceful').gracefulStop({
@@ -542,6 +549,8 @@ require('../../_shared/graceful').gracefulStop({
() => require('../../_shared/usage').stopRecorder(800),
() => require('../../_shared/usage').stopRecorder(800),
() => runApi.pool.close(),
() => runApi.pool.close(),
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
() => require('./revocation-events').close(),
() => require('./revocation-events').close(),
],
],
});
});
@@ -3,45 +3,41 @@
@@ -3,45 +3,41 @@
* Sign-out everywhere reaches OpenVibe.Tools (roadmap WS-B task 4; Contracts 0.39.0
* Sign-out everywhere reaches OpenVibe.Tools (roadmap WS-B task 4; Contracts 0.39.0
* network.user.token_valid_after). The gateway is the one consumer: POST /internal/events (the
* network.user.token_valid_after). The gateway is the one consumer: POST /internal/events (the
* endpoint of its Events subscription, TOOLS_EVENTS_SECRET, signature v2 only, never through the
* endpoint of its Events subscription, TOOLS_EVENTS_SECRET, signature v2 only, never through the
- * proxy) writes each person's cutoff into the shared file every app's guard reads
- * (apps/_shared/guard/revocations.js). The subscription is created at boot when EVENTS_URL, the
- * service secret and TOOLS_EVENTS_SECRET are set (grant tools events.subscription.manage).
+ * proxy) writes each person's cutoff into PostgreSQL through openvibe-sdk/auth createPgRevocationStore
+ * (the `token_revocations` table of the one `tools` database, migrations/0003); every app's guard
+ * reads it (apps/_shared/guard/revocations.js). The subscription is created at boot when EVENTS_URL,
+ * the service secret and TOOLS_EVENTS_SECRET are set (grant tools events.subscription.manage).
+ *
+ * The gateway owns the write, so it also bumps the shared Valkey version the readers watch: with it,
+ * a revocation reaches every app on their next read rather than after their cache ttl.
*/
*/
-const fs = require('fs');
-const path = require('path');
const express = require('express');
const express = require('express');
-const { revocationsFile } = require('../../_shared/guard/revocations');
+const { createPgRevocationStore } = require('openvibe-sdk/auth');
const TOPIC = 'network.user.token_valid_after';
const TOPIC = 'network.user.token_valid_after';
let store = null;
let store = null;
let storeDb = null;
let storeDb = null;
-function cutoffs() {
+let storeValkey = null;
+
+/** The gateway calls this once, at boot, with the one `tools` database and the shared Valkey. */
+function configure({ db = null, valkey = null } = {}) { storeDb = db; storeValkey = valkey; store = null; }
+
+async function cutoffs() {
if (store) return store;
if (store) return store;
- const Database = require('better-sqlite3');
- const file = revocationsFile();
- fs.mkdirSync(path.dirname(file), { recursive: true });
- const db = new Database(file);
- storeDb = db;
- // A rollback journal, not WAL: the other apps open the file read-only under ProtectSystem=strict and could not
- // create a WAL's -shm file. Writes are one row per sign-out-everywhere.
- db.pragma('journal_mode = DELETE');
- db.pragma('busy_timeout = 2000');
- store = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });
+ if (!storeDb) throw new Error('revocation-events: configure({ db }) was not called');
+ const s = createPgRevocationStore(storeDb, { table: 'token_revocations' });
+ await s.load();
+ store = s;
return store;
return store;
}
}
-/** Close the cutoff store (graceful stop); the next request opens it again. */
-function close() {
- const db = storeDb;
- store = null;
- storeDb = null;
- if (db) try { db.close(); } catch { /* already closed */ }
-}
+/** Close the cutoff store (graceful stop); the next request loads it again. */
+function close() { store = null; }
const secrets = () => String(process.env.TOOLS_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 32);
const secrets = () => String(process.env.TOOLS_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 32);
const stats = { received: 0, revoked: 0, refused: 0 };
const stats = { received: 0, revoked: 0, refused: 0 };
function handler() {
function handler() {
const { parseDelivery } = require('openvibe-sdk/events');
const { parseDelivery } = require('openvibe-sdk/events');
- return [express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ return [express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return res.status(404).json({ error: 'Not found' });
if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return res.status(404).json({ error: 'Not found' });
const keys = secrets();
const keys = secrets();
if (!keys.length) return res.status(503).json({ error: 'TOOLS_EVENTS_SECRET is not set' });
if (!keys.length) return res.status(503).json({ error: 'TOOLS_EVENTS_SECRET is not set' });
@@ -50,8 +46,11 @@ function handler() {
@@ -50,8 +46,11 @@ function handler() {
for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
stats.received++;
stats.received++;
- const outcome = cutoffs().apply(d.event);
- if (outcome === 'revoked') stats.revoked++;
+ const outcome = await (await cutoffs()).apply(d.event);
+ if (outcome === 'revoked') {
+ stats.revoked++;
+ if (storeValkey) { try { await storeValkey.client.incr(storeValkey.key('token_revocations_version')); } catch { /* the readers fall back to their ttl */ } }
+ }
res.json({ event_id: d.event.event_id || null, outcome });
res.json({ event_id: d.event.event_id || null, outcome });
}];
}];
}
}
@@ -80,4 +79,4 @@ async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = co
@@ -80,4 +79,4 @@ async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = co
return 'created';
return 'created';
}
}
-module.exports = { handler, ensureSubscription, close, stats, TOPIC, _cutoffs: cutoffs };
+module.exports = { handler, ensureSubscription, close, configure, stats, TOPIC, _cutoffs: cutoffs };
@@ -1,24 +1,21 @@
@@ -1,24 +1,21 @@
'use strict';
'use strict';
// Sign-out everywhere across Tools (WS-B task 4): the gateway's POST /internal/events (signature v2,
// Sign-out everywhere across Tools (WS-B task 4): the gateway's POST /internal/events (signature v2,
-// never through the proxy) writes the person's cutoff into the shared file; any app's guard reader then
-// refuses their older tokens and accepts newer ones; forged, foreign and proxied deliveries change nothing.
+// never through the proxy) writes the person's cutoff into PostgreSQL through openvibe-sdk/auth
+// createPgRevocationStore; any app's guard reader then refuses their older tokens and accepts newer
+// ones; forged, foreign and proxied deliveries change nothing.
+//
+// The shared file of the SQLite design is gone (plan T8 decision 4): writer and reader share one
+// openvibe-sdk/db handle here (one PGlite database), which is what DATABASE_URL gives production.
const assert = require('assert');
const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
const http = require('http');
const http = require('http');
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tools-revoke-'));
-process.env.TOOLS_REVOCATIONS_DB = path.join(tmp, 'shared', 'token-revocations.db');
process.env.TOOLS_EVENTS_SECRET = 't'.repeat(40);
process.env.TOOLS_EVENTS_SECRET = 't'.repeat(40);
const express = require('express');
const express = require('express');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
+const { revocationSchema } = require('openvibe-sdk/auth');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const events = require('../server/revocation-events');
const events = require('../server/revocation-events');
-const { createCutoffReader, DEFAULT_FILE } = require('../../_shared/guard/revocations');
-
-// The default lives where the gateway's unit may write (ReadWritePaths=apps/gateway/data).
-assert.strictEqual(DEFAULT_FILE, path.resolve(__dirname, '..', 'data', 'token-revocations.db'));
+const { createCutoffReader } = require('../../_shared/guard/revocations');
const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
const at = Date.parse('2026-09-25T12:00:00Z');
const at = Date.parse('2026-09-25T12:00:00Z');
@@ -26,6 +23,10 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_t
@@ -26,6 +23,10 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_t
payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'password_changed' }, ...over });
payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'password_changed' }, ...over });
(async () => {
(async () => {
+ const db = createDb({ pglite: true, service: 'revocations-test' });
+ await db.exec(revocationSchema('token_revocations'));
+ events.configure({ db });
+
const app = express();
const app = express();
app.post('/internal/events', ...events.handler());
app.post('/internal/events', ...events.handler());
const srv = http.createServer(app);
const srv = http.createServer(app);
@@ -36,25 +37,25 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_t
@@ -36,25 +37,25 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_t
return fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
return fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
};
};
let clock = 1_000_000;
let clock = 1_000_000;
- const reader = createCutoffReader({ Database, file: process.env.TOOLS_REVOCATIONS_DB, ttlMs: 5000, now: () => clock });
+ const reader = createCutoffReader({ db, ttlMs: 5000, now: () => clock });
try {
try {
const old = { subject_id: SUBJECT, iat: at / 1000 - 30 };
const old = { subject_id: SUBJECT, iat: at / 1000 - 30 };
- assert.strictEqual(reader.isRevoked(old), false, 'no file yet: nothing revoked');
+ await reader.refresh();
+ assert.strictEqual(reader.isRevoked(old), false, 'no cutoff yet: nothing revoked');
assert.strictEqual((await post(ev(), { secret: 'y'.repeat(40) })).status, 401);
assert.strictEqual((await post(ev(), { secret: 'y'.repeat(40) })).status, 401);
assert.strictEqual((await post(ev(), { headers: { 'cf-connecting-ip': '1.2.3.4' } })).status, 404);
assert.strictEqual((await post(ev(), { headers: { 'cf-connecting-ip': '1.2.3.4' } })).status, 404);
assert.strictEqual((await post(ev({ source: 'live' }))).body.outcome, 'ignored:source');
assert.strictEqual((await post(ev({ source: 'live' }))).body.outcome, 'ignored:source');
assert.strictEqual((await post(ev())).body.outcome, 'revoked');
assert.strictEqual((await post(ev())).body.outcome, 'revoked');
- assert.ok(fs.existsSync(process.env.TOOLS_REVOCATIONS_DB), 'the shared file exists');
- clock += 20_000; // past the reader's retry pause and memo
+ clock += 20_000; // past the reader's cache ttl
+ await reader.refresh();
assert.strictEqual(reader.isRevoked(old), true, 'another app sees the cutoff');
assert.strictEqual(reader.isRevoked(old), true, 'another app sees the cutoff');
assert.strictEqual(reader.isRevoked({ subject_id: SUBJECT, iat: at / 1000 + 1 }), false);
assert.strictEqual(reader.isRevoked({ subject_id: SUBJECT, iat: at / 1000 + 1 }), false);
assert.strictEqual(reader.isRevoked({ subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4', iat: 1 }), false);
assert.strictEqual(reader.isRevoked({ subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4', iat: 1 }), false);
assert.strictEqual((await post(ev())).body.outcome, 'unchanged');
assert.strictEqual((await post(ev())).body.outcome, 'unchanged');
- const guardSrc = fs.readFileSync(path.join(__dirname, '../../_shared/guard/index.js'), 'utf8');
- assert.ok(/if \(claims && !cutoffs\.isRevoked\(claims\)\)/.test(guardSrc), 'every app\'s guard checks it');
} finally {
} finally {
srv.close();
srv.close();
- fs.rmSync(tmp, { recursive: true, force: true });
+ await db.close();
+ events.close();
}
}
console.log('tools revocations: all checks passed');
console.log('tools revocations: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -129,9 +129,13 @@ function checkProblem(r, status, code, where) {
@@ -129,9 +129,13 @@ function checkProblem(r, status, code, where) {
checkRun(r.body, 'headers refused');
checkRun(r.body, 'headers refused');
assert.deepStrictEqual([r.body.state, r.body.error.status, r.body.error.code], ['failed', 403, 'tools.net.target_not_public']);
assert.deepStrictEqual([r.body.state, r.body.error.status, r.body.error.code], ['failed', 403, 'tools.net.target_not_public']);
- // ── Probes: a token with tools.net.probe, nobody else ──
+ // ── Probes: a token with tools.net.probe, a signed-in person under their quota, nobody else ──
checkProblem(await run('ping', { input: { target: '127.0.0.1' } }), 401, 'token.missing', 'probe, anonymous');
checkProblem(await run('ping', { input: { target: '127.0.0.1' } }), 401, 'token.missing', 'probe, anonymous');
- checkProblem(await run('ping', { input: { target: '127.0.0.1' } }, { headers: { Authorization: `Bearer ${net.user()}` } }), 403, 'capability.denied', 'probe, a person');
+ // Plan T8, Phase 1: the net pages call the run API with the session token, so a signed-in person
+ // may run a probe (under their per-person quota); the SSRF guard still keeps it off loopback.
+ r = await run('ping', { input: { target: '127.0.0.1' } }, { headers: { Authorization: `Bearer ${net.user()}` } });
+ checkRun(r.body, 'probe, a person');
+ assert.deepStrictEqual([r.body.state, r.body.error.code], ['failed', 'tools.net.target_not_public'], 'a person runs a probe: it ran and the SSRF guard refused loopback');
checkProblem(await run('ping', { input: { target: '127.0.0.1' } }, { headers: { Authorization: `Bearer ${net.service(['tools.tool.run'])}` } }), 403, 'capability.denied', 'probe, runner token');
checkProblem(await run('ping', { input: { target: '127.0.0.1' } }, { headers: { Authorization: `Bearer ${net.service(['tools.tool.run'])}` } }), 403, 'capability.denied', 'probe, runner token');
r = await run('ping', { input: { target: '127.0.0.1', count: 1 } }, { headers: { Authorization: `Bearer ${net.service(['tools.net.probe'], { name: 'prober' })}` } });
r = await run('ping', { input: { target: '127.0.0.1', count: 1 } }, { headers: { Authorization: `Bearer ${net.service(['tools.net.probe'], { name: 'prober' })}` } });
checkRun(r.body, 'ping with tools.net.probe');
checkRun(r.body, 'ping with tools.net.probe');
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -16,16 +17,24 @@
@@ -16,16 +17,24 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "pg": "^8.23.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"to-ico": "^1.1.5",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
"node_modules/@img/sharp-libvips-linux-x64": {
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
@@ -64,6 +73,12 @@
@@ -64,6 +73,12 @@
"@img/sharp-libvips-linux-x64": "1.0.4"
"@img/sharp-libvips-linux-x64": "1.0.4"
}
}
},
},
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -442,6 +457,15 @@
@@ -442,6 +457,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/color": {
"node_modules/color": {
"version": "4.2.3",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -636,6 +660,15 @@
@@ -636,6 +660,15 @@
"node": ">=0.4.0"
"node": ">=0.4.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1253,6 +1286,49 @@
@@ -1253,6 +1286,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ip-regex": {
"node_modules/ip-regex": {
"version": "1.0.3",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-1.0.3.tgz",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-1.0.3.tgz",
@@ -1466,12 +1542,24 @@
@@ -1466,12 +1542,24 @@
"xtend": "^4.0.0"
"xtend": "^4.0.0"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1865,6 +1953,95 @@
@@ -1865,6 +1953,95 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/phin": {
"node_modules/phin": {
"version": "3.7.1",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz",
"resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz",
@@ -1920,6 +2097,45 @@
@@ -1920,6 +2097,45 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2095,6 +2311,27 @@
@@ -2095,6 +2311,27 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/request": {
"node_modules/request": {
"version": "2.88.2",
"version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
@@ -2435,6 +2672,15 @@
@@ -2435,6 +2672,15 @@
"is-arrayish": "^0.3.1"
"is-arrayish": "^0.3.1"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/sshpk": {
"node_modules/sshpk": {
"version": "1.18.0",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@@ -2460,6 +2706,12 @@
@@ -2460,6 +2706,12 @@
"node": ">=0.10.0"
"node": ">=0.10.0"
}
}
},
},
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11,8 +11,11 @@
@@ -11,8 +11,11 @@
"license": "MIT",
"license": "MIT",
"openvibeToolsShared": "^1.2.0",
"openvibeToolsShared": "^1.2.0",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
"cors": "^2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
@@ -36,21 +36,21 @@ const sdk = require('openvibe-sdk');
@@ -36,21 +36,21 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
-const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
-const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
-fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
-const analyticsDb = new Database(analyticsDbPath);
-analyticsDb.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img', { retention: { days: 30 } });
// ── Guard (apps/_shared/guard) ───────────────────────────────
// ── Guard (apps/_shared/guard) ───────────────────────────────
// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else
// Who is asking (Network sign-in with aud openvibe.tools, service tokens, the browser session, else
// the address), tiered quotas by each tool's descriptor, upload sniffing, the sync semaphore and the
// the address), tiered quotas by each tool's descriptor, upload sniffing, the sync semaphore and the
// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.
// abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.
const SPECS = require('./descriptors').SPECS;
const SPECS = require('./descriptors').SPECS;
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4): guard_abuse in
+// PostgreSQL, guard_salt/guard_day and the quota buckets in Valkey. `ready` resolves when the schema is in.
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-img' });
+const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-img', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'img', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, specs: SPECS,
+ app: 'img', db: toolsDb.db, valkey: toolsValkey, contracts, specs: SPECS,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
});
});
const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);
const { apiLimiter, processLimiter, burstLimiter } = guard.legacyLimiters(rateLimit);
@@ -89,16 +89,19 @@ const release = require('../../_shared/release').toolsRelease('img', require);
@@ -89,16 +89,19 @@ const release = require('../../_shared/release').toolsRelease('img', require);
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
const { observe, checks: ready } = require('../../_shared/observe');
const { observe, checks: ready } = require('../../_shared/observe');
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools-img', release: release.release,
service: 'tools-img', release: release.release,
checks: [
checks: [
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, jobs, analytics, revocations)' }),
ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
ready.jobRuntime('job_runtime', () => jobs),
ready.jobRuntime('job_runtime', () => jobs),
ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
ready.networkKey('network_key', guard.keys.get, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
...jobsRuntime.readyChecks(() => jobs),
...jobsRuntime.readyChecks(() => jobs),
codec.heif.readyCheck('HEIC (iPhone) photos: heif-dec or heif-convert with an HEVC decoder plugin (libheif-examples + libheif-plugin-libde265); other formats work without it'),
codec.heif.readyCheck('HEIC (iPhone) photos: heif-dec or heif-convert with an HEVC decoder plugin (libheif-examples + libheif-plugin-libde265); other formats work without it'),
@@ -311,12 +314,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -311,12 +314,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
-app.get('/api/internal/analytics', internalAccess, (req, res) => {
- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
+app.get('/api/internal/analytics', internalAccess, async (req, res) => {
+ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
-app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
+app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
+ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -370,9 +373,10 @@ require('../../_shared/graceful').gracefulStop({
@@ -370,9 +373,10 @@ require('../../_shared/graceful').gracefulStop({
close: [
close: [
() => require('../../_shared/usage').stopRecorder(800),
() => require('../../_shared/usage').stopRecorder(800),
() => analytics.destroy(),
() => analytics.destroy(),
- () => analyticsDb.close(),
() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
() => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
() => pool.close(),
() => pool.close(),
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
],
],
});
});
@@ -71,7 +71,10 @@ const { startApp } = require('../../_shared/test/spawn');
@@ -71,7 +71,10 @@ const { startApp } = require('../../_shared/test/spawn');
assert.match(metrics, /tools_guard_refused_total\{reason="sniff",tool="convert"\} [12]/);
assert.match(metrics, /tools_guard_refused_total\{reason="sniff",tool="convert"\} [12]/);
assert.match(metrics, /tools_guard_sync\{kind="limit"\} 2/);
assert.match(metrics, /tools_guard_sync\{kind="limit"\} 2/);
assert.match(metrics, /tools_guard_enforcing 0/);
assert.match(metrics, /tools_guard_enforcing 0/);
- assert.ok(fs.existsSync(path.join(data, 'guard.db')), 'guard.db in the data directory');
+ // The guard no longer keeps a per-app guard.db: guard_abuse is in the one tools database and
+ // guard_salt/guard_day in Valkey (plan T8 decision 4), reported by the tools_db readiness check.
+ const ready = await (await fetch(`${app.base}/api/ready`)).json();
+ assert.strictEqual(ready.checks.tools_db.status, 'ok', 'the guard serves from the one tools database');
} finally {
} finally {
await app.kill();
await app.kill();
fs.rmSync(data, { recursive: true, force: true });
fs.rmSync(data, { recursive: true, force: true });
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"axios": "^1.7.0",
"axios": "^1.7.0",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cheerio": "^1.0.0-rc.12",
"cheerio": "^1.0.0-rc.12",
@@ -18,11 +19,25 @@
@@ -18,11 +19,25 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"node-cache": "^5.1.2",
"node-cache": "^5.1.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "pg": "^8.23.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -313,6 +328,15 @@
@@ -313,6 +328,15 @@
"node": ">=0.8"
"node": ">=0.8"
}
}
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/combined-stream": {
"node_modules/combined-stream": {
"version": "1.0.8",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -461,6 +485,15 @@
@@ -461,6 +485,15 @@
"node": ">=0.4.0"
"node": ">=0.4.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1088,6 +1121,49 @@
@@ -1088,6 +1121,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1146,12 +1222,24 @@
@@ -1146,12 +1222,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1485,6 +1573,134 @@
@@ -1485,6 +1573,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1625,6 +1841,27 @@
@@ -1625,6 +1841,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1831,6 +2068,21 @@
@@ -1831,6 +2068,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1989,6 +2241,15 @@
@@ -1989,6 +2241,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -12,6 +12,9 @@
@@ -12,6 +12,9 @@
"dependencies": {
"dependencies": {
"axios": "^1.7.0",
"axios": "^1.7.0",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
+ "@electric-sql/pglite": "^0.5.8",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"cheerio": "^1.0.0-rc.12",
"cheerio": "^1.0.0-rc.12",
@@ -18,27 +18,25 @@ const cookieParser = require('cookie-parser');
@@ -18,27 +18,25 @@ const cookieParser = require('cookie-parser');
const config = require('./config');
const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
-const Database = require('better-sqlite3');
-const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
const { hostGuard, stampedPage } = require('../../_shared/host-role');
const { hostGuard, stampedPage } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createPacer, Busy } = require('../../_shared/guard/semaphore');
const { createPacer, Busy } = require('../../_shared/guard/semaphore');
-const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
-fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
-const analyticsDb = new Database(analyticsDbPath);
-analyticsDb.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps', { retention: { days: 30 } });
// ── Guard (apps/_shared/guard) ───────────────────────────────
// ── Guard (apps/_shared/guard) ───────────────────────────────
// Who is asking (Network sign-in with aud openvibe.tools, else the address — the food app forwards its
// Who is asking (Network sign-in with aud openvibe.tools, else the address — the food app forwards its
// visitor's), the tools-map quota on the data routes (descriptor maps: cost 2 a call) and the abuse log
// visitor's), the tools-map quota on the data routes (descriptor maps: cost 2 a call) and the abuse log
// (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.
// (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.
const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';
const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-maps' });
+const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-maps', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'maps', dataDir: path.join(__dirname, '..', 'data'), Database, specs: require('./descriptors').SPECS,
+ app: 'maps', db: toolsDb.db, valkey: toolsValkey, specs: require('./descriptors').SPECS,
issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),
publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),
});
});
@@ -49,11 +47,14 @@ const release = require('../../_shared/release').toolsRelease('maps', require);
@@ -49,11 +47,14 @@ const release = require('../../_shared/release').toolsRelease('maps', require);
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
const { observe, checks: ready } = require('../../_shared/observe');
const { observe, checks: ready } = require('../../_shared/observe');
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools-maps', release: release.release,
service: 'tools-maps', release: release.release,
checks: [
checks: [
- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only' }),
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),
],
],
// The public data sources maps queries (OSM and others) are not probed here: each request says when one fails.
// The public data sources maps queries (OSM and others) are not probed here: each request says when one fails.
});
});
@@ -476,12 +477,12 @@ app.get('/api/meal-plan', (req, res) => {
@@ -476,12 +477,12 @@ app.get('/api/meal-plan', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
-app.get('/api/internal/analytics', internalAccess, (req, res) => {
- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
+app.get('/api/internal/analytics', internalAccess, async (req, res) => {
+ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
-app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
+app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
+ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -504,7 +505,8 @@ require('../../_shared/graceful').gracefulStop({
@@ -504,7 +505,8 @@ require('../../_shared/graceful').gracefulStop({
close: [
close: [
() => require('../../_shared/usage').stopRecorder(800),
() => require('../../_shared/usage').stopRecorder(800),
() => analytics.destroy(),
() => analytics.destroy(),
- () => analyticsDb.close(),
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
],
],
});
});
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.6",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -16,10 +17,24 @@
@@ -16,10 +17,24 @@
"express": "^4.18.2",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
+ "iovalkey": "^0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "pg": "^8.23.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -188,6 +203,15 @@
@@ -188,6 +203,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -287,6 +311,15 @@
@@ -287,6 +311,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -698,6 +731,49 @@
@@ -698,6 +731,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -756,12 +832,24 @@
@@ -756,12 +832,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1022,6 +1110,134 @@
@@ -1022,6 +1110,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1141,6 +1357,27 @@
@@ -1141,6 +1357,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1347,6 +1584,21 @@
@@ -1347,6 +1584,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1474,6 +1726,15 @@
@@ -1474,6 +1726,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -15,6 +15,9 @@
@@ -15,6 +15,9 @@
"cors": "^2.8.5",
"cors": "^2.8.5",
"cookie-parser": "^1.4.6",
"cookie-parser": "^1.4.6",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"dotenv": "^16.3.1",
"dotenv": "^16.3.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
@@ -11,25 +11,23 @@ const fs = require('fs');
@@ -11,25 +11,23 @@ const fs = require('fs');
const config = require('./config');
const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
-const Database = require('better-sqlite3');
-const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
const { hostGuard } = require('../../_shared/host-role');
const { hostGuard } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
-const analyticsDbPath = path.resolve(__dirname, '..', process.env.DATA_DIR || 'data', 'analytics.db');
-fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
-const analyticsDb = new Database(analyticsDbPath);
-analyticsDb.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text', { retention: { days: 30 } });
// ── Guard (apps/_shared/guard) ───────────────────────────────
// ── Guard (apps/_shared/guard) ───────────────────────────────
// Who is asking (Network sign-in with aud openvibe.tools, else the address), the tools-api quota on
// Who is asking (Network sign-in with aud openvibe.tools, else the address), the tools-api quota on
// /api/ and the abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.
// /api/ and the abuse log (data/guard.db). TOOLS_GUARD=report (default) records what it would refuse.
const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';
const NETWORK_URL = process.env.OV_NETWORK_URL || 'https://openvibe.network';
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-text' });
+const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-text', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'text', dataDir: path.resolve(__dirname, '..', process.env.DATA_DIR || 'data'), Database, specs: require('./descriptors').SPECS,
+ app: 'text', db: toolsDb.db, valkey: toolsValkey, specs: require('./descriptors').SPECS,
issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
issuer: NETWORK_URL, networkUrl: NETWORK_URL, networkInternalUrl: process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),
publicKeyFiles: [process.env.OV_NETWORK_PUBLIC_KEY].filter(Boolean),
});
});
@@ -40,11 +38,14 @@ const release = require('../../_shared/release').toolsRelease('text', require);
@@ -40,11 +38,14 @@ const release = require('../../_shared/release').toolsRelease('text', require);
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
const { observe, checks: ready } = require('../../_shared/observe');
const { observe, checks: ready } = require('../../_shared/observe');
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools-text', release: release.release,
service: 'tools-text', release: release.release,
checks: [
checks: [
- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; the text tools run in the browser and on this process' }),
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),
],
],
});
});
guard.attachMetrics(obs.registry);
guard.attachMetrics(obs.registry);
@@ -118,12 +119,12 @@ app.use(hostGuard({ knows: (h) => Object.prototype.hasOwnProperty.call(HOSTNAME_
@@ -118,12 +119,12 @@ app.use(hostGuard({ knows: (h) => Object.prototype.hasOwnProperty.call(HOSTNAME_
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
-app.get('/api/internal/analytics', internalAccess, (req, res) => {
- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
+app.get('/api/internal/analytics', internalAccess, async (req, res) => {
+ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
-app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
+app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
+ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -187,7 +188,8 @@ require('../../_shared/graceful').gracefulStop({
@@ -187,7 +188,8 @@ require('../../_shared/graceful').gracefulStop({
close: [
close: [
() => require('../../_shared/usage').stopRecorder(800),
() => require('../../_shared/usage').stopRecorder(800),
() => analytics.destroy(),
() => analytics.destroy(),
- () => analyticsDb.close(),
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
],
],
});
});
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -16,12 +17,26 @@
@@ -16,12 +17,26 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "pg": "^8.23.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"license": "MIT",
"license": "MIT",
@@ -164,6 +179,15 @@
@@ -164,6 +179,15 @@
"version": "1.1.4",
"version": "1.1.4",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"license": "MIT",
"license": "MIT",
@@ -245,6 +269,15 @@
@@ -245,6 +269,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"license": "MIT",
"license": "MIT",
@@ -589,6 +622,49 @@
@@ -589,6 +622,49 @@
"version": "1.3.8",
"version": "1.3.8",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"license": "MIT",
"license": "MIT",
@@ -637,10 +713,22 @@
@@ -637,10 +713,22 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"license": "MIT"
"license": "MIT"
@@ -849,6 +937,134 @@
@@ -849,6 +937,134 @@
"version": "0.1.13",
"version": "0.1.13",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"license": "MIT",
"license": "MIT",
@@ -955,6 +1171,27 @@
@@ -955,6 +1171,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"funding": [
"funding": [
@@ -1135,6 +1372,21 @@
@@ -1135,6 +1372,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"license": "MIT",
"license": "MIT",
@@ -1247,6 +1499,15 @@
@@ -1247,6 +1499,15 @@
"node_modules/wrappy": {
"node_modules/wrappy": {
"version": "1.0.2",
"version": "1.0.2",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -18,6 +18,9 @@
@@ -18,6 +18,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0",
+ "@electric-sql/pglite": "^0.5.8",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
@@ -22,21 +22,19 @@ const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/loc
@@ -22,21 +22,19 @@ const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/loc
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
-const Database = require('better-sqlite3');
-const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
+const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
-const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
-fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
-const analyticsDb = new Database(analyticsDbPath);
-analyticsDb.pragma('journal_mode = WAL');
-const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt', { retention: { days: 30 } });
// ── Guard (apps/_shared/guard) ───────────────────────────────
// ── Guard (apps/_shared/guard) ───────────────────────────────
// Who is asking (Network sign-in with aud openvibe.tools, the browser session, else the address),
// Who is asking (Network sign-in with aud openvibe.tools, the browser session, else the address),
// the tools-download quota (a download costs 50, descriptor yt) and the abuse log (data/guard.db).
// the tools-download quota (a download costs 50, descriptor yt) and the abuse log (data/guard.db).
// TOOLS_GUARD=report (default) records what it would refuse; the older limiters below stay until enforce.
// TOOLS_GUARD=report (default) records what it would refuse; the older limiters below stay until enforce.
+// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
+const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-yt' });
+const analytics = new AnalyticsTrackerPg(toolsDb.db, 'openvibe-yt', { retention: { days: 30 } }); // ADR-021; PostgreSQL (plan T8)
+const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
const guard = createGuard({
const guard = createGuard({
- app: 'yt', dataDir: path.resolve(__dirname, '..', config.dataDir || 'data'), Database, specs: require('./descriptors').SPECS,
+ app: 'yt', db: toolsDb.db, valkey: toolsValkey, specs: require('./descriptors').SPECS,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
issuer: config.networkUrl, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, publicKeyFiles: config.publicKeyPaths,
});
});
@@ -46,12 +44,15 @@ const release = require('../../_shared/release').toolsRelease('yt', require);
@@ -46,12 +44,15 @@ const release = require('../../_shared/release').toolsRelease('yt', require);
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
const { observe, checks: ready, which } = require('../../_shared/observe');
const { observe, checks: ready, which } = require('../../_shared/observe');
+// Serve only once the schema is in: the first PGlite migration must not run inside a request (it
+// would block the event loop and time out a satellite's readiness check).
+app.use((req, res, next) => toolsDb.ready.then(() => next(), next));
const obs = observe({
const obs = observe({
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
service: 'tools-yt', release: release.release,
service: 'tools-yt', release: release.release,
checks: [
checks: [
+ ready.postgres('tools_db', () => toolsDb.db, { description: 'the one tools database (guard_abuse, analytics, revocations)' }),
ready.writableDir('downloads_dir', path.resolve(config.downloadsDir), { description: 'downloads are written here before they are served' }),
ready.writableDir('downloads_dir', path.resolve(config.downloadsDir), { description: 'downloads are written here before they are served' }),
- ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; downloads work without it' }),
ready.binary('yt_dlp', config.ytdlpPath, { description: 'every download runs yt-dlp' }),
ready.binary('yt_dlp', config.ytdlpPath, { description: 'every download runs yt-dlp' }),
ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'merging video+audio and audio conversion' }),
ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'merging video+audio and audio conversion' }),
...(process.env.YT_COOKIES_FILE ? [ready.readableFile('yt_cookies', process.env.YT_COOKIES_FILE, { description: 'YT_COOKIES_FILE is set; yt-dlp is given these cookies' })] : []),
...(process.env.YT_COOKIES_FILE ? [ready.readableFile('yt_cookies', process.env.YT_COOKIES_FILE, { description: 'YT_COOKIES_FILE is set; yt-dlp is given these cookies' })] : []),
@@ -284,12 +285,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -284,12 +285,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
-app.get('/api/internal/analytics', internalAccess, (req, res) => {
- try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
+app.get('/api/internal/analytics', internalAccess, async (req, res) => {
+ try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: await analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
-app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
- try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
+app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
+ try { res.json({ ok: true, bots: await analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -341,7 +342,8 @@ require('../../_shared/graceful').gracefulStop({
@@ -341,7 +342,8 @@ require('../../_shared/graceful').gracefulStop({
close: [
close: [
() => require('../../_shared/usage').stopRecorder(800),
() => require('../../_shared/usage').stopRecorder(800),
() => analytics.destroy(),
() => analytics.destroy(),
- () => analyticsDb.close(),
() => guard.close(),
() => guard.close(),
+ () => toolsDb.db.close(),
+ () => toolsValkey && toolsValkey.close(),
],
],
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+-- phase: expand
+-- token_revocations (plan T8, decision 4): the sign-out-everywhere cutoffs. The gateway writes them
+-- through openvibe-sdk/auth createPgRevocationStore and every app's guard reads them; the PostgreSQL
+-- store needs the table to exist (revocationSchema() in the SDK is the same DDL), so it lives here.
+-- 0001's note that the SDK creates it at boot is corrected by this file.
+CREATE TABLE IF NOT EXISTS token_revocations (
+ subject_id text COLLATE "C" PRIMARY KEY,
+ valid_after_ms bigint NOT NULL,
+ reason text,
+ updated_at bigint NOT NULL
+);
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
"description": "OpenVibe.Tools \u2014 the tools suite on openvibe.tools and *.openvibe.tools",
"description": "OpenVibe.Tools \u2014 the tools suite on openvibe.tools and *.openvibe.tools",
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
+ "test:pg": "DATABASE_URL=\"$OV_TEST_PG_URL\" DATABASE_DIRECT_URL=\"$OV_TEST_PG_DIRECT_URL\" VALKEY_URL=\"$OV_TEST_VALKEY_URL\" node test/run.js",
"install:all": "npm --prefix apps/gateway install && npm --prefix apps/maps install && npm --prefix apps/food install && npm --prefix apps/img install && npm --prefix apps/yt install && npm --prefix apps/audio install && npm --prefix apps/text install && npm --prefix apps/docs install",
"install:all": "npm --prefix apps/gateway install && npm --prefix apps/maps install && npm --prefix apps/food install && npm --prefix apps/img install && npm --prefix apps/yt install && npm --prefix apps/audio install && npm --prefix apps/text install && npm --prefix apps/docs install",
"start:gateway": "npm --prefix apps/gateway start",
"start:gateway": "npm --prefix apps/gateway start",
"start:maps": "npm --prefix apps/maps start",
"start:maps": "npm --prefix apps/maps start",
@@ -42,9 +42,45 @@ function targets(args) {
@@ -42,9 +42,45 @@ function targets(args) {
}
}
const USAGE_APPS = ` --app <name> only this app (repeatable); default: every app that has data/analytics.db
const USAGE_APPS = ` --app <name> only this app (repeatable); default: every app that has data/analytics.db
- (an app's DATA_DIR other than data/ needs --db)`;
+ (an app's DATA_DIR other than data/ needs --db)
+ --pg prune the one tools database (DATABASE_URL) with openvibe-shared/analytics/pg instead
+ of the apps' analytics.db files; --days <n> and --apply apply here too`;
+
+/** The tracker's service names in the one tools database (plan T8). */
+const SERVICES = ['openvibe-gateway', 'openvibe-maps', 'openvibe-food', 'openvibe-img', 'openvibe-yt', 'openvibe-audio', 'openvibe-text', 'openvibe-docs'];
+
+/**
+ * The PostgreSQL branch (plan T8, decision 5): after the cutover the raw analytics events are rows in the
+ * one tools database, not SQLite files. Prunes them with openvibe-shared/analytics/pg (the rollups stay);
+ * without --apply it counts only, as the SQLite dry run does. The backup/scrub/VACUUM options are for the
+ * pre-cutover files and do not apply here.
+ */
+async function pgMain(argv, log) {
+ const i = argv.indexOf('--days');
+ const raw = i >= 0 ? parseInt(argv[i + 1], 10) : 30;
+ const days = Number.isFinite(raw) ? Math.min(Math.max(raw, 1), 3650) : 30;
+ const apply = argv.includes('--apply');
+ const { createDb } = fromApps('openvibe-sdk/db');
+ const { pruneRawEventsPg } = fromApps('openvibe-shared/analytics/pg');
+ const db = createDb({ url: process.env.DATABASE_URL, service: 'tools-analytics-prune', max: 1, log });
+ try {
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
+ const rows = await db.query('SELECT service, COUNT(*)::bigint AS n FROM analytics_events WHERE created_at < $1 GROUP BY service ORDER BY service', [cutoff]);
+ for (const r of rows) log(`${r.service}: ${r.n} raw events older than ${days} days`);
+ if (!rows.length) log(`nothing older than ${days} days`);
+ if (apply) {
+ const r = await pruneRawEventsPg(db, { days });
+ log(`removed ${r.removed} raw events, plus visitor days and day salts, older than ${r.cutoff}`);
+ } else {
+ log('dry run: nothing changed (pass --apply)');
+ }
+ } finally {
+ await db.close();
+ }
+}
function main(argv, log) {
function main(argv, log) {
+ if (argv.includes('--pg') || (process.env.DATABASE_URL && !argv.includes('--db'))) return pgMain(argv, log);
const cli = fromApps('openvibe-shared/analytics/prune-cli');
const cli = fromApps('openvibe-shared/analytics/prune-cli');
return cli.main(argv, {
return cli.main(argv, {
Database: loadSqlite(),
Database: loadSqlite(),
@@ -57,4 +93,4 @@ function main(argv, log) {
@@ -57,4 +93,4 @@ function main(argv, log) {
if (require.main === module) fromApps('openvibe-shared/analytics/prune-cli').run(main);
if (require.main === module) fromApps('openvibe-shared/analytics/prune-cli').run(main);
-module.exports = { main, targets, loadSqlite };
+module.exports = { main, pgMain, targets, loadSqlite, SERVICES };