openvibers/openvibe.live Commits
Revocation propagation (WS-B task 4, Contracts 0.39.0): POST /internal/network-events (signature v2, LIVE_EVENTS_SECRET else MEDIA_EVENTS_SECRET) records network.user.token_valid_after per subject, only ever forwards; verifyToken refuses a Network session token issued before its subject's cutoff (reason revoked), so a signed-out device stops working on Live at once. scripts/subscribe-media-events.js --network subscribes to it. test/revocation.test.js
Changes
@@@ -25,7 +25,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.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.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -2814,9 +2814,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.35.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",-- "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",++ "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",@@@ -43,7 +43,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.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.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -12,6 +12,9 @@* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --dry-run # list, change nothing* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --disable # rollback* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --enable # undo a --disable++ * sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --network # Network's++ * network.user.token_valid_after → /internal/network-events (server/auth/network-events.js;++ * signed with LIVE_EVENTS_SECRET when set, else MEDIA_EVENTS_SECRET); combines with the flags above** [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]*@@@ -28,10 +31,12 @@ const fs = require('fs');const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';++const NETWORK_TOPICS = ['network.user.token_valid_after'];++const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';function parseArgs(argv) {-- const o = { liveEnv: DEFAULT_LIVE_ENV, endpoint: DEFAULT_ENDPOINT, action: 'create' };++ const o = { liveEnv: DEFAULT_LIVE_ENV, endpoint: null, action: 'create', topics: TOPICS };for (let i = 0; i < argv.length; i++) {const a = argv[i];if (a === '--dry-run') o.action = 'list';@@@ -39,8 +44,10 @@ function parseArgs(argv) {else if (a === '--enable') o.action = 'enable';else if (a === '--live-env') o.liveEnv = argv[++i];else if (a === '--endpoint') o.endpoint = argv[++i];++ else if (a === '--network') o.topics = NETWORK_TOPICS;else throw new Error(`unknown argument ${a}`);}++ if (o.endpoint === null) o.endpoint = o.topics === NETWORK_TOPICS ? NETWORK_ENDPOINT : DEFAULT_ENDPOINT;if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');return o;}@@@ -74,9 +81,10 @@ async function liveToken({ env, fetchImpl }) {* run({ action, endpoint, env, fetchImpl, log }) -> { action, subscriptions: [{ topic, subscription_id, created?, existed?, enabled? }] }* `env` is the parsed Live env file.*/--async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchImpl = globalThis.fetch, log = console.log }) {++async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, topics = TOPICS, env, fetchImpl = globalThis.fetch, log = console.log }) {const events = String(env.EVENTS_URL || 'http://127.0.0.1:4300').replace(/\/+$/, '');-- const secret = env.MEDIA_EVENTS_SECRET || '';++ // Network events are verified with LIVE_EVENTS_SECRET when set (server/auth/network-events.js).++ const secret = (topics === NETWORK_TOPICS && env.LIVE_EVENTS_SECRET) || env.MEDIA_EVENTS_SECRET || '';if (!env.OV_OAUTH_CLIENT_SECRET) throw new Error('OV_OAUTH_CLIENT_SECRET is not set in the Live env file');if (action === 'create' && secret.length < 32) throw new Error('MEDIA_EVENTS_SECRET must be set in the Live env file first (32+ characters: openssl rand -hex 32)');@@@ -90,7 +98,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchIconst describe = (s, topic) => `${s.id} (${topic} → ${endpoint}, ${isOn(s) ? 'enabled' : 'disabled'})`;const out = [];-- for (const topic of TOPICS) {++ for (const topic of topics) {const existing = all.find((s) => s.topic_pattern === topic && s.endpoint === endpoint) || null;if (action === 'list') {log(existing ? `exists: ${describe(existing, topic)}` : `no subscription ${topic} → ${endpoint} for ${clientId}; would create one`);@@@ -124,7 +132,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchIcontinue;}if (!r.ok) throw new Error(`Events answered ${r.status} for ${topic}: ${b.code || ''} ${b.detail || ''}`.trim());-- log(`subscribed: ${b.id} (${topic} → ${endpoint}), signed with Live's MEDIA_EVENTS_SECRET`);++ log(`subscribed: ${b.id} (${topic} → ${endpoint}), signed with Live's ${topics === NETWORK_TOPICS && env.LIVE_EVENTS_SECRET ? 'LIVE' : 'MEDIA'}_EVENTS_SECRET`);out.push({ topic, subscription_id: b.id, existed: false, created: true });}return { action, subscriptions: out };@@@ -134,7 +142,7 @@ if (require.main === module) {let o;let env;try { o = parseArgs(process.argv.slice(2)); env = readLiveEnv(o.liveEnv); } catch (err) { console.error(`subscribe-media-events: ${err.message}`); process.exit(2); }-- run({ action: o.action, endpoint: o.endpoint, env }).then(() => process.exit(0), (err) => { console.error(`subscribe-media-events: ${err.message}`); process.exit(1); });++ run({ action: o.action, endpoint: o.endpoint, topics: o.topics, env }).then(() => process.exit(0), (err) => { console.error(`subscribe-media-events: ${err.message}`); process.exit(1); });}--module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT };++module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT, NETWORK_TOPICS, NETWORK_ENDPOINT };@@@ -59,7 +59,9 @@ function verifyToken(token) {algorithms: ['RS256'],issuer: getNetworkIssuer(),});-- return isUserSessionClaims(decoded) ? decoded : null;++ if (!isUserSessionClaims(decoded)) return null;++ // Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).++ return require('./revocations').isRevoked(decoded) ? null : decoded;} catch {return null;}@@@ -77,6 +79,7 @@ function verifyTokenWithReason(token) {issuer: getNetworkIssuer(),});if (!isUserSessionClaims(decoded)) return { ok: false, reason: 'not_a_session_token' };++ if (require('./revocations').isRevoked(decoded)) return { ok: false, reason: 'revoked' };return { ok: true, decoded };} catch (err) {return { ok: false, reason: err.name, message: err.message };nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++/**++ * OpenVibe.Events → Live: identity events from OpenVibe.Network (roadmap WS-B task 4).++ *++ * POST /internal/network-events the endpoint of Live's subscription to++ * network.user.token_valid_after (scripts/subscribe-media-events.js++ * --network), signed with LIVE_EVENTS_SECRET, else MEDIA_EVENTS_SECRET++ * (Live's one events secret), signature v2 only.++ *++ * Applying is idempotent (a cutoff only ever moves forward), so a redelivery is a no-op without an++ * inbox. Anything signed but not ours to act on is acknowledged (204) so it is not redelivered.++ */++const revocations = require('./revocations');++++const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const stats = { received: 0, revoked: 0, unchanged: 0, ignored: 0, refused: 0 };++++function secret() { return process.env.LIVE_EVENTS_SECRET || process.env.MEDIA_EVENTS_SECRET || ''; }++++/** Apply one envelope; returns 'revoked' | 'unchanged' | 'ignored:<why>'. */++function apply(ev) {++ if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';++ if (ev.event_type !== 'network.user.token_valid_after') return 'ignored:type';++ if (ev.source !== 'network') return 'ignored:source';++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};++ const subject = p.subject && p.subject.id;++ const ms = Date.parse(p.valid_after);++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';++ return revocations.record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null) ? 'revoked' : 'unchanged';++}++++/** Express handler (needs req.rawBody from the express.json verify hook). */++function handler(req, res) {++ const key = secret();++ if (key.length < 32) return res.status(503).json({ error: 'LIVE_EVENTS_SECRET is not set' });++ const { parseDelivery } = require('openvibe-sdk/events');++ const delivery = parseDelivery(req.rawBody, req.headers, key, { requireV2: true });++ if (!delivery) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }++ stats.received++;++ const out = apply(delivery.event);++ if (out === 'revoked') stats.revoked++; else if (out === 'unchanged') stats.unchanged++; else stats.ignored++;++ res.status(204).end();++}++++module.exports = { handler, apply, stats, secret };nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++/**++ * Per-person token cutoffs from OpenVibe.Network (roadmap WS-B task 4; 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; Live receives it at++ * POST /internal/network-events (./network-events.js). auth.js refuses a Network session token issued++ * before its subject's cutoff (Network's rule: iat * 1000 < valid_after), so a signed-out phone stops++ * working on Live at once instead of when its 7-day token expires.++ */++const db = require('../db/database');++++let ready = false;++const cache = new Map(); // subject → valid_after ms (0 = none known)++++function ensureSchema() {++ if (ready) return;++ db.run(`CREATE TABLE IF NOT EXISTS token_revocations (++ subject_id TEXT PRIMARY KEY,++ valid_after_ms INTEGER NOT NULL,++ reason TEXT,++ updated_at INTEGER NOT NULL++ )`);++ ready = true;++}++++/** The cutoff for a subject in ms, 0 when none. */++function cutoffFor(subject) {++ if (!subject) return 0;++ if (cache.has(subject)) return cache.get(subject);++ ensureSchema();++ const row = db.get('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?', [subject]);++ const ms = row ? Number(row.valid_after_ms) || 0 : 0;++ if (cache.size > 50000) cache.clear();++ cache.set(subject, ms);++ return ms;++}++++/** Keep the later cutoff (events can arrive out of order). Returns true when it moved forward. */++function record(subject, validAfterMs, reason = null, now = Date.now()) {++ ensureSchema();++ if (!(validAfterMs > cutoffFor(subject))) return false;++ db.run(`INSERT INTO token_revocations (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 > token_revocations.valid_after_ms`, [subject, validAfterMs, reason, now]);++ cache.set(subject, validAfterMs);++ return true;++}++++/** Was a session token with these (verified) claims issued before its subject's cutoff? */++function isRevoked(claims) {++ if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;++ return claims.iat * 1000 < cutoffFor(claims.subject_id);++}++++function _reset() { cache.clear(); ready = false; }++++module.exports = { ensureSchema, cutoffFor, record, isRevoked, _reset };@@@ -586,6 +586,8 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {app.post('/internal/media-webhook', require('./media-proxy/webhook'));// OpenVibe.Events delivery of media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET)app.post('/internal/media-events', require('./media-proxy/media-events').handler);++// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.++app.post('/internal/network-events', require('./auth/network-events').handler);// OpenVibe.Events → Live: OpenRe session lifecycle mirrored into `streams` (signed delivery,// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++// Revocation propagation in Live (WS-B task 4, Contracts 0.39.0 network.user.token_valid_after):++// POST /internal/network-events (signature v2) records a person's cutoff, only ever forwards; auth.js++// then refuses their Network session tokens issued before it and accepts newer ones; other people,++// unsigned, forged and foreign events change nothing; the subscribe script knows the --network topic.++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 tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-revoke-'));++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');++process.env.LIVE_EVENTS_SECRET = 's'.repeat(40);++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const jwt = require('jsonwebtoken');++const express = require('express');++const { ids } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const db = require('../server/db/database');++db.initDb();++const auth = require('../server/auth/auth');++auth.reloadNetworkKey();++const networkEvents = require('../server/auth/network-events');++const { parseArgs, NETWORK_TOPICS, NETWORK_ENDPOINT } = require('../scripts/subscribe-media-events');++++const ALICE = ids.newId('user'), BOB = ids.newId('user');++const now = () => Math.floor(Date.now() / 1000);++const token = (subject, iatOffset) => jwt.sign({ sub: 7, id: 7, subject_id: subject, username: 'x', role: 'user', iat: now() + iatOffset }, keys.privateKey, { algorithm: 'RS256', issuer: auth.getNetworkIssuer(), expiresIn: '1h' });++const envelope = (subject, validAfterMs, over = {}) => ({++ event_id: ids.newId('event'), event_type: 'network.user.token_valid_after', version: 1, source: 'network',++ actor: { type: 'user', id: subject }, timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: subject },++ payload: { subject: { type: 'user', id: subject }, valid_after: new Date(validAfterMs).toISOString(), reason: 'signed_out_everywhere' }, ...over,++});++++(async () => {++ const app = express();++ app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));++ app.post('/internal/network-events', networkEvents.handler);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${server.address().port}/internal/network-events`;++ const deliver = (ev, secret = process.env.LIVE_EVENTS_SECRET) => {++ const body = JSON.stringify({ event: ev, seq: 1 });++ const headers = { 'content-type': 'application/json', ...(secret ? signDeliveryHeaders(body, secret) : {}) };++ return fetch(url, { method: 'POST', headers, body }).then((r) => r.status);++ };++ try {++ const oldA = token(ALICE, -60), oldB = token(BOB, -60);++ assert.ok(auth.verifyToken(oldA) && auth.verifyToken(oldB), 'valid before any cutoff');++++ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000), null), 401, 'unsigned');++ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000), 'f'.repeat(40)), 401, 'wrong secret');++ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000, { source: 'live' })), 204);++ assert.ok(auth.verifyToken(oldA), 'a foreign source changes nothing');++++ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000)), 204);++ assert.strictEqual(networkEvents.stats.revoked, 1);++ assert.strictEqual(auth.verifyToken(oldA), null, 'the old token is refused');++ assert.deepStrictEqual(auth.verifyTokenWithReason(oldA), { ok: false, reason: 'revoked' });++ assert.ok(auth.verifyToken(token(ALICE, 0)), 'a token issued after the cutoff works');++ assert.ok(auth.verifyToken(oldB), 'someone else is untouched');++++ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 600000)), 204);++ assert.strictEqual(networkEvents.stats.unchanged, 1, 'an older cutoff never moves it back');++ assert.strictEqual(auth.verifyToken(oldA), null);++ assert.strictEqual(db.get('SELECT COUNT(*) AS n FROM token_revocations').n, 1, 'kept in the database');++++ const o = parseArgs(['--network', '--dry-run']);++ assert.deepStrictEqual([o.topics, o.endpoint, o.action], [NETWORK_TOPICS, NETWORK_ENDPOINT, 'list']);++ assert.ok(/app\.post\('\/internal\/network-events'/.test(fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8')), 'mounted');++ } finally {++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ quiet('revocation: all checks passed');++})().catch((e) => { console.error = quiet; quiet(e); process.exit(1); });@@@ -90,7 +90,7 @@ const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CLawait assert.rejects(run({ env: { ...ENV, OV_OAUTH_CLIENT_SECRET: '' }, fetchImpl: stub().fetchImpl, log: logs() }), /OV_OAUTH_CLIENT_SECRET/);await assert.rejects(run({ env: ENV, fetchImpl: stub({ tokenStatus: 401 }).fetchImpl, log: logs() }), /Network refused a token/);-- assert.deepStrictEqual(parseArgs(['--dry-run', '--endpoint', 'http://x/internal/media-events']), { liveEnv: '/etc/openvibe/live.env', endpoint: 'http://x/internal/media-events', action: 'list' });++ assert.deepStrictEqual(parseArgs(['--dry-run', '--endpoint', 'http://x/internal/media-events']), { liveEnv: '/etc/openvibe/live.env', endpoint: 'http://x/internal/media-events', action: 'list', topics: TOPICS });assert.throws(() => parseArgs(['--bogus']), /unknown argument/);console.log('subscribe-media-events: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.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.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"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",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -2814,9 +2814,9 @@
@@ -2814,9 +2814,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.35.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
- "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",
+ "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",
@@ -43,7 +43,7 @@
@@ -43,7 +43,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.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.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"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",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -12,6 +12,9 @@
@@ -12,6 +12,9 @@
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --dry-run # list, change nothing
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --dry-run # list, change nothing
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --disable # rollback
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --disable # rollback
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --enable # undo a --disable
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --enable # undo a --disable
+ * sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --network # Network's
+ * network.user.token_valid_after → /internal/network-events (server/auth/network-events.js;
+ * signed with LIVE_EVENTS_SECRET when set, else MEDIA_EVENTS_SECRET); combines with the flags above
*
*
* [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]
* [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]
*
*
@@ -28,10 +31,12 @@ const fs = require('fs');
@@ -28,10 +31,12 @@ const fs = require('fs');
const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];
const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';
+const NETWORK_TOPICS = ['network.user.token_valid_after'];
+const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
function parseArgs(argv) {
function parseArgs(argv) {
- const o = { liveEnv: DEFAULT_LIVE_ENV, endpoint: DEFAULT_ENDPOINT, action: 'create' };
+ const o = { liveEnv: DEFAULT_LIVE_ENV, endpoint: null, action: 'create', topics: TOPICS };
for (let i = 0; i < argv.length; i++) {
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const a = argv[i];
if (a === '--dry-run') o.action = 'list';
if (a === '--dry-run') o.action = 'list';
@@ -39,8 +44,10 @@ function parseArgs(argv) {
@@ -39,8 +44,10 @@ function parseArgs(argv) {
else if (a === '--enable') o.action = 'enable';
else if (a === '--enable') o.action = 'enable';
else if (a === '--live-env') o.liveEnv = argv[++i];
else if (a === '--live-env') o.liveEnv = argv[++i];
else if (a === '--endpoint') o.endpoint = argv[++i];
else if (a === '--endpoint') o.endpoint = argv[++i];
+ else if (a === '--network') o.topics = NETWORK_TOPICS;
else throw new Error(`unknown argument ${a}`);
else throw new Error(`unknown argument ${a}`);
}
}
+ if (o.endpoint === null) o.endpoint = o.topics === NETWORK_TOPICS ? NETWORK_ENDPOINT : DEFAULT_ENDPOINT;
if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');
if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');
return o;
return o;
}
}
@@ -74,9 +81,10 @@ async function liveToken({ env, fetchImpl }) {
@@ -74,9 +81,10 @@ async function liveToken({ env, fetchImpl }) {
* run({ action, endpoint, env, fetchImpl, log }) -> { action, subscriptions: [{ topic, subscription_id, created?, existed?, enabled? }] }
* run({ action, endpoint, env, fetchImpl, log }) -> { action, subscriptions: [{ topic, subscription_id, created?, existed?, enabled? }] }
* `env` is the parsed Live env file.
* `env` is the parsed Live env file.
*/
*/
-async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchImpl = globalThis.fetch, log = console.log }) {
+async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, topics = TOPICS, env, fetchImpl = globalThis.fetch, log = console.log }) {
const events = String(env.EVENTS_URL || 'http://127.0.0.1:4300').replace(/\/+$/, '');
const events = String(env.EVENTS_URL || 'http://127.0.0.1:4300').replace(/\/+$/, '');
- const secret = env.MEDIA_EVENTS_SECRET || '';
+ // Network events are verified with LIVE_EVENTS_SECRET when set (server/auth/network-events.js).
+ const secret = (topics === NETWORK_TOPICS && env.LIVE_EVENTS_SECRET) || env.MEDIA_EVENTS_SECRET || '';
if (!env.OV_OAUTH_CLIENT_SECRET) throw new Error('OV_OAUTH_CLIENT_SECRET is not set in the Live env file');
if (!env.OV_OAUTH_CLIENT_SECRET) throw new Error('OV_OAUTH_CLIENT_SECRET is not set in the Live env file');
if (action === 'create' && secret.length < 32) throw new Error('MEDIA_EVENTS_SECRET must be set in the Live env file first (32+ characters: openssl rand -hex 32)');
if (action === 'create' && secret.length < 32) throw new Error('MEDIA_EVENTS_SECRET must be set in the Live env file first (32+ characters: openssl rand -hex 32)');
@@ -90,7 +98,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchI
@@ -90,7 +98,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchI
const describe = (s, topic) => `${s.id} (${topic} → ${endpoint}, ${isOn(s) ? 'enabled' : 'disabled'})`;
const describe = (s, topic) => `${s.id} (${topic} → ${endpoint}, ${isOn(s) ? 'enabled' : 'disabled'})`;
const out = [];
const out = [];
- for (const topic of TOPICS) {
+ for (const topic of topics) {
const existing = all.find((s) => s.topic_pattern === topic && s.endpoint === endpoint) || null;
const existing = all.find((s) => s.topic_pattern === topic && s.endpoint === endpoint) || null;
if (action === 'list') {
if (action === 'list') {
log(existing ? `exists: ${describe(existing, topic)}` : `no subscription ${topic} → ${endpoint} for ${clientId}; would create one`);
log(existing ? `exists: ${describe(existing, topic)}` : `no subscription ${topic} → ${endpoint} for ${clientId}; would create one`);
@@ -124,7 +132,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchI
@@ -124,7 +132,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, env, fetchI
continue;
continue;
}
}
if (!r.ok) throw new Error(`Events answered ${r.status} for ${topic}: ${b.code || ''} ${b.detail || ''}`.trim());
if (!r.ok) throw new Error(`Events answered ${r.status} for ${topic}: ${b.code || ''} ${b.detail || ''}`.trim());
- log(`subscribed: ${b.id} (${topic} → ${endpoint}), signed with Live's MEDIA_EVENTS_SECRET`);
+ log(`subscribed: ${b.id} (${topic} → ${endpoint}), signed with Live's ${topics === NETWORK_TOPICS && env.LIVE_EVENTS_SECRET ? 'LIVE' : 'MEDIA'}_EVENTS_SECRET`);
out.push({ topic, subscription_id: b.id, existed: false, created: true });
out.push({ topic, subscription_id: b.id, existed: false, created: true });
}
}
return { action, subscriptions: out };
return { action, subscriptions: out };
@@ -134,7 +142,7 @@ if (require.main === module) {
@@ -134,7 +142,7 @@ if (require.main === module) {
let o;
let o;
let env;
let env;
try { o = parseArgs(process.argv.slice(2)); env = readLiveEnv(o.liveEnv); } catch (err) { console.error(`subscribe-media-events: ${err.message}`); process.exit(2); }
try { o = parseArgs(process.argv.slice(2)); env = readLiveEnv(o.liveEnv); } catch (err) { console.error(`subscribe-media-events: ${err.message}`); process.exit(2); }
- run({ action: o.action, endpoint: o.endpoint, env }).then(() => process.exit(0), (err) => { console.error(`subscribe-media-events: ${err.message}`); process.exit(1); });
+ run({ action: o.action, endpoint: o.endpoint, topics: o.topics, env }).then(() => process.exit(0), (err) => { console.error(`subscribe-media-events: ${err.message}`); process.exit(1); });
}
}
-module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT };
+module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT, NETWORK_TOPICS, NETWORK_ENDPOINT };
@@ -59,7 +59,9 @@ function verifyToken(token) {
@@ -59,7 +59,9 @@ function verifyToken(token) {
algorithms: ['RS256'],
algorithms: ['RS256'],
issuer: getNetworkIssuer(),
issuer: getNetworkIssuer(),
});
});
- return isUserSessionClaims(decoded) ? decoded : null;
+ if (!isUserSessionClaims(decoded)) return null;
+ // Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).
+ return require('./revocations').isRevoked(decoded) ? null : decoded;
} catch {
} catch {
return null;
return null;
}
}
@@ -77,6 +79,7 @@ function verifyTokenWithReason(token) {
@@ -77,6 +79,7 @@ function verifyTokenWithReason(token) {
issuer: getNetworkIssuer(),
issuer: getNetworkIssuer(),
});
});
if (!isUserSessionClaims(decoded)) return { ok: false, reason: 'not_a_session_token' };
if (!isUserSessionClaims(decoded)) return { ok: false, reason: 'not_a_session_token' };
+ if (require('./revocations').isRevoked(decoded)) return { ok: false, reason: 'revoked' };
return { ok: true, decoded };
return { ok: true, decoded };
} catch (err) {
} catch (err) {
return { ok: false, reason: err.name, message: err.message };
return { ok: false, reason: err.name, message: err.message };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+/**
+ * OpenVibe.Events → Live: identity events from OpenVibe.Network (roadmap WS-B task 4).
+ *
+ * POST /internal/network-events the endpoint of Live's subscription to
+ * network.user.token_valid_after (scripts/subscribe-media-events.js
+ * --network), signed with LIVE_EVENTS_SECRET, else MEDIA_EVENTS_SECRET
+ * (Live's one events secret), signature v2 only.
+ *
+ * Applying is idempotent (a cutoff only ever moves forward), so a redelivery is a no-op without an
+ * inbox. Anything signed but not ours to act on is acknowledged (204) so it is not redelivered.
+ */
+const revocations = require('./revocations');
+
+const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const stats = { received: 0, revoked: 0, unchanged: 0, ignored: 0, refused: 0 };
+
+function secret() { return process.env.LIVE_EVENTS_SECRET || process.env.MEDIA_EVENTS_SECRET || ''; }
+
+/** Apply one envelope; returns 'revoked' | 'unchanged' | 'ignored:<why>'. */
+function apply(ev) {
+ if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';
+ if (ev.event_type !== 'network.user.token_valid_after') return 'ignored:type';
+ if (ev.source !== 'network') return 'ignored:source';
+ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
+ const subject = p.subject && p.subject.id;
+ const ms = Date.parse(p.valid_after);
+ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
+ return revocations.record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null) ? 'revoked' : 'unchanged';
+}
+
+/** Express handler (needs req.rawBody from the express.json verify hook). */
+function handler(req, res) {
+ const key = secret();
+ if (key.length < 32) return res.status(503).json({ error: 'LIVE_EVENTS_SECRET is not set' });
+ const { parseDelivery } = require('openvibe-sdk/events');
+ const delivery = parseDelivery(req.rawBody, req.headers, key, { requireV2: true });
+ if (!delivery) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
+ stats.received++;
+ const out = apply(delivery.event);
+ if (out === 'revoked') stats.revoked++; else if (out === 'unchanged') stats.unchanged++; else stats.ignored++;
+ res.status(204).end();
+}
+
+module.exports = { handler, apply, stats, secret };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+/**
+ * Per-person token cutoffs from OpenVibe.Network (roadmap WS-B task 4; 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; Live receives it at
+ * POST /internal/network-events (./network-events.js). auth.js refuses a Network session token issued
+ * before its subject's cutoff (Network's rule: iat * 1000 < valid_after), so a signed-out phone stops
+ * working on Live at once instead of when its 7-day token expires.
+ */
+const db = require('../db/database');
+
+let ready = false;
+const cache = new Map(); // subject → valid_after ms (0 = none known)
+
+function ensureSchema() {
+ if (ready) return;
+ db.run(`CREATE TABLE IF NOT EXISTS token_revocations (
+ subject_id TEXT PRIMARY KEY,
+ valid_after_ms INTEGER NOT NULL,
+ reason TEXT,
+ updated_at INTEGER NOT NULL
+ )`);
+ ready = true;
+}
+
+/** The cutoff for a subject in ms, 0 when none. */
+function cutoffFor(subject) {
+ if (!subject) return 0;
+ if (cache.has(subject)) return cache.get(subject);
+ ensureSchema();
+ const row = db.get('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?', [subject]);
+ const ms = row ? Number(row.valid_after_ms) || 0 : 0;
+ if (cache.size > 50000) cache.clear();
+ cache.set(subject, ms);
+ return ms;
+}
+
+/** Keep the later cutoff (events can arrive out of order). Returns true when it moved forward. */
+function record(subject, validAfterMs, reason = null, now = Date.now()) {
+ ensureSchema();
+ if (!(validAfterMs > cutoffFor(subject))) return false;
+ db.run(`INSERT INTO token_revocations (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 > token_revocations.valid_after_ms`, [subject, validAfterMs, reason, now]);
+ cache.set(subject, validAfterMs);
+ return true;
+}
+
+/** Was a session token with these (verified) claims issued before its subject's cutoff? */
+function isRevoked(claims) {
+ if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;
+ return claims.iat * 1000 < cutoffFor(claims.subject_id);
+}
+
+function _reset() { cache.clear(); ready = false; }
+
+module.exports = { ensureSchema, cutoffFor, record, isRevoked, _reset };
@@ -586,6 +586,8 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
@@ -586,6 +586,8 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
app.post('/internal/media-webhook', require('./media-proxy/webhook'));
app.post('/internal/media-webhook', require('./media-proxy/webhook'));
// OpenVibe.Events delivery of media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET)
// OpenVibe.Events delivery of media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET)
app.post('/internal/media-events', require('./media-proxy/media-events').handler);
app.post('/internal/media-events', require('./media-proxy/media-events').handler);
+// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.
+app.post('/internal/network-events', require('./auth/network-events').handler);
// OpenVibe.Events → Live: OpenRe session lifecycle mirrored into `streams` (signed delivery,
// OpenVibe.Events → Live: OpenRe session lifecycle mirrored into `streams` (signed delivery,
// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+// Revocation propagation in Live (WS-B task 4, Contracts 0.39.0 network.user.token_valid_after):
+// POST /internal/network-events (signature v2) records a person's cutoff, only ever forwards; auth.js
+// then refuses their Network session tokens issued before it and accepts newer ones; other people,
+// unsigned, forged and foreign events change nothing; the subscribe script knows the --network topic.
+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 tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-revoke-'));
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
+process.env.LIVE_EVENTS_SECRET = 's'.repeat(40);
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const jwt = require('jsonwebtoken');
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const db = require('../server/db/database');
+db.initDb();
+const auth = require('../server/auth/auth');
+auth.reloadNetworkKey();
+const networkEvents = require('../server/auth/network-events');
+const { parseArgs, NETWORK_TOPICS, NETWORK_ENDPOINT } = require('../scripts/subscribe-media-events');
+
+const ALICE = ids.newId('user'), BOB = ids.newId('user');
+const now = () => Math.floor(Date.now() / 1000);
+const token = (subject, iatOffset) => jwt.sign({ sub: 7, id: 7, subject_id: subject, username: 'x', role: 'user', iat: now() + iatOffset }, keys.privateKey, { algorithm: 'RS256', issuer: auth.getNetworkIssuer(), expiresIn: '1h' });
+const envelope = (subject, validAfterMs, over = {}) => ({
+ event_id: ids.newId('event'), event_type: 'network.user.token_valid_after', version: 1, source: 'network',
+ actor: { type: 'user', id: subject }, timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: subject },
+ payload: { subject: { type: 'user', id: subject }, valid_after: new Date(validAfterMs).toISOString(), reason: 'signed_out_everywhere' }, ...over,
+});
+
+(async () => {
+ const app = express();
+ app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
+ app.post('/internal/network-events', networkEvents.handler);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${server.address().port}/internal/network-events`;
+ const deliver = (ev, secret = process.env.LIVE_EVENTS_SECRET) => {
+ const body = JSON.stringify({ event: ev, seq: 1 });
+ const headers = { 'content-type': 'application/json', ...(secret ? signDeliveryHeaders(body, secret) : {}) };
+ return fetch(url, { method: 'POST', headers, body }).then((r) => r.status);
+ };
+ try {
+ const oldA = token(ALICE, -60), oldB = token(BOB, -60);
+ assert.ok(auth.verifyToken(oldA) && auth.verifyToken(oldB), 'valid before any cutoff');
+
+ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000), null), 401, 'unsigned');
+ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000), 'f'.repeat(40)), 401, 'wrong secret');
+ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000, { source: 'live' })), 204);
+ assert.ok(auth.verifyToken(oldA), 'a foreign source changes nothing');
+
+ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 5000)), 204);
+ assert.strictEqual(networkEvents.stats.revoked, 1);
+ assert.strictEqual(auth.verifyToken(oldA), null, 'the old token is refused');
+ assert.deepStrictEqual(auth.verifyTokenWithReason(oldA), { ok: false, reason: 'revoked' });
+ assert.ok(auth.verifyToken(token(ALICE, 0)), 'a token issued after the cutoff works');
+ assert.ok(auth.verifyToken(oldB), 'someone else is untouched');
+
+ assert.strictEqual(await deliver(envelope(ALICE, Date.now() - 600000)), 204);
+ assert.strictEqual(networkEvents.stats.unchanged, 1, 'an older cutoff never moves it back');
+ assert.strictEqual(auth.verifyToken(oldA), null);
+ assert.strictEqual(db.get('SELECT COUNT(*) AS n FROM token_revocations').n, 1, 'kept in the database');
+
+ const o = parseArgs(['--network', '--dry-run']);
+ assert.deepStrictEqual([o.topics, o.endpoint, o.action], [NETWORK_TOPICS, NETWORK_ENDPOINT, 'list']);
+ assert.ok(/app\.post\('\/internal\/network-events'/.test(fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8')), 'mounted');
+ } finally {
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ quiet('revocation: all checks passed');
+})().catch((e) => { console.error = quiet; quiet(e); process.exit(1); });
@@ -90,7 +90,7 @@ const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CL
@@ -90,7 +90,7 @@ const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CL
await assert.rejects(run({ env: { ...ENV, OV_OAUTH_CLIENT_SECRET: '' }, fetchImpl: stub().fetchImpl, log: logs() }), /OV_OAUTH_CLIENT_SECRET/);
await assert.rejects(run({ env: { ...ENV, OV_OAUTH_CLIENT_SECRET: '' }, fetchImpl: stub().fetchImpl, log: logs() }), /OV_OAUTH_CLIENT_SECRET/);
await assert.rejects(run({ env: ENV, fetchImpl: stub({ tokenStatus: 401 }).fetchImpl, log: logs() }), /Network refused a token/);
await assert.rejects(run({ env: ENV, fetchImpl: stub({ tokenStatus: 401 }).fetchImpl, log: logs() }), /Network refused a token/);
- assert.deepStrictEqual(parseArgs(['--dry-run', '--endpoint', 'http://x/internal/media-events']), { liveEnv: '/etc/openvibe/live.env', endpoint: 'http://x/internal/media-events', action: 'list' });
+ assert.deepStrictEqual(parseArgs(['--dry-run', '--endpoint', 'http://x/internal/media-events']), { liveEnv: '/etc/openvibe/live.env', endpoint: 'http://x/internal/media-events', action: 'list', topics: TOPICS });
assert.throws(() => parseArgs(['--bogus']), /unknown argument/);
assert.throws(() => parseArgs(['--bogus']), /unknown argument/);
console.log('subscribe-media-events: all checks passed');
console.log('subscribe-media-events: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });