openvibers/openvibe.community Commits
Observability (Track O): GET /metrics (loopback only) with HTTP golden signals by route template, process metrics and release_info; /api/ready built on openvibe-shared/ready (db required; Network key, Live or Media optional -> degraded) instead of an unconditional {ready:true}; nginx refuses /metrics; openvibe-shared v1.3.0
Changes
@@@ -109,7 +109,8 @@ API and machine endpoints:| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) || `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) || `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |--| `GET /api/health`, `GET /api/ready` | Liveness / readiness |++| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |++| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) || `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` || `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` || `GET /auth/logout` | Clears session, sets `ov_sso_hint=guest`, honours `?next=` |@@@ -118,6 +119,18 @@ API and machine endpoints:| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes || `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |++**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only++when the required `db` check fails (a real query on Community's SQLite). `network_jwks` (the++Network signing key; without it nobody can sign in or write as a signed-in viewer or service),++`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`++(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready++(comments, forum, Pulse and public reads still work) and is listed in `degraded` with++`status: "degraded"`. Every check reports `status`, `required`, `latency_ms` and `checked_at`.++Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP++golden signals by route template (`http_requests_total{method,route,status_class}`,++`http_request_duration_seconds`, `http_requests_in_flight`), process metrics and++`release_info`; content counts (pastes, comments, threads) are deliberately not metrics.++Cookies are host-only for `openvibe.community`: `ov_token` (24 h access JWT, JS-readable sothe shared navbar can use it), `ov_refresh` (httpOnly, `/auth`), `ov_sso_hint`(`account`/`guest`, 1 year, JS-readable — the navbar only tries a silent sign-in when it says@@@ -109,9 +109,14 @@ server {proxy_pass http://127.0.0.1:4200;proxy_http_version 1.1;proxy_set_header Host $host;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4200/metrics). Node also++ # refuses any request carrying X-Forwarded-For, which every location above sets. ──++ location = /metrics { return 404; }++# ── Everything else (server-rendered pages) ─────────────location / {proxy_pass http://127.0.0.1:4200;@@@ -18,7 +18,7 @@"highlight.js": "^11.11.1","multer": "^2.4.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"},"engines": {"node": ">=20.0.0"@@@ -1072,9 +1072,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"@@@ -22,7 +22,7 @@"highlight.js": "^11.11.1","multer": "^2.4.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"},"engines": {"node": ">=20.0.0"@@@ -11,7 +11,7 @@* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)-- * GET /p/:slug/download GET /api/health, /api/ready++ * GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)* GET|POST /new create GET /robots.txt, /sitemap.xml, /feed.xml, /s/feed.xml* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh* GET /s … spaces, threads, posts (forum/routes.js)@@@ -64,6 +64,12 @@ function createApp(opts = {}) {const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);++ // What this server runs (ADR-016); the shared navbar's release-watch polls it.++ const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });++ // HTTP golden signals by route template, process metrics, release_info; GET /metrics answers++ // direct loopback callers only (Track O). Request metrics only: no content counts.++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });++ app.locals.metrics = metrics.registry;app.use(helmet({contentSecurityPolicy: {@@@ -158,10 +164,11 @@ function createApp(opts = {}) {app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));-- // What this server runs (ADR-016); the shared navbar's release-watch polls it.-- const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });app.get('/release.json', release.handler);-- app.get('/api/ready', (_req, res) => res.json({ ready: true }));++ // Readiness reports what is actually served: 503 only without the database; Network key,++ // Live (live mode) and Media (community mode) failures degrade (server/observability.js).++ const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl });++ app.get('/api/ready', readiness.handler);// ── Static assets (content-hashed ?v= → immutable) ───────app.use(express.static(PUBLIC_DIR, {nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).++ *++ * db required a real query on Community's SQLite (the schema is there and answers)++ * network_jwks optional the Network signing key has loaded. Without it public pages and reads++ * still work, but nobody can sign in or write as a signed-in viewer or++ * service (those answer 503), so it degrades rather than fails++ * live optional in PASTES_AUTHORITY=live mode: Live answers /api/health (paste pages and++ * /api/pastes read through it; comments, forum and Pulse do not)++ * media optional in PASTES_AUTHORITY=community mode: Media answers /healthz++ * (screenshot and file uploads; paste text is local)++ *++ * Request metrics come from openvibe-shared/metrics in app.js. Content counts (pastes, comments,++ * threads) are deliberately not metrics.++ */++const { createReadiness } = require('openvibe-shared/ready');++++const PING_TTL_MS = 15_000;++++function probe(url, fetchImpl) {++ return async () => {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });++ try { await res.body?.cancel(); } catch { /* not needed */ }++ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };++ };++}++++function createCommunityReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch }) {++ const checks = [++ {++ name: 'db', required: true,++ check: () => {++ const n = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;++ return n > 0 || 'database has no tables';++ },++ },++ {++ name: 'network_jwks', required: false,++ check: () => {++ if (auth.client.publicKey) return true;++ // Not loaded: ask again (ensureKey throttles itself to one fetch per 30 s) and report now.++ auth.ensureKey().catch(() => {});++ return 'Network signing key not loaded yet: sign-in and signed-in writes are unavailable';++ },++ },++ ];++ if (config.pastesAuthority === 'community') {++ checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });++ } else {++ checks.push({ name: 'live', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.liveInternalUrl}/api/health`, fetchImpl) });++ }++ return createReadiness({++ service: 'community',++ release,++ checks,++ details: (body) => {++ const out = { pastes_authority: config.pastesAuthority };++ if (relay && relay.enabled && body.checks.db.status === 'ok') {++ const q = {};++ for (const r of db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) q[r.status] = r.n;++ out.discord_relay = { enabled: true, deliveries: q };++ }++ return out;++ },++ });++}++++module.exports = { createCommunityReadiness };@@@ -16,6 +16,7 @@ function start({ publicPem, issuer }) {req.on('data', (c) => chunks.push(c));req.on('end', () => {const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.method === 'GET' && req.url === '/healthz') return json(200, { ok: true, service: 'mock-media' });if (req.method !== 'POST' || req.url !== '/api/v1/community/files') return json(404, { error: 'not found' });const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });if (!v.ok) return json(401, { code: v.code, error: v.reason });nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/** Track O: GET /metrics is loopback-only with route templates; /api/ready reports what is actually served. */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/app');++++function raw(base, path, headers = {}) {++ return new Promise((resolve, reject) => http.get(base + path, { headers }, (res) => {++ let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b }));++ }).on('error', reject));++}++++(async () => {++ const t = await boot();++ // Let the boot-time key fetch land.++ await t.app.locals.auth.ensureKey();++++ await check('live mode: ready, with db required and Network key and Live optional', async () => {++ const r = await t.get('/api/ready');++ assert.strictEqual(r.status, 200, r.text);++ const b = r.json();++ assert.strictEqual(b.ready, true);++ assert.strictEqual(b.status, 'ready');++ assert.strictEqual(b.service, 'community');++ assert.strictEqual(b.pastes_authority, 'live');++ assert.deepStrictEqual(Object.keys(b.checks), ['db', 'network_jwks', 'live']);++ assert.strictEqual(b.checks.db.required, true);++ assert.strictEqual(b.checks.network_jwks.required, false);++ assert.strictEqual(b.checks.live.required, false);++ for (const c of Object.values(b.checks)) {++ assert.strictEqual(c.status, 'ok');++ assert.strictEqual(typeof c.latency_ms, 'number');++ assert.ok(Date.parse(c.checked_at));++ }++ });++++ await check('/metrics: 404 through a proxy, route templates (never slugs or ids) to a loopback caller', async () => {++ await t.get('/api/pastes/abc123secretslug');++ await t.get('/p/another-secret-slug');++ await t.get('/no/such/page/99999');++ const proxied = await raw(t.base, '/metrics', { 'X-Forwarded-For': '203.0.113.9' });++ assert.strictEqual(proxied.status, 404);++ const m = await raw(t.base, '/metrics');++ assert.strictEqual(m.status, 200);++ assert.ok(/http_requests_total\{method="GET",route="\/api\/ready",status_class="2xx"\} 1/.test(m.body), m.body.slice(0, 2000));++ assert.ok(m.body.includes('route="/p/:slug"'));++ const labels = m.body.split('\n').filter((l) => l.startsWith('http_')).map((l) => (l.match(/\{[^}]*\}/) || [''])[0]).join('\n');++ assert.ok(!/secret|99999/.test(labels), 'no raw paths in labels');++ assert.ok(/release_info\{service="community",release="[0-9a-f]{7,12}"\} 1/.test(m.body));++ assert.ok(!/pastes_total|comments_total|threads_total/.test(m.body), 'no content counts');++ });++++ await check('Live down: still ready (comments, forum and Pulse are served), live degraded', async () => {++ await t.live.close();++ await new Promise((r) => setTimeout(r, 20));++ // The Live probe is cached for 15 s; a fresh app instance sees the outage immediately.++ const { createCommunityReadiness } = require('../server/observability');++ const rd = createCommunityReadiness({ db: t.app.locals.db, auth: t.app.locals.auth, config: t.app.locals.config });++ const b = await rd.run();++ assert.strictEqual(b.ready, true);++ assert.strictEqual(b.status, 'degraded');++ assert.deepStrictEqual(b.degraded, ['live']);++ });++++ await check('Network key not loaded: degraded, not down', async () => {++ const { createCommunityReadiness } = require('../server/observability');++ const auth = { client: { publicKey: null }, ensureKey: async () => null };++ const b = await createCommunityReadiness({ db: t.app.locals.db, auth, config: { ...t.app.locals.config, pastesAuthority: 'community', mediaInternalUrl: t.media.url } }).run();++ assert.strictEqual(b.ready, true);++ assert.deepStrictEqual(b.degraded, ['network_jwks']);++ assert.strictEqual(b.checks.media.status, 'ok', 'community mode probes Media instead of Live');++ assert.match(b.checks.network_jwks.error, /not loaded/);++ });++++ await check('database unusable: 503 not_ready with db failed', async () => {++ t.app.locals.db.close();++ const r = await t.get('/api/ready');++ assert.strictEqual(r.status, 503);++ const b = r.json();++ assert.strictEqual(b.ready, false);++ assert.deepStrictEqual(b.failed, ['db']);++ });++++ await t.close().catch(() => {});++ done();++})();
@@ -109,7 +109,8 @@ API and machine endpoints:
@@ -109,7 +109,8 @@ API and machine endpoints:
| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
-| `GET /api/health`, `GET /api/ready` | Liveness / readiness |
+| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
+| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
| `GET /auth/logout` | Clears session, sets `ov_sso_hint=guest`, honours `?next=` |
| `GET /auth/logout` | Clears session, sets `ov_sso_hint=guest`, honours `?next=` |
@@ -118,6 +119,18 @@ API and machine endpoints:
@@ -118,6 +119,18 @@ API and machine endpoints:
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
+**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
+when the required `db` check fails (a real query on Community's SQLite). `network_jwks` (the
+Network signing key; without it nobody can sign in or write as a signed-in viewer or service),
+`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`
+(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready
+(comments, forum, Pulse and public reads still work) and is listed in `degraded` with
+`status: "degraded"`. Every check reports `status`, `required`, `latency_ms` and `checked_at`.
+Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP
+golden signals by route template (`http_requests_total{method,route,status_class}`,
+`http_request_duration_seconds`, `http_requests_in_flight`), process metrics and
+`release_info`; content counts (pastes, comments, threads) are deliberately not metrics.
+
Cookies are host-only for `openvibe.community`: `ov_token` (24 h access JWT, JS-readable so
Cookies are host-only for `openvibe.community`: `ov_token` (24 h access JWT, JS-readable so
the shared navbar can use it), `ov_refresh` (httpOnly, `/auth`), `ov_sso_hint`
the shared navbar can use it), `ov_refresh` (httpOnly, `/auth`), `ov_sso_hint`
(`account`/`guest`, 1 year, JS-readable — the navbar only tries a silent sign-in when it says
(`account`/`guest`, 1 year, JS-readable — the navbar only tries a silent sign-in when it says
@@ -109,9 +109,14 @@ server {
@@ -109,9 +109,14 @@ server {
proxy_pass http://127.0.0.1:4200;
proxy_pass http://127.0.0.1:4200;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4200/metrics). Node also
+ # refuses any request carrying X-Forwarded-For, which every location above sets. ──
+ location = /metrics { return 404; }
+
# ── Everything else (server-rendered pages) ─────────────
# ── Everything else (server-rendered pages) ─────────────
location / {
location / {
proxy_pass http://127.0.0.1:4200;
proxy_pass http://127.0.0.1:4200;
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
"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"
},
},
"engines": {
"engines": {
"node": ">=20.0.0"
"node": ">=20.0.0"
@@ -1072,9 +1072,8 @@
@@ -1072,9 +1072,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"
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
"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"
},
},
"engines": {
"engines": {
"node": ">=20.0.0"
"node": ">=20.0.0"
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
- * GET /p/:slug/download GET /api/health, /api/ready
+ * GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
* GET|POST /new create GET /robots.txt, /sitemap.xml, /feed.xml, /s/feed.xml
* GET|POST /new create GET /robots.txt, /sitemap.xml, /feed.xml, /s/feed.xml
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
* GET /s … spaces, threads, posts (forum/routes.js)
* GET /s … spaces, threads, posts (forum/routes.js)
@@ -64,6 +64,12 @@ function createApp(opts = {}) {
@@ -64,6 +64,12 @@ function createApp(opts = {}) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
+ // What this server runs (ADR-016); the shared navbar's release-watch polls it.
+ const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });
+ // HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
+ // direct loopback callers only (Track O). Request metrics only: no content counts.
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
+ app.locals.metrics = metrics.registry;
app.use(helmet({
app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
@@ -158,10 +164,11 @@ function createApp(opts = {}) {
@@ -158,10 +164,11 @@ function createApp(opts = {}) {
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
- // What this server runs (ADR-016); the shared navbar's release-watch polls it.
- const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });
app.get('/release.json', release.handler);
app.get('/release.json', release.handler);
- app.get('/api/ready', (_req, res) => res.json({ ready: true }));
+ // Readiness reports what is actually served: 503 only without the database; Network key,
+ // Live (live mode) and Media (community mode) failures degrade (server/observability.js).
+ const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl });
+ app.get('/api/ready', readiness.handler);
// ── Static assets (content-hashed ?v= → immutable) ───────
// ── Static assets (content-hashed ?v= → immutable) ───────
app.use(express.static(PUBLIC_DIR, {
app.use(express.static(PUBLIC_DIR, {
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).
+ *
+ * db required a real query on Community's SQLite (the schema is there and answers)
+ * network_jwks optional the Network signing key has loaded. Without it public pages and reads
+ * still work, but nobody can sign in or write as a signed-in viewer or
+ * service (those answer 503), so it degrades rather than fails
+ * live optional in PASTES_AUTHORITY=live mode: Live answers /api/health (paste pages and
+ * /api/pastes read through it; comments, forum and Pulse do not)
+ * media optional in PASTES_AUTHORITY=community mode: Media answers /healthz
+ * (screenshot and file uploads; paste text is local)
+ *
+ * Request metrics come from openvibe-shared/metrics in app.js. Content counts (pastes, comments,
+ * threads) are deliberately not metrics.
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+
+const PING_TTL_MS = 15_000;
+
+function probe(url, fetchImpl) {
+ return async () => {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
+ try { await res.body?.cancel(); } catch { /* not needed */ }
+ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };
+ };
+}
+
+function createCommunityReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch }) {
+ const checks = [
+ {
+ name: 'db', required: true,
+ check: () => {
+ const n = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;
+ return n > 0 || 'database has no tables';
+ },
+ },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ if (auth.client.publicKey) return true;
+ // Not loaded: ask again (ensureKey throttles itself to one fetch per 30 s) and report now.
+ auth.ensureKey().catch(() => {});
+ return 'Network signing key not loaded yet: sign-in and signed-in writes are unavailable';
+ },
+ },
+ ];
+ if (config.pastesAuthority === 'community') {
+ checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });
+ } else {
+ checks.push({ name: 'live', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.liveInternalUrl}/api/health`, fetchImpl) });
+ }
+ return createReadiness({
+ service: 'community',
+ release,
+ checks,
+ details: (body) => {
+ const out = { pastes_authority: config.pastesAuthority };
+ if (relay && relay.enabled && body.checks.db.status === 'ok') {
+ const q = {};
+ for (const r of db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) q[r.status] = r.n;
+ out.discord_relay = { enabled: true, deliveries: q };
+ }
+ return out;
+ },
+ });
+}
+
+module.exports = { createCommunityReadiness };
@@ -16,6 +16,7 @@ function start({ publicPem, issuer }) {
@@ -16,6 +16,7 @@ function start({ publicPem, issuer }) {
req.on('data', (c) => chunks.push(c));
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
req.on('end', () => {
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.method === 'GET' && req.url === '/healthz') return json(200, { ok: true, service: 'mock-media' });
if (req.method !== 'POST' || req.url !== '/api/v1/community/files') return json(404, { error: 'not found' });
if (req.method !== 'POST' || req.url !== '/api/v1/community/files') return json(404, { error: 'not found' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/** Track O: GET /metrics is loopback-only with route templates; /api/ready reports what is actually served. */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/app');
+
+function raw(base, path, headers = {}) {
+ return new Promise((resolve, reject) => http.get(base + path, { headers }, (res) => {
+ let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b }));
+ }).on('error', reject));
+}
+
+(async () => {
+ const t = await boot();
+ // Let the boot-time key fetch land.
+ await t.app.locals.auth.ensureKey();
+
+ await check('live mode: ready, with db required and Network key and Live optional', async () => {
+ const r = await t.get('/api/ready');
+ assert.strictEqual(r.status, 200, r.text);
+ const b = r.json();
+ assert.strictEqual(b.ready, true);
+ assert.strictEqual(b.status, 'ready');
+ assert.strictEqual(b.service, 'community');
+ assert.strictEqual(b.pastes_authority, 'live');
+ assert.deepStrictEqual(Object.keys(b.checks), ['db', 'network_jwks', 'live']);
+ assert.strictEqual(b.checks.db.required, true);
+ assert.strictEqual(b.checks.network_jwks.required, false);
+ assert.strictEqual(b.checks.live.required, false);
+ for (const c of Object.values(b.checks)) {
+ assert.strictEqual(c.status, 'ok');
+ assert.strictEqual(typeof c.latency_ms, 'number');
+ assert.ok(Date.parse(c.checked_at));
+ }
+ });
+
+ await check('/metrics: 404 through a proxy, route templates (never slugs or ids) to a loopback caller', async () => {
+ await t.get('/api/pastes/abc123secretslug');
+ await t.get('/p/another-secret-slug');
+ await t.get('/no/such/page/99999');
+ const proxied = await raw(t.base, '/metrics', { 'X-Forwarded-For': '203.0.113.9' });
+ assert.strictEqual(proxied.status, 404);
+ const m = await raw(t.base, '/metrics');
+ assert.strictEqual(m.status, 200);
+ assert.ok(/http_requests_total\{method="GET",route="\/api\/ready",status_class="2xx"\} 1/.test(m.body), m.body.slice(0, 2000));
+ assert.ok(m.body.includes('route="/p/:slug"'));
+ const labels = m.body.split('\n').filter((l) => l.startsWith('http_')).map((l) => (l.match(/\{[^}]*\}/) || [''])[0]).join('\n');
+ assert.ok(!/secret|99999/.test(labels), 'no raw paths in labels');
+ assert.ok(/release_info\{service="community",release="[0-9a-f]{7,12}"\} 1/.test(m.body));
+ assert.ok(!/pastes_total|comments_total|threads_total/.test(m.body), 'no content counts');
+ });
+
+ await check('Live down: still ready (comments, forum and Pulse are served), live degraded', async () => {
+ await t.live.close();
+ await new Promise((r) => setTimeout(r, 20));
+ // The Live probe is cached for 15 s; a fresh app instance sees the outage immediately.
+ const { createCommunityReadiness } = require('../server/observability');
+ const rd = createCommunityReadiness({ db: t.app.locals.db, auth: t.app.locals.auth, config: t.app.locals.config });
+ const b = await rd.run();
+ assert.strictEqual(b.ready, true);
+ assert.strictEqual(b.status, 'degraded');
+ assert.deepStrictEqual(b.degraded, ['live']);
+ });
+
+ await check('Network key not loaded: degraded, not down', async () => {
+ const { createCommunityReadiness } = require('../server/observability');
+ const auth = { client: { publicKey: null }, ensureKey: async () => null };
+ const b = await createCommunityReadiness({ db: t.app.locals.db, auth, config: { ...t.app.locals.config, pastesAuthority: 'community', mediaInternalUrl: t.media.url } }).run();
+ assert.strictEqual(b.ready, true);
+ assert.deepStrictEqual(b.degraded, ['network_jwks']);
+ assert.strictEqual(b.checks.media.status, 'ok', 'community mode probes Media instead of Live');
+ assert.match(b.checks.network_jwks.error, /not loaded/);
+ });
+
+ await check('database unusable: 503 not_ready with db failed', async () => {
+ t.app.locals.db.close();
+ const r = await t.get('/api/ready');
+ assert.strictEqual(r.status, 503);
+ const b = r.json();
+ assert.strictEqual(b.ready, false);
+ assert.deepStrictEqual(b.failed, ['db']);
+ });
+
+ await t.close().catch(() => {});
+ done();
+})();