openvibers/openvibe.sdk Commits
v0.9.0: openvibe-sdk/auth createRevocationStore — per-person token cutoffs from network.user.token_valid_after (Contracts 0.39.0): apply(event), isRevoked(claims) with Network's rule iat * 1000 < valid_after, only forward, source network only; SQLite-backed or in memory
Changes
@@@ -3,6 +3,10 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.9.0 (2026-09-24)++++`openvibe-sdk/auth` **`createRevocationStore(db?)`**: token cutoffs from Network's `network.user.token_valid_after` (Contracts 0.39.0), so a service refuses a person's older tokens as soon as they sign out everywhere, change their password or are banned. `apply(event)`, `isRevoked(claims)`, `cutoffFor(subject)`, `record()`; SQLite-backed or in memory. Additive.++## 0.8.0 (2026-09-24)`openvibe-sdk/frame`: **the OpenVibe Frame**, the navbar, footer and themes every OpenVibe site sits in, for any app, first-party or not. It covers:@@@ -209,6 +209,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun- `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps.- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.++- `createRevocationStore(db?)` keeps each person's token cutoff from Network's `network.user.token_valid_after` event (Contracts 0.39.0: sign out everywhere, a password change or reset, a ban, staff ending someone's sessions). Subscribe to the event, pass each delivery to `store.apply(event)` (`'revoked'` means the cutoff moved: close that person's sockets, drop their cached sessions), and after `verifyUserToken()` refuse the token when `store.isRevoked(claims)`. That is Network's rule, `iat * 1000 < valid_after`. With a better-sqlite3 handle the cutoffs survive restarts; the store only ever moves a cutoff forward and ignores anything not from `network`.### Receiving event webhooks@@@ -4,6 +4,6 @@ import cjs from '../src/auth/index.js';export const {createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,-- verifyAppToken, exchangeCode, refreshUserToken,++ verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.8.0",++ "version": "0.9.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.8.0",++ "version": "0.9.0","license": "MIT","devDependencies": {"better-sqlite3": "^11.10.0",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.8.0",++ "version": "0.9.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -7,5 +7,6 @@ const browser = require('./browser');const { createServiceTokenClient } = require('./tokens');const { verifyUserToken, verifyAppToken } = require('./jwt');const { exchangeCode, refreshUserToken } = require('./oauth');++const { createRevocationStore, TOKEN_VALID_AFTER } = require('./revocations');--module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken };++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++/**++ * Per-person token cutoffs from OpenVibe.Network (Contracts 0.39.0 network.user.token_valid_after).++ *++ * When someone signs out everywhere, changes or resets their password, is banned or has their sessions++ * ended by staff, Network moves their cutoff and publishes it. A service that accepts Network user++ * tokens subscribes to the event, applies it here and asks isRevoked() after verifying a token:++ * Network's rule is `iat * 1000 < valid_after` → refuse.++ *++ * const cutoffs = createRevocationStore(db) // a better-sqlite3 handle; omit for memory only++ * cutoffs.apply(envelope) → 'revoked' | 'unchanged' | 'ignored:<why>' (idempotent: keeps the later cutoff)++ * cutoffs.isRevoked(claims) → true when claims.iat is before claims.subject_id's cutoff++ * cutoffs.cutoffFor(subject) → ms (0 = none)++ * cutoffs.record(subject, validAfterMs, reason?) → true when it moved forward++ *++ * apply() only acts on source network and a well-formed payload, so a forged or foreign envelope that++ * slipped past a signature check still changes nothing. Closing sockets and dropping caches is the++ * caller's: apply() returns 'revoked' when the cutoff moved.++ */++++const EVENT_TYPE = 'network.user.token_valid_after';++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const TABLE_RE = /^[a-z_][a-z0-9_]{0,62}$/;++++function createRevocationStore(db = null, { table = 'ov_token_revocations', now = () => Date.now(), maxCache = 50000 } = {}) {++ if (!TABLE_RE.test(table)) throw new TypeError(`createRevocationStore: bad table name ${table}`);++ const cache = new Map();++ let q = null;++ function ready() {++ if (!db || q) return q;++ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (++ subject_id TEXT PRIMARY KEY,++ valid_after_ms INTEGER NOT NULL,++ reason TEXT,++ updated_at INTEGER NOT NULL++ )`);++ q = {++ get: db.prepare(`SELECT valid_after_ms FROM ${table} WHERE subject_id = ?`),++ put: db.prepare(`INSERT INTO ${table} (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)++ ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at++ WHERE excluded.valid_after_ms > ${table}.valid_after_ms`),++ };++ return q;++ }++++ function cutoffFor(subject) {++ if (!subject) return 0;++ if (cache.has(subject)) return cache.get(subject);++ const s = ready();++ const row = s ? s.get.get(subject) : null;++ const ms = row ? Number(row.valid_after_ms) || 0 : 0;++ if (cache.size >= maxCache) cache.clear();++ cache.set(subject, ms);++ return ms;++ }++++ function record(subject, validAfterMs, reason = null) {++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(validAfterMs)) return false;++ if (!(validAfterMs > cutoffFor(subject))) return false;++ const s = ready();++ if (s) s.put.run(subject, validAfterMs, reason, now());++ cache.set(subject, validAfterMs);++ return true;++ }++++ function apply(event) {++ if (!event || event.event_type !== EVENT_TYPE) return 'ignored:type';++ if (event.source !== 'network') return 'ignored:source';++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};++ const subject = p.subject && p.subject.type === 'user' ? p.subject.id : null;++ const ms = Date.parse(p.valid_after);++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';++ return record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null) ? 'revoked' : 'unchanged';++ }++++ 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 { apply, record, isRevoked, cutoffFor, EVENT_TYPE };++}++++module.exports = { createRevocationStore, TOKEN_VALID_AFTER: EVENT_TYPE };nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++// openvibe-sdk/auth createRevocationStore: network.user.token_valid_after cutoffs (Contracts 0.39.0).++const assert = require('assert');++const Database = require('better-sqlite3');++const { createRevocationStore, TOKEN_VALID_AFTER } = require('../src/auth');++++const A = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++const B = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4';++const ev = (subject, iso, over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_type: TOKEN_VALID_AFTER, version: 1, source: 'network', payload: { subject: { type: 'user', id: subject }, valid_after: iso, reason: 'signed_out_everywhere' }, ...over });++const T = Date.parse('2026-09-24T20:00:00Z');++++const db = new Database(':memory:');++const s = createRevocationStore(db);++assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 - 10 }), false, 'nothing known');++assert.strictEqual(s.apply(ev(A, new Date(T).toISOString())), 'revoked');++assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 - 1 }), true, 'issued before: refused');++assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 }), false, 'issued in the cutoff second: accepted (Network rule)');++assert.strictEqual(s.isRevoked({ subject_id: B, iat: T / 1000 - 1 }), false, 'someone else');++assert.strictEqual(s.isRevoked({ subject_id: A }), false, 'no iat, nothing to compare');++assert.strictEqual(s.apply(ev(A, new Date(T - 60000).toISOString())), 'unchanged', 'never moves back');++assert.strictEqual(s.apply(ev(A, new Date(T).toISOString())), 'unchanged', 'a redelivery');++assert.strictEqual(s.apply(ev(A, new Date(T + 1).toISOString(), { source: 'live' })), 'ignored:source');++assert.strictEqual(s.apply(ev('gst_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', new Date(T).toISOString())), 'ignored:payload');++assert.strictEqual(s.apply({ ...ev(A, 'soon') }), 'ignored:payload');++assert.strictEqual(s.apply({ event_type: 'network.module.updated' }), 'ignored:type');++assert.strictEqual(s.cutoffFor(A), T);++++// Survives a restart (a new store on the same database); memory-only works without one.++assert.strictEqual(createRevocationStore(db).cutoffFor(A), T);++const mem = createRevocationStore();++assert.strictEqual(mem.apply(ev(B, new Date(T).toISOString())), 'revoked');++assert.strictEqual(mem.isRevoked({ subject_id: B, iat: T / 1000 - 5 }), true);++assert.throws(() => createRevocationStore(db, { table: 'x; DROP TABLE y' }), /bad table name/);++console.log('revocations: all checks passed');@@@ -117,3 +117,20 @@ export interface ExchangeCodeOptions {}export declare function exchangeCode(opts: ExchangeCodeOptions): Promise<UserTokenResponse>;export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;++++/** Per-person token cutoffs from network.user.token_valid_after (Contracts 0.39.0). */++export interface RevocationStore {++ /** Apply an envelope: 'revoked' when the cutoff moved, 'unchanged', or 'ignored:<why>'. */++ apply(event: unknown): 'revoked' | 'unchanged' | `ignored:${string}`;++ /** Keep the later cutoff; true when it moved forward. */++ record(subject: string, validAfterMs: number, reason?: string | null): boolean;++ /** True when claims.iat (seconds) is before claims.subject_id's cutoff. */++ isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean;++ /** The cutoff in ms, 0 when none. */++ cutoffFor(subject: string): number;++ readonly EVENT_TYPE: 'network.user.token_valid_after';++}++export interface RevocationStoreOptions { table?: string; now?: () => number; maxCache?: number }++/** A better-sqlite3 handle keeps cutoffs across restarts; without one they live in memory. */++export declare function createRevocationStore(db?: unknown, opts?: RevocationStoreOptions): RevocationStore;++export declare const TOKEN_VALID_AFTER: 'network.user.token_valid_after';
@@ -3,6 +3,10 @@
@@ -3,6 +3,10 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.9.0 (2026-09-24)
+
+`openvibe-sdk/auth` **`createRevocationStore(db?)`**: token cutoffs from Network's `network.user.token_valid_after` (Contracts 0.39.0), so a service refuses a person's older tokens as soon as they sign out everywhere, change their password or are banned. `apply(event)`, `isRevoked(claims)`, `cutoffFor(subject)`, `record()`; SQLite-backed or in memory. Additive.
+
## 0.8.0 (2026-09-24)
## 0.8.0 (2026-09-24)
`openvibe-sdk/frame`: **the OpenVibe Frame**, the navbar, footer and themes every OpenVibe site sits in, for any app, first-party or not. It covers:
`openvibe-sdk/frame`: **the OpenVibe Frame**, the navbar, footer and themes every OpenVibe site sits in, for any app, first-party or not. It covers:
@@ -209,6 +209,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
@@ -209,6 +209,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps.
- `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps.
- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
+- `createRevocationStore(db?)` keeps each person's token cutoff from Network's `network.user.token_valid_after` event (Contracts 0.39.0: sign out everywhere, a password change or reset, a ban, staff ending someone's sessions). Subscribe to the event, pass each delivery to `store.apply(event)` (`'revoked'` means the cutoff moved: close that person's sockets, drop their cached sessions), and after `verifyUserToken()` refuse the token when `store.isRevoked(claims)`. That is Network's rule, `iat * 1000 < valid_after`. With a better-sqlite3 handle the cutoffs survive restarts; the store only ever moves a cutoff forward and ignores anything not from `network`.
### Receiving event webhooks
### Receiving event webhooks
@@ -4,6 +4,6 @@ import cjs from '../src/auth/index.js';
@@ -4,6 +4,6 @@ import cjs from '../src/auth/index.js';
export const {
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
- verifyAppToken, exchangeCode, refreshUserToken,
+ verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.8.0",
+ "version": "0.9.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.8.0",
+ "version": "0.9.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.8.0",
+ "version": "0.9.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -7,5 +7,6 @@ const browser = require('./browser');
@@ -7,5 +7,6 @@ const browser = require('./browser');
const { createServiceTokenClient } = require('./tokens');
const { createServiceTokenClient } = require('./tokens');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { exchangeCode, refreshUserToken } = require('./oauth');
+const { createRevocationStore, TOKEN_VALID_AFTER } = require('./revocations');
-module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken };
+module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Per-person token cutoffs from OpenVibe.Network (Contracts 0.39.0 network.user.token_valid_after).
+ *
+ * When someone signs out everywhere, changes or resets their password, is banned or has their sessions
+ * ended by staff, Network moves their cutoff and publishes it. A service that accepts Network user
+ * tokens subscribes to the event, applies it here and asks isRevoked() after verifying a token:
+ * Network's rule is `iat * 1000 < valid_after` → refuse.
+ *
+ * const cutoffs = createRevocationStore(db) // a better-sqlite3 handle; omit for memory only
+ * cutoffs.apply(envelope) → 'revoked' | 'unchanged' | 'ignored:<why>' (idempotent: keeps the later cutoff)
+ * cutoffs.isRevoked(claims) → true when claims.iat is before claims.subject_id's cutoff
+ * cutoffs.cutoffFor(subject) → ms (0 = none)
+ * cutoffs.record(subject, validAfterMs, reason?) → true when it moved forward
+ *
+ * apply() only acts on source network and a well-formed payload, so a forged or foreign envelope that
+ * slipped past a signature check still changes nothing. Closing sockets and dropping caches is the
+ * caller's: apply() returns 'revoked' when the cutoff moved.
+ */
+
+const EVENT_TYPE = 'network.user.token_valid_after';
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TABLE_RE = /^[a-z_][a-z0-9_]{0,62}$/;
+
+function createRevocationStore(db = null, { table = 'ov_token_revocations', now = () => Date.now(), maxCache = 50000 } = {}) {
+ if (!TABLE_RE.test(table)) throw new TypeError(`createRevocationStore: bad table name ${table}`);
+ const cache = new Map();
+ let q = null;
+ function ready() {
+ if (!db || q) return q;
+ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
+ subject_id TEXT PRIMARY KEY,
+ valid_after_ms INTEGER NOT NULL,
+ reason TEXT,
+ updated_at INTEGER NOT NULL
+ )`);
+ q = {
+ get: db.prepare(`SELECT valid_after_ms FROM ${table} WHERE subject_id = ?`),
+ put: db.prepare(`INSERT INTO ${table} (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at
+ WHERE excluded.valid_after_ms > ${table}.valid_after_ms`),
+ };
+ return q;
+ }
+
+ function cutoffFor(subject) {
+ if (!subject) return 0;
+ if (cache.has(subject)) return cache.get(subject);
+ const s = ready();
+ const row = s ? s.get.get(subject) : null;
+ const ms = row ? Number(row.valid_after_ms) || 0 : 0;
+ if (cache.size >= maxCache) cache.clear();
+ cache.set(subject, ms);
+ return ms;
+ }
+
+ function record(subject, validAfterMs, reason = null) {
+ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(validAfterMs)) return false;
+ if (!(validAfterMs > cutoffFor(subject))) return false;
+ const s = ready();
+ if (s) s.put.run(subject, validAfterMs, reason, now());
+ cache.set(subject, validAfterMs);
+ return true;
+ }
+
+ function apply(event) {
+ if (!event || event.event_type !== EVENT_TYPE) return 'ignored:type';
+ if (event.source !== 'network') return 'ignored:source';
+ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
+ const subject = p.subject && p.subject.type === 'user' ? p.subject.id : null;
+ const ms = Date.parse(p.valid_after);
+ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
+ return record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null) ? 'revoked' : 'unchanged';
+ }
+
+ 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 { apply, record, isRevoked, cutoffFor, EVENT_TYPE };
+}
+
+module.exports = { createRevocationStore, TOKEN_VALID_AFTER: EVENT_TYPE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+// openvibe-sdk/auth createRevocationStore: network.user.token_valid_after cutoffs (Contracts 0.39.0).
+const assert = require('assert');
+const Database = require('better-sqlite3');
+const { createRevocationStore, TOKEN_VALID_AFTER } = require('../src/auth');
+
+const A = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+const B = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4';
+const ev = (subject, iso, over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_type: TOKEN_VALID_AFTER, version: 1, source: 'network', payload: { subject: { type: 'user', id: subject }, valid_after: iso, reason: 'signed_out_everywhere' }, ...over });
+const T = Date.parse('2026-09-24T20:00:00Z');
+
+const db = new Database(':memory:');
+const s = createRevocationStore(db);
+assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 - 10 }), false, 'nothing known');
+assert.strictEqual(s.apply(ev(A, new Date(T).toISOString())), 'revoked');
+assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 - 1 }), true, 'issued before: refused');
+assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 }), false, 'issued in the cutoff second: accepted (Network rule)');
+assert.strictEqual(s.isRevoked({ subject_id: B, iat: T / 1000 - 1 }), false, 'someone else');
+assert.strictEqual(s.isRevoked({ subject_id: A }), false, 'no iat, nothing to compare');
+assert.strictEqual(s.apply(ev(A, new Date(T - 60000).toISOString())), 'unchanged', 'never moves back');
+assert.strictEqual(s.apply(ev(A, new Date(T).toISOString())), 'unchanged', 'a redelivery');
+assert.strictEqual(s.apply(ev(A, new Date(T + 1).toISOString(), { source: 'live' })), 'ignored:source');
+assert.strictEqual(s.apply(ev('gst_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', new Date(T).toISOString())), 'ignored:payload');
+assert.strictEqual(s.apply({ ...ev(A, 'soon') }), 'ignored:payload');
+assert.strictEqual(s.apply({ event_type: 'network.module.updated' }), 'ignored:type');
+assert.strictEqual(s.cutoffFor(A), T);
+
+// Survives a restart (a new store on the same database); memory-only works without one.
+assert.strictEqual(createRevocationStore(db).cutoffFor(A), T);
+const mem = createRevocationStore();
+assert.strictEqual(mem.apply(ev(B, new Date(T).toISOString())), 'revoked');
+assert.strictEqual(mem.isRevoked({ subject_id: B, iat: T / 1000 - 5 }), true);
+assert.throws(() => createRevocationStore(db, { table: 'x; DROP TABLE y' }), /bad table name/);
+console.log('revocations: all checks passed');
@@ -117,3 +117,20 @@ export interface ExchangeCodeOptions {
@@ -117,3 +117,20 @@ export interface ExchangeCodeOptions {
}
}
export declare function exchangeCode(opts: ExchangeCodeOptions): Promise<UserTokenResponse>;
export declare function exchangeCode(opts: ExchangeCodeOptions): Promise<UserTokenResponse>;
export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
+
+/** Per-person token cutoffs from network.user.token_valid_after (Contracts 0.39.0). */
+export interface RevocationStore {
+ /** Apply an envelope: 'revoked' when the cutoff moved, 'unchanged', or 'ignored:<why>'. */
+ apply(event: unknown): 'revoked' | 'unchanged' | `ignored:${string}`;
+ /** Keep the later cutoff; true when it moved forward. */
+ record(subject: string, validAfterMs: number, reason?: string | null): boolean;
+ /** True when claims.iat (seconds) is before claims.subject_id's cutoff. */
+ isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean;
+ /** The cutoff in ms, 0 when none. */
+ cutoffFor(subject: string): number;
+ readonly EVENT_TYPE: 'network.user.token_valid_after';
+}
+export interface RevocationStoreOptions { table?: string; now?: () => number; maxCache?: number }
+/** A better-sqlite3 handle keeps cutoffs across restarts; without one they live in memory. */
+export declare function createRevocationStore(db?: unknown, opts?: RevocationStoreOptions): RevocationStore;
+export declare const TOKEN_VALID_AFTER: 'network.user.token_valid_after';