openvibers/openvibe.shared Commits
1.3.0: openvibe-shared/metrics (Prometheus registry, golden-signal HTTP middleware by route template, process metrics, release_info, loopback-only /metrics) and openvibe-shared/ready (named required/optional checks; degraded never reads as down)
Changes
@@@ -24,4 +24,4 @@ jobs:git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEADmkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/nullnpm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"-- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"++ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"@@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 1.3.0 — 2026-09-23++++Observability and readiness (roadmap Track O, §15.19). Additive only; both modules are Node-only++and have no dependencies.++++- `openvibe-shared/metrics`: a Prometheus text-format registry (counter, gauge with optional++ scrape-time `collect()`, histogram with fixed buckets) that caps every metric's label++ combinations (past `maxSeries` they fold into one `_overflow` series, counted by++ `metrics_series_overflow_total`). `instrument(app, { service, release })` mounts the golden-signal++ middleware — `http_requests_total{method,route,status_class}`,++ `http_request_duration_seconds{method,route}`, `http_requests_in_flight` — keyed by the route++ TEMPLATE (`req.baseUrl` with id-like segments as `:id` + `req.route.path`, or a caller's++ `normalize(req)`; unmatched requests are `unmatched`, never the raw URL), process metrics++ (`process_resident_memory_bytes`, `nodejs_heap_*`, `nodejs_eventloop_lag_seconds{stat}`,++ `process_cpu_seconds_total`, `process_uptime_seconds`), `release_info{service,release}`, and++ `GET /metrics`, which answers only a direct loopback caller (127.0.0.1/::1 with no++ `X-Forwarded-For`, `X-Real-IP`, `Forwarded` or `CF-Connecting-IP`) and 404s everyone else.++- `openvibe-shared/ready`: `createReadiness({ service, release, checks, details })` → `handler` and++ `run()`. Each named check reports `status`, `required`, `latency_ms` and `checked_at` (a cached++ check keeps the time it really ran). `ready` is false (HTTP 503) only when a required check fails;++ failed optional checks are listed in `degraded` and the service stays ready (`status:++ "degraded"`). Failure reasons are one line with URL credentials, query strings and++ `token=`/`key=` values removed.++## 1.2.1 — 2026-09-23- `release-watch.js` never reloads a tab that is playing `<video>`/`<audio>` or holds a live@@@ -18,7 +18,7 @@ were reconciled first.| Kind | Files ||---|---|| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `openvibe-sw.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics`, `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`; `footer` and `icons` (= `ov-icons.js`) work on both sides |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics`, `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `release`, `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides || Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |Every module file has an `exports` entry, so `require('openvibe-shared/navbar')`,@@@ -44,6 +44,29 @@ const seo = require('openvibe-shared/seo');const { AnalyticsTracker } = require('openvibe-shared/analytics');```++### Metrics and readiness (server)++++```js++const release = require('openvibe-shared/release').createRelease({ service: 'community' });++const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });++// before any route: HTTP golden signals by route template, process metrics, release_info,++// and GET /metrics for direct loopback callers only (404 through a proxy)++m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], collect: () => rows });++++const ready = require('openvibe-shared/ready').createReadiness({++ service: 'community', release: release.release,++ checks: [++ { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },++ { name: 'media', required: false, cacheMs: 30000, check: async () => (await ping()) || 'unreachable' },++ ],++});++app.get('/api/ready', ready.handler); // 503 only when a required check fails; optional → degraded++```++++A check fails when it throws, times out, returns `false`, a string (the reason) or++`{ ok: false, error }`. Name a dependency required only when the service really cannot serve++without it.++### Browser side: two options1. **From the Network** (what most pages do today):@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "1.0.0",++ "version": "1.3.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,364 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/metrics — Prometheus text-format metrics with no dependencies (roadmap Track O,++// §15.19): a registry (counter, gauge, histogram), golden-signal HTTP middleware keyed by ROUTE++// TEMPLATE, process metrics, release_info, and a /metrics handler only loopback callers reach.++//++// const metrics = require('openvibe-shared/metrics');++// const m = metrics.instrument(app, { service: 'community', release: release.release });++// // mounted: the HTTP middleware (call this before any route) and GET /metrics (loopback only)++// const depth = m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'],++// collect: () => db.prepare('SELECT state, COUNT(*) n FROM jobs GROUP BY state').all()++// .map(r => ({ labels: { state: r.state }, value: r.n })) });++//++// Route labels are templates, never raw URLs: `req.baseUrl` (with id-like segments replaced by++// `:id`) + `req.route.path`, or what `normalize(req)` returns. A request no route matched is++// `unmatched`. Every metric caps its label combinations (`maxSeries`); past the cap new++// combinations collapse into one `_overflow` series and metrics_series_overflow_total counts them.++// ═══════════════════════════════════════════════════════════════++++const NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;++const LABEL_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;++const DEFAULT_BUCKETS = Object.freeze([0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]);++const DEFAULT_MAX_SERIES = 500;++const OVERFLOW = '_overflow';++const MAX_LABEL_VALUE = 120;++++const escHelp = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n');++const escValue = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');++function fmt(n) {++ if (n === Infinity) return '+Inf';++ if (n === -Infinity) return '-Inf';++ if (Number.isNaN(n)) return 'NaN';++ return String(n);++}++const labelText = (names, values) => (names.length ? `{${names.map((n, i) => `${n}="${escValue(values[i])}"`).join(',')}}` : '');++++function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {++ const metrics = new Map();++ let overflow = null; // counter, created on first use so an untroubled registry stays quiet++++ function noteOverflow(name) {++ if (!overflow) overflow = counter({ name: 'metrics_series_overflow_total', help: 'Label combinations collapsed into the _overflow series because a metric reached its series cap', labelNames: ['metric'] });++ overflow.inc({ metric: name });++ }++++ function base(kind, { name, help, labelNames = [], maxSeries: cap = maxSeries }) {++ if (!NAME_RE.test(String(name || ''))) throw new TypeError(`metrics: invalid metric name ${name}`);++ if (metrics.has(name)) throw new Error(`metrics: ${name} is already registered`);++ if (!help) throw new TypeError(`metrics: ${name} needs help text`);++ for (const l of labelNames) {++ if (!LABEL_RE.test(l) || l.startsWith('__') || (kind === 'histogram' && l === 'le')) throw new TypeError(`metrics: invalid label ${l} on ${name}`);++ }++ const series = new Map(); // key -> { values: [..], ...state }++ function key(labels = {}) {++ const values = labelNames.map((l) => {++ const v = labels[l];++ return v === undefined || v === null ? '' : String(v).slice(0, MAX_LABEL_VALUE);++ });++ return { k: values.join('\u0000'), values };++ }++ function get(labels, init) {++ let { k, values } = key(labels);++ let s = series.get(k);++ if (s) return s;++ if (series.size >= cap && labelNames.length) {++ noteOverflow(name);++ values = labelNames.map(() => OVERFLOW);++ k = values.join('\u0000');++ s = series.get(k);++ if (s) return s;++ }++ s = { values, ...init() };++ series.set(k, s);++ return s;++ }++ return { name, help, labelNames, series, get, kind };++ }++++ function counter(opts) {++ const m = base('counter', opts);++ const api = {++ name: m.name,++ inc(labels, value = 1) {++ if (typeof labels === 'number') { value = labels; labels = {}; }++ if (!(value >= 0)) throw new RangeError(`metrics: ${m.name} can only increase`);++ m.get(labels, () => ({ value: 0 })).value += value;++ },++ get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: 0 }).value,++ reset: () => m.series.clear(),++ render() {++ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} counter`];++ for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);++ return out;++ },++ };++ metrics.set(m.name, api);++ return api;++ }++++ // collect(): a number (no labels) or [{ labels, value }] read at scrape time. A throwing collect++ // leaves the gauge out of that scrape rather than reporting a stale or invented value.++ function gauge(opts) {++ const m = base('gauge', opts);++ const collect = typeof opts.collect === 'function' ? opts.collect : null;++ const api = {++ name: m.name,++ set(labels, value) {++ if (typeof labels === 'number') { value = labels; labels = {}; }++ m.get(labels, () => ({ value: 0 })).value = Number(value);++ },++ inc(labels, value = 1) {++ if (typeof labels === 'number') { value = labels; labels = {}; }++ m.get(labels, () => ({ value: 0 })).value += value;++ },++ dec(labels, value = 1) {++ if (typeof labels === 'number') { value = labels; labels = {}; }++ m.get(labels, () => ({ value: 0 })).value -= value;++ },++ get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value,++ reset: () => m.series.clear(),++ render() {++ if (collect) {++ let got;++ try { got = collect(); } catch { return []; }++ if (got === undefined || got === null) return [];++ m.series.clear();++ if (typeof got === 'number') api.set({}, got);++ else for (const row of got) api.set(row.labels || {}, row.value);++ }++ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} gauge`];++ for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);++ return out;++ },++ };++ metrics.set(m.name, api);++ return api;++ }++++ function histogram(opts) {++ const m = base('histogram', opts);++ const buckets = [...(opts.buckets || DEFAULT_BUCKETS)].map(Number).sort((a, b) => a - b);++ if (!buckets.length || buckets.some((b) => !Number.isFinite(b))) throw new TypeError(`metrics: ${m.name} needs finite buckets`);++ const init = () => ({ counts: new Array(buckets.length).fill(0), sum: 0, count: 0 });++ const api = {++ name: m.name,++ buckets,++ observe(labels, value) {++ if (typeof labels === 'number') { value = labels; labels = {}; }++ if (!Number.isFinite(value)) return;++ const s = m.get(labels, init);++ for (let i = 0; i < buckets.length; i++) if (value <= buckets[i]) s.counts[i]++;++ s.sum += value;++ s.count++;++ },++ startTimer(labels = {}) {++ const t0 = process.hrtime.bigint();++ return (more = {}) => {++ const seconds = Number(process.hrtime.bigint() - t0) / 1e9;++ api.observe({ ...labels, ...more }, seconds);++ return seconds;++ };++ },++ get: (labels) => {++ const s = m.series.get(labelsKey(m.labelNames, labels));++ return s ? { count: s.count, sum: s.sum, buckets: Object.fromEntries(buckets.map((b, i) => [b, s.counts[i]])) } : null;++ },++ reset: () => m.series.clear(),++ render() {++ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} histogram`];++ const names = [...m.labelNames, 'le'];++ for (const s of m.series.values()) {++ buckets.forEach((b, i) => out.push(`${m.name}_bucket${labelText(names, [...s.values, fmt(b)])} ${s.counts[i]}`));++ out.push(`${m.name}_bucket${labelText(names, [...s.values, '+Inf'])} ${s.count}`);++ out.push(`${m.name}_sum${labelText(m.labelNames, s.values)} ${fmt(s.sum)}`);++ out.push(`${m.name}_count${labelText(m.labelNames, s.values)} ${s.count}`);++ }++ return out;++ },++ };++ metrics.set(m.name, api);++ return api;++ }++++ // Callbacks run just before a scrape renders (process metrics sample themselves here).++ const beforeScrape = [];++ function metricsText() {++ for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } }++ const lines = [];++ for (const m of metrics.values()) lines.push(...m.render());++ return lines.join('\n') + '\n';++ }++++ return {++ counter, gauge, histogram,++ getMetric: (name) => metrics.get(name) || null,++ onScrape: (f) => { beforeScrape.push(f); },++ metrics: metricsText,++ contentType: 'text/plain; version=0.0.4; charset=utf-8',++ };++}++++function labelsKey(names, labels = {}) {++ return names.map((l) => (labels[l] === undefined || labels[l] === null ? '' : String(labels[l]).slice(0, MAX_LABEL_VALUE))).join('\u0000');++}++++// ── Route templates ─────────────────────────────────────────────++++// A path segment that is an identifier rather than a route word: numbers, UUIDs, ULIDs, prefixed++// subject ids (usr_…), hex digests, and long mixed tokens.++const ID_SEGMENT = /^(?:\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26}|[a-z]{2,5}_[0-9A-Za-z]{8,}|[0-9a-f]{12,}|(?=[^/]*\d)[A-Za-z0-9_-]{16,})$/i;++++/** Replace id-like segments of a concrete path with `:id` (query strings are dropped). */++function templatePath(p) {++ const clean = String(p || '').split('?')[0];++ return clean.split('/').map((seg) => (seg && ID_SEGMENT.test(seg) ? ':id' : seg)).join('/') || '/';++}++++/** The route label of a finished request: the matched route's template, or 'unmatched'. */++function routeLabel(req) {++ if (!req.route || req.route.path === undefined) return 'unmatched';++ const rp = req.route.path;++ const tail = rp instanceof RegExp ? '(regex)' : Array.isArray(rp) ? String(rp[0]) : String(rp);++ const base = req.baseUrl ? templatePath(req.baseUrl) : '';++ const joined = `${base}${tail === '/' && base ? '' : tail}`;++ return (joined || '/').slice(0, MAX_LABEL_VALUE);++}++++// ── HTTP golden signals ─────────────────────────────────────────++++// skip(req): requests never recorded (long-lived streams such as SSE, whose "duration" is a session).++function httpMetrics(registry, { buckets = DEFAULT_BUCKETS, normalize = null, skip = null, maxRoutes = 300 } = {}) {++ const requests = registry.counter({ name: 'http_requests_total', help: 'HTTP requests by method, route template and status class', labelNames: ['method', 'route', 'status_class'], maxSeries: maxRoutes * 4 });++ const duration = registry.histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration by method and route template', labelNames: ['method', 'route'], buckets, maxSeries: maxRoutes });++ const inFlight = registry.gauge({ name: 'http_requests_in_flight', help: 'HTTP requests being served' });++ inFlight.set(0);++ const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);++++ function middleware(req, res, next) {++ try { if (skip && skip(req)) return next(); } catch { /* record it */ }++ const t0 = process.hrtime.bigint();++ inFlight.inc();++ let done = false;++ const finish = () => {++ if (done) return;++ done = true;++ inFlight.dec();++ let route;++ try { route = normalize ? normalize(req, res) : null; } catch { route = null; }++ if (!route) route = routeLabel(req);++ const method = METHODS.has(req.method) ? req.method : 'OTHER';++ // Closed before the response finished: the client went away (or a stream was cut).++ const status = res.writableFinished || res.finished ? `${Math.floor((res.statusCode || 0) / 100)}xx` : 'aborted';++ requests.inc({ method, route, status_class: status });++ duration.observe({ method, route }, Number(process.hrtime.bigint() - t0) / 1e9);++ };++ res.once('finish', finish);++ res.once('close', finish);++ next();++ }++ return { middleware, requests, duration, inFlight };++}++++// ── Process metrics ─────────────────────────────────────────────++++function processMetrics(registry, { eventLoopResolutionMs = 20 } = {}) {++ let loop = null;++ try {++ loop = require('perf_hooks').monitorEventLoopDelay({ resolution: eventLoopResolutionMs });++ loop.enable();++ } catch { loop = null; }++ const startSeconds = Math.round((Date.now() - process.uptime() * 1000) / 1000);++ const rss = registry.gauge({ name: 'process_resident_memory_bytes', help: 'Resident set size in bytes' });++ const heapUsed = registry.gauge({ name: 'nodejs_heap_used_bytes', help: 'V8 heap in use in bytes' });++ const heapTotal = registry.gauge({ name: 'nodejs_heap_total_bytes', help: 'V8 heap allocated in bytes' });++ const cpu = registry.gauge({ name: 'process_cpu_seconds_total', help: 'User and system CPU time spent, in seconds' });++ const uptime = registry.gauge({ name: 'process_uptime_seconds', help: 'Seconds since the process started' });++ registry.gauge({ name: 'process_start_time_seconds', help: 'Start time of the process since the Unix epoch, in seconds' }).set(startSeconds);++ const lag = loop && registry.gauge({ name: 'nodejs_eventloop_lag_seconds', help: 'Event-loop delay since the previous scrape', labelNames: ['stat'] });++ registry.onScrape(() => {++ const mem = process.memoryUsage();++ rss.set(mem.rss);++ heapUsed.set(mem.heapUsed);++ heapTotal.set(mem.heapTotal);++ const c = process.cpuUsage();++ cpu.set((c.user + c.system) / 1e6);++ uptime.set(Math.round(process.uptime()));++ if (loop && lag) {++ // The histogram is in nanoseconds; with no samples yet it reports NaN/huge values.++ const ok = loop.count > 0 || loop.max > 0;++ lag.set({ stat: 'mean' }, ok ? loop.mean / 1e9 : 0);++ lag.set({ stat: 'p99' }, ok ? loop.percentile(99) / 1e9 : 0);++ lag.set({ stat: 'max' }, ok ? loop.max / 1e9 : 0);++ loop.reset();++ }++ });++ return { stop: () => { if (loop) loop.disable(); } };++}++++/** release_info{service,release} 1 — the deployed release, from openvibe-shared/release. */++function releaseInfo(registry, { service, release }) {++ const g = registry.gauge({ name: 'release_info', help: 'The release this process runs (value is always 1)', labelNames: ['service', 'release'] });++ g.set({ service: String(service || 'unknown'), release: String(release || 'unknown') }, 1);++ return g;++}++++// ── /metrics ───────────────────────────────────────────────────++++const LOOPBACK = /^(?:127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;++const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded', 'cf-connecting-ip', 'true-client-ip'];++++/** True only for a direct loopback connection that no proxy relayed. */++function isLoopbackDirect(req) {++ const addr = String((req.socket && req.socket.remoteAddress) || (req.connection && req.connection.remoteAddress) || '');++ if (!LOOPBACK.test(addr)) return false;++ const h = req.headers || {};++ return !PROXY_HEADERS.some((name) => h[name] !== undefined);++}++++function metricsHandler(registry) {++ return (req, res) => {++ if (!isLoopbackDirect(req)) {++ res.statusCode = 404;++ res.setHeader('Content-Type', 'application/json; charset=utf-8');++ return res.end('{"error":"Not found"}');++ }++ let body;++ try { body = registry.metrics(); } catch (err) {++ res.statusCode = 500;++ res.setHeader('Content-Type', 'text/plain; charset=utf-8');++ return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`);++ }++ res.statusCode = 200;++ res.setHeader('Content-Type', registry.contentType);++ res.setHeader('Cache-Control', 'no-store');++ res.end(body);++ };++}++++/**++ * Everything at once, for an Express app: a registry with process metrics and release_info, the HTTP++ * middleware mounted first, and GET <path> (default /metrics) for loopback callers.++ * Call it before any route or body parser is mounted.++ */++function instrument(app, { service, release, path = '/metrics', normalize = null, skip = null, buckets, maxRoutes, registry = createRegistry() } = {}) {++ const http = httpMetrics(registry, { normalize, skip, buckets, maxRoutes });++ const proc = processMetrics(registry);++ releaseInfo(registry, { service, release });++ app.use(http.middleware);++ app.get(path, metricsHandler(registry));++ return { registry, http, stop: proc.stop };++}++++module.exports = {++ createRegistry,++ httpMetrics,++ processMetrics,++ releaseInfo,++ metricsHandler,++ isLoopbackDirect,++ instrument,++ routeLabel,++ templatePath,++ DEFAULT_BUCKETS,++};@@@ -1,15 +1,64 @@{"name": "openvibe-shared",-- "version": "1.0.0",++ "version": "1.3.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.0.0",++ "version": "1.3.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"++ },++ "devDependencies": {++ "express": "^4.22.3"++ }++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"}},"node_modules/buffer-equal-constant-time": {@@@ -18,6 +67,140 @@"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==","license": "BSD-3-Clause"},++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/debug/node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },"node_modules/ecdsa-sig-formatter": {"version": "1.0.11","resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",@@@ -27,6 +210,298 @@"safe-buffer": "^5.0.1"}},++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "dev": true,++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "dev": true,++ "license": "ISC"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },"node_modules/jsonwebtoken": {"version": "9.0.3","resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",@@@ -112,12 +587,202 @@"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==","license": "MIT"},++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "dev": true,++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "dev": true,++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },"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/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "dev": true,++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -138,6 +803,13 @@],"license": "MIT"},++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "dev": true,++ "license": "MIT"++ },"node_modules/semver": {"version": "7.8.5","resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",@@@ -149,6 +821,194 @@"engines": {"node": ">=10"}++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "dev": true,++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }}}}@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.2.1",++ "version": "1.3.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -19,6 +19,7 @@"./icons": "./ov-icons.js","./island": "./island.js","./legal": "./legal.js",++ "./metrics": "./metrics.js","./middleware": "./middleware.js","./nav-icons": "./nav-icons.js","./navbar": "./navbar.js",@@@ -28,6 +29,7 @@"./ov-icons": "./ov-icons.js","./ov-mark": "./ov-mark.js","./panels": "./panels.js",++ "./ready": "./ready.js","./release": "./release.js","./release-watch": "./release-watch.js","./seo": "./seo.js",@@@ -58,5 +60,8 @@"license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"++ },++ "devDependencies": {++ "express": "^4.22.3"}}nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/ready — a truthful readiness response (roadmap Track O, §15.19: "readiness must++// report the capability actually served").++//++// const ready = require('openvibe-shared/ready').createReadiness({++// service: 'community', release: release.release,++// checks: [++// { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },++// { name: 'network_jwks', required: true, check: () => keys.loaded() || 'not loaded yet' },++// { name: 'media', required: false, cacheMs: 30000, check: async () => { … } },++// ],++// details: () => ({ queue_depth: store.depth() }), // optional extra fields++// });++// app.get('/api/ready', ready.handler);++//++// Every check runs on each request (or from its cache, keeping the time it really ran) and reports++// status, latency_ms and checked_at. A check passes when it returns anything but `false` or a string++// (`false`/a string is a failure; the string is the reason) or `{ ok: false, detail }`, and does not++// throw or time out. Overall `ready` is false only when a REQUIRED check fails (HTTP 503); a failed++// optional check keeps the service ready (HTTP 200) and is listed in `degraded`, so a missing optional++// dependency never reads as "healthy" or as "down".++// ═══════════════════════════════════════════════════════════════++++const NAME_RE = /^[a-z][a-z0-9_.-]{0,63}$/;++++/** A failure reason safe to show publicly: one line, no URL query strings or credentials, short. */++function safeReason(v) {++ let s = v instanceof Error ? (v.name === 'TimeoutError' || v.name === 'AbortError' ? 'timeout' : v.message) : String(v);++ s = s.split('\n')[0]++ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1') // user:pass@++ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#][^\s]*/gi, '$1') // ?query / #fragment++ .replace(/\b(?:key|token|secret|password|signature)=[^\s&]+/gi, (m) => `${m.split('=')[0]}=…`);++ return s.slice(0, 200) || 'failed';++}++++function withTimeout(promise, ms) {++ let t;++ const timer = new Promise((_, reject) => { t = setTimeout(() => reject(Object.assign(new Error('timeout'), { name: 'TimeoutError' })), ms); });++ return Promise.race([promise, timer]).finally(() => clearTimeout(t));++}++++function createReadiness({ service, release = null, checks = [], details = null, timeoutMs = 2000, now = () => new Date() } = {}) {++ if (!service) throw new TypeError('createReadiness: service required');++ const list = checks.map((c) => {++ if (!NAME_RE.test(String(c.name || ''))) throw new TypeError(`createReadiness: bad check name ${c.name}`);++ if (typeof c.check !== 'function') throw new TypeError(`createReadiness: check ${c.name} needs a function`);++ return { name: c.name, required: c.required !== false, check: c.check, timeoutMs: c.timeoutMs || timeoutMs, cacheMs: Math.max(0, c.cacheMs || 0), description: c.description || null };++ });++ const names = new Set();++ for (const c of list) { if (names.has(c.name)) throw new Error(`createReadiness: duplicate check ${c.name}`); names.add(c.name); }++ const cache = new Map(); // name -> { at: ms, result }++ const inflight = new Map();++++ async function runOne(c) {++ const hit = cache.get(c.name);++ if (hit && c.cacheMs && Date.now() - hit.at < c.cacheMs) return hit.result;++ if (inflight.has(c.name)) return inflight.get(c.name);++ const p = (async () => {++ const t0 = process.hrtime.bigint();++ const out = { status: 'ok', required: c.required };++ try {++ const v = await withTimeout(Promise.resolve().then(() => c.check()), c.timeoutMs);++ if (v === false) { out.status = 'fail'; out.error = 'check returned false'; }++ else if (typeof v === 'string') { out.status = 'fail'; out.error = safeReason(v); }++ else if (v && typeof v === 'object' && !Array.isArray(v)) {++ if (v.ok === false) { out.status = 'fail'; out.error = safeReason(v.error || v.detail || 'failed'); }++ if (v.detail !== undefined && (v.ok !== false || typeof v.detail === 'object')) out.detail = v.detail;++ }++ } catch (err) {++ out.status = 'fail';++ out.error = safeReason(err);++ }++ out.latency_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e5) / 10;++ out.checked_at = now().toISOString();++ if (c.description) out.description = c.description;++ cache.set(c.name, { at: Date.now(), result: out });++ return out;++ })().finally(() => inflight.delete(c.name));++ inflight.set(c.name, p);++ return p;++ }++++ async function run() {++ const results = await Promise.all(list.map(runOne));++ const out = {};++ const failed = []; const degraded = [];++ list.forEach((c, i) => {++ out[c.name] = results[i];++ if (results[i].status !== 'ok') (c.required ? failed : degraded).push(c.name);++ });++ const ready = failed.length === 0;++ const body = {++ ready,++ status: !ready ? 'not_ready' : degraded.length ? 'degraded' : 'ready',++ service,++ release: typeof release === 'function' ? release() : release,++ checked_at: now().toISOString(),++ failed,++ degraded,++ checks: out,++ };++ if (details) {++ try {++ const extra = await withTimeout(Promise.resolve().then(() => details(body)), timeoutMs);++ if (extra && typeof extra === 'object') for (const [k, v] of Object.entries(extra)) if (!(k in body)) body[k] = v;++ } catch (err) { body.details_error = safeReason(err); }++ }++ return body;++ }++++ async function handler(_req, res) {++ let body;++ try { body = await run(); } catch (err) {++ body = { ready: false, status: 'not_ready', service, checked_at: now().toISOString(), failed: ['readiness'], degraded: [], checks: {}, error: safeReason(err) };++ }++ res.statusCode = body.ready ? 200 : 503;++ res.setHeader('Content-Type', 'application/json; charset=utf-8');++ res.setHeader('Cache-Control', 'no-store');++ res.end(JSON.stringify(body));++ }++++ return { run, handler, checks: list.map((c) => ({ name: c.name, required: c.required })) };++}++++module.exports = { createReadiness, safeReason };nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++// metrics.js: Prometheus text format, cardinality guard, route templates (never raw URLs), the++// loopback-only /metrics handler, and the golden-signal middleware on a real Express-shaped app.++const assert = require('assert');++const http = require('http');++const metrics = require('../metrics');++++(async () => {++ // ── Registry and text format ──++ const r = metrics.createRegistry({ maxSeries: 3 });++ const c = r.counter({ name: 'jobs_total', help: 'Jobs\nrun', labelNames: ['state'] });++ c.inc({ state: 'done' });++ c.inc({ state: 'done' }, 2);++ c.inc({ state: 'a"b\\c\nd' });++ assert.strictEqual(c.get({ state: 'done' }), 3);++ assert.throws(() => c.inc({ state: 'done' }, -1), /only increase/);++ assert.throws(() => r.counter({ name: 'jobs_total', help: 'x' }), /already registered/);++ assert.throws(() => r.counter({ name: 'bad-name', help: 'x' }), /invalid metric name/);++ assert.throws(() => r.histogram({ name: 'h_bad', help: 'x', labelNames: ['le'] }), /invalid label/);++ assert.throws(() => r.gauge({ name: 'no_help' }), /help/);++++ const g = r.gauge({ name: 'queue_depth', help: 'Depth', labelNames: ['status'] });++ g.set({ status: 'pending' }, 4); g.inc({ status: 'pending' }); g.dec({ status: 'pending' }, 2);++ assert.strictEqual(g.get({ status: 'pending' }), 3);++ const collected = r.gauge({ name: 'objects', help: 'Objects by state', labelNames: ['state'], collect: () => [{ labels: { state: 'local' }, value: 7 }, { labels: { state: 'b2' }, value: 2 }] });++ r.gauge({ name: 'broken', help: 'Collect throws', collect: () => { throw new Error('db gone'); } });++ r.gauge({ name: 'scalar', help: 'A plain number', collect: () => 5 });++ assert.ok(collected);++++ const h = r.histogram({ name: 'lat_seconds', help: 'Latency', labelNames: ['op'], buckets: [0.1, 1] });++ h.observe({ op: 'x' }, 0.05); h.observe({ op: 'x' }, 0.5); h.observe({ op: 'x' }, 3); h.observe({ op: 'x' }, NaN);++ assert.deepStrictEqual(h.get({ op: 'x' }), { count: 3, sum: 3.55, buckets: { 0.1: 1, 1: 2 } });++ const end = h.startTimer({ op: 'y' });++ assert.ok(end() >= 0);++++ let text = r.metrics();++ assert.ok(text.endsWith('\n'));++ assert.ok(text.includes('# HELP jobs_total Jobs\\nrun\n# TYPE jobs_total counter\n'));++ assert.ok(text.includes('jobs_total{state="done"} 3\n'));++ assert.ok(text.includes('jobs_total{state="a\\"b\\\\c\\nd"} 1\n'), 'label values are escaped');++ assert.ok(text.includes('queue_depth{status="pending"} 3\n'));++ assert.ok(text.includes('objects{state="local"} 7\nobjects{state="b2"} 2\n'));++ assert.ok(text.includes('scalar 5\n'));++ assert.ok(!text.includes('broken'), 'a throwing collect is left out, never reported stale');++ assert.ok(text.includes('lat_seconds_bucket{op="x",le="0.1"} 1\nlat_seconds_bucket{op="x",le="1"} 2\nlat_seconds_bucket{op="x",le="+Inf"} 3\nlat_seconds_sum{op="x"} 3.55\nlat_seconds_count{op="x"} 3\n'));++++ // Cardinality guard: past maxSeries, new combinations fold into one _overflow series.++ for (let i = 0; i < 50; i++) c.inc({ state: `s${i}` });++ text = r.metrics();++ assert.strictEqual((text.match(/^jobs_total\{/gm) || []).length, 4, 'three series plus _overflow');++ assert.ok(text.includes('jobs_total{state="_overflow"} 49\n'));++ assert.ok(text.includes('metrics_series_overflow_total{metric="jobs_total"} 49\n'));++++ // ── Route templates ──++ assert.strictEqual(metrics.templatePath('/api/v1/pastes/12345?x=1'), '/api/v1/pastes/:id');++ assert.strictEqual(metrics.templatePath('/u/usr_01J9ZX4YQ0ABCDEF/x'), '/u/:id/x');++ assert.strictEqual(metrics.templatePath('/o/0f8fad5b-d9cb-469f-a165-70867728950e'), '/o/:id');++ assert.strictEqual(metrics.templatePath('/o/01J9ZX4YQ0ABCDEFGHJKMNPQRS'), '/o/:id');++ assert.strictEqual(metrics.templatePath('/api/v1/live/vods'), '/api/v1/live/vods', 'route words stay');++ assert.strictEqual(metrics.routeLabel({ route: { path: '/:id' }, baseUrl: '/api/v1/live/vods' }), '/api/v1/live/vods/:id');++ assert.strictEqual(metrics.routeLabel({ route: { path: '/' }, baseUrl: '/api/things' }), '/api/things');++ assert.strictEqual(metrics.routeLabel({ route: { path: '/' }, baseUrl: '' }), '/');++ assert.strictEqual(metrics.routeLabel({ route: { path: /^\/x/ }, baseUrl: '' }), '(regex)');++ assert.strictEqual(metrics.routeLabel({ route: { path: ['/a', '/b'] }, baseUrl: '' }), '/a');++ assert.strictEqual(metrics.routeLabel({ baseUrl: '/secret/123', originalUrl: '/secret/123?q=1' }), 'unmatched');++++ // ── Loopback-only guard ──++ const req = (addr, headers = {}) => ({ socket: { remoteAddress: addr }, headers });++ assert.ok(metrics.isLoopbackDirect(req('127.0.0.1')));++ assert.ok(metrics.isLoopbackDirect(req('::1')));++ assert.ok(metrics.isLoopbackDirect(req('::ffff:127.0.0.1')));++ assert.ok(!metrics.isLoopbackDirect(req('10.0.0.2')));++ assert.ok(!metrics.isLoopbackDirect(req('::ffff:10.0.0.2')));++ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { 'x-forwarded-for': '1.2.3.4' })), 'nginx on loopback is not a local caller');++ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { 'x-real-ip': '1.2.3.4' })));++ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { forwarded: 'for=1.2.3.4' })));++++ // ── The whole thing on a real server (a minimal Express-compatible router stand-in is not++ // enough for req.route; use express when it is installed, else a hand-rolled app) ──++ let express = null;++ try { express = require('express'); } catch { /* not a dependency of this package */ }++ if (express) {++ const app = express();++ const m = metrics.instrument(app, { service: 'shared-test', release: 'abc1234', skip: (q) => q.path === '/sse' });++ const router = express.Router();++ router.get('/:id', (q, s) => s.status(q.params.id === '0' ? 404 : 200).json({ id: q.params.id }));++ app.use('/api/items', router);++ app.get('/boom', () => { throw new Error('x'); });++ app.get('/sse', (_q, s) => s.end('ok'));++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = (p, headers = {}) => new Promise((resolve, reject) => http.get(base + p, { headers }, (res) => { let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b, type: res.headers['content-type'] })); }).on('error', reject));++ await get('/api/items/42?token=secret');++ await get('/api/items/43');++ await get('/api/items/0');++ await get('/boom');++ await get('/sse');++ await get('/nope/9999/secret-path');++ const out = await get('/metrics');++ assert.strictEqual(out.status, 200);++ assert.match(out.type, /^text\/plain; version=0\.0\.4/);++ assert.ok(out.body.includes('http_requests_total{method="GET",route="/api/items/:id",status_class="2xx"} 2\n'), out.body);++ assert.ok(out.body.includes('http_requests_total{method="GET",route="/api/items/:id",status_class="4xx"} 1\n'));++ assert.ok(out.body.includes('http_requests_total{method="GET",route="/boom",status_class="5xx"} 1\n'));++ assert.ok(out.body.includes('http_requests_total{method="GET",route="unmatched",status_class="4xx"} 1\n'));++ const labelSets = out.body.split('\n').filter((l) => l.startsWith('http_')).map((l) => (l.match(/\{[^}]*\}/) || [''])[0]).join('\n');++ assert.ok(!/42|43|secret|9999|nope/.test(labelSets), 'no ids, queries or raw paths in labels');++ assert.ok(!out.body.includes('route="/sse"'), 'skipped requests are not recorded');++ assert.ok(out.body.includes('http_request_duration_seconds_count{method="GET",route="/api/items/:id"} 3\n'));++ assert.ok(/http_requests_in_flight 1\n/.test(out.body), 'the scrape itself is in flight');++ assert.ok(out.body.includes('release_info{service="shared-test",release="abc1234"} 1\n'));++ for (const n of ['process_resident_memory_bytes', 'nodejs_heap_used_bytes', 'process_uptime_seconds', 'process_cpu_seconds_total', 'nodejs_eventloop_lag_seconds{stat="p99"}']) assert.ok(out.body.includes(`\n${n} `), n);++ const proxied = await get('/metrics', { 'X-Forwarded-For': '203.0.113.9' });++ assert.strictEqual(proxied.status, 404, 'a proxied caller gets 404');++ m.stop();++ server.close();++ } else {++ console.log('metrics: express not installed; middleware checked through the handler only');++ }++++ // Handler without express: 404 to non-loopback, text to loopback.++ const reg = metrics.createRegistry();++ metrics.releaseInfo(reg, { service: 's', release: 'r' });++ const handler = metrics.metricsHandler(reg);++ const fakeRes = () => { const o = { headers: {}, statusCode: 0, body: '' }; o.setHeader = (k, v) => { o.headers[k] = v; }; o.end = (b) => { o.body = b; }; return o; };++ let res = fakeRes(); handler(req('192.0.2.1'), res);++ assert.strictEqual(res.statusCode, 404);++ res = fakeRes(); handler(req('127.0.0.1'), res);++ assert.strictEqual(res.statusCode, 200);++ assert.ok(res.body.includes('release_info{service="s",release="r"} 1'));++ console.log('metrics: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -19,7 +19,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);}// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).--for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons']) require(`openvibe-shared/${sub}`);++for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready']) require(`openvibe-shared/${sub}`);assert.strictEqual(typeof require('openvibe-shared/navbar').init, 'function');assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), 'semver version');assert.ok(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').includes(`## ${pkg.version}`), 'CHANGELOG has an entry for this version');nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++// ready.js: required vs optional checks, status/latency/checked_at on every check, timeouts,++// cached checks keep the time they really ran, and failure reasons never leak secrets.++const assert = require('assert');++const { createReadiness, safeReason } = require('../ready');++++(async () => {++ assert.throws(() => createReadiness({}), /service/);++ assert.throws(() => createReadiness({ service: 's', checks: [{ name: 'Bad Name', check: () => true }] }), /bad check name/);++ assert.throws(() => createReadiness({ service: 's', checks: [{ name: 'db', check: () => true }, { name: 'db', check: () => true }] }), /duplicate/);++++ let dbUp = true; let mediaUp = true; let remoteCalls = 0;++ const ready = createReadiness({++ service: 'community',++ release: 'abc1234',++ checks: [++ { name: 'db', required: true, check: () => { if (!dbUp) throw new Error('SQLITE_CANTOPEN: unable to open database file'); return true; } },++ { name: 'network_jwks', required: true, check: () => ({ ok: true, detail: { kid: 'ov-network-1' } }) },++ { name: 'media', required: false, check: () => (mediaUp ? true : 'Media answered 502') },++ { name: 'remote_tier', required: false, cacheMs: 60000, check: async () => { remoteCalls++; return { ok: false, error: 'GET https://user:[email protected]/bucket?X-Amz-Signature=abc failed' }; } },++ { name: 'slow', required: false, timeoutMs: 30, check: () => new Promise((resolve) => setTimeout(resolve, 500)) },++ ],++ details: () => ({ queue: 3, ready: 'ignored: never overrides a computed field' }),++ });++++ let body = await ready.run();++ assert.strictEqual(body.ready, true, 'optional failures keep the service ready');++ assert.strictEqual(body.status, 'degraded');++ assert.deepStrictEqual(body.failed, []);++ assert.deepStrictEqual(body.degraded, ['remote_tier', 'slow']);++ assert.strictEqual(body.service, 'community');++ assert.strictEqual(body.release, 'abc1234');++ assert.strictEqual(body.queue, 3);++ for (const [name, c] of Object.entries(body.checks)) {++ assert.ok(['ok', 'fail'].includes(c.status), name);++ assert.strictEqual(typeof c.latency_ms, 'number', `${name} latency`);++ assert.ok(!Number.isNaN(Date.parse(c.checked_at)), `${name} checked_at`);++ assert.strictEqual(typeof c.required, 'boolean');++ }++ assert.deepStrictEqual(body.checks.network_jwks.detail, { kid: 'ov-network-1' });++ assert.strictEqual(body.checks.slow.error, 'timeout');++ const leaked = body.checks.remote_tier.error;++ assert.ok(!/pw@|Signature=abc|\?/.test(leaked), `no credentials or query in reasons: ${leaked}`);++ assert.ok(leaked.includes('https://b2.example/bucket'));++++ // Cached optional check: not re-run, and still reports when it actually ran.++ const firstAt = body.checks.remote_tier.checked_at;++ await new Promise((r) => setTimeout(r, 5));++ body = await ready.run();++ assert.strictEqual(remoteCalls, 1);++ assert.strictEqual(body.checks.remote_tier.checked_at, firstAt);++++ // All healthy apart from the cached remote tier.++ mediaUp = false;++ body = await ready.run();++ assert.ok(body.degraded.includes('media'));++ assert.strictEqual(body.checks.media.error, 'Media answered 502');++++ // A required failure is not ready, with the reason.++ dbUp = false;++ const res = { headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; } };++ await ready.handler({}, res);++ assert.strictEqual(res.statusCode, 503);++ assert.strictEqual(res.headers['Cache-Control'], 'no-store');++ body = JSON.parse(res.body);++ assert.strictEqual(body.ready, false);++ assert.strictEqual(body.status, 'not_ready');++ assert.deepStrictEqual(body.failed, ['db']);++ assert.match(body.checks.db.error, /SQLITE_CANTOPEN/);++++ dbUp = true; mediaUp = true;++ const ok = createReadiness({ service: 'x', checks: [{ name: 'db', check: () => 1 }, { name: 'falsey', required: false, check: () => false }] });++ const res2 = { setHeader() {}, end(b) { this.body = b; } };++ await ok.handler({}, res2);++ assert.strictEqual(res2.statusCode, 200);++ assert.strictEqual(JSON.parse(res2.body).checks.falsey.error, 'check returned false');++++ const none = await createReadiness({ service: 'x' }).run();++ assert.strictEqual(none.ready, true);++ assert.strictEqual(none.status, 'ready');++++ assert.strictEqual(safeReason(Object.assign(new Error('x'), { name: 'AbortError' })), 'timeout');++ assert.strictEqual(safeReason('bad token=abc123&x=1'), 'bad token=…&x=1');++ console.log('ready: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -24,4 +24,4 @@ jobs:
@@ -24,4 +24,4 @@ jobs:
git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
+ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.3.0 — 2026-09-23
+
+Observability and readiness (roadmap Track O, §15.19). Additive only; both modules are Node-only
+and have no dependencies.
+
+- `openvibe-shared/metrics`: a Prometheus text-format registry (counter, gauge with optional
+ scrape-time `collect()`, histogram with fixed buckets) that caps every metric's label
+ combinations (past `maxSeries` they fold into one `_overflow` series, counted by
+ `metrics_series_overflow_total`). `instrument(app, { service, release })` mounts the golden-signal
+ middleware — `http_requests_total{method,route,status_class}`,
+ `http_request_duration_seconds{method,route}`, `http_requests_in_flight` — keyed by the route
+ TEMPLATE (`req.baseUrl` with id-like segments as `:id` + `req.route.path`, or a caller's
+ `normalize(req)`; unmatched requests are `unmatched`, never the raw URL), process metrics
+ (`process_resident_memory_bytes`, `nodejs_heap_*`, `nodejs_eventloop_lag_seconds{stat}`,
+ `process_cpu_seconds_total`, `process_uptime_seconds`), `release_info{service,release}`, and
+ `GET /metrics`, which answers only a direct loopback caller (127.0.0.1/::1 with no
+ `X-Forwarded-For`, `X-Real-IP`, `Forwarded` or `CF-Connecting-IP`) and 404s everyone else.
+- `openvibe-shared/ready`: `createReadiness({ service, release, checks, details })` → `handler` and
+ `run()`. Each named check reports `status`, `required`, `latency_ms` and `checked_at` (a cached
+ check keeps the time it really ran). `ready` is false (HTTP 503) only when a required check fails;
+ failed optional checks are listed in `degraded` and the service stays ready (`status:
+ "degraded"`). Failure reasons are one line with URL credentials, query strings and
+ `token=`/`key=` values removed.
+
## 1.2.1 — 2026-09-23
## 1.2.1 — 2026-09-23
- `release-watch.js` never reloads a tab that is playing `<video>`/`<audio>` or holds a live
- `release-watch.js` never reloads a tab that is playing `<video>`/`<audio>` or holds a live
@@ -18,7 +18,7 @@ were reconciled first.
@@ -18,7 +18,7 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `openvibe-sw.js` |
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `openvibe-sw.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics`, `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`; `footer` and `icons` (= `ov-icons.js`) work on both sides |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics`, `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `release`, `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
Every module file has an `exports` entry, so `require('openvibe-shared/navbar')`,
Every module file has an `exports` entry, so `require('openvibe-shared/navbar')`,
@@ -44,6 +44,29 @@ const seo = require('openvibe-shared/seo');
@@ -44,6 +44,29 @@ const seo = require('openvibe-shared/seo');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
```
```
+### Metrics and readiness (server)
+
+```js
+const release = require('openvibe-shared/release').createRelease({ service: 'community' });
+const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
+// before any route: HTTP golden signals by route template, process metrics, release_info,
+// and GET /metrics for direct loopback callers only (404 through a proxy)
+m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], collect: () => rows });
+
+const ready = require('openvibe-shared/ready').createReadiness({
+ service: 'community', release: release.release,
+ checks: [
+ { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },
+ { name: 'media', required: false, cacheMs: 30000, check: async () => (await ping()) || 'unreachable' },
+ ],
+});
+app.get('/api/ready', ready.handler); // 503 only when a required check fails; optional → degraded
+```
+
+A check fails when it throws, times out, returns `false`, a string (the reason) or
+`{ ok: false, error }`. Name a dependency required only when the service really cannot serve
+without it.
+
### Browser side: two options
### Browser side: two options
1. **From the Network** (what most pages do today):
1. **From the Network** (what most pages do today):
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.0.0",
+ "version": "1.3.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"updated": "2026-09-22"
"updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,364 @@
@@ -0,0 +1,364 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/metrics — Prometheus text-format metrics with no dependencies (roadmap Track O,
+// §15.19): a registry (counter, gauge, histogram), golden-signal HTTP middleware keyed by ROUTE
+// TEMPLATE, process metrics, release_info, and a /metrics handler only loopback callers reach.
+//
+// const metrics = require('openvibe-shared/metrics');
+// const m = metrics.instrument(app, { service: 'community', release: release.release });
+// // mounted: the HTTP middleware (call this before any route) and GET /metrics (loopback only)
+// const depth = m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'],
+// collect: () => db.prepare('SELECT state, COUNT(*) n FROM jobs GROUP BY state').all()
+// .map(r => ({ labels: { state: r.state }, value: r.n })) });
+//
+// Route labels are templates, never raw URLs: `req.baseUrl` (with id-like segments replaced by
+// `:id`) + `req.route.path`, or what `normalize(req)` returns. A request no route matched is
+// `unmatched`. Every metric caps its label combinations (`maxSeries`); past the cap new
+// combinations collapse into one `_overflow` series and metrics_series_overflow_total counts them.
+// ═══════════════════════════════════════════════════════════════
+
+const NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
+const LABEL_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+const DEFAULT_BUCKETS = Object.freeze([0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]);
+const DEFAULT_MAX_SERIES = 500;
+const OVERFLOW = '_overflow';
+const MAX_LABEL_VALUE = 120;
+
+const escHelp = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
+const escValue = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
+function fmt(n) {
+ if (n === Infinity) return '+Inf';
+ if (n === -Infinity) return '-Inf';
+ if (Number.isNaN(n)) return 'NaN';
+ return String(n);
+}
+const labelText = (names, values) => (names.length ? `{${names.map((n, i) => `${n}="${escValue(values[i])}"`).join(',')}}` : '');
+
+function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
+ const metrics = new Map();
+ let overflow = null; // counter, created on first use so an untroubled registry stays quiet
+
+ function noteOverflow(name) {
+ if (!overflow) overflow = counter({ name: 'metrics_series_overflow_total', help: 'Label combinations collapsed into the _overflow series because a metric reached its series cap', labelNames: ['metric'] });
+ overflow.inc({ metric: name });
+ }
+
+ function base(kind, { name, help, labelNames = [], maxSeries: cap = maxSeries }) {
+ if (!NAME_RE.test(String(name || ''))) throw new TypeError(`metrics: invalid metric name ${name}`);
+ if (metrics.has(name)) throw new Error(`metrics: ${name} is already registered`);
+ if (!help) throw new TypeError(`metrics: ${name} needs help text`);
+ for (const l of labelNames) {
+ if (!LABEL_RE.test(l) || l.startsWith('__') || (kind === 'histogram' && l === 'le')) throw new TypeError(`metrics: invalid label ${l} on ${name}`);
+ }
+ const series = new Map(); // key -> { values: [..], ...state }
+ function key(labels = {}) {
+ const values = labelNames.map((l) => {
+ const v = labels[l];
+ return v === undefined || v === null ? '' : String(v).slice(0, MAX_LABEL_VALUE);
+ });
+ return { k: values.join('\u0000'), values };
+ }
+ function get(labels, init) {
+ let { k, values } = key(labels);
+ let s = series.get(k);
+ if (s) return s;
+ if (series.size >= cap && labelNames.length) {
+ noteOverflow(name);
+ values = labelNames.map(() => OVERFLOW);
+ k = values.join('\u0000');
+ s = series.get(k);
+ if (s) return s;
+ }
+ s = { values, ...init() };
+ series.set(k, s);
+ return s;
+ }
+ return { name, help, labelNames, series, get, kind };
+ }
+
+ function counter(opts) {
+ const m = base('counter', opts);
+ const api = {
+ name: m.name,
+ inc(labels, value = 1) {
+ if (typeof labels === 'number') { value = labels; labels = {}; }
+ if (!(value >= 0)) throw new RangeError(`metrics: ${m.name} can only increase`);
+ m.get(labels, () => ({ value: 0 })).value += value;
+ },
+ get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: 0 }).value,
+ reset: () => m.series.clear(),
+ render() {
+ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} counter`];
+ for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);
+ return out;
+ },
+ };
+ metrics.set(m.name, api);
+ return api;
+ }
+
+ // collect(): a number (no labels) or [{ labels, value }] read at scrape time. A throwing collect
+ // leaves the gauge out of that scrape rather than reporting a stale or invented value.
+ function gauge(opts) {
+ const m = base('gauge', opts);
+ const collect = typeof opts.collect === 'function' ? opts.collect : null;
+ const api = {
+ name: m.name,
+ set(labels, value) {
+ if (typeof labels === 'number') { value = labels; labels = {}; }
+ m.get(labels, () => ({ value: 0 })).value = Number(value);
+ },
+ inc(labels, value = 1) {
+ if (typeof labels === 'number') { value = labels; labels = {}; }
+ m.get(labels, () => ({ value: 0 })).value += value;
+ },
+ dec(labels, value = 1) {
+ if (typeof labels === 'number') { value = labels; labels = {}; }
+ m.get(labels, () => ({ value: 0 })).value -= value;
+ },
+ get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value,
+ reset: () => m.series.clear(),
+ render() {
+ if (collect) {
+ let got;
+ try { got = collect(); } catch { return []; }
+ if (got === undefined || got === null) return [];
+ m.series.clear();
+ if (typeof got === 'number') api.set({}, got);
+ else for (const row of got) api.set(row.labels || {}, row.value);
+ }
+ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} gauge`];
+ for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);
+ return out;
+ },
+ };
+ metrics.set(m.name, api);
+ return api;
+ }
+
+ function histogram(opts) {
+ const m = base('histogram', opts);
+ const buckets = [...(opts.buckets || DEFAULT_BUCKETS)].map(Number).sort((a, b) => a - b);
+ if (!buckets.length || buckets.some((b) => !Number.isFinite(b))) throw new TypeError(`metrics: ${m.name} needs finite buckets`);
+ const init = () => ({ counts: new Array(buckets.length).fill(0), sum: 0, count: 0 });
+ const api = {
+ name: m.name,
+ buckets,
+ observe(labels, value) {
+ if (typeof labels === 'number') { value = labels; labels = {}; }
+ if (!Number.isFinite(value)) return;
+ const s = m.get(labels, init);
+ for (let i = 0; i < buckets.length; i++) if (value <= buckets[i]) s.counts[i]++;
+ s.sum += value;
+ s.count++;
+ },
+ startTimer(labels = {}) {
+ const t0 = process.hrtime.bigint();
+ return (more = {}) => {
+ const seconds = Number(process.hrtime.bigint() - t0) / 1e9;
+ api.observe({ ...labels, ...more }, seconds);
+ return seconds;
+ };
+ },
+ get: (labels) => {
+ const s = m.series.get(labelsKey(m.labelNames, labels));
+ return s ? { count: s.count, sum: s.sum, buckets: Object.fromEntries(buckets.map((b, i) => [b, s.counts[i]])) } : null;
+ },
+ reset: () => m.series.clear(),
+ render() {
+ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} histogram`];
+ const names = [...m.labelNames, 'le'];
+ for (const s of m.series.values()) {
+ buckets.forEach((b, i) => out.push(`${m.name}_bucket${labelText(names, [...s.values, fmt(b)])} ${s.counts[i]}`));
+ out.push(`${m.name}_bucket${labelText(names, [...s.values, '+Inf'])} ${s.count}`);
+ out.push(`${m.name}_sum${labelText(m.labelNames, s.values)} ${fmt(s.sum)}`);
+ out.push(`${m.name}_count${labelText(m.labelNames, s.values)} ${s.count}`);
+ }
+ return out;
+ },
+ };
+ metrics.set(m.name, api);
+ return api;
+ }
+
+ // Callbacks run just before a scrape renders (process metrics sample themselves here).
+ const beforeScrape = [];
+ function metricsText() {
+ for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } }
+ const lines = [];
+ for (const m of metrics.values()) lines.push(...m.render());
+ return lines.join('\n') + '\n';
+ }
+
+ return {
+ counter, gauge, histogram,
+ getMetric: (name) => metrics.get(name) || null,
+ onScrape: (f) => { beforeScrape.push(f); },
+ metrics: metricsText,
+ contentType: 'text/plain; version=0.0.4; charset=utf-8',
+ };
+}
+
+function labelsKey(names, labels = {}) {
+ return names.map((l) => (labels[l] === undefined || labels[l] === null ? '' : String(labels[l]).slice(0, MAX_LABEL_VALUE))).join('\u0000');
+}
+
+// ── Route templates ─────────────────────────────────────────────
+
+// A path segment that is an identifier rather than a route word: numbers, UUIDs, ULIDs, prefixed
+// subject ids (usr_…), hex digests, and long mixed tokens.
+const ID_SEGMENT = /^(?:\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26}|[a-z]{2,5}_[0-9A-Za-z]{8,}|[0-9a-f]{12,}|(?=[^/]*\d)[A-Za-z0-9_-]{16,})$/i;
+
+/** Replace id-like segments of a concrete path with `:id` (query strings are dropped). */
+function templatePath(p) {
+ const clean = String(p || '').split('?')[0];
+ return clean.split('/').map((seg) => (seg && ID_SEGMENT.test(seg) ? ':id' : seg)).join('/') || '/';
+}
+
+/** The route label of a finished request: the matched route's template, or 'unmatched'. */
+function routeLabel(req) {
+ if (!req.route || req.route.path === undefined) return 'unmatched';
+ const rp = req.route.path;
+ const tail = rp instanceof RegExp ? '(regex)' : Array.isArray(rp) ? String(rp[0]) : String(rp);
+ const base = req.baseUrl ? templatePath(req.baseUrl) : '';
+ const joined = `${base}${tail === '/' && base ? '' : tail}`;
+ return (joined || '/').slice(0, MAX_LABEL_VALUE);
+}
+
+// ── HTTP golden signals ─────────────────────────────────────────
+
+// skip(req): requests never recorded (long-lived streams such as SSE, whose "duration" is a session).
+function httpMetrics(registry, { buckets = DEFAULT_BUCKETS, normalize = null, skip = null, maxRoutes = 300 } = {}) {
+ const requests = registry.counter({ name: 'http_requests_total', help: 'HTTP requests by method, route template and status class', labelNames: ['method', 'route', 'status_class'], maxSeries: maxRoutes * 4 });
+ const duration = registry.histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration by method and route template', labelNames: ['method', 'route'], buckets, maxSeries: maxRoutes });
+ const inFlight = registry.gauge({ name: 'http_requests_in_flight', help: 'HTTP requests being served' });
+ inFlight.set(0);
+ const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
+
+ function middleware(req, res, next) {
+ try { if (skip && skip(req)) return next(); } catch { /* record it */ }
+ const t0 = process.hrtime.bigint();
+ inFlight.inc();
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ inFlight.dec();
+ let route;
+ try { route = normalize ? normalize(req, res) : null; } catch { route = null; }
+ if (!route) route = routeLabel(req);
+ const method = METHODS.has(req.method) ? req.method : 'OTHER';
+ // Closed before the response finished: the client went away (or a stream was cut).
+ const status = res.writableFinished || res.finished ? `${Math.floor((res.statusCode || 0) / 100)}xx` : 'aborted';
+ requests.inc({ method, route, status_class: status });
+ duration.observe({ method, route }, Number(process.hrtime.bigint() - t0) / 1e9);
+ };
+ res.once('finish', finish);
+ res.once('close', finish);
+ next();
+ }
+ return { middleware, requests, duration, inFlight };
+}
+
+// ── Process metrics ─────────────────────────────────────────────
+
+function processMetrics(registry, { eventLoopResolutionMs = 20 } = {}) {
+ let loop = null;
+ try {
+ loop = require('perf_hooks').monitorEventLoopDelay({ resolution: eventLoopResolutionMs });
+ loop.enable();
+ } catch { loop = null; }
+ const startSeconds = Math.round((Date.now() - process.uptime() * 1000) / 1000);
+ const rss = registry.gauge({ name: 'process_resident_memory_bytes', help: 'Resident set size in bytes' });
+ const heapUsed = registry.gauge({ name: 'nodejs_heap_used_bytes', help: 'V8 heap in use in bytes' });
+ const heapTotal = registry.gauge({ name: 'nodejs_heap_total_bytes', help: 'V8 heap allocated in bytes' });
+ const cpu = registry.gauge({ name: 'process_cpu_seconds_total', help: 'User and system CPU time spent, in seconds' });
+ const uptime = registry.gauge({ name: 'process_uptime_seconds', help: 'Seconds since the process started' });
+ registry.gauge({ name: 'process_start_time_seconds', help: 'Start time of the process since the Unix epoch, in seconds' }).set(startSeconds);
+ const lag = loop && registry.gauge({ name: 'nodejs_eventloop_lag_seconds', help: 'Event-loop delay since the previous scrape', labelNames: ['stat'] });
+ registry.onScrape(() => {
+ const mem = process.memoryUsage();
+ rss.set(mem.rss);
+ heapUsed.set(mem.heapUsed);
+ heapTotal.set(mem.heapTotal);
+ const c = process.cpuUsage();
+ cpu.set((c.user + c.system) / 1e6);
+ uptime.set(Math.round(process.uptime()));
+ if (loop && lag) {
+ // The histogram is in nanoseconds; with no samples yet it reports NaN/huge values.
+ const ok = loop.count > 0 || loop.max > 0;
+ lag.set({ stat: 'mean' }, ok ? loop.mean / 1e9 : 0);
+ lag.set({ stat: 'p99' }, ok ? loop.percentile(99) / 1e9 : 0);
+ lag.set({ stat: 'max' }, ok ? loop.max / 1e9 : 0);
+ loop.reset();
+ }
+ });
+ return { stop: () => { if (loop) loop.disable(); } };
+}
+
+/** release_info{service,release} 1 — the deployed release, from openvibe-shared/release. */
+function releaseInfo(registry, { service, release }) {
+ const g = registry.gauge({ name: 'release_info', help: 'The release this process runs (value is always 1)', labelNames: ['service', 'release'] });
+ g.set({ service: String(service || 'unknown'), release: String(release || 'unknown') }, 1);
+ return g;
+}
+
+// ── /metrics ───────────────────────────────────────────────────
+
+const LOOPBACK = /^(?:127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;
+const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded', 'cf-connecting-ip', 'true-client-ip'];
+
+/** True only for a direct loopback connection that no proxy relayed. */
+function isLoopbackDirect(req) {
+ const addr = String((req.socket && req.socket.remoteAddress) || (req.connection && req.connection.remoteAddress) || '');
+ if (!LOOPBACK.test(addr)) return false;
+ const h = req.headers || {};
+ return !PROXY_HEADERS.some((name) => h[name] !== undefined);
+}
+
+function metricsHandler(registry) {
+ return (req, res) => {
+ if (!isLoopbackDirect(req)) {
+ res.statusCode = 404;
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ return res.end('{"error":"Not found"}');
+ }
+ let body;
+ try { body = registry.metrics(); } catch (err) {
+ res.statusCode = 500;
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
+ return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`);
+ }
+ res.statusCode = 200;
+ res.setHeader('Content-Type', registry.contentType);
+ res.setHeader('Cache-Control', 'no-store');
+ res.end(body);
+ };
+}
+
+/**
+ * Everything at once, for an Express app: a registry with process metrics and release_info, the HTTP
+ * middleware mounted first, and GET <path> (default /metrics) for loopback callers.
+ * Call it before any route or body parser is mounted.
+ */
+function instrument(app, { service, release, path = '/metrics', normalize = null, skip = null, buckets, maxRoutes, registry = createRegistry() } = {}) {
+ const http = httpMetrics(registry, { normalize, skip, buckets, maxRoutes });
+ const proc = processMetrics(registry);
+ releaseInfo(registry, { service, release });
+ app.use(http.middleware);
+ app.get(path, metricsHandler(registry));
+ return { registry, http, stop: proc.stop };
+}
+
+module.exports = {
+ createRegistry,
+ httpMetrics,
+ processMetrics,
+ releaseInfo,
+ metricsHandler,
+ isLoopbackDirect,
+ instrument,
+ routeLabel,
+ templatePath,
+ DEFAULT_BUCKETS,
+};
@@ -1,15 +1,64 @@
@@ -1,15 +1,64 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.0.0",
+ "version": "1.3.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.0.0",
+ "version": "1.3.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
+ },
+ "devDependencies": {
+ "express": "^4.22.3"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
@@ -18,6 +67,140 @@
@@ -18,6 +67,140 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause"
},
},
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/debug/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ecdsa-sig-formatter": {
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -27,6 +210,298 @@
@@ -27,6 +210,298 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/jsonwebtoken": {
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -112,12 +587,202 @@
@@ -112,12 +587,202 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/ms": {
"node_modules/ms": {
"version": "2.1.3",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"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",
@@ -138,6 +803,13 @@
@@ -138,6 +803,13 @@
],
],
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/semver": {
"node_modules/semver": {
"version": "7.8.5",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -149,6 +821,194 @@
@@ -149,6 +821,194 @@
"engines": {
"engines": {
"node": ">=10"
"node": ">=10"
}
}
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
}
}
}
}
}
}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.2.1",
+ "version": "1.3.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
"./island": "./island.js",
"./island": "./island.js",
"./legal": "./legal.js",
"./legal": "./legal.js",
+ "./metrics": "./metrics.js",
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
"./nav-icons": "./nav-icons.js",
"./nav-icons": "./nav-icons.js",
"./navbar": "./navbar.js",
"./navbar": "./navbar.js",
@@ -28,6 +29,7 @@
@@ -28,6 +29,7 @@
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
"./ov-mark": "./ov-mark.js",
"./ov-mark": "./ov-mark.js",
"./panels": "./panels.js",
"./panels": "./panels.js",
+ "./ready": "./ready.js",
"./release": "./release.js",
"./release": "./release.js",
"./release-watch": "./release-watch.js",
"./release-watch": "./release-watch.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
@@ -58,5 +60,8 @@
@@ -58,5 +60,8 @@
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
+ },
+ "devDependencies": {
+ "express": "^4.22.3"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/ready — a truthful readiness response (roadmap Track O, §15.19: "readiness must
+// report the capability actually served").
+//
+// const ready = require('openvibe-shared/ready').createReadiness({
+// service: 'community', release: release.release,
+// checks: [
+// { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },
+// { name: 'network_jwks', required: true, check: () => keys.loaded() || 'not loaded yet' },
+// { name: 'media', required: false, cacheMs: 30000, check: async () => { … } },
+// ],
+// details: () => ({ queue_depth: store.depth() }), // optional extra fields
+// });
+// app.get('/api/ready', ready.handler);
+//
+// Every check runs on each request (or from its cache, keeping the time it really ran) and reports
+// status, latency_ms and checked_at. A check passes when it returns anything but `false` or a string
+// (`false`/a string is a failure; the string is the reason) or `{ ok: false, detail }`, and does not
+// throw or time out. Overall `ready` is false only when a REQUIRED check fails (HTTP 503); a failed
+// optional check keeps the service ready (HTTP 200) and is listed in `degraded`, so a missing optional
+// dependency never reads as "healthy" or as "down".
+// ═══════════════════════════════════════════════════════════════
+
+const NAME_RE = /^[a-z][a-z0-9_.-]{0,63}$/;
+
+/** A failure reason safe to show publicly: one line, no URL query strings or credentials, short. */
+function safeReason(v) {
+ let s = v instanceof Error ? (v.name === 'TimeoutError' || v.name === 'AbortError' ? 'timeout' : v.message) : String(v);
+ s = s.split('\n')[0]
+ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1') // user:pass@
+ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#][^\s]*/gi, '$1') // ?query / #fragment
+ .replace(/\b(?:key|token|secret|password|signature)=[^\s&]+/gi, (m) => `${m.split('=')[0]}=…`);
+ return s.slice(0, 200) || 'failed';
+}
+
+function withTimeout(promise, ms) {
+ let t;
+ const timer = new Promise((_, reject) => { t = setTimeout(() => reject(Object.assign(new Error('timeout'), { name: 'TimeoutError' })), ms); });
+ return Promise.race([promise, timer]).finally(() => clearTimeout(t));
+}
+
+function createReadiness({ service, release = null, checks = [], details = null, timeoutMs = 2000, now = () => new Date() } = {}) {
+ if (!service) throw new TypeError('createReadiness: service required');
+ const list = checks.map((c) => {
+ if (!NAME_RE.test(String(c.name || ''))) throw new TypeError(`createReadiness: bad check name ${c.name}`);
+ if (typeof c.check !== 'function') throw new TypeError(`createReadiness: check ${c.name} needs a function`);
+ return { name: c.name, required: c.required !== false, check: c.check, timeoutMs: c.timeoutMs || timeoutMs, cacheMs: Math.max(0, c.cacheMs || 0), description: c.description || null };
+ });
+ const names = new Set();
+ for (const c of list) { if (names.has(c.name)) throw new Error(`createReadiness: duplicate check ${c.name}`); names.add(c.name); }
+ const cache = new Map(); // name -> { at: ms, result }
+ const inflight = new Map();
+
+ async function runOne(c) {
+ const hit = cache.get(c.name);
+ if (hit && c.cacheMs && Date.now() - hit.at < c.cacheMs) return hit.result;
+ if (inflight.has(c.name)) return inflight.get(c.name);
+ const p = (async () => {
+ const t0 = process.hrtime.bigint();
+ const out = { status: 'ok', required: c.required };
+ try {
+ const v = await withTimeout(Promise.resolve().then(() => c.check()), c.timeoutMs);
+ if (v === false) { out.status = 'fail'; out.error = 'check returned false'; }
+ else if (typeof v === 'string') { out.status = 'fail'; out.error = safeReason(v); }
+ else if (v && typeof v === 'object' && !Array.isArray(v)) {
+ if (v.ok === false) { out.status = 'fail'; out.error = safeReason(v.error || v.detail || 'failed'); }
+ if (v.detail !== undefined && (v.ok !== false || typeof v.detail === 'object')) out.detail = v.detail;
+ }
+ } catch (err) {
+ out.status = 'fail';
+ out.error = safeReason(err);
+ }
+ out.latency_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e5) / 10;
+ out.checked_at = now().toISOString();
+ if (c.description) out.description = c.description;
+ cache.set(c.name, { at: Date.now(), result: out });
+ return out;
+ })().finally(() => inflight.delete(c.name));
+ inflight.set(c.name, p);
+ return p;
+ }
+
+ async function run() {
+ const results = await Promise.all(list.map(runOne));
+ const out = {};
+ const failed = []; const degraded = [];
+ list.forEach((c, i) => {
+ out[c.name] = results[i];
+ if (results[i].status !== 'ok') (c.required ? failed : degraded).push(c.name);
+ });
+ const ready = failed.length === 0;
+ const body = {
+ ready,
+ status: !ready ? 'not_ready' : degraded.length ? 'degraded' : 'ready',
+ service,
+ release: typeof release === 'function' ? release() : release,
+ checked_at: now().toISOString(),
+ failed,
+ degraded,
+ checks: out,
+ };
+ if (details) {
+ try {
+ const extra = await withTimeout(Promise.resolve().then(() => details(body)), timeoutMs);
+ if (extra && typeof extra === 'object') for (const [k, v] of Object.entries(extra)) if (!(k in body)) body[k] = v;
+ } catch (err) { body.details_error = safeReason(err); }
+ }
+ return body;
+ }
+
+ async function handler(_req, res) {
+ let body;
+ try { body = await run(); } catch (err) {
+ body = { ready: false, status: 'not_ready', service, checked_at: now().toISOString(), failed: ['readiness'], degraded: [], checks: {}, error: safeReason(err) };
+ }
+ res.statusCode = body.ready ? 200 : 503;
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ res.end(JSON.stringify(body));
+ }
+
+ return { run, handler, checks: list.map((c) => ({ name: c.name, required: c.required })) };
+}
+
+module.exports = { createReadiness, safeReason };
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+// metrics.js: Prometheus text format, cardinality guard, route templates (never raw URLs), the
+// loopback-only /metrics handler, and the golden-signal middleware on a real Express-shaped app.
+const assert = require('assert');
+const http = require('http');
+const metrics = require('../metrics');
+
+(async () => {
+ // ── Registry and text format ──
+ const r = metrics.createRegistry({ maxSeries: 3 });
+ const c = r.counter({ name: 'jobs_total', help: 'Jobs\nrun', labelNames: ['state'] });
+ c.inc({ state: 'done' });
+ c.inc({ state: 'done' }, 2);
+ c.inc({ state: 'a"b\\c\nd' });
+ assert.strictEqual(c.get({ state: 'done' }), 3);
+ assert.throws(() => c.inc({ state: 'done' }, -1), /only increase/);
+ assert.throws(() => r.counter({ name: 'jobs_total', help: 'x' }), /already registered/);
+ assert.throws(() => r.counter({ name: 'bad-name', help: 'x' }), /invalid metric name/);
+ assert.throws(() => r.histogram({ name: 'h_bad', help: 'x', labelNames: ['le'] }), /invalid label/);
+ assert.throws(() => r.gauge({ name: 'no_help' }), /help/);
+
+ const g = r.gauge({ name: 'queue_depth', help: 'Depth', labelNames: ['status'] });
+ g.set({ status: 'pending' }, 4); g.inc({ status: 'pending' }); g.dec({ status: 'pending' }, 2);
+ assert.strictEqual(g.get({ status: 'pending' }), 3);
+ const collected = r.gauge({ name: 'objects', help: 'Objects by state', labelNames: ['state'], collect: () => [{ labels: { state: 'local' }, value: 7 }, { labels: { state: 'b2' }, value: 2 }] });
+ r.gauge({ name: 'broken', help: 'Collect throws', collect: () => { throw new Error('db gone'); } });
+ r.gauge({ name: 'scalar', help: 'A plain number', collect: () => 5 });
+ assert.ok(collected);
+
+ const h = r.histogram({ name: 'lat_seconds', help: 'Latency', labelNames: ['op'], buckets: [0.1, 1] });
+ h.observe({ op: 'x' }, 0.05); h.observe({ op: 'x' }, 0.5); h.observe({ op: 'x' }, 3); h.observe({ op: 'x' }, NaN);
+ assert.deepStrictEqual(h.get({ op: 'x' }), { count: 3, sum: 3.55, buckets: { 0.1: 1, 1: 2 } });
+ const end = h.startTimer({ op: 'y' });
+ assert.ok(end() >= 0);
+
+ let text = r.metrics();
+ assert.ok(text.endsWith('\n'));
+ assert.ok(text.includes('# HELP jobs_total Jobs\\nrun\n# TYPE jobs_total counter\n'));
+ assert.ok(text.includes('jobs_total{state="done"} 3\n'));
+ assert.ok(text.includes('jobs_total{state="a\\"b\\\\c\\nd"} 1\n'), 'label values are escaped');
+ assert.ok(text.includes('queue_depth{status="pending"} 3\n'));
+ assert.ok(text.includes('objects{state="local"} 7\nobjects{state="b2"} 2\n'));
+ assert.ok(text.includes('scalar 5\n'));
+ assert.ok(!text.includes('broken'), 'a throwing collect is left out, never reported stale');
+ assert.ok(text.includes('lat_seconds_bucket{op="x",le="0.1"} 1\nlat_seconds_bucket{op="x",le="1"} 2\nlat_seconds_bucket{op="x",le="+Inf"} 3\nlat_seconds_sum{op="x"} 3.55\nlat_seconds_count{op="x"} 3\n'));
+
+ // Cardinality guard: past maxSeries, new combinations fold into one _overflow series.
+ for (let i = 0; i < 50; i++) c.inc({ state: `s${i}` });
+ text = r.metrics();
+ assert.strictEqual((text.match(/^jobs_total\{/gm) || []).length, 4, 'three series plus _overflow');
+ assert.ok(text.includes('jobs_total{state="_overflow"} 49\n'));
+ assert.ok(text.includes('metrics_series_overflow_total{metric="jobs_total"} 49\n'));
+
+ // ── Route templates ──
+ assert.strictEqual(metrics.templatePath('/api/v1/pastes/12345?x=1'), '/api/v1/pastes/:id');
+ assert.strictEqual(metrics.templatePath('/u/usr_01J9ZX4YQ0ABCDEF/x'), '/u/:id/x');
+ assert.strictEqual(metrics.templatePath('/o/0f8fad5b-d9cb-469f-a165-70867728950e'), '/o/:id');
+ assert.strictEqual(metrics.templatePath('/o/01J9ZX4YQ0ABCDEFGHJKMNPQRS'), '/o/:id');
+ assert.strictEqual(metrics.templatePath('/api/v1/live/vods'), '/api/v1/live/vods', 'route words stay');
+ assert.strictEqual(metrics.routeLabel({ route: { path: '/:id' }, baseUrl: '/api/v1/live/vods' }), '/api/v1/live/vods/:id');
+ assert.strictEqual(metrics.routeLabel({ route: { path: '/' }, baseUrl: '/api/things' }), '/api/things');
+ assert.strictEqual(metrics.routeLabel({ route: { path: '/' }, baseUrl: '' }), '/');
+ assert.strictEqual(metrics.routeLabel({ route: { path: /^\/x/ }, baseUrl: '' }), '(regex)');
+ assert.strictEqual(metrics.routeLabel({ route: { path: ['/a', '/b'] }, baseUrl: '' }), '/a');
+ assert.strictEqual(metrics.routeLabel({ baseUrl: '/secret/123', originalUrl: '/secret/123?q=1' }), 'unmatched');
+
+ // ── Loopback-only guard ──
+ const req = (addr, headers = {}) => ({ socket: { remoteAddress: addr }, headers });
+ assert.ok(metrics.isLoopbackDirect(req('127.0.0.1')));
+ assert.ok(metrics.isLoopbackDirect(req('::1')));
+ assert.ok(metrics.isLoopbackDirect(req('::ffff:127.0.0.1')));
+ assert.ok(!metrics.isLoopbackDirect(req('10.0.0.2')));
+ assert.ok(!metrics.isLoopbackDirect(req('::ffff:10.0.0.2')));
+ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { 'x-forwarded-for': '1.2.3.4' })), 'nginx on loopback is not a local caller');
+ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { 'x-real-ip': '1.2.3.4' })));
+ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { forwarded: 'for=1.2.3.4' })));
+
+ // ── The whole thing on a real server (a minimal Express-compatible router stand-in is not
+ // enough for req.route; use express when it is installed, else a hand-rolled app) ──
+ let express = null;
+ try { express = require('express'); } catch { /* not a dependency of this package */ }
+ if (express) {
+ const app = express();
+ const m = metrics.instrument(app, { service: 'shared-test', release: 'abc1234', skip: (q) => q.path === '/sse' });
+ const router = express.Router();
+ router.get('/:id', (q, s) => s.status(q.params.id === '0' ? 404 : 200).json({ id: q.params.id }));
+ app.use('/api/items', router);
+ app.get('/boom', () => { throw new Error('x'); });
+ app.get('/sse', (_q, s) => s.end('ok'));
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = (p, headers = {}) => new Promise((resolve, reject) => http.get(base + p, { headers }, (res) => { let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b, type: res.headers['content-type'] })); }).on('error', reject));
+ await get('/api/items/42?token=secret');
+ await get('/api/items/43');
+ await get('/api/items/0');
+ await get('/boom');
+ await get('/sse');
+ await get('/nope/9999/secret-path');
+ const out = await get('/metrics');
+ assert.strictEqual(out.status, 200);
+ assert.match(out.type, /^text\/plain; version=0\.0\.4/);
+ assert.ok(out.body.includes('http_requests_total{method="GET",route="/api/items/:id",status_class="2xx"} 2\n'), out.body);
+ assert.ok(out.body.includes('http_requests_total{method="GET",route="/api/items/:id",status_class="4xx"} 1\n'));
+ assert.ok(out.body.includes('http_requests_total{method="GET",route="/boom",status_class="5xx"} 1\n'));
+ assert.ok(out.body.includes('http_requests_total{method="GET",route="unmatched",status_class="4xx"} 1\n'));
+ const labelSets = out.body.split('\n').filter((l) => l.startsWith('http_')).map((l) => (l.match(/\{[^}]*\}/) || [''])[0]).join('\n');
+ assert.ok(!/42|43|secret|9999|nope/.test(labelSets), 'no ids, queries or raw paths in labels');
+ assert.ok(!out.body.includes('route="/sse"'), 'skipped requests are not recorded');
+ assert.ok(out.body.includes('http_request_duration_seconds_count{method="GET",route="/api/items/:id"} 3\n'));
+ assert.ok(/http_requests_in_flight 1\n/.test(out.body), 'the scrape itself is in flight');
+ assert.ok(out.body.includes('release_info{service="shared-test",release="abc1234"} 1\n'));
+ for (const n of ['process_resident_memory_bytes', 'nodejs_heap_used_bytes', 'process_uptime_seconds', 'process_cpu_seconds_total', 'nodejs_eventloop_lag_seconds{stat="p99"}']) assert.ok(out.body.includes(`\n${n} `), n);
+ const proxied = await get('/metrics', { 'X-Forwarded-For': '203.0.113.9' });
+ assert.strictEqual(proxied.status, 404, 'a proxied caller gets 404');
+ m.stop();
+ server.close();
+ } else {
+ console.log('metrics: express not installed; middleware checked through the handler only');
+ }
+
+ // Handler without express: 404 to non-loopback, text to loopback.
+ const reg = metrics.createRegistry();
+ metrics.releaseInfo(reg, { service: 's', release: 'r' });
+ const handler = metrics.metricsHandler(reg);
+ const fakeRes = () => { const o = { headers: {}, statusCode: 0, body: '' }; o.setHeader = (k, v) => { o.headers[k] = v; }; o.end = (b) => { o.body = b; }; return o; };
+ let res = fakeRes(); handler(req('192.0.2.1'), res);
+ assert.strictEqual(res.statusCode, 404);
+ res = fakeRes(); handler(req('127.0.0.1'), res);
+ assert.strictEqual(res.statusCode, 200);
+ assert.ok(res.body.includes('release_info{service="s",release="r"} 1'));
+ console.log('metrics: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -19,7 +19,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
@@ -19,7 +19,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
}
}
// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
-for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons']) require(`openvibe-shared/${sub}`);
+for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready']) require(`openvibe-shared/${sub}`);
assert.strictEqual(typeof require('openvibe-shared/navbar').init, 'function');
assert.strictEqual(typeof require('openvibe-shared/navbar').init, 'function');
assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), 'semver version');
assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), 'semver version');
assert.ok(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').includes(`## ${pkg.version}`), 'CHANGELOG has an entry for this version');
assert.ok(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').includes(`## ${pkg.version}`), 'CHANGELOG has an entry for this version');
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+// ready.js: required vs optional checks, status/latency/checked_at on every check, timeouts,
+// cached checks keep the time they really ran, and failure reasons never leak secrets.
+const assert = require('assert');
+const { createReadiness, safeReason } = require('../ready');
+
+(async () => {
+ assert.throws(() => createReadiness({}), /service/);
+ assert.throws(() => createReadiness({ service: 's', checks: [{ name: 'Bad Name', check: () => true }] }), /bad check name/);
+ assert.throws(() => createReadiness({ service: 's', checks: [{ name: 'db', check: () => true }, { name: 'db', check: () => true }] }), /duplicate/);
+
+ let dbUp = true; let mediaUp = true; let remoteCalls = 0;
+ const ready = createReadiness({
+ service: 'community',
+ release: 'abc1234',
+ checks: [
+ { name: 'db', required: true, check: () => { if (!dbUp) throw new Error('SQLITE_CANTOPEN: unable to open database file'); return true; } },
+ { name: 'network_jwks', required: true, check: () => ({ ok: true, detail: { kid: 'ov-network-1' } }) },
+ { name: 'media', required: false, check: () => (mediaUp ? true : 'Media answered 502') },
+ { name: 'remote_tier', required: false, cacheMs: 60000, check: async () => { remoteCalls++; return { ok: false, error: 'GET https://user:[email protected]/bucket?X-Amz-Signature=abc failed' }; } },
+ { name: 'slow', required: false, timeoutMs: 30, check: () => new Promise((resolve) => setTimeout(resolve, 500)) },
+ ],
+ details: () => ({ queue: 3, ready: 'ignored: never overrides a computed field' }),
+ });
+
+ let body = await ready.run();
+ assert.strictEqual(body.ready, true, 'optional failures keep the service ready');
+ assert.strictEqual(body.status, 'degraded');
+ assert.deepStrictEqual(body.failed, []);
+ assert.deepStrictEqual(body.degraded, ['remote_tier', 'slow']);
+ assert.strictEqual(body.service, 'community');
+ assert.strictEqual(body.release, 'abc1234');
+ assert.strictEqual(body.queue, 3);
+ for (const [name, c] of Object.entries(body.checks)) {
+ assert.ok(['ok', 'fail'].includes(c.status), name);
+ assert.strictEqual(typeof c.latency_ms, 'number', `${name} latency`);
+ assert.ok(!Number.isNaN(Date.parse(c.checked_at)), `${name} checked_at`);
+ assert.strictEqual(typeof c.required, 'boolean');
+ }
+ assert.deepStrictEqual(body.checks.network_jwks.detail, { kid: 'ov-network-1' });
+ assert.strictEqual(body.checks.slow.error, 'timeout');
+ const leaked = body.checks.remote_tier.error;
+ assert.ok(!/pw@|Signature=abc|\?/.test(leaked), `no credentials or query in reasons: ${leaked}`);
+ assert.ok(leaked.includes('https://b2.example/bucket'));
+
+ // Cached optional check: not re-run, and still reports when it actually ran.
+ const firstAt = body.checks.remote_tier.checked_at;
+ await new Promise((r) => setTimeout(r, 5));
+ body = await ready.run();
+ assert.strictEqual(remoteCalls, 1);
+ assert.strictEqual(body.checks.remote_tier.checked_at, firstAt);
+
+ // All healthy apart from the cached remote tier.
+ mediaUp = false;
+ body = await ready.run();
+ assert.ok(body.degraded.includes('media'));
+ assert.strictEqual(body.checks.media.error, 'Media answered 502');
+
+ // A required failure is not ready, with the reason.
+ dbUp = false;
+ const res = { headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; } };
+ await ready.handler({}, res);
+ assert.strictEqual(res.statusCode, 503);
+ assert.strictEqual(res.headers['Cache-Control'], 'no-store');
+ body = JSON.parse(res.body);
+ assert.strictEqual(body.ready, false);
+ assert.strictEqual(body.status, 'not_ready');
+ assert.deepStrictEqual(body.failed, ['db']);
+ assert.match(body.checks.db.error, /SQLITE_CANTOPEN/);
+
+ dbUp = true; mediaUp = true;
+ const ok = createReadiness({ service: 'x', checks: [{ name: 'db', check: () => 1 }, { name: 'falsey', required: false, check: () => false }] });
+ const res2 = { setHeader() {}, end(b) { this.body = b; } };
+ await ok.handler({}, res2);
+ assert.strictEqual(res2.statusCode, 200);
+ assert.strictEqual(JSON.parse(res2.body).checks.falsey.error, 'check returned false');
+
+ const none = await createReadiness({ service: 'x' }).run();
+ assert.strictEqual(none.ready, true);
+ assert.strictEqual(none.status, 'ready');
+
+ assert.strictEqual(safeReason(Object.assign(new Error('x'), { name: 'AbortError' })), 'timeout');
+ assert.strictEqual(safeReason('bad token=abc123&x=1'), 'bad token=…&x=1');
+ console.log('ready: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });