openvibers/openvibe.tools Commits
Sign-out everywhere across Tools (WS-B task 4, SDK 0.9.1): the gateway consumes network.user.token_valid_after (POST /internal/events, TOOLS_EVENTS_SECRET, signature v2, never through the proxy) into a shared SQLite file (TOOLS_REVOCATIONS_DB, default <repo>/data/token-revocations.db); every app's guard reads it and treats a token issued before the person's cutoff as nobody signed in; the subscription is created at boot. test: apps/gateway/test/revocations.test.js
Changes
@@@ -109,6 +109,8 @@ function createGuard(o = {}) {return saltValue;};++ // Network's sign-out-everywhere cutoffs, written by the gateway (./revocations.js).++ const cutoffs = o.cutoffs || require('./revocations').createCutoffReader({ Database: o.Database, file: o.revocationsFile, now });const keys = o.keys || tokens.createKeySource({ networkUrl: o.networkUrl, networkInternalUrl: o.networkInternalUrl, files: o.publicKeyFiles || [], log });const issuer = o.issuer;const audience = env.OV_TOOLS_AUDIENCE || tokens.AUDIENCE;@@@ -175,7 +177,8 @@ function createGuard(o = {}) {if (!token || tokens.looksLikePrincipal(tokens.peek(token))) return next();const verify = () => {const claims = tokens.verifyUserToken(token, { publicKey: keys.get(), issuer, audience, now: now() });-- if (claims) { req.user = claims; req.token = token; }++ // Signed out everywhere, password changed or banned since this token was issued: nobody.++ if (claims && !cutoffs.isRevoked(claims)) { req.user = claims; req.token = token; }};if (keys.get() || typeof keys.ensure !== 'function') { verify(); return next(); }Promise.resolve(keys.ensure()).then(verify, () => {}).finally(() => next());nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++// Sign-out everywhere for every Tools app (roadmap WS-B task 4; Contracts 0.39.0++// network.user.token_valid_after). The gateway receives the event and keeps each person's cutoff in++// one SQLite file on the host (apps/gateway/server/revocation-events.js); every app's guard reads it++// here and treats a token issued before the cutoff as nobody signed in (Network's rule:++// iat * 1000 < valid_after). Lookups are memoised briefly per subject, so a revocation reaches every++// app within `ttlMs`. Without better-sqlite3 or before the gateway has written the file, nothing is revoked.++const fs = require('fs');++const path = require('path');++++const DEFAULT_FILE = path.resolve(__dirname, '..', '..', '..', 'data', 'token-revocations.db');++++function revocationsFile(env = process.env) { return env.TOOLS_REVOCATIONS_DB || DEFAULT_FILE; }++++function createCutoffReader({ Database = null, file = revocationsFile(), ttlMs = 15_000, now = Date.now } = {}) {++ let stmt = null, lastTry = 0;++ const memo = new Map();++ function open() {++ if (stmt || !Database) return stmt;++ if (now() - lastTry < 10_000) return null;++ lastTry = now();++ try {++ if (!fs.existsSync(file)) return null;++ const db = new Database(file, { readonly: true, fileMustExist: true });++ db.pragma('busy_timeout = 1000');++ stmt = db.prepare('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?');++ } catch { stmt = null; }++ return stmt;++ }++ function cutoffFor(subject) {++ if (!subject) return 0;++ const hit = memo.get(subject);++ if (hit && now() - hit.at < ttlMs) return hit.ms;++ let ms = 0;++ const s = open();++ if (s) { try { const row = s.get(subject); ms = row ? Number(row.valid_after_ms) || 0 : 0; } catch { ms = hit ? hit.ms : 0; } }++ if (memo.size > 20000) memo.clear();++ memo.set(subject, { at: now(), ms });++ return ms;++ }++ function isRevoked(claims) {++ if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;++ return claims.iat * 1000 < cutoffFor(claims.subject_id);++ }++ return { isRevoked, cutoffFor };++}++++module.exports = { createCutoffReader, revocationsFile, DEFAULT_FILE };@@@ -18,6 +18,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"}},@@@ -1050,6 +1051,15 @@"node": ">=20"}},++ "node_modules/openvibe-sdk": {++ "version": "0.9.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",++ "integrity": "sha512-P0DffeA3t/GHWtLICqvVqOBrL6momFD5Xvgbbdo6/98pN4dqs1oPF8iXROJXi+jA5C0lHgCC866gFr9+PVLr5g==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },"node_modules/openvibe-shared": {"version": "1.12.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",@@@ -19,6 +19,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"}}@@@ -30,6 +30,8 @@ const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');const { createGuard, TRUST_PROXY } = require('../../_shared/guard');const app = express();++// Network's sign-out-everywhere cutoffs for every Tools app (./revocation-events.js): before any body parser.++app.post('/internal/events', ...require('./revocation-events').handler());// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.const release = require('../../_shared/release').toolsRelease('gateway', require); // its components: apps/_shared/release.js@@@ -463,6 +465,12 @@ const searchIndex = require('./search-index').searchIndexerFromEnv({ snapshot: tif (searchIndex.indexer) searchIndex.indexer.start(); else console.log(`[SearchIndex] off: ${searchIndex.reason}`);app.listen(config.port, config.host, () => {++ // Subscribe to Network's token cutoffs (retried a few times; off without EVENTS_URL / TOOLS_EVENTS_SECRET).++ const subscribe = (n) => require('./revocation-events').ensureSubscription({ port: config.port }).catch((e) => {++ console.warn('[Events] token cutoff subscription not ready:', e.message);++ if (n < 5) setTimeout(() => subscribe(n + 1), 60_000 * n).unref();++ });++ subscribe(1);console.log(`\n╔════════════════════════════════════════════╗`);console.log(`║ 🔧 openvibe.tools — Tools Gateway ║`);console.log(`╠════════════════════════════════════════════╣`);nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * Sign-out everywhere reaches OpenVibe.Tools (roadmap WS-B task 4; Contracts 0.39.0++ * network.user.token_valid_after). The gateway is the one consumer: POST /internal/events (the++ * endpoint of its Events subscription, TOOLS_EVENTS_SECRET, signature v2 only, never through the++ * proxy) writes each person's cutoff into the shared file every app's guard reads++ * (apps/_shared/guard/revocations.js). The subscription is created at boot when EVENTS_URL, the++ * service secret and TOOLS_EVENTS_SECRET are set (grant tools events.subscription.manage).++ */++const fs = require('fs');++const path = require('path');++const express = require('express');++const { revocationsFile } = require('../../_shared/guard/revocations');++++const TOPIC = 'network.user.token_valid_after';++let store = null;++function cutoffs() {++ if (store) return store;++ const Database = require('better-sqlite3');++ const file = revocationsFile();++ fs.mkdirSync(path.dirname(file), { recursive: true });++ const db = new Database(file);++ db.pragma('journal_mode = WAL');++ db.pragma('busy_timeout = 2000');++ store = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });++ return store;++}++const secrets = () => String(process.env.TOOLS_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 32);++const stats = { received: 0, revoked: 0, refused: 0 };++++function handler() {++ const { parseDelivery } = require('openvibe-sdk/events');++ return [express.raw({ type: () => true, limit: '256kb' }), (req, res) => {++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return res.status(404).json({ error: 'Not found' });++ const keys = secrets();++ if (!keys.length) return res.status(503).json({ error: 'TOOLS_EVENTS_SECRET is not set' });++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ let d = null;++ for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }++ if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }++ stats.received++;++ const outcome = cutoffs().apply(d.event);++ if (outcome === 'revoked') stats.revoked++;++ res.json({ event_id: d.event.event_id || null, outcome });++ }];++}++++async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = console } = {}) {++ const eventsUrl = String(process.env.EVENTS_URL || '').replace(/\/+$/, '');++ const secret = secrets()[0];++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!eventsUrl || !secret || !clientSecret) return 'off';++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const tokens = createServiceTokenClient({++ tokenUrl: `${String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '')}/oauth/token`,++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'tools', clientSecret, fetch: fetchImpl,++ });++ const endpoint = process.env.TOOLS_EVENTS_ENDPOINT || `http://127.0.0.1:${port}/internal/events`;++ const token = await tokens.getToken({ audience: 'openvibe.events', scope: 'events.subscription.manage' });++ const headers = { authorization: `Bearer ${token}`, accept: 'application/json' };++ const list = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { headers });++ if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`);++ const subs = ((await list.json()).subscriptions || []);++ if (subs.some((s) => s.topic_pattern === TOPIC && s.endpoint === endpoint)) return 'exists';++ const r = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ topic_pattern: TOPIC, endpoint, secret }) });++ if (r.status === 409) return 'exists';++ if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${TOPIC} subscription`);++ log.log && log.log(`[Events] subscription created: ${TOPIC} → ${endpoint}`);++ return 'created';++}++++module.exports = { handler, ensureSubscription, stats, TOPIC, _cutoffs: cutoffs };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++// Sign-out everywhere across Tools (WS-B task 4): the gateway's POST /internal/events (signature v2,++// never through the proxy) writes the person's cutoff into the shared file; any app's guard reader then++// refuses their older tokens and accepts newer ones; forged, foreign and proxied deliveries change nothing.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tools-revoke-'));++process.env.TOOLS_REVOCATIONS_DB = path.join(tmp, 'shared', 'token-revocations.db');++process.env.TOOLS_EVENTS_SECRET = 't'.repeat(40);++const express = require('express');++const Database = require('better-sqlite3');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const events = require('../server/revocation-events');++const { createCutoffReader } = require('../../_shared/guard/revocations');++++const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++const at = Date.parse('2026-09-25T12:00:00Z');++const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_type: events.TOPIC, version: 1, source: 'network',++ payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'password_changed' }, ...over });++++(async () => {++ const app = express();++ app.post('/internal/events', ...events.handler());++ const srv = http.createServer(app);++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${srv.address().port}/internal/events`;++ const post = (event, { secret = process.env.TOOLS_EVENTS_SECRET, headers = {} } = {}) => {++ const body = JSON.stringify({ event, seq: 1 });++ return fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));++ };++ let clock = 1_000_000;++ const reader = createCutoffReader({ Database, file: process.env.TOOLS_REVOCATIONS_DB, ttlMs: 5000, now: () => clock });++ try {++ const old = { subject_id: SUBJECT, iat: at / 1000 - 30 };++ assert.strictEqual(reader.isRevoked(old), false, 'no file yet: nothing revoked');++ assert.strictEqual((await post(ev(), { secret: 'y'.repeat(40) })).status, 401);++ assert.strictEqual((await post(ev(), { headers: { 'cf-connecting-ip': '1.2.3.4' } })).status, 404);++ assert.strictEqual((await post(ev({ source: 'live' }))).body.outcome, 'ignored:source');++ assert.strictEqual((await post(ev())).body.outcome, 'revoked');++ assert.ok(fs.existsSync(process.env.TOOLS_REVOCATIONS_DB), 'the shared file exists');++ clock += 20_000; // past the reader's retry pause and memo++ assert.strictEqual(reader.isRevoked(old), true, 'another app sees the cutoff');++ assert.strictEqual(reader.isRevoked({ subject_id: SUBJECT, iat: at / 1000 + 1 }), false);++ assert.strictEqual(reader.isRevoked({ subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4', iat: 1 }), false);++ assert.strictEqual((await post(ev())).body.outcome, 'unchanged');++ const guardSrc = fs.readFileSync(path.join(__dirname, '../../_shared/guard/index.js'), 'utf8');++ assert.ok(/if \(claims && !cutoffs\.isRevoked\(claims\)\)/.test(guardSrc), 'every app\'s guard checks it');++ } finally {++ srv.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('tools revocations: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -109,6 +109,8 @@ function createGuard(o = {}) {
@@ -109,6 +109,8 @@ function createGuard(o = {}) {
return saltValue;
return saltValue;
};
};
+ // Network's sign-out-everywhere cutoffs, written by the gateway (./revocations.js).
+ const cutoffs = o.cutoffs || require('./revocations').createCutoffReader({ Database: o.Database, file: o.revocationsFile, now });
const keys = o.keys || tokens.createKeySource({ networkUrl: o.networkUrl, networkInternalUrl: o.networkInternalUrl, files: o.publicKeyFiles || [], log });
const keys = o.keys || tokens.createKeySource({ networkUrl: o.networkUrl, networkInternalUrl: o.networkInternalUrl, files: o.publicKeyFiles || [], log });
const issuer = o.issuer;
const issuer = o.issuer;
const audience = env.OV_TOOLS_AUDIENCE || tokens.AUDIENCE;
const audience = env.OV_TOOLS_AUDIENCE || tokens.AUDIENCE;
@@ -175,7 +177,8 @@ function createGuard(o = {}) {
@@ -175,7 +177,8 @@ function createGuard(o = {}) {
if (!token || tokens.looksLikePrincipal(tokens.peek(token))) return next();
if (!token || tokens.looksLikePrincipal(tokens.peek(token))) return next();
const verify = () => {
const verify = () => {
const claims = tokens.verifyUserToken(token, { publicKey: keys.get(), issuer, audience, now: now() });
const claims = tokens.verifyUserToken(token, { publicKey: keys.get(), issuer, audience, now: now() });
- if (claims) { req.user = claims; req.token = token; }
+ // Signed out everywhere, password changed or banned since this token was issued: nobody.
+ if (claims && !cutoffs.isRevoked(claims)) { req.user = claims; req.token = token; }
};
};
if (keys.get() || typeof keys.ensure !== 'function') { verify(); return next(); }
if (keys.get() || typeof keys.ensure !== 'function') { verify(); return next(); }
Promise.resolve(keys.ensure()).then(verify, () => {}).finally(() => next());
Promise.resolve(keys.ensure()).then(verify, () => {}).finally(() => next());
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+// Sign-out everywhere for every Tools app (roadmap WS-B task 4; Contracts 0.39.0
+// network.user.token_valid_after). The gateway receives the event and keeps each person's cutoff in
+// one SQLite file on the host (apps/gateway/server/revocation-events.js); every app's guard reads it
+// here and treats a token issued before the cutoff as nobody signed in (Network's rule:
+// iat * 1000 < valid_after). Lookups are memoised briefly per subject, so a revocation reaches every
+// app within `ttlMs`. Without better-sqlite3 or before the gateway has written the file, nothing is revoked.
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_FILE = path.resolve(__dirname, '..', '..', '..', 'data', 'token-revocations.db');
+
+function revocationsFile(env = process.env) { return env.TOOLS_REVOCATIONS_DB || DEFAULT_FILE; }
+
+function createCutoffReader({ Database = null, file = revocationsFile(), ttlMs = 15_000, now = Date.now } = {}) {
+ let stmt = null, lastTry = 0;
+ const memo = new Map();
+ function open() {
+ if (stmt || !Database) return stmt;
+ if (now() - lastTry < 10_000) return null;
+ lastTry = now();
+ try {
+ if (!fs.existsSync(file)) return null;
+ const db = new Database(file, { readonly: true, fileMustExist: true });
+ db.pragma('busy_timeout = 1000');
+ stmt = db.prepare('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?');
+ } catch { stmt = null; }
+ return stmt;
+ }
+ function cutoffFor(subject) {
+ if (!subject) return 0;
+ const hit = memo.get(subject);
+ if (hit && now() - hit.at < ttlMs) return hit.ms;
+ let ms = 0;
+ const s = open();
+ if (s) { try { const row = s.get(subject); ms = row ? Number(row.valid_after_ms) || 0 : 0; } catch { ms = hit ? hit.ms : 0; } }
+ if (memo.size > 20000) memo.clear();
+ memo.set(subject, { at: now(), ms });
+ return ms;
+ }
+ function isRevoked(claims) {
+ if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;
+ return claims.iat * 1000 < cutoffFor(claims.subject_id);
+ }
+ return { isRevoked, cutoffFor };
+}
+
+module.exports = { createCutoffReader, revocationsFile, DEFAULT_FILE };
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
}
}
},
},
@@ -1050,6 +1051,15 @@
@@ -1050,6 +1051,15 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-sdk": {
+ "version": "0.9.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
+ "integrity": "sha512-P0DffeA3t/GHWtLICqvVqOBrL6momFD5Xvgbbdo6/98pN4dqs1oPF8iXROJXi+jA5C0lHgCC866gFr9+PVLr5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"version": "1.12.0",
"version": "1.12.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
}
}
}
}
@@ -30,6 +30,8 @@ const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
@@ -30,6 +30,8 @@ const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const app = express();
const app = express();
+// Network's sign-out-everywhere cutoffs for every Tools app (./revocation-events.js): before any body parser.
+app.post('/internal/events', ...require('./revocation-events').handler());
// 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('../../_shared/release').toolsRelease('gateway', require); // its components: apps/_shared/release.js
const release = require('../../_shared/release').toolsRelease('gateway', require); // its components: apps/_shared/release.js
@@ -463,6 +465,12 @@ const searchIndex = require('./search-index').searchIndexerFromEnv({ snapshot: t
@@ -463,6 +465,12 @@ const searchIndex = require('./search-index').searchIndexerFromEnv({ snapshot: t
if (searchIndex.indexer) searchIndex.indexer.start(); else console.log(`[SearchIndex] off: ${searchIndex.reason}`);
if (searchIndex.indexer) searchIndex.indexer.start(); else console.log(`[SearchIndex] off: ${searchIndex.reason}`);
app.listen(config.port, config.host, () => {
app.listen(config.port, config.host, () => {
+ // Subscribe to Network's token cutoffs (retried a few times; off without EVENTS_URL / TOOLS_EVENTS_SECRET).
+ const subscribe = (n) => require('./revocation-events').ensureSubscription({ port: config.port }).catch((e) => {
+ console.warn('[Events] token cutoff subscription not ready:', e.message);
+ if (n < 5) setTimeout(() => subscribe(n + 1), 60_000 * n).unref();
+ });
+ subscribe(1);
console.log(`\n╔════════════════════════════════════════════╗`);
console.log(`\n╔════════════════════════════════════════════╗`);
console.log(`║ 🔧 openvibe.tools — Tools Gateway ║`);
console.log(`║ 🔧 openvibe.tools — Tools Gateway ║`);
console.log(`╠════════════════════════════════════════════╣`);
console.log(`╠════════════════════════════════════════════╣`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * Sign-out everywhere reaches OpenVibe.Tools (roadmap WS-B task 4; Contracts 0.39.0
+ * network.user.token_valid_after). The gateway is the one consumer: POST /internal/events (the
+ * endpoint of its Events subscription, TOOLS_EVENTS_SECRET, signature v2 only, never through the
+ * proxy) writes each person's cutoff into the shared file every app's guard reads
+ * (apps/_shared/guard/revocations.js). The subscription is created at boot when EVENTS_URL, the
+ * service secret and TOOLS_EVENTS_SECRET are set (grant tools events.subscription.manage).
+ */
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const { revocationsFile } = require('../../_shared/guard/revocations');
+
+const TOPIC = 'network.user.token_valid_after';
+let store = null;
+function cutoffs() {
+ if (store) return store;
+ const Database = require('better-sqlite3');
+ const file = revocationsFile();
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const db = new Database(file);
+ db.pragma('journal_mode = WAL');
+ db.pragma('busy_timeout = 2000');
+ store = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });
+ return store;
+}
+const secrets = () => String(process.env.TOOLS_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 32);
+const stats = { received: 0, revoked: 0, refused: 0 };
+
+function handler() {
+ const { parseDelivery } = require('openvibe-sdk/events');
+ return [express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return res.status(404).json({ error: 'Not found' });
+ const keys = secrets();
+ if (!keys.length) return res.status(503).json({ error: 'TOOLS_EVENTS_SECRET is not set' });
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ let d = null;
+ for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
+ if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
+ stats.received++;
+ const outcome = cutoffs().apply(d.event);
+ if (outcome === 'revoked') stats.revoked++;
+ res.json({ event_id: d.event.event_id || null, outcome });
+ }];
+}
+
+async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const eventsUrl = String(process.env.EVENTS_URL || '').replace(/\/+$/, '');
+ const secret = secrets()[0];
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!eventsUrl || !secret || !clientSecret) return 'off';
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const tokens = createServiceTokenClient({
+ tokenUrl: `${String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '')}/oauth/token`,
+ clientId: process.env.OV_OAUTH_CLIENT_ID || 'tools', clientSecret, fetch: fetchImpl,
+ });
+ const endpoint = process.env.TOOLS_EVENTS_ENDPOINT || `http://127.0.0.1:${port}/internal/events`;
+ const token = await tokens.getToken({ audience: 'openvibe.events', scope: 'events.subscription.manage' });
+ const headers = { authorization: `Bearer ${token}`, accept: 'application/json' };
+ const list = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { headers });
+ if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`);
+ const subs = ((await list.json()).subscriptions || []);
+ if (subs.some((s) => s.topic_pattern === TOPIC && s.endpoint === endpoint)) return 'exists';
+ const r = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ topic_pattern: TOPIC, endpoint, secret }) });
+ if (r.status === 409) return 'exists';
+ if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${TOPIC} subscription`);
+ log.log && log.log(`[Events] subscription created: ${TOPIC} → ${endpoint}`);
+ return 'created';
+}
+
+module.exports = { handler, ensureSubscription, stats, TOPIC, _cutoffs: cutoffs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+// Sign-out everywhere across Tools (WS-B task 4): the gateway's POST /internal/events (signature v2,
+// never through the proxy) writes the person's cutoff into the shared file; any app's guard reader then
+// refuses their older tokens and accepts newer ones; forged, foreign and proxied deliveries change nothing.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tools-revoke-'));
+process.env.TOOLS_REVOCATIONS_DB = path.join(tmp, 'shared', 'token-revocations.db');
+process.env.TOOLS_EVENTS_SECRET = 't'.repeat(40);
+const express = require('express');
+const Database = require('better-sqlite3');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const events = require('../server/revocation-events');
+const { createCutoffReader } = require('../../_shared/guard/revocations');
+
+const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+const at = Date.parse('2026-09-25T12:00:00Z');
+const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_type: events.TOPIC, version: 1, source: 'network',
+ payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'password_changed' }, ...over });
+
+(async () => {
+ const app = express();
+ app.post('/internal/events', ...events.handler());
+ const srv = http.createServer(app);
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${srv.address().port}/internal/events`;
+ const post = (event, { secret = process.env.TOOLS_EVENTS_SECRET, headers = {} } = {}) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ return fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
+ };
+ let clock = 1_000_000;
+ const reader = createCutoffReader({ Database, file: process.env.TOOLS_REVOCATIONS_DB, ttlMs: 5000, now: () => clock });
+ try {
+ const old = { subject_id: SUBJECT, iat: at / 1000 - 30 };
+ assert.strictEqual(reader.isRevoked(old), false, 'no file yet: nothing revoked');
+ assert.strictEqual((await post(ev(), { secret: 'y'.repeat(40) })).status, 401);
+ assert.strictEqual((await post(ev(), { headers: { 'cf-connecting-ip': '1.2.3.4' } })).status, 404);
+ assert.strictEqual((await post(ev({ source: 'live' }))).body.outcome, 'ignored:source');
+ assert.strictEqual((await post(ev())).body.outcome, 'revoked');
+ assert.ok(fs.existsSync(process.env.TOOLS_REVOCATIONS_DB), 'the shared file exists');
+ clock += 20_000; // past the reader's retry pause and memo
+ assert.strictEqual(reader.isRevoked(old), true, 'another app sees the cutoff');
+ assert.strictEqual(reader.isRevoked({ subject_id: SUBJECT, iat: at / 1000 + 1 }), false);
+ assert.strictEqual(reader.isRevoked({ subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4', iat: 1 }), false);
+ assert.strictEqual((await post(ev())).body.outcome, 'unchanged');
+ const guardSrc = fs.readFileSync(path.join(__dirname, '../../_shared/guard/index.js'), 'utf8');
+ assert.ok(/if \(claims && !cutoffs\.isRevoked\(claims\)\)/.test(guardSrc), 'every app\'s guard checks it');
+ } finally {
+ srv.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('tools revocations: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });