openvibers/openvibe.network Commits

Revocation propagation (WS-B task 4, Contracts 0.39.0): revokeTokens() moves token_valid_after, ends Network sessions and queues network.user.token_valid_after in the same transaction; password change and reset, bans, POST /api/auth/sign-out-everywhere and admin POST /users/:id/sign-out use it. 'Revoke All Sessions' only deactivated session rows while every token stayed valid: it is now 'Sign out other devices' (all tokens end, this browser gets a fresh one), next to 'Sign out everywhere, this device too'. test/revocation.test.js

OP OpenVibers committed 24 Sept 2026 · 8033d01642 · 7 files, +259 −25


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 6e97633b..832cf060 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.38.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
-- "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",
++ "version": "0.39.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
++ "integrity": "sha512-ek3tryU3pPwmFSg621gzr4ss1OE/gpufBnxk9XFDLSfUe2RuZpYVZCJNdQ0F/9GfA83Xop+xJW8+IjRyjFg6Yg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 92dda6b0..2cfd9c34 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
ddiff --git a/public/my.html b/public/my.html
iindex 0e73ba8e..56a95530 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -457,8 +457,10 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3>
<div id="sessions-list"><div class="loading-spinner">Loading sessions...</div></div>
-- <div class="btn-row" style="margin-top:12px">
-- <button class="btn btn-danger" onclick="revokeAllSessions()">Revoke All Sessions</button>
++ <p style="margin-top:12px;color:var(--text-secondary);font-size:13px">Lost a phone or used a shared computer? Signing out ends every sign-in on every OpenVibe site at once, and open chats and games disconnect.</p>
++ <div class="btn-row" style="margin-top:10px">
++ <button class="btn btn-danger" onclick="revokeAllSessions()"><i class="fa-solid fa-mobile-screen-button"></i> Sign out other devices</button>
++ <button class="btn" onclick="signOutEverywhere()"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere, this device too</button>
</div>
</div>
</div>
@@@ -1361,9 +1363,24 @@ async function revokeSession(id) {
}
async function revokeAllSessions() {
-- if (!confirm('Revoke all sessions? You will need to log in again on other devices.')) return;
-- try { await apiFetch('/api/auth/sessions', { method: 'DELETE' }); loadSessions(); }
-- catch (err) { alert('Error: ' + err.message); }
++ if (!confirm('Sign out every other device? They will need to sign in again. You stay signed in here.')) return;
++ try {
++ const data = await apiFetch('/api/auth/sessions', { method: 'DELETE' });
++ if (data.token) {
++ localStorage.setItem('ov_token', data.token);
++ document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ }
++ loadSessions();
++ alert('Signed out everywhere else.');
++ } catch (err) { alert('Error: ' + err.message); }
++}
++
++async function signOutEverywhere() {
++ if (!confirm('Sign out on every device and every OpenVibe site, this one included?')) return;
++ try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
++ catch (err) { alert('Error: ' + err.message); return; }
++ try { localStorage.removeItem('ov_token'); } catch { /* storage may be blocked */ }
++ location.href = '/sso/fanout?action=logout&next=%2Flogin';
}
// Anon info
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex ea617461..bd512414 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -18,6 +18,8 @@ const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('..
const secrets = require('../secrets');
const { issueResetToken } = require('../auth/reset-tokens');
const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
++const revocation = require('../auth/revocation');
++const staffActor = (u) => (u && /^usr_/.test(String(u.subject_id || '')) ? { type: 'user', id: u.subject_id } : { type: 'system', id: 'network' });
const LOCAL_REFRESH_SERVICES = new Set(['network']);
@@@ -719,6 +721,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason })
);
++ // A ban ends the person's tokens on every site at once (network.user.token_valid_after).
++ if (banned) { revocation.revokeTokens(db, Number(req.params.id), { reason: 'banned', actor: staffActor(req.user) }); revocation.kick(db); }
// Notify the user
if (banned) {
@@@ -738,6 +742,21 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
});
++ // End someone's sessions on every device and site (a stolen token, a shared computer).
++ router.post('/users/:id/sign-out', (req, res) => {
++ try {
++ const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);
++ if (!target) return res.status(404).json({ ok: false, error: 'No such user' });
++ if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner ends the owner's sessions" });
++ const { validAfter } = revocation.revokeTokens(db, target.id, { reason: 'staff_revoked', actor: staffActor(req.user) });
++ revocation.kick(db);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_sign_out', JSON.stringify({ targetId: target.id, valid_after: validAfter }));
++ res.json({ ok: true, valid_after: validAfter });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
// ═══════════════════════════════════════════════════════
// Broadcast Notifications
// ═══════════════════════════════════════════════════════
ddiff --git a/server/auth/revocation.js b/server/auth/revocation.js
nnew file mode 100644
iindex 00000000..3effd31e
---- /dev/null
++++ b/server/auth/revocation.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * Ending a person's tokens everywhere (roadmap WS-B task 4; Contracts 0.39.0
++ * network.user.token_valid_after).
++ *
++ * revokeTokens() moves users.token_valid_after to now, ends their Network sessions and, in the same
++ * transaction, queues network.user.token_valid_after in network_event_outbox (relayed to
++ * OpenVibe.Events by server/developer/event-relay.js). Network itself already refuses a token whose
++ * iat * 1000 < token_valid_after (auth/session.js, /api/auth/refresh) and a refresh token created
++ * before it (auth/oauth-routes.js); the event makes Chat, Community, Live, Media, Tools and Games do
++ * the same and close the sockets those tokens opened.
++ *
++ * Reasons: password_changed | password_reset | signed_out_everywhere | banned | account_deleted |
++ * staff_revoked. Call it inside the caller's transaction when there is one (it nests), then kick().
++ */
++const { ids, validate } = require('openvibe-contracts');
++const eventRelay = require('../developer/event-relay');
++
++const EVENT_TYPE = 'network.user.token_valid_after';
++const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked'];
++
++const iso = (v) => new Date(String(v) + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).toISOString();
++
++function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
++ const payload = { subject: { type: 'user', id: subjectId }, valid_after: validAfter, reason };
++ const pv = validate(`${EVENT_TYPE}@1`, payload);
++ if (!pv.valid) throw new Error(`revocation: bad payload: ${pv.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
++ const ms = Date.now();
++ const env = {
++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
++ actor, timestamp: new Date(ms).toISOString(), visibility: 'internal',
++ subject: { type: 'user', id: subjectId }, payload,
++ };
++ if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) env.trace_id = ctx.traceId;
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new Error(`revocation: bad envelope: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
++ return env;
++}
++
++/**
++ * @param {object} db better-sqlite3 database
++ * @param {number} userId users.id
++ * @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself
++ * @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject
++ */
++function revokeTokens(db, userId, { reason, actor, ctx } = {}) {
++ if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
++ return db.transaction(() => {
++ const user = db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);
++ if (!user) throw new Error(`revocation: no user ${userId}`);
++ db.prepare('UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
++ db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);
++ const validAfter = iso(db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id).t);
++ let event = null;
++ if (ids.isSubjectId('user', user.subject_id)) {
++ const by = actor || { type: 'user', id: user.subject_id };
++ event = buildEnvelope({ subjectId: user.subject_id, validAfter, reason, actor: by, ctx });
++ eventRelay.writerFor(db).enqueue(event, { traceparent: ctx && ctx.traceparent });
++ }
++ return { validAfter, event };
++ })();
++}
++
++/** After the commit: wake the relay if this process runs one. */
++function kick(db) {
++ const live = eventRelay.outboxFor(db);
++ if (live) live.kick();
++}
++
++module.exports = { revokeTokens, kick, buildEnvelope, EVENT_TYPE, REASONS };
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex cd57f7be..20d39435 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -12,6 +12,7 @@ const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const subjects = require('../identity/subjects');
const { staffClaims } = require('./staff-claims');
++const revocation = require('./revocation');
const router = express.Router();
@@@ -269,15 +270,13 @@ router.post('/reset-password', (req, res) => {
const hash = bcrypt.hashSync(newPassword, 10);
const tx = db.transaction(() => {
-- db.prepare(`
-- UPDATE users
-- SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
-- WHERE id = ?
-- `).run(hash, reset.user_id);
-- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(reset.user_id);
++ db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, reset.user_id);
++ // Every token and session from before the reset ends, on every site (network.user.token_valid_after).
++ revocation.revokeTokens(db, reset.user_id, { reason: 'password_reset', ctx: req.ov });
db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);
});
tx();
++ revocation.kick(db);
if (notifService) {
notifService.create({
@@@ -439,10 +438,13 @@ router.post('/change-password', requireAuth, (req, res) => {
}
const hash = bcrypt.hashSync(new_password, 10);
-- db.prepare('UPDATE users SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP WHERE id = ?')
-- .run(hash, req.user.id);
-- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);
-- db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);
++ db.transaction(() => {
++ db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);
++ // Every other token and session ends, on every site; this one gets a fresh token below.
++ revocation.revokeTokens(db, req.user.id, { reason: 'password_changed', ctx: req.ov });
++ db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);
++ })();
++ revocation.kick(db);
// Issue fresh token
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
@@@ -509,6 +511,20 @@ function logAnonIp(db, anonId, ip) {
// POST /api/auth/anon-session
// Creates a temporary anonymous identity with a unique number.
// If force_new=true, always creates a new anon identity.
++// ── Sign out on every device ─────────────────────────────────
++// POST /api/auth/sign-out-everywhere: every token and session this person holds ends, here and (via
++// network.user.token_valid_after) on every OpenVibe site; open chat and game sockets close. The page
++// then runs the sign-out fanout so this browser's site sessions are cleared too.
++router.post('/sign-out-everywhere', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const { validAfter } = revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
++ revocation.kick(db);
++ try { db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'sign_out_everywhere', JSON.stringify({ valid_after: validAfter })); } catch { /* audit is best effort */ }
++ require('./session').clearSessionCookies(res);
++ res.set('Cache-Control', 'no-store');
++ res.json({ ok: true, valid_after: validAfter });
++});
++
// Server-side sign-out: the page-side storage is cleared by the caller; this drops the cookies
// only the server can (ov_sso is httpOnly). Idempotent, no auth required.
router.post('/logout', (req, res) => {
@@@ -726,12 +742,17 @@ router.delete('/sessions/:id', requireAuth, (req, res) => {
res.json({ ok: true, revoked: result.changes > 0 });
});
--// ── Revoke All Sessions ──────────────────────────────────────
--// DELETE /api/auth/sessions
++// ── Sign out other devices ───────────────────────────────────
++// DELETE /api/auth/sessions: every token and session ends on every site (network.user.token_valid_after),
++// and this browser gets a fresh token so it stays signed in (same as a password change).
router.delete('/sessions', requireAuth, (req, res) => {
const db = getDb(req);
-- const result = db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);
-- res.json({ ok: true, revoked: result.changes });
++ const active = db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(req.user.id).n;
++ revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
++ revocation.kick(db);
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
++ res.set('Cache-Control', 'no-store');
++ res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req)) });
});
// ═══════════════════════════════════════════════════════════════
ddiff --git a/test/revocation.test.js b/test/revocation.test.js
nnew file mode 100644
iindex 00000000..4188990e
---- /dev/null
++++ b/test/revocation.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++// Revocation propagation (WS-B task 4, Contracts 0.39.0 network.user.token_valid_after): signing out
++// every device, a password change, a reset and a ban move the cutoff, end Network's sessions and queue
++// one validated event for the person's subject in the same transaction; older tokens stop working here.
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const cookieParser = require('cookie-parser');
++const jwt = require('jsonwebtoken');
++const bcrypt = require('bcryptjs');
++const { validate } = require('openvibe-contracts');
++const { initDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const revocation = require('../server/auth/revocation');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-revoke-'));
++const log = console.log; console.log = () => {};
++const db = initDb(path.join(dir, 'network.db'));
++console.log = log;
++const ISSUER = 'https://openvibe.network';
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
++
++const hash = bcrypt.hashSync('oldpass1', 4);
++const mk = (name, withSubject = true) => {
++ const id = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(name, hash).lastInsertRowid;
++ if (withSubject) subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
++ else db.prepare('UPDATE users SET subject_id = NULL WHERE id = ?').run(id);
++ return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++};
++// A session token issued 10 seconds ago (the cutoff has second precision; a token from the same second survives).
++const oldToken = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id || undefined, username: u.username, role: u.role || 'user', iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.user.token_valid_after');
++
++const app = express();
++app.locals.db = db; app.locals.config = config; app.locals.privateKey = keys.privateKey; app.locals.publicKey = keys.publicKey;
++app.use(express.json()); app.use(cookieParser());
++app.use('/api/auth', require('../server/auth/routes'));
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/auth`;
++ const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
++ .then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++ try {
++ revocation.revokeTokens.length; // module loads
++ assert.throws(() => revocation.revokeTokens(db, 1, { reason: 'because' }), /unknown reason/);
++
++ // Sign out everywhere, this device too.
++ const ann = mk('ann'); const annTok = oldToken(ann);
++ assert.strictEqual((await call('GET', '/me', annTok)).status, 200);
++ db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-ann', 1, datetime('now', '+1 day'))").run(ann.id);
++ let r = await call('POST', '/sign-out-everywhere', annTok);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual((await call('GET', '/me', annTok)).status, 401, 'the old token is refused');
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(ann.id).n, 0);
++ let ev = events();
++ assert.strictEqual(ev.length, 1);
++ assert.deepStrictEqual([ev[0].source, ev[0].subject, ev[0].payload.subject.id, ev[0].payload.reason, ev[0].payload.valid_after], ['network', { type: 'user', id: ann.subject_id }, ann.subject_id, 'signed_out_everywhere', r.body.valid_after]);
++ assert.ok(validate('events.event-envelope@1', ev[0]).valid && validate('network.user.token_valid_after@1', ev[0].payload).valid);
++ assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ann.subject_id }, 'the person did it');
++
++ // Sign out other devices: the old token dies, this browser gets a fresh one.
++ const bob = mk('bob'); const bobTok = oldToken(bob);
++ r = await call('DELETE', '/sessions', bobTok);
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.token);
++ assert.strictEqual((await call('GET', '/me', bobTok)).status, 401);
++ assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200, 'the fresh token works');
++ assert.strictEqual(events().at(-1).payload.reason, 'signed_out_everywhere');
++
++ // Password change: every older token ends; the reply's token works.
++ const cat = mk('cat'); const catTok = oldToken(cat);
++ r = await call('POST', '/change-password', catTok, { current_password: 'oldpass1', new_password: 'newpass2' });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual((await call('GET', '/me', catTok)).status, 401);
++ assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200);
++ assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).payload.subject.id], ['password_changed', cat.subject_id]);
++
++ // A ban (staff actor) and an account without a subject (no event, the cutoff still moves).
++ const dan = mk('dan');
++ revocation.revokeTokens(db, dan.id, { reason: 'banned', actor: { type: 'user', id: ann.subject_id } });
++ assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).actor.id], ['banned', ann.subject_id]);
++ const eve = mk('eve', false);
++ const n = events().length;
++ const out = revocation.revokeTokens(db, eve.id, { reason: 'staff_revoked' });
++ assert.strictEqual(out.event, null);
++ assert.strictEqual(events().length, n);
++ assert.ok(db.prepare('SELECT token_valid_after FROM users WHERE id = ?').get(eve.id).token_valid_after);
++
++ // Wiring: reset and ban call revokeTokens; the admin can end someone's sessions.
++ const authSrc = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');
++ assert.ok(/revokeTokens\(db, reset\.user_id, \{ reason: 'password_reset'/.test(authSrc));
++ const adminSrc = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');
++ assert.ok(/reason: 'banned'/.test(adminSrc) && /router\.post\('\/users\/:id\/sign-out'/.test(adminSrc) && /reason: 'staff_revoked'/.test(adminSrc));
++ assert.ok(!/token_valid_after = CURRENT_TIMESTAMP/.test(authSrc), 'every cutoff goes through revokeTokens (so it is announced)');
++ } finally {
++ server.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ console.log('revocation: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });