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
Changes
@@@ -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"@@@ -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": {@@@ -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 }));@@@ -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); }@@@ -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 = () => Daconst 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' }));nnew file mode 100644@@@ -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); });
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
"engines": {
"engines": {
@@ -1057,9 +1057,9 @@
@@ -1057,9 +1057,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1073,9 +1073,9 @@
@@ -1073,9 +1073,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -26,8 +26,8 @@
@@ -26,8 +26,8 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
"engines": {
"engines": {
@@ -111,7 +111,10 @@ function createApp(opts = {}) {
@@ -111,7 +111,10 @@ function createApp(opts = {}) {
// ── Community's database, identity, and what lives in it in every mode ──
// ── Community's database, identity, and what lives in it in every mode ──
const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
const network = opts.network || createNetworkIdentity({ config, db });
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 pulse = createPulse({ db, network, config });
const relay = opts.relay || createDiscordRelay({
const relay = opts.relay || createDiscordRelay({
db, config, enabled: config.discordRelay.enabled,
db, config, enabled: config.discordRelay.enabled,
@@ -166,7 +169,7 @@ function createApp(opts = {}) {
@@ -166,7 +169,7 @@ function createApp(opts = {}) {
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter }));
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter }));
app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers }));
app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
// 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.locals.pulseConsumer = pulseConsumer;
app.use('/internal/events', pulseConsumer.router);
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
@@ -47,7 +47,7 @@ function hasCap(viewer, capabilityId) {
@@ -47,7 +47,7 @@ function hasCap(viewer, capabilityId) {
return !!(viewer && viewer.kind === 'service' && capabilities.check(viewer.claims, capabilityId).allowed);
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) {
async function fromServiceToken(req, token) {
const publicKey = await auth.ensureKey();
const publicKey = await auth.ensureKey();
if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
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 }) {
@@ -106,6 +106,8 @@ function createViewerResolver({ auth, config, network }) {
async function fromUserToken(token) {
async function fromUserToken(token) {
const claims = await auth.verify(token);
const claims = await auth.verify(token);
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
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;
let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
if (!subject && network && claims.sub != null) {
if (!subject && network && claims.sub != null) {
try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
@@ -9,6 +9,9 @@
@@ -9,6 +9,9 @@
* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
* news.story.published "News: <topic>" → the story (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
* 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.
* 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');
@@ -23,7 +26,7 @@ const { parseDelivery, createInbox } = require('openvibe-sdk/events');
const store = require('./store');
const store = require('./store');
const CONSUMER = 'community';
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 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 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);
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) {
@@ -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 };
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 keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const inbox = createInbox(db, { table: 'community_event_inbox', now });
const inbox = createInbox(db, { table: 'community_event_inbox', now });
inbox.ensureSchema();
inbox.ensureSchema();
@@ -76,6 +79,12 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, now = () => Da
@@ -76,6 +79,12 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, now = () => Da
const event = delivery.event;
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 }'); }
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();
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.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' }); }
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' }));
const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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); });