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

OP OpenVibers committed 25 Sept 2026 · ab20dc4441 · 7 files, +200 −1


Changes

ddiff --git a/apps/_shared/guard/index.js b/apps/_shared/guard/index.js
iindex 92722c6..a26e015 100644
---- a/apps/_shared/guard/index.js
++++ b/apps/_shared/guard/index.js
@@@ -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());
ddiff --git a/apps/_shared/guard/revocations.js b/apps/_shared/guard/revocations.js
nnew file mode 100644
iindex 0000000..fa9269d
---- /dev/null
++++ b/apps/_shared/guard/revocations.js
@@@ -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 };
ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex ae5caf3..990d0cb 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -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",
ddiff --git a/apps/gateway/package.json b/apps/gateway/package.json
iindex 59c0bc9..0dde9b5 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -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"
}
}
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 5336c0d..f6846e2 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -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: t
if (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(`╠════════════════════════════════════════════╣`);
ddiff --git a/apps/gateway/server/revocation-events.js b/apps/gateway/server/revocation-events.js
nnew file mode 100644
iindex 0000000..7c1584a
---- /dev/null
++++ b/apps/gateway/server/revocation-events.js
@@@ -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 };
ddiff --git a/apps/gateway/test/revocations.test.js b/apps/gateway/test/revocations.test.js
nnew file mode 100644
iindex 0000000..743818f
---- /dev/null
++++ b/apps/gateway/test/revocations.test.js
@@@ -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); });