openvibers/openvibe.tools Commits

Metrics and truthful readiness on the gateway and every satellite (Track O): openvibe-shared v1.3.0; GET /metrics for direct loopback callers only (route templates, proxy:<app> on the gateway, tools_jobs{app,state}); GET /api/ready with required vs optional checks (job store, job runtime, writable dirs; Network key, binaries, Media results, satellites and Community optional → degraded); nginx 404s /metrics

OP OpenVibers committed 23 Sept 2026 · 70e9a19058 · 38 files, +627 −54


Changes

ddiff --git a/README.md b/README.md
iindex 5af4311..b514cc4 100644
---- a/README.md
++++ b/README.md
@@@ -119,6 +119,33 @@ through Network's registry, `GET /api/v1/registry/services` (fetched on boot and
answer is kept). Until it answers, a local list is used and `/api/catalog.json` says `services.source: "fallback"`.
Services the registry marks `placeholder` or `retired` are not linked. `OV_REGISTRY_URL` overrides the URL.
++## Metrics and readiness
++
++Every server (gateway and satellites) mounts `apps/_shared/observe.js` with `openvibe-shared/metrics` and
++`openvibe-shared/ready`:
++
++- `GET /metrics` — Prometheus text for **direct loopback callers only** (127.0.0.1/::1 with no
++ `X-Forwarded-For`/`X-Real-IP`); anything through nginx or the gateway gets 404, and each nginx server block
++ also answers `location = /metrics` with 404. HTTP golden signals are labelled by route template
++ (`/api/v1/jobs/:id`), proxied satellite traffic on the gateway by satellite (`proxy:img`), SPA pages and
++ static files by a fixed label — never the raw URL. `release_info{service="tools"|"tools-<app>"}`, process
++ metrics, and on img/audio/docs `tools_jobs{app,state}` (a group-by on jobs.db) and
++ `tools_jobs_executing{app,kind="executing"|"limit"}`.
++- `GET /api/ready` — named checks with `status`, `required`, `latency_ms`, `checked_at`; HTTP 503 only when a
++ required check fails, otherwise 200 with failed optional checks listed in `degraded`. `/api/health` is
++ unchanged.
++
++| Server | Required | Optional (degraded when failing) |
++|---|---|---|
++| gateway (4001) | `catalog` | `network_key`, `service_directory` (Network registry vs fallback list), `community` (`/api/ready`), `satellite_<app>` for all seven (`/api/ready`, cached 15 s) |
++| img, docs (4012, 4016) | `jobs_db` (query), `job_runtime` (worker started), `data_dir`, `uploads_dir`, `output_dir` (write test) | `analytics_db`, `network_key`, `media_results` (only with `TOOLS_JOB_RESULTS=media`) |
++| audio (4014) | same as img | same as img, plus `ffmpeg` on PATH |
++| yt (4013) | `downloads_dir` | `analytics_db`, `yt_dlp`, `ffmpeg`, `yt_cookies` (when `YT_COOKIES_FILE` is set), `network_key` |
++| food (4011) | `maps` (every food API is proxied to it) | `analytics_db` |
++| maps, text (4010, 4015) | — | `analytics_db` (maps' external data sources are not probed) |
++
++`TOOLS_SATELLITE_PORTS="img=5012,…"` overrides the gateway's satellite ports (tests, a moved unit).
++
## Tests
`npm test` (Node 22) syntax-checks every server file and runs every `apps/*/test/*.test.js`, each in its own
ddiff --git a/apps/_shared/jobs/index.js b/apps/_shared/jobs/index.js
iindex 2d3d302..7799dbd 100644
---- a/apps/_shared/jobs/index.js
++++ b/apps/_shared/jobs/index.js
@@@ -92,4 +92,25 @@ function setupJobs(o) {
return system;
}
--module.exports = { setupJobs, createJobSystem, mountJobRoutes, createOwnerResolver, createMediaResults, mediaFromEnv, mediaOrigin, contentDisposition, JobError, CAPS };
++/**
++ * Readiness checks the job runtime adds to a satellite's /api/ready (apps/_shared/observe.js):
++ * with TOOLS_JOB_RESULTS=media, whether results really go to Media and Media answers (optional —
++ * a failure there is reported as degraded, never hidden).
++ */
++function readyChecks(getSystem, env = process.env) {
++ if (String(env.TOOLS_JOB_RESULTS || 'local').toLowerCase() !== 'media') return [];
++ const url = `${String(env.OV_MEDIA_INTERNAL_URL || env.MEDIA_URL || 'http://127.0.0.1:4100').replace(/\/+$/, '')}/healthz`;
++ return [{
++ name: 'media_results', required: false, cacheMs: 15000, timeoutMs: 1500,
++ description: 'TOOLS_JOB_RESULTS=media: finished job files are uploaded to OpenVibe.Media (liveness of Media, not its readiness)',
++ check: async () => {
++ const sys = getSystem();
++ if (!sys) return 'job runtime not set up yet';
++ if (!sys.media) return 'TOOLS_JOB_RESULTS=media, but results stay local (OV_OAUTH_CLIENT_SECRET is not set)';
++ const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
++ return res.ok ? true : `Media answered HTTP ${res.status}`;
++ },
++ }];
++}
++
++module.exports = { setupJobs, readyChecks, createJobSystem, mountJobRoutes, createOwnerResolver, createMediaResults, mediaFromEnv, mediaOrigin, contentDisposition, JobError, CAPS };
ddiff --git a/apps/_shared/jobs/system.js b/apps/_shared/jobs/system.js
iindex 306726c..d975764 100644
---- a/apps/_shared/jobs/system.js
++++ b/apps/_shared/jobs/system.js
@@@ -458,7 +458,10 @@ function createJobSystem(o) {
lastSeq: (id) => store.lastSeq(id),
types: () => [...types.keys()],
media,
-- stats: () => ({ running: active.size, concurrency, ...store.counts(), results: media ? 'media' : 'local' }),
++ // running (after the spread) is the store's count of rows in 'running'; executing is this process's.
++ stats: () => ({ running: active.size, concurrency, ...store.counts(), executing: active.size, results: media ? 'media' : 'local' }),
++ /** True between start() and stop(): the worker picks up queued jobs. */
++ isRunning: () => started && !stopped,
store,
JobError,
};
ddiff --git a/apps/_shared/observe.js b/apps/_shared/observe.js
nnew file mode 100644
iindex 0000000..7bdcf95
---- /dev/null
++++ b/apps/_shared/observe.js
@@@ -0,0 +1,176 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Observability for the gateway and every satellite (roadmap Track O): Prometheus metrics at
++// GET /metrics (direct loopback callers only) and a truthful GET /api/ready built from each server's
++// real dependencies. Like the rest of apps/_shared it has no dependencies of its own: the app passes
++// openvibe-shared/metrics and openvibe-shared/ready.
++//
++// const obs = require('../../_shared/observe').observe({
++// app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++// service: 'tools-img', release: release.release,
++// checks: [ checks.sqlite('analytics_db', analyticsDb, { required: false }), … ],
++// jobs: () => jobs, // the satellite's job system, once it exists (tools_jobs{app,state})
++// });
++//
++// Call it right after `const app = express()`, before any other middleware: the HTTP metrics must
++// see every request, and /metrics and /api/ready answer before CORS, host guards and rate limits.
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const path = require('path');
++
++/**
++ * @param {object} o
++ * @param {object} o.app
++ * @param {object} o.metrics require('openvibe-shared/metrics')
++ * @param {object} o.ready require('openvibe-shared/ready')
++ * @param {string} o.service 'tools' (gateway) | 'tools-<app>'
++ * @param {string} o.release
++ * @param {object[]} [o.checks] openvibe-shared/ready checks
++ * @param {() => object|null} [o.jobs] the job system (apps/_shared/jobs), read lazily
++ * @param {Function} [o.normalize] route label for requests no Express route matched
++ * @param {Function} [o.skip] requests never recorded (long-lived streams)
++ * @param {Function} [o.details] extra readiness fields
++ * @param {boolean} [o.mountReady] false: the caller mounts readiness.handler where it belongs
++ */
++function observe(o) {
++ const app = o.app;
++ const inst = o.metrics.instrument(app, {
++ service: o.service,
++ release: o.release,
++ // Requests no route matched (static files, SPA pages, the gateway's proxied satellites) get a
++ // fixed label from the app, never their URL.
++ normalize: (req, res) => {
++ if (req.route) return null;
++ if (o.normalize) { const l = o.normalize(req, res); if (l) return l; }
++ return res.statusCode === 404 ? null : 'static';
++ },
++ // Job progress streams (SSE) last as long as the job; their duration is not a latency.
++ skip: (req) => /^\/api\/v1\/jobs\/[^/]+\/events$/.test(req.path) || (o.skip ? o.skip(req) : false),
++ });
++ const appName = o.service.replace(/^tools-?/, '') || 'gateway';
++ if (o.jobs) {
++ inst.registry.gauge({
++ name: 'tools_jobs', help: 'Jobs in this satellite\'s job store by state', labelNames: ['app', 'state'],
++ collect: () => {
++ const sys = o.jobs();
++ if (!sys) return null;
++ return Object.entries(sys.store.counts()).map(([state, n]) => ({ labels: { app: appName, state }, value: n }));
++ },
++ });
++ inst.registry.gauge({
++ name: 'tools_jobs_executing', help: 'Jobs executing in this process now, and the concurrency limit', labelNames: ['app', 'kind'],
++ collect: () => {
++ const sys = o.jobs();
++ if (!sys) return null;
++ const s = sys.stats();
++ return [{ labels: { app: appName, kind: 'executing' }, value: s.executing }, { labels: { app: appName, kind: 'limit' }, value: s.concurrency }];
++ },
++ });
++ }
++ const readiness = o.ready.createReadiness({ service: o.service, release: o.release, checks: o.checks || [], details: o.details });
++ // The gateway mounts it itself, after its host router: on a satellite's host /api/ready is the satellite's.
++ if (o.mountReady !== false) app.get('/api/ready', readiness.handler);
++ return { registry: inst.registry, readiness, stop: inst.stop };
++}
++
++// ── Check builders ──────────────────────────────────────────────
++
++const checks = {
++ /** A real query on a better-sqlite3 handle (or a function returning one). */
++ sqlite(name, db, { required = true, sql = 'SELECT 1 AS ok', description } = {}) {
++ return {
++ name, required, description,
++ check: () => {
++ const h = typeof db === 'function' ? db() : db;
++ if (!h) return 'database not open';
++ const row = h.prepare(sql).get();
++ return row ? true : 'query returned no row';
++ },
++ };
++ },
++
++ /** This process can write to the directory (a real create + remove). A missing directory is
++ * created first, as the app itself does on first use (mkdir -p). */
++ writableDir(name, dir, { required = true, description } = {}) {
++ return {
++ name, required, description,
++ check: () => {
++ fs.mkdirSync(dir, { recursive: true });
++ const p = path.join(dir, `.ready-${process.pid}-${Date.now()}`);
++ fs.writeFileSync(p, '');
++ fs.unlinkSync(p);
++ return true;
++ },
++ };
++ },
++
++ /** The job runtime: started and not stopped, its store answering (counts are a real query). */
++ jobRuntime(name, getSystem, { required = true, description = 'job runtime: worker started, jobs.db answering' } = {}) {
++ return {
++ name, required, description,
++ check: () => {
++ const sys = getSystem();
++ if (!sys) return 'job runtime not set up yet';
++ if (!sys.isRunning()) return 'job runtime is not running';
++ const s = sys.stats(); // store.counts() inside: a real query on jobs.db
++ return { ok: true, detail: { queued: s.queued, running: s.running, executing: s.executing, concurrency: s.concurrency, results: s.results } };
++ },
++ };
++ },
++
++ /** An executable on PATH (or at an explicit path). Looked up, not run. */
++ binary(name, bin, { required = false, description, env = process.env } = {}) {
++ return {
++ name, required, description, cacheMs: 60_000,
++ check: () => {
++ const found = which(bin, env);
++ return found ? { ok: true, detail: { path: found } } : `${bin} not found`;
++ },
++ };
++ },
++
++ /** A file this process must be able to read (cookies, keys). */
++ readableFile(name, file, { required = false, description } = {}) {
++ return {
++ name, required, description,
++ check: () => { fs.accessSync(file, fs.constants.R_OK); return true; },
++ };
++ },
++
++ /** The Network public key used to verify tokens offline is loaded. */
++ networkKey(name, getKey, { required = false, description } = {}) {
++ return {
++ name, required, description,
++ check: () => (getKey() ? true : 'Network public key not loaded'),
++ };
++ },
++
++ /** Another server's /api/ready (or any URL): up when it answers 2xx within the timeout. */
++ upstream(name, url, { required = false, description, cacheMs = 15_000, timeoutMs = 1500, fetchImpl = (...a) => globalThis.fetch(...a) } = {}) {
++ return {
++ name, required, description, cacheMs, timeoutMs,
++ check: async () => {
++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/json' } });
++ let body = null;
++ try { body = await res.json(); } catch { /* not JSON */ }
++ const status = body && typeof body.status === 'string' ? body.status : null;
++ if (!res.ok) return `answered HTTP ${res.status}${status ? ` (${status}${body.failed && body.failed.length ? `: ${body.failed.join(', ')}` : ''})` : ''}`;
++ return { ok: true, detail: { http_status: res.status, status: status || 'up', degraded: body && Array.isArray(body.degraded) ? body.degraded : [] } };
++ },
++ };
++ },
++};
++
++function which(bin, env = process.env) {
++ const exe = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
++ if (bin.includes('/')) return exe(bin) ? bin : null;
++ for (const dir of String(env.PATH || '').split(path.delimiter)) {
++ if (!dir) continue;
++ const p = path.join(dir, bin);
++ if (exe(p)) return p;
++ }
++ return null;
++}
++
++module.exports = { observe, checks, which };
ddiff --git a/apps/_shared/test/observe.test.js b/apps/_shared/test/observe.test.js
nnew file mode 100644
iindex 0000000..2c02163
---- /dev/null
++++ b/apps/_shared/test/observe.test.js
@@@ -0,0 +1,157 @@
++'use strict';
++// Track O for Tools, against real processes: every satellite and the gateway serve GET /metrics to
++// direct loopback callers only (route templates, never raw URLs), a truthful GET /api/ready (status,
++// latency_ms and checked_at on every check; 503 only when a required check fails), tools_jobs from
++// the job store, and the gateway reports a down satellite as degraded (200), never as down itself.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp, freePort } = require('./spawn');
++const { dep } = require('./deps');
++const { observe, checks, which } = require('../observe');
++
++const express = dep('express');
++const metrics = dep('openvibe-shared/metrics');
++const ready = dep('openvibe-shared/ready');
++
++const get = async (url, headers = {}) => { const r = await fetch(url, { headers }); return { status: r.status, text: await r.text() }; };
++const json = async (url) => { const r = await fetch(url); return { status: r.status, body: await r.json() }; };
++
++function assertShape(body, service) {
++ assert.strictEqual(body.service, service);
++ assert.strictEqual(typeof body.ready, 'boolean');
++ assert.ok(['ready', 'degraded', 'not_ready'].includes(body.status));
++ assert.ok(Array.isArray(body.failed) && Array.isArray(body.degraded));
++ for (const [name, c] of Object.entries(body.checks)) {
++ assert.ok(['ok', 'fail'].includes(c.status), `${service} ${name} status`);
++ assert.strictEqual(typeof c.required, 'boolean', `${service} ${name} required`);
++ assert.strictEqual(typeof c.latency_ms, 'number', `${service} ${name} latency_ms`);
++ assert.ok(!Number.isNaN(Date.parse(c.checked_at)), `${service} ${name} checked_at`);
++ }
++}
++
++(async () => {
++ const procs = [];
++ try {
++ // ── In process: a required failure is 503; optional failures are degraded 200 ──
++ {
++ const app = express();
++ let dbOk = true;
++ const obs = observe({
++ app, metrics, ready, service: 'tools-test', release: 'abc1234567',
++ checks: [
++ { name: 'db', required: true, check: () => (dbOk ? true : 'SQLITE_IOERR') },
++ checks.upstream('satellite_dead', `http://127.0.0.1:${await freePort()}/api/ready`),
++ checks.binary('nope', 'definitely-not-a-binary-ov'),
++ ],
++ });
++ app.get('/x/:id', (_q, s) => s.json({ ok: true }));
++ const server = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ let r = await json(`${base}/api/ready`);
++ assert.strictEqual(r.status, 200, 'optional failures keep it ready');
++ assert.strictEqual(r.body.status, 'degraded');
++ assert.deepStrictEqual(r.body.degraded.sort(), ['nope', 'satellite_dead']);
++ assertShape(r.body, 'tools-test');
++ dbOk = false;
++ r = await json(`${base}/api/ready`);
++ assert.strictEqual(r.status, 503);
++ assert.deepStrictEqual(r.body.failed, ['db']);
++ assert.strictEqual(r.body.checks.db.error, 'SQLITE_IOERR');
++ obs.stop();
++ server.close();
++ assert.ok(which('node') || which(process.execPath), 'which() finds executables');
++ }
++
++ // ── img satellite: jobs, metrics, readiness ──
++ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-obs-img-'));
++ const img = await startApp('img', { DATA_DIR: data, UPLOADS_DIR: path.join(data, 'uploads'), OUTPUT_DIR: path.join(data, 'output'), TOOLS_JOBS_CONCURRENCY_IMG: '0' });
++ procs.push(img);
++ const fd = new FormData();
++ fd.append('type', 'img.process');
++ fd.append('input', JSON.stringify({ tool: 'convert', format: 'webp' }));
++ fd.append('file', new Blob([Buffer.from('not really a png')], { type: 'image/png' }), 'a.png');
++ const sub = await fetch(`${img.base}/api/v1/jobs`, { method: 'POST', body: fd });
++ assert.strictEqual(sub.status, 202, await sub.clone().text());
++ const job = await sub.json();
++ const cookie = (sub.headers.get('set-cookie') || '').split(';')[0];
++ const seen = await fetch(`${img.base}/api/v1/jobs/${job.id}?secret=zzz`, { headers: { cookie } });
++ assert.strictEqual(seen.status, 200);
++
++ let r = await json(`${img.base}/api/ready`);
++ assertShape(r.body, 'tools-img');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ for (const n of ['jobs_db', 'job_runtime', 'data_dir', 'uploads_dir', 'output_dir']) {
++ assert.strictEqual(r.body.checks[n].required, true, `${n} is required`);
++ assert.strictEqual(r.body.checks[n].status, 'ok', `${n} ok`);
++ }
++ assert.strictEqual(r.body.checks.job_runtime.detail.queued, 1, 'the queued job is counted');
++ assert.strictEqual(r.body.checks.network_key.required, false);
++ assert.strictEqual(r.body.checks.network_key.status, 'fail', 'the test Network is unreachable, and readiness says so');
++ assert.ok(r.body.degraded.includes('network_key'));
++ assert.strictEqual(r.body.status, 'degraded');
++
++ let m = await get(`${img.base}/metrics`);
++ assert.strictEqual(m.status, 200);
++ assert.ok(m.text.includes('tools_jobs{app="img",state="queued"} 1\n'), m.text);
++ assert.ok(m.text.includes('tools_jobs_executing{app="img",kind="limit"} 0\n'));
++ assert.ok(m.text.includes('release_info{service="tools-img",'));
++ assert.ok(m.text.includes('http_requests_total{method="POST",route="/api/v1/jobs",status_class="2xx"} 1\n'));
++ assert.ok(m.text.includes('http_requests_total{method="GET",route="/api/v1/jobs/:id",status_class="2xx"} 1\n'));
++ const labels = m.text.split('\n').filter(l => l.startsWith('http_')).map(l => (l.match(/\{[^}]*\}/) || [''])[0]).join('\n');
++ assert.ok(!labels.includes(job.id) && !labels.includes('secret'), 'no job ids or queries in labels');
++ m = await get(`${img.base}/metrics`, { 'X-Forwarded-For': '203.0.113.7' });
++ assert.strictEqual(m.status, 404, 'a proxied caller never sees metrics');
++
++ // Required failure on a real satellite: its uploads directory stops being writable.
++ fs.chmodSync(path.join(data, 'uploads'), 0o500);
++ r = await json(`${img.base}/api/ready`);
++ fs.chmodSync(path.join(data, 'uploads'), 0o700);
++ if (process.getuid && process.getuid() !== 0) {
++ assert.strictEqual(r.status, 503, 'a required dependency down is not ready');
++ assert.deepStrictEqual(r.body.failed, ['uploads_dir']);
++ }
++
++ // ── Other satellites answer the same shape ──
++ for (const [app, service, extra] of [['text', 'tools-text', {}], ['yt', 'tools-yt', { DOWNLOADS_DIR: path.join(data, 'yt') }]]) {
++ fs.mkdirSync(path.join(data, 'yt'), { recursive: true });
++ const p = await startApp(app, { DATA_DIR: data, ...extra });
++ procs.push(p);
++ const x = await json(`${p.base}/api/ready`);
++ assertShape(x.body, service);
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++ const mm = await get(`${p.base}/metrics`);
++ assert.ok(mm.text.includes(`release_info{service="${service}",`));
++ }
++
++ // ── Gateway: satellites are optional checks; one up, the rest down → degraded 200 ──
++ const deadPorts = await Promise.all(['maps', 'food', 'yt', 'audio', 'text', 'docs'].map(async n => `${n}=${await freePort()}`));
++ const gwPort = await freePort();
++ const gw = await startApp('gateway', { TOOLS_SATELLITE_PORTS: [`img=${img.port}`, ...deadPorts].join(','), OV_COMMUNITY_INTERNAL_URL: `http://127.0.0.1:${await freePort()}` }, gwPort);
++ procs.push(gw);
++ r = await json(`${gw.base}/api/ready`);
++ assertShape(r.body, 'tools');
++ assert.strictEqual(r.status, 200, 'down satellites never make the gateway not ready');
++ assert.strictEqual(r.body.ready, true);
++ assert.strictEqual(r.body.status, 'degraded');
++ assert.strictEqual(r.body.checks.catalog.required, true);
++ assert.strictEqual(r.body.checks.catalog.status, 'ok');
++ assert.strictEqual(r.body.checks.satellite_img.status, 'ok');
++ assert.strictEqual(r.body.checks.satellite_img.detail.status, 'degraded', 'the satellite\'s own degradation is passed on');
++ for (const n of ['maps', 'food', 'yt', 'audio', 'text', 'docs']) {
++ assert.strictEqual(r.body.checks[`satellite_${n}`].status, 'fail', n);
++ assert.ok(r.body.degraded.includes(`satellite_${n}`));
++ }
++ assert.ok(r.body.degraded.includes('community'));
++ await fetch(`${gw.base}/api/catalog.json?x=1`);
++ m = await get(`${gw.base}/metrics`);
++ assert.strictEqual(m.status, 200);
++ assert.ok(m.text.includes('release_info{service="tools",'));
++ assert.ok(/http_requests_total\{method="GET",route="\/api\/ready",status_class="2xx"\} 1\n/.test(m.text));
++ assert.ok(!m.text.includes('x=1'));
++ console.log('observe (metrics + readiness, gateway and satellites): all checks passed');
++ } finally {
++ await Promise.all(procs.map(p => p.kill('SIGKILL')));
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/audio/deploy/nginx/audio.openvibe.tools.conf b/apps/audio/deploy/nginx/audio.openvibe.tools.conf
iindex f15310b..295943c 100644
---- a/apps/audio/deploy/nginx/audio.openvibe.tools.conf
++++ b/apps/audio/deploy/nginx/audio.openvibe.tools.conf
@@@ -71,6 +71,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
# API routes (rate limited)
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location /api/ {
limit_req zone=openvibeaudio_api burst=20 nodelay;
ddiff --git a/apps/audio/package-lock.json b/apps/audio/package-lock.json
iindex e7fab58..6aef9bd 100644
---- a/apps/audio/package-lock.json
++++ b/apps/audio/package-lock.json
@@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"uuid": "^11.1.0"
}
},
@@@ -1174,9 +1174,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/audio/package.json b/apps/audio/package.json
iindex f613b6e..0801e05 100644
---- a/apps/audio/package.json
++++ b/apps/audio/package.json
@@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"uuid": "^11.1.0"
}
}
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex cbadd3d..013d18a 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -40,7 +40,27 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio');
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
++// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
++const { observe, checks: ready } = require('../../_shared/observe');
++observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools-audio', release: release.release,
++ checks: [
++ ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
++ ready.jobRuntime('job_runtime', () => jobs),
++ ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
++ ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
++ ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
++ ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
++ ready.networkKey('network_key', auth.getPublicKey, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
++ ...jobsRuntime.readyChecks(() => jobs),
++ ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'every audio tool runs ffmpeg; without it jobs and conversions fail' }),
++ ],
++ jobs: () => jobs,
++});
++app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
ddiff --git a/apps/docs/deploy/nginx/docs.openvibe.tools.conf b/apps/docs/deploy/nginx/docs.openvibe.tools.conf
iindex 9441e01..d0d834d 100644
---- a/apps/docs/deploy/nginx/docs.openvibe.tools.conf
++++ b/apps/docs/deploy/nginx/docs.openvibe.tools.conf
@@@ -51,6 +51,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
# API routes (rate limited)
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location /api/ {
limit_req zone=openvibedocs_api burst=20 nodelay;
ddiff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json
iindex ed12f4b..d79fc6c 100644
---- a/apps/docs/package-lock.json
++++ b/apps/docs/package-lock.json
@@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
@@@ -1253,9 +1253,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/docs/package.json b/apps/docs/package.json
iindex 842e978..a8d9b6b 100644
---- a/apps/docs/package.json
++++ b/apps/docs/package.json
@@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex e4b675c..3173c57 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -38,7 +38,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs');
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
++// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
++const { observe, checks: ready } = require('../../_shared/observe');
++observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools-docs', release: release.release,
++ checks: [
++ ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
++ ready.jobRuntime('job_runtime', () => jobs),
++ ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
++ ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
++ ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
++ ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
++ ready.networkKey('network_key', auth.getPublicKey, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
++ ...jobsRuntime.readyChecks(() => jobs),
++ ],
++ jobs: () => jobs,
++});
++app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
ddiff --git a/apps/food/deploy/nginx/food.openvibe.tools.conf b/apps/food/deploy/nginx/food.openvibe.tools.conf
iindex 2e65967..aef50cf 100644
---- a/apps/food/deploy/nginx/food.openvibe.tools.conf
++++ b/apps/food/deploy/nginx/food.openvibe.tools.conf
@@@ -34,6 +34,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
# ── API (rate limited) ──────────────────────────────────
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location /api/ {
limit_req zone=openvibefood_api burst=30 nodelay;
proxy_pass http://127.0.0.1:4011;
ddiff --git a/apps/food/package-lock.json b/apps/food/package-lock.json
iindex f0cb8dd..6ee91dd 100644
---- a/apps/food/package-lock.json
++++ b/apps/food/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
}
},
"node_modules/accepts": {
@@@ -968,9 +968,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/food/package.json b/apps/food/package.json
iindex 1c18423..377d5b2 100644
---- a/apps/food/package.json
++++ b/apps/food/package.json
@@@ -16,6 +16,6 @@
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
}
}
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex 929aef1..6c725f4 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -28,7 +28,20 @@ const MAPS_API = process.env.MAPS_API || 'http://127.0.0.1:4010';
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
++// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
++const { observe, checks: ready } = require('../../_shared/observe');
++observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools-food', release: release.release,
++ checks: [
++ // Every food API answer comes from the maps satellite; without it this server only has its page.
++ ready.upstream('maps', `${MAPS_API}/api/ready`, { required: true, cacheMs: 5000, description: 'food APIs are proxied to the maps satellite' }),
++ ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only' }),
++ ],
++});
++app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
ddiff --git a/apps/gateway/deploy/nginx/openvibe.tools.conf b/apps/gateway/deploy/nginx/openvibe.tools.conf
iindex 396760f..09fe806 100644
---- a/apps/gateway/deploy/nginx/openvibe.tools.conf
++++ b/apps/gateway/deploy/nginx/openvibe.tools.conf
@@@ -41,6 +41,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
# ── OAuth session endpoints (strict rate limit) ─────────
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location /auth/ {
limit_req zone=ovtools_auth burst=10 nodelay;
proxy_pass http://127.0.0.1:4001;
ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex 20786c9..f36c2ea 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
}
},
"node_modules/accepts": {
@@@ -742,9 +742,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/gateway/package.json b/apps/gateway/package.json
iindex 0f68d30..667e9af 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -17,6 +17,6 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
}
}
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex d08faee..9dbb442 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -30,7 +30,48 @@ const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++
++// The satellites on this host (ports), for readiness and the analytics roll-up.
++// TOOLS_SATELLITE_PORTS="img=5012,yt=5013" overrides single entries (tests, a moved unit).
++const SATELLITES = { maps: 4010, food: 4011, img: 4012, yt: 4013, audio: 4014, text: 4015, docs: 4016 };
++for (const pair of String(process.env.TOOLS_SATELLITE_PORTS || '').split(',')) {
++ const [name, port] = pair.split('=').map(x => String(x || '').trim());
++ if (SATELLITES[name] && /^\d{2,5}$/.test(port)) SATELLITES[name] = Number(port);
++}
++const SATELLITE_BY_PORT = new Map(Object.entries(SATELLITES).map(([name, port]) => [port, name]));
++
++// Metrics (GET /metrics, direct loopback callers only) and readiness (roadmap Track O). The metrics
++// middleware goes first so it sees every request, proxied ones included; /api/ready is mounted after
++// the host router, so on a satellite's host it is the satellite's own.
++const { observe, checks: ready } = require('../../_shared/observe');
++const registry = require('./registry');
++let auth = null; // created below; the key check reads it at request time
++const obs = observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools', release: release.release, mountReady: false,
++ normalize: (req) => {
++ if (req.ovHost && req.ovHost.port) return `proxy:${SATELLITE_BY_PORT.get(req.ovHost.port) || 'other'}`;
++ if (req.baseUrl === '/api/pastes') return '/api/pastes/*';
++ if (isNetHost(req)) return 'page:net';
++ if (isDevHost(req)) return 'page:dev';
++ return null;
++ },
++ checks: [
++ {
++ name: 'catalog', required: true, description: 'the tool catalog every apex page, host route and proxy decision is built from',
++ check: () => { const n = registry.get().tools.length; return n > 0 ? { ok: true, detail: { tools: n } } : 'catalog is empty'; },
++ },
++ ready.networkKey('network_key', () => auth && auth.client.publicKey, { description: 'verifies signed-in visitors offline; signed-out use works without it' }),
++ {
++ name: 'service_directory', required: false, description: 'other services\' origins from the Network registry (a built-in fallback list is used without it)',
++ check: () => { const s = registry.services.status(); return s.source === 'registry' ? { ok: true, detail: { as_of: s.as_of, last_ok: s.last_ok } } : `using the built-in fallback list${s.last_error ? ` (${s.last_error})` : ''}`; },
++ },
++ ready.upstream('community', `${config.communityUrl}/api/ready`, { description: 'paste API proxy (pastes.openvibe.tools)' }),
++ ...Object.entries(SATELLITES).map(([name, port]) => ready.upstream(`satellite_${name}`, `http://127.0.0.1:${port}/api/ready`, { description: `${name} satellite (port ${port}); its hosts fail without it` })),
++ ],
++});
++app.get('/release.json', release.handler);
function getRequestHost(req) {
return String(req.headers.host || '').split(':')[0].toLowerCase();
@@@ -55,7 +96,6 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
}));
// ── Host roles: aliases redirect, unknown hosts leave, satellite hosts are proxied ──
--const registry = require('./registry');
const { hostRoles } = require('./registry/host-middleware');
registry.start();
app.use(hostRoles({
@@@ -65,6 +105,8 @@ app.use(hostRoles({
isLegacyHost: (sub) => NET_TOOL_MAP.has(sub) || !!NET_ALIASES[sub] || DEV_TOOL_MAP.has(sub) || !!DEV_ALIASES[sub] || ['pastes', 'paste', 'my', 'login'].includes(sub),
}));
++app.get('/api/ready', obs.readiness.handler);
++
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
@@@ -109,7 +151,7 @@ app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60 }));
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────────
--const auth = createAuthClient(config);
++auth = createAuthClient(config);
app.locals.auth = auth;
app.locals.config = config;
@@@ -211,7 +253,6 @@ function sendTool(req, res, file) {
// The gateway keeps no analytics of its own; it adds up the satellites'. Internal key + loopback only.
{
const { requireInternal } = require('../../_shared/internal-auth');
-- const SATELLITES = { maps: 4010, food: 4011, img: 4012, yt: 4013, audio: 4014, text: 4015, docs: 4016 };
app.get('/api/internal/analytics', requireInternal, async (req, res) => {
const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
const hours = req.query.hours ? Math.min(parseInt(req.query.hours, 10), 8760) : null;
ddiff --git a/apps/img/deploy/nginx/img.openvibe.tools.conf b/apps/img/deploy/nginx/img.openvibe.tools.conf
iindex 6a5fa99..e11472c 100644
---- a/apps/img/deploy/nginx/img.openvibe.tools.conf
++++ b/apps/img/deploy/nginx/img.openvibe.tools.conf
@@@ -53,6 +53,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
# API routes (rate limited)
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location /api/ {
limit_req zone=openvibeimg_api burst=20 nodelay;
ddiff --git a/apps/img/package-lock.json b/apps/img/package-lock.json
iindex a6356de..f9490eb 100644
---- a/apps/img/package-lock.json
++++ b/apps/img/package-lock.json
@@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"uuid": "^11.1.0"
@@@ -1766,9 +1766,8 @@
"license": "MIT"
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/img/package.json b/apps/img/package.json
iindex bb5a1ed..4d434b9 100644
---- a/apps/img/package.json
++++ b/apps/img/package.json
@@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"uuid": "^11.1.0"
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex 8fb960d..245f105 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -38,7 +38,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img');
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
++// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
++const { observe, checks: ready } = require('../../_shared/observe');
++observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools-img', release: release.release,
++ checks: [
++ ready.sqlite('jobs_db', () => jobs.db, { sql: 'SELECT COUNT(*) AS n FROM tool_jobs', description: 'job store (jobs.db)' }),
++ ready.jobRuntime('job_runtime', () => jobs),
++ ready.writableDir('data_dir', path.resolve(__dirname, '..', config.dataDir), { description: 'jobs.db and job inputs/results' }),
++ ready.writableDir('uploads_dir', path.resolve(config.uploadsDir), { description: 'synchronous uploads' }),
++ ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
++ ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
++ ready.networkKey('network_key', auth.getPublicKey, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
++ ...jobsRuntime.readyChecks(() => jobs),
++ ],
++ jobs: () => jobs,
++});
++app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
ddiff --git a/apps/maps/deploy/nginx/maps.openvibe.tools.conf b/apps/maps/deploy/nginx/maps.openvibe.tools.conf
iindex 0d06c98..85aea82 100644
---- a/apps/maps/deploy/nginx/maps.openvibe.tools.conf
++++ b/apps/maps/deploy/nginx/maps.openvibe.tools.conf
@@@ -34,6 +34,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
# ── SSE streaming (no buffering) ───────────────────────────
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location = /api/search/stream {
limit_req zone=openvibemaps_api burst=10 nodelay;
proxy_pass http://127.0.0.1:4010;
ddiff --git a/apps/maps/package-lock.json b/apps/maps/package-lock.json
iindex 4cc7534..1622a5b 100644
---- a/apps/maps/package-lock.json
++++ b/apps/maps/package-lock.json
@@@ -19,7 +19,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"node-cache": "^5.1.2",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
}
},
"node_modules/accepts": {
@@@ -1383,9 +1383,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/maps/package.json b/apps/maps/package.json
iindex 902088b..c9f7fb4 100644
---- a/apps/maps/package.json
++++ b/apps/maps/package.json
@@@ -11,7 +11,7 @@
"dependencies": {
"axios": "^1.7.0",
"better-sqlite3": "^11.0.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"cheerio": "^1.0.0-rc.12",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex dff8393..8b5dd8b 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -30,7 +30,19 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps');
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
++// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
++const { observe, checks: ready } = require('../../_shared/observe');
++observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools-maps', release: release.release,
++ checks: [
++ ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only' }),
++ ],
++ // The public data sources maps queries (OSM and others) are not probed here: each request says when one fails.
++});
++app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
ddiff --git a/apps/text/deploy/nginx/text.openvibe.tools.conf b/apps/text/deploy/nginx/text.openvibe.tools.conf
iindex f5bb3a4..9b552e8 100644
---- a/apps/text/deploy/nginx/text.openvibe.tools.conf
++++ b/apps/text/deploy/nginx/text.openvibe.tools.conf
@@@ -88,6 +88,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Static assets + hostname routing
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location / {
proxy_pass http://127.0.0.1:4015;
proxy_http_version 1.1;
ddiff --git a/apps/text/package-lock.json b/apps/text/package-lock.json
iindex 8da0483..e1ed26f 100644
---- a/apps/text/package-lock.json
++++ b/apps/text/package-lock.json
@@@ -16,7 +16,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
}
},
"node_modules/accepts": {
@@@ -969,9 +969,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/text/package.json b/apps/text/package.json
iindex 0156be8..1313041 100644
---- a/apps/text/package.json
++++ b/apps/text/package.json
@@@ -16,7 +16,7 @@
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"dotenv": "^16.3.1",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
"license": "MIT"
}
ddiff --git a/apps/text/server/index.js b/apps/text/server/index.js
iindex 674a2b2..d5566f1 100644
---- a/apps/text/server/index.js
++++ b/apps/text/server/index.js
@@@ -23,7 +23,18 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text');
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
++// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
++const { observe, checks: ready } = require('../../_shared/observe');
++observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools-text', release: release.release,
++ checks: [
++ ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; the text tools run in the browser and on this process' }),
++ ],
++});
++app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
ddiff --git a/apps/yt/deploy/nginx/yt.openvibe.tools.conf b/apps/yt/deploy/nginx/yt.openvibe.tools.conf
iindex 58eaccb..0554976 100644
---- a/apps/yt/deploy/nginx/yt.openvibe.tools.conf
++++ b/apps/yt/deploy/nginx/yt.openvibe.tools.conf
@@@ -25,6 +25,11 @@ server {
client_max_body_size 2m;
# SSE — no buffering for streaming progress
++ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
++ location = /metrics {
++ return 404;
++ }
++
location ~ ^/api/status/.+/stream$ {
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
ddiff --git a/apps/yt/package-lock.json b/apps/yt/package-lock.json
iindex 4d3068a..2301939 100644
---- a/apps/yt/package-lock.json
++++ b/apps/yt/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"uuid": "^11.1.0"
}
},
@@@ -800,9 +800,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/apps/yt/package.json b/apps/yt/package.json
iindex 1be08c8..1a2640e 100644
---- a/apps/yt/package.json
++++ b/apps/yt/package.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"better-sqlite3": "^11.0.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"jsonwebtoken": "^9.0.2",
"uuid": "^11.1.0"
}
ddiff --git a/apps/yt/server/auth.js b/apps/yt/server/auth.js
iindex 0a56f55..e0dff02 100644
---- a/apps/yt/server/auth.js
++++ b/apps/yt/server/auth.js
@@@ -114,4 +114,7 @@ function requireAuth(req, res, next) {
next();
}
--module.exports = { extractToken, verifyToken, optionalAuth, requireAuth, loadPublicKey };
++/** The Network public key (PEM) once loaded (readiness reports it). */
++function getPublicKey() { return publicKey; }
++
++module.exports = { extractToken, verifyToken, optionalAuth, requireAuth, loadPublicKey, getPublicKey };
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex 939fa67..28cb4f1 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -31,7 +31,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt');
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
--{ const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') }); app.get('/release.json', release.handler); }
++const release = require('openvibe-shared/release').createRelease({ service: 'tools', root: require('path').join(__dirname, '..', '..', '..') });
++// Metrics (GET /metrics, direct loopback callers only) and GET /api/ready from this server's real
++// dependencies (roadmap Track O). First, so the HTTP metrics see every request.
++const { observe, checks: ready } = require('../../_shared/observe');
++observe({
++ app, metrics: require('openvibe-shared/metrics'), ready: require('openvibe-shared/ready'),
++ service: 'tools-yt', release: release.release,
++ checks: [
++ ready.writableDir('downloads_dir', path.resolve(config.downloadsDir), { description: 'downloads are written here before they are served' }),
++ ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; downloads work without it' }),
++ ready.binary('yt_dlp', config.ytdlpPath, { description: 'every download runs yt-dlp' }),
++ ready.binary('ffmpeg', process.env.FFMPEG_PATH || 'ffmpeg', { description: 'merging video+audio and audio conversion' }),
++ ...(process.env.YT_COOKIES_FILE ? [ready.readableFile('yt_cookies', process.env.YT_COOKIES_FILE, { description: 'YT_COOKIES_FILE is set; yt-dlp is given these cookies' })] : []),
++ ready.networkKey('network_key', require('./auth').getPublicKey, { description: 'recognises signed-in visitors (higher limits); anonymous use works without it' }),
++ ],
++ // Download progress streams last as long as the download.
++ skip: (req) => /^\/api\/status\/[^/]+\/stream$/.test(req.path),
++ details: () => ({ yt_proxy_configured: !!String(process.env.YT_PROXY || '').trim() }),
++});
++app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));