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

OP OpenVibers committed 24 Sept 2026 · a48c5a2af2 · 9 files, +217 −14


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 391e568d..7bccf88d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 0deb9c16..eb81a495 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/subscribe-media-events.js b/scripts/subscribe-media-events.js
iindex 3f395df2..f8eed304 100644
---- a/scripts/subscribe-media-events.js
++++ b/scripts/subscribe-media-events.js
@@@ -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, fetchI
const 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, fetchI
continue;
}
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 };
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex bc68d3d7..ac8cfaab 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -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 };
ddiff --git a/server/auth/network-events.js b/server/auth/network-events.js
nnew file mode 100644
iindex 00000000..fb9481c4
---- /dev/null
++++ b/server/auth/network-events.js
@@@ -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 };
ddiff --git a/server/auth/revocations.js b/server/auth/revocations.js
nnew file mode 100644
iindex 00000000..33b16e64
---- /dev/null
++++ b/server/auth/revocations.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 6aaaf695..b7615a29 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/test/revocation.test.js b/test/revocation.test.js
nnew file mode 100644
iindex 00000000..d3a442b6
---- /dev/null
++++ b/test/revocation.test.js
@@@ -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); });
ddiff --git a/test/subscribe-media-events.test.js b/test/subscribe-media-events.test.js
iindex 15b73fec..ebaa36d4 100644
---- a/test/subscribe-media-events.test.js
++++ b/test/subscribe-media-events.test.js
@@@ -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, 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); });