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
Changes
@@@ -119,6 +119,33 @@ through Network's registry, `GET /api/v1/registry/services` (fetched on boot andanswer 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@@@ -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 };@@@ -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,};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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;@@@ -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"@@@ -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"}}@@@ -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)));@@@ -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;@@@ -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"@@@ -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"@@@ -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)));@@@ -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;@@@ -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"@@@ -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"}}@@@ -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)));@@@ -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;@@@ -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"@@@ -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"}}@@@ -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;@@@ -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;@@@ -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"@@@ -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"@@@ -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)));@@@ -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;@@@ -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"@@@ -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",@@@ -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)));@@@ -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;@@@ -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"@@@ -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"}@@@ -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)));@@@ -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;@@@ -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"@@@ -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"}@@@ -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 };@@@ -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)));
@@ -119,6 +119,33 @@ through Network's registry, `GET /api/v1/registry/services` (fetched on boot and
@@ -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"`.
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.
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
## Tests
`npm test` (Node 22) syntax-checks every server file and runs every `apps/*/test/*.test.js`, each in its own
`npm test` (Node 22) syntax-checks every server file and runs every `apps/*/test/*.test.js`, each in its own
@@ -92,4 +92,25 @@ function setupJobs(o) {
@@ -92,4 +92,25 @@ function setupJobs(o) {
return system;
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 };
@@ -458,7 +458,10 @@ function createJobSystem(o) {
@@ -458,7 +458,10 @@ function createJobSystem(o) {
lastSeq: (id) => store.lastSeq(id),
lastSeq: (id) => store.lastSeq(id),
types: () => [...types.keys()],
types: () => [...types.keys()],
media,
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,
store,
JobError,
JobError,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,176 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -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); });
@@ -71,6 +71,11 @@ server {
@@ -71,6 +71,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# API routes (rate limited)
# API routes (rate limited)
+ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
+ location = /metrics {
+ return 404;
+ }
+
location /api/ {
location /api/ {
limit_req zone=openvibeaudio_api burst=20 nodelay;
limit_req zone=openvibeaudio_api burst=20 nodelay;
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
"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"
"uuid": "^11.1.0"
}
}
},
},
@@ -1174,9 +1174,8 @@
@@ -1174,9 +1174,8 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
"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"
"uuid": "^11.1.0"
}
}
}
}
@@ -40,7 +40,27 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio');
@@ -40,7 +40,27 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio');
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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.
// 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)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
@@ -51,6 +51,11 @@ server {
@@ -51,6 +51,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# API routes (rate limited)
# API routes (rate limited)
+ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
+ location = /metrics {
+ return 404;
+ }
+
location /api/ {
location /api/ {
limit_req zone=openvibedocs_api burst=20 nodelay;
limit_req zone=openvibedocs_api burst=20 nodelay;
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
"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",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -1253,9 +1253,8 @@
@@ -1253,9 +1253,8 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
"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",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -38,7 +38,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs');
@@ -38,7 +38,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs');
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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.
// 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)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
@@ -34,6 +34,11 @@ server {
@@ -34,6 +34,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# ── API (rate limited) ──────────────────────────────────
# ── API (rate limited) ──────────────────────────────────
+ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
+ location = /metrics {
+ return 404;
+ }
+
location /api/ {
location /api/ {
limit_req zone=openvibefood_api burst=30 nodelay;
limit_req zone=openvibefood_api burst=30 nodelay;
proxy_pass http://127.0.0.1:4011;
proxy_pass http://127.0.0.1:4011;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.18.2",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
- "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": {
"node_modules/accepts": {
@@ -968,9 +968,8 @@
@@ -968,9 +968,8 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -16,6 +16,6 @@
@@ -16,6 +16,6 @@
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"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"
}
}
}
}
@@ -28,7 +28,20 @@ const MAPS_API = process.env.MAPS_API || 'http://127.0.0.1:4010';
@@ -28,7 +28,20 @@ const MAPS_API = process.env.MAPS_API || 'http://127.0.0.1:4010';
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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.
// 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)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
@@ -41,6 +41,11 @@ server {
@@ -41,6 +41,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# ── OAuth session endpoints (strict rate limit) ─────────
# ── 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/ {
location /auth/ {
limit_req zone=ovtools_auth burst=10 nodelay;
limit_req zone=ovtools_auth burst=10 nodelay;
proxy_pass http://127.0.0.1:4001;
proxy_pass http://127.0.0.1:4001;
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.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": {
"node_modules/accepts": {
@@ -742,9 +742,8 @@
@@ -742,9 +742,8 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -17,6 +17,6 @@
@@ -17,6 +17,6 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.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"
}
}
}
}
@@ -30,7 +30,48 @@ const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
@@ -30,7 +30,48 @@ const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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) {
function getRequestHost(req) {
return String(req.headers.host || '').split(':')[0].toLowerCase();
return String(req.headers.host || '').split(':')[0].toLowerCase();
@@ -55,7 +96,6 @@ app.use(helmet({
@@ -55,7 +96,6 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
crossOriginEmbedderPolicy: false,
}));
}));
// ── Host roles: aliases redirect, unknown hosts leave, satellite hosts are proxied ──
// ── Host roles: aliases redirect, unknown hosts leave, satellite hosts are proxied ──
-const registry = require('./registry');
const { hostRoles } = require('./registry/host-middleware');
const { hostRoles } = require('./registry/host-middleware');
registry.start();
registry.start();
app.use(hostRoles({
app.use(hostRoles({
@@ -65,6 +105,8 @@ 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),
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(cookieParser());
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
@@ -109,7 +151,7 @@ app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
@@ -109,7 +151,7 @@ app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60 }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60 }));
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────────
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────────
-const auth = createAuthClient(config);
+auth = createAuthClient(config);
app.locals.auth = auth;
app.locals.auth = auth;
app.locals.config = config;
app.locals.config = config;
@@ -211,7 +253,6 @@ function sendTool(req, res, file) {
@@ -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.
// The gateway keeps no analytics of its own; it adds up the satellites'. Internal key + loopback only.
{
{
const { requireInternal } = require('../../_shared/internal-auth');
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) => {
app.get('/api/internal/analytics', requireInternal, async (req, res) => {
const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
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;
const hours = req.query.hours ? Math.min(parseInt(req.query.hours, 10), 8760) : null;
@@ -53,6 +53,11 @@ server {
@@ -53,6 +53,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# API routes (rate limited)
# API routes (rate limited)
+ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
+ location = /metrics {
+ return 404;
+ }
+
location /api/ {
location /api/ {
limit_req zone=openvibeimg_api burst=20 nodelay;
limit_req zone=openvibeimg_api burst=20 nodelay;
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
"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",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"to-ico": "^1.1.5",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -1766,9 +1766,8 @@
@@ -1766,9 +1766,8 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
"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",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"to-ico": "^1.1.5",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -38,7 +38,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img');
@@ -38,7 +38,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img');
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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.
// 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)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
@@ -34,6 +34,11 @@ server {
@@ -34,6 +34,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# ── SSE streaming (no buffering) ───────────────────────────
# ── 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 {
location = /api/search/stream {
limit_req zone=openvibemaps_api burst=10 nodelay;
limit_req zone=openvibemaps_api burst=10 nodelay;
proxy_pass http://127.0.0.1:4010;
proxy_pass http://127.0.0.1:4010;
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"node-cache": "^5.1.2",
"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": {
"node_modules/accepts": {
@@ -1383,9 +1383,8 @@
@@ -1383,9 +1383,8 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
"dependencies": {
"dependencies": {
"axios": "^1.7.0",
"axios": "^1.7.0",
"better-sqlite3": "^11.0.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",
"cheerio": "^1.0.0-rc.12",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -30,7 +30,19 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps');
@@ -30,7 +30,19 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps');
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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.
// 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)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
@@ -88,6 +88,11 @@ server {
@@ -88,6 +88,11 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Static assets + hostname routing
# Static assets + hostname routing
+ # Prometheus metrics are for loopback scrapes only (the app also 404s any proxied caller).
+ location = /metrics {
+ return 404;
+ }
+
location / {
location / {
proxy_pass http://127.0.0.1:4015;
proxy_pass http://127.0.0.1:4015;
proxy_http_version 1.1;
proxy_http_version 1.1;
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.18.2",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"helmet": "^7.1.0",
- "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": {
"node_modules/accepts": {
@@ -969,9 +969,8 @@
@@ -969,9 +969,8 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.1.5",
"dotenv": "^16.3.1",
"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"
"license": "MIT"
}
}
@@ -23,7 +23,18 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text');
@@ -23,7 +23,18 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text');
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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.
// 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)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
@@ -25,6 +25,11 @@ server {
@@ -25,6 +25,11 @@ server {
client_max_body_size 2m;
client_max_body_size 2m;
# SSE — no buffering for streaming progress
# 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$ {
location ~ ^/api/status/.+/stream$ {
proxy_pass http://127.0.0.1:4013;
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_http_version 1.1;
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"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"
"uuid": "^11.1.0"
}
}
},
},
@@ -800,9 +800,8 @@
@@ -800,9 +800,8 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^11.0.0",
- "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",
"jsonwebtoken": "^9.0.2",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
@@ -114,4 +114,7 @@ function requireAuth(req, res, next) {
@@ -114,4 +114,7 @@ function requireAuth(req, res, next) {
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 };
@@ -31,7 +31,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt');
@@ -31,7 +31,26 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt');
const app = express();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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.
// 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)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));