openvibers/openvibe.community Commits

Revocation propagation (WS-B task 4, Contracts 0.39.0, SDK 0.9.1): Community subscribes to network.user.token_valid_after, applies it once through the inbox into openvibe-sdk createRevocationStore, and the viewer resolver treats a token issued before the person's cutoff as anonymous. test/revocation.test.js

OP OpenVibers committed 24 Sept 2026 · 6b5f6e1b98 · 6 files, +81 −15


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 49b758e..0ccafee 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,8 +17,8 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.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.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
"engines": {
@@@ -1057,9 +1057,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.36.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
-- "integrity": "sha512-dDl0IDlVngVksOJZAisLpUJ32RIZJHy6MfpxkNfi/m4AHi86TWLkGcj3b1BdiyL7DjtNm6aX6PfA3ff1O9Dbdg==",
++ "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",
@@@ -1073,9 +1073,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.7.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
-- "integrity": "sha512-G+nevM1cujNypp0oU0Ss1iVTQjbnDRmeUhS5uup6+OBBYQEoUwCBP/d2O3TBioQ2mjroAEbsCTACTxulok/BAw==",
++ "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"
ddiff --git a/package.json b/package.json
iindex eaf0040..167f60b 100644
---- a/package.json
++++ b/package.json
@@@ -26,8 +26,8 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.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.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
"engines": {
ddiff --git a/server/app.js b/server/app.js
iindex 171a7ff..92ff128 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -111,7 +111,10 @@ function createApp(opts = {}) {
// ── Community's database, identity, and what lives in it in every mode ──
const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
const network = opts.network || createNetworkIdentity({ config, db });
-- const viewers = createViewerResolver({ auth, config, network });
++ // Network's per-person token cutoffs (network.user.token_valid_after): sign out everywhere, password
++ // changes and bans refuse older tokens here at once (WS-B task 4).
++ const revocations = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });
++ const viewers = createViewerResolver({ auth, config, network, revocations });
const pulse = createPulse({ db, network, config });
const relay = opts.relay || createDiscordRelay({
db, config, enabled: config.discordRelay.enabled,
@@@ -166,7 +169,7 @@ function createApp(opts = {}) {
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter }));
app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
-- const pulseConsumer = require('./pulse/consumer').createPulseConsumer({ db, vipCache: vip && vip.cache, secrets: String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean) });
++ const pulseConsumer = require('./pulse/consumer').createPulseConsumer({ db, vipCache: vip && vip.cache, revocations, secrets: String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean) });
app.locals.pulseConsumer = pulseConsumer;
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
ddiff --git a/server/identity/viewer.js b/server/identity/viewer.js
iindex 7c4a6bc..e098ce8 100644
---- a/server/identity/viewer.js
++++ b/server/identity/viewer.js
@@@ -47,7 +47,7 @@ function hasCap(viewer, capabilityId) {
return !!(viewer && viewer.kind === 'service' && capabilities.check(viewer.claims, capabilityId).allowed);
}
--function createViewerResolver({ auth, config, network }) {
++function createViewerResolver({ auth, config, network, revocations = null }) {
async function fromServiceToken(req, token) {
const publicKey = await auth.ensureKey();
if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
@@@ -106,6 +106,8 @@ function createViewerResolver({ auth, config, network }) {
async function fromUserToken(token) {
const claims = await auth.verify(token);
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
++ // Issued before Network's cutoff for this person (signed out everywhere, password changed, banned).
++ if (revocations && revocations.isRevoked(claims)) return null;
let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
if (!subject && network && claims.sub != null) {
try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex fc45f24..f312ae3 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -9,6 +9,9 @@
* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
* news.story.published "News: <topic>" → the story (public, indexable only)
*
++ * And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token
++ * cutoff (openvibe-sdk createRevocationStore); the viewer resolver refuses their older tokens.
++ *
* And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only
* answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.
*
@@@ -23,7 +26,7 @@ const { parseDelivery, createInbox } = require('openvibe-sdk/events');
const store = require('./store');
const CONSUMER = 'community';
--const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed']);
++const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after']);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);
@@@ -58,7 +61,7 @@ function itemFor(event) {
return { source_service: k[0], source_type: k[1], source_id: subjectId, title, url, actor_subject: actor, origin: actor ? 'user' : 'system', occurred_at: at };
}
--function createPulseConsumer({ db, secrets = [], vipCache = null, now = () => Date.now(), log = console } = {}) {
++function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, now = () => Date.now(), log = console } = {}) {
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const inbox = createInbox(db, { table: 'community_event_inbox', now });
inbox.ensureSchema();
@@@ -76,6 +79,12 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, now = () => Da
const event = delivery.event;
if (!event || !EVENT_ID_RE.test(String(event.event_id || '')) || typeof event.event_type !== 'string') { stats.refused++; return problem(400, 'community.bad_delivery', 'body must be { event: <envelope>, seq }'); }
stats.received++; stats.last_at = new Date(now()).toISOString();
++ if (event.event_type === 'network.user.token_valid_after') {
++ if (!revocations) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_store' }); }
++ const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: revocations.apply(event) }));
++ if (r.duplicate) stats.duplicates++; else stats.applied++;
++ return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
++ }
if (event.event_type === 'vip.membership.changed') {
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
ddiff --git a/test/revocation.test.js b/test/revocation.test.js
nnew file mode 100644
iindex 0000000..9ac667e
---- /dev/null
++++ b/test/revocation.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * Revocation propagation in Community (WS-B task 4, Contracts 0.39.0 network.user.token_valid_after):
++ * a signed delivery moves the person's cutoff once (a redelivery is a no-op, anything not from Network
++ * changes nothing), and the viewer resolver then treats their older token as anonymous while a newer
++ * token and other people still sign in.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { createRevocationStore } = require('openvibe-sdk/auth');
++const { openDb } = require('../server/db');
++const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
++const { createViewerResolver } = require('../server/identity/viewer');
++
++const SECRET = `whsec_${'ab'.repeat(32)}`;
++const db = openDb(':memory:');
++const revocations = createRevocationStore(db, { table: 'token_revocations' });
++const app = express();
++app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], revocations, log: { error() {}, log() {}, warn() {} } }).router);
++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', BEN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
++const at = Date.now() - 5000;
++const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_type: 'network.user.token_valid_after', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
++ subject: { type: 'user', id: ANN }, actor: { type: 'user', id: ANN },
++ payload: { subject: { type: 'user', id: ANN }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' }, ...over });
++
++(async () => {
++ assert.ok(TOPICS.includes('network.user.token_valid_after'), 'Community subscribes to it');
++ const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
++ const post = (event) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } }).then(async (r) => ({ status: r.status, json: await r.json() }));
++ };
++ const claimsOf = (subject, iatMs) => ({ sub: 7, subject_id: subject, username: 'someone', role: 'user', iat: Math.floor(iatMs / 1000), exp: Math.floor(Date.now() / 1000) + 3600 });
++ let current = null;
++ const viewers = createViewerResolver({ auth: { verify: async () => current }, config: { allowSandbox: false }, network: null, revocations });
++ const who = async (claims) => { current = claims; return viewers.resolve({ headers: { authorization: 'Bearer opaque' }, cookies: {} }); };
++ try {
++ assert.strictEqual((await who(claimsOf(ANN, at - 60000))).kind, 'user', 'before the event');
++ let r = await post(evt());
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'revoked']);
++ assert.strictEqual((await post(evt())).json.duplicate, true, 'a redelivery is a no-op');
++ assert.strictEqual((await who(claimsOf(ANN, at - 60000))).kind, 'anonymous', 'the older token no longer signs in');
++ assert.strictEqual((await who(claimsOf(ANN, at + 1000))).kind, 'user', 'a newer token does');
++ assert.strictEqual((await who(claimsOf(BEN, at - 60000))).kind, 'user', 'someone else is untouched');
++ r = await post(evt({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN02', source: 'live', payload: { subject: { type: 'user', id: BEN }, valid_after: new Date().toISOString(), reason: 'banned' } }));
++ assert.strictEqual(r.json.outcome, 'ignored:source');
++ assert.strictEqual((await who(claimsOf(BEN, at - 60000))).kind, 'user');
++ } finally { srv.close(); }
++ console.log('revocation: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });