openvibers/openvibe.live Commits

Live consumes Media's outcomes from OpenVibe.Events: POST /internal/media-events takes Live's subscriptions to media.vod.*, media.clip.* and media.storage.* (MEDIA_EVENTS_SECRET, signature v2 only via openvibe-sdk parseDelivery requireV2), Live's tenant only (MEDIA_APP_ID), media.object.uploaded ignored. What Live does with an outcome moved to server/media-proxy/outcomes.js, shared with the direct webhook, and each outcome is applied once whichever copy arrives first: an inbox receipt (consumer live-media-outcomes, key media:<object type>:<object id>:<event id>, Media puts the event id in the webhook body since cbf8410) in the same transaction as Live's writes; recorder/AI/ops-alert side effects run after the commit for the winning copy only. MEDIA_EVENTS_AUTHORITY=webhook (default) | both | events picks the path that acts; under events a webhook with an event_id is acknowledged and dropped, one without still acts. scripts/subscribe-media-events.js creates, lists (--dry-run), disables and re-enables the three subscriptions as Live. docs/architecture.md: the events Live produces and consumes, the transition and the webhook removal steps. Tests: test/media-events.test.js (both paths, each authority, duplicates both ways, tenant/source filters, v2 signatures), test/subscribe-media-events.test.js

OP OpenVibers committed 23 Sept 2026 · d2715e5f54 · 11 files, +761 −110


Changes

ddiff --git a/.env.example b/.env.example
iindex ea95709c..130ee3fa 100644
---- a/.env.example
++++ b/.env.example
@@@ -77,6 +77,13 @@ MEDIA_APP_ID=live
MEDIA_API_KEY=
# Shared secret for verifying Media → Live webhooks (X-OVMedia-Signature HMAC)
MEDIA_WEBHOOK_SECRET=
++# Media outcomes over OpenVibe.Events (roadmap Wave 3): secret of Live's subscriptions to
++# media.vod.* / media.clip.* / media.storage.* (endpoint http://127.0.0.1:3000/internal/media-events,
++# created by scripts/subscribe-media-events.js; 32+ characters, openssl rand -hex 32).
++#MEDIA_EVENTS_SECRET=
++# Which path acts on a Media outcome: webhook (default) | both (transition: whichever arrives first,
++# the other is a no-op) | events (the webhook is only acknowledged). See docs/architecture.md.
++#MEDIA_EVENTS_AUTHORITY=webhook
# ── RTMP ingest ───────────────────────────────────────────────────────────────
RTMP_PORT=1935
ddiff --git a/README.md b/README.md
iindex c6dbba5f..f2f927f5 100644
---- a/README.md
++++ b/README.md
@@@ -44,7 +44,7 @@ The media subsystem lives in **OpenVibe.Media**:
- On stream start, Live creates a VOD in Media and starts ingest — RTMP streams are pulled by Media from `rtmp://127.0.0.1:1935/live/<key>`; WebRTC/WHIP streams are forwarded over RTP to ports Media allocates (UDP 12000-12199); browser MediaRecorder chunks are proxied to Media's chunks endpoints.
- The SPA's existing `/api/vods…`, `/api/clips…`, `/api/pastes…`, `/api/thumbnails/:filename` calls are preserved by thin proxies; big media files 302-redirect to `https://openvibe.media`.
--- Media calls back at `POST /internal/media-webhook` (`X-OVMedia-Signature` HMAC) on `vod.ready` / `clip.ready` — driving recording state, AI jobs, and clip chat announcements.
++- Media reports `vod.ready` / `clip.ready` (and failures, storage alerts) as `media.*` OpenVibe.Events events (`POST /internal/media-events`) and, during the transition, the direct webhook `POST /internal/media-webhook` (`X-OVMedia-Signature` HMAC) — driving recording state, AI jobs, and clip chat announcements. Each outcome is applied once; `MEDIA_EVENTS_AUTHORITY` picks the path (see [docs/architecture.md](docs/architecture.md#media-outcomes-over-events)).
- Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state` / `clip_ai_state` in live.db.
### Authentication & currencies
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex b23fac9e..c96a9168 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -169,3 +169,48 @@ SSO and wallet come from OpenVibe.Network; media storage and processing from Ope
inter-service contracts are in `../CONTRACTS.md` (OpenVibers workspace). Deployment:
[deploy.md](deploy.md). Security posture: [../SECURITY_AUDIT.md](../SECURITY_AUDIT.md). Measurements:
[performance-audit.md](performance-audit.md).
++
++### Durable events (OpenVibe.Events)
++
++Live publishes through one transactional outbox (`event_outbox`, [server/events/stream-events.js](../server/events/stream-events.js);
++off unless `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set). Every event is queued in the same SQLite
++transaction as the change it describes.
++
++| event | when | subject |
++|---|---|---|
++| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` |
++| `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |
++
++The chat deploy notice itself is unchanged: stored in `chat_messages` (local chat) or handed to
++OpenVibe.Chat over the bridge (`CHAT_AUTHORITY=chat`). Once Chat consumes `live.release.deployed`, the
++bridge's `deployNotice` hop can be deleted.
++
++Live consumes, each on its own endpoint with its own subscription secret, signature v2 only, applied once
++through the SDK inbox (`idempotency_receipts`):
++
++| topics | endpoint | secret | subscribe with |
++|---|---|---|---|
++| `openre.session.*` | `/internal/openre-events` | `OPENRE_EVENTS_SECRET` | OpenRe's `scripts/subscribe-live-events.js` |
++| `media.vod.*`, `media.clip.*`, `media.storage.*` | `/internal/media-events` | `MEDIA_EVENTS_SECRET` | `scripts/subscribe-media-events.js` |
++
++#### Media outcomes over Events
++
++Media reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` both as the signed
++webhook (`POST /internal/media-webhook`, `MEDIA_WEBHOOK_SECRET`) and as `media.*` events. Media writes the
++event in the state change's transaction and puts its `event_id` in the webhook body, so
++[server/media-proxy/outcomes.js](../server/media-proxy/outcomes.js) applies each outcome once, whichever
++copy arrives first (receipt `media:<object type>:<object id>:<event id>`, consumer
++`live-media-outcomes`). Only Live's tenant (`MEDIA_APP_ID`) counts; `media.object.uploaded` is ignored.
++`MEDIA_EVENTS_AUTHORITY` picks the path that acts:
++
++- `webhook` (default): the webhook acts; Events deliveries are acknowledged and dropped.
++- `both`: the transition window; either acts, the other copy is a no-op.
++- `events`: Events acts; a webhook with an `event_id` is acknowledged and dropped (one without, from a
++ Media whose outbox is off, has no durable twin and still acts).
++
++Removing the webhook, once `events` has run clean (every recording and clip since the switch has its
++`vod_ai_state`/`clip_ai_state` row and `live-media-outcomes` receipts, and Events shows no DLQ entries for
++Live's subscriptions): clear Live's `webhook_url` in Media's `apps` row (then Media sends Live nothing
++directly), wait a release, and delete `/internal/media-webhook`, `server/media-proxy/webhook.js`,
++`MEDIA_WEBHOOK_SECRET` and the `webhook`/`both` modes. Rollback before that step: set
++`MEDIA_EVENTS_AUTHORITY=webhook` (or `scripts/subscribe-media-events.js --disable`).
ddiff --git a/scripts/subscribe-media-events.js b/scripts/subscribe-media-events.js
nnew file mode 100644
iindex 00000000..3f395df2
---- /dev/null
++++ b/scripts/subscribe-media-events.js
@@@ -0,0 +1,140 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Live's OpenVibe.Events subscriptions to OpenVibe.Media's outcomes (roadmap Wave 3 exit):
++ * media.vod.*, media.clip.* and media.storage.* → Live's POST /internal/media-events, signed with
++ * the secret Live holds as MEDIA_EVENTS_SECRET (server/media-proxy/media-events.js). Events names
++ * the consumer after the calling service, so this runs AS LIVE: it reads Live's env file (Live's
++ * OAuth client, grant events.subscription.manage on audience openvibe.events) and asks Network for
++ * a token with it. Nothing secret is printed. Run on the host as root (the env file is 0600):
++ *
++ * sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js # create (or report)
++ * 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
++ *
++ * [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]
++ *
++ * Env names used (from the Live env file): OV_OAUTH_CLIENT_ID (default live), OV_OAUTH_CLIENT_SECRET,
++ * OV_NETWORK_INTERNAL_URL (default http://127.0.0.1:4000), EVENTS_URL (default http://127.0.0.1:4300)
++ * and MEDIA_EVENTS_SECRET (32+ characters; `openssl rand -hex 32`, put it in the env file and restart
++ * Live first). An existing subscription with the same topic and endpoint is reported, not duplicated.
++ * Subscribing changes nothing Live does until MEDIA_EVENTS_AUTHORITY is `both` or `events`: in the
++ * default `webhook` mode deliveries are acknowledged and dropped. A new subscription gets no history;
++ * replay with POST /api/v1/deliveries/replay (events.delivery.admin) if wanted — duplicates of
++ * outcomes the webhook already applied are no-ops.
++ */
++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 DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
++
++function parseArgs(argv) {
++ const o = { liveEnv: DEFAULT_LIVE_ENV, endpoint: DEFAULT_ENDPOINT, action: 'create' };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (a === '--dry-run') o.action = 'list';
++ else if (a === '--disable') o.action = 'disable';
++ 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 throw new Error(`unknown argument ${a}`);
++ }
++ if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');
++ return o;
++}
++
++function readLiveEnv(file = DEFAULT_LIVE_ENV) {
++ let text;
++ try { text = fs.readFileSync(file, 'utf8'); } catch (err) { throw new Error(`cannot read ${file} (${err.code}); run as root`); }
++ return require('dotenv').parse(text);
++}
++
++async function readJson(res) {
++ const text = await res.text();
++ try { return text ? JSON.parse(text) : {}; } catch { return { detail: text.slice(0, 200) }; }
++}
++
++async function liveToken({ env, fetchImpl }) {
++ const clientId = env.OV_OAUTH_CLIENT_ID || 'live';
++ const network = String(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ const res = await fetchImpl(`${network}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: env.OV_OAUTH_CLIENT_SECRET, audience: 'openvibe.events' }).toString(),
++ signal: AbortSignal.timeout(15000),
++ });
++ const tok = await readJson(res);
++ if (!res.ok || !tok.access_token) throw new Error(`Network refused a token for ${clientId} (${res.status} ${tok.error || tok.code || ''})`.trim());
++ return { token: tok.access_token, clientId };
++}
++
++/**
++ * 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 }) {
++ const events = String(env.EVENTS_URL || 'http://127.0.0.1:4300').replace(/\/+$/, '');
++ const 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)');
++
++ const { token, clientId } = await liveToken({ env, fetchImpl });
++ const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
++ const listRes = await fetchImpl(`${events}/api/v1/subscriptions`, { headers, signal: AbortSignal.timeout(15000) });
++ const list = await readJson(listRes);
++ if (!listRes.ok) throw new Error(`Events answered ${listRes.status} listing subscriptions: ${list.code || ''} ${list.detail || ''}`.trim());
++ const all = list.subscriptions || [];
++ const isOn = (s) => !(s.enabled === false || s.enabled === 0);
++ const describe = (s, topic) => `${s.id} (${topic} → ${endpoint}, ${isOn(s) ? 'enabled' : 'disabled'})`;
++
++ const out = [];
++ 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`);
++ out.push({ topic, subscription_id: existing ? existing.id : null, existed: Boolean(existing) });
++ continue;
++ }
++ if (action === 'disable' || action === 'enable') {
++ if (!existing) { log(`no subscription ${topic} → ${endpoint} to ${action}`); out.push({ topic, subscription_id: null }); continue; }
++ const r = await fetchImpl(`${events}/api/v1/subscriptions/${encodeURIComponent(existing.id)}/${action}`, { method: 'POST', headers, signal: AbortSignal.timeout(15000) });
++ const b = await readJson(r);
++ if (!r.ok) throw new Error(`Events answered ${r.status} to ${action} ${existing.id}: ${b.code || ''} ${b.detail || ''}`.trim());
++ log(`${action}d: ${describe({ ...existing, enabled: action === 'enable' }, topic)}`);
++ out.push({ topic, subscription_id: existing.id, enabled: action === 'enable' });
++ continue;
++ }
++ if (existing) {
++ log(`subscription exists: ${describe(existing, topic)}`);
++ out.push({ topic, subscription_id: existing.id, existed: true, created: false });
++ continue;
++ }
++ const r = await fetchImpl(`${events}/api/v1/subscriptions`, {
++ method: 'POST',
++ headers: { ...headers, 'Content-Type': 'application/json' },
++ body: JSON.stringify({ topic_pattern: topic, endpoint, secret }),
++ signal: AbortSignal.timeout(15000),
++ });
++ const b = await readJson(r);
++ if (r.status === 409 && b.subscription_id) {
++ log(`subscription exists: ${b.subscription_id} (${topic} → ${endpoint})`);
++ out.push({ topic, subscription_id: b.subscription_id, existed: true, created: false });
++ 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`);
++ out.push({ topic, subscription_id: b.id, existed: false, created: true });
++ }
++ return { action, subscriptions: out };
++}
++
++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); });
++}
++
++module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT };
ddiff --git a/server/index.js b/server/index.js
iindex 1f3b897a..053b3236 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -544,9 +544,14 @@ app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH
setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),
}));
--// OpenVibe.Media → Live webhook (vod.ready / clip.ready …). Mounted BEFORE the
--// /internal router because it authenticates with an HMAC signature, not X-Internal-Key.
++// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …), two ways during the Wave 3
++// transition, applied once whichever arrives first; MEDIA_EVENTS_AUTHORITY=webhook|both|events
++// picks the one that acts (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router
++// because they authenticate with HMAC signatures, not X-Internal-Key.
++// direct webhook (MEDIA_WEBHOOK_SECRET), to be removed once Events is proven
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);
// 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/server/media-proxy/media-events.js b/server/media-proxy/media-events.js
nnew file mode 100644
iindex 00000000..6d1de273
---- /dev/null
++++ b/server/media-proxy/media-events.js
@@@ -0,0 +1,53 @@
++'use strict';
++/**
++ * OpenVibe.Events → Live: Media's outcomes (roadmap Wave 3 exit, audit item 7).
++ *
++ * POST /internal/media-events the endpoint of Live's OpenVibe.Events subscriptions to
++ * media.vod.*, media.clip.* and media.storage.*
++ * (scripts/subscribe-media-events.js), signed with
++ * MEDIA_EVENTS_SECRET, signature v2 only.
++ *
++ * The same completions Media also sends to /internal/media-webhook; ./outcomes.js applies each one
++ * once whichever copy arrives first (inbox receipt keyed by Media object + event id) and
++ * MEDIA_EVENTS_AUTHORITY decides whether this path acts (`both` or `events`) or only
++ * acknowledges (`webhook`, the default until the subscription is proven).
++ *
++ * Only Live's own tenant counts: Events carries every Media tenant's VODs and clips, and a
++ * vod/clip event whose payload.app_id is not MEDIA_APP_ID is acknowledged and ignored. Storage
++ * events are service-wide (app_id null) and apply.
++ */
++const outcomes = require('./outcomes');
++
++/** Express handler (needs req.rawBody from the express.json verify hook). */
++function handler(req, res) {
++ const secret = process.env.MEDIA_EVENTS_SECRET || '';
++ if (!secret) return res.status(503).json({ error: 'MEDIA_EVENTS_SECRET is not set' });
++ const { parseDelivery } = require('openvibe-sdk/events');
++ // v2 only: HMAC over "<t>.<raw body>", t within ±300 s; a v1-only or stale delivery is refused.
++ const delivery = parseDelivery(req.rawBody, req.headers, secret, { requireV2: true });
++ if (!delivery) return res.status(401).json({ error: 'bad signature' });
++ const ev = delivery.event;
++ // Signed but unusable: acknowledge so it is not redelivered forever.
++ if (!ev || !outcomes.EVENT_ID_RE.test(String(ev.event_id || ''))) return res.status(204).end();
++
++ const name = ev.source === 'media' ? outcomes.WEBHOOK_NAMES[ev.event_type] : null;
++ if (!name) return res.status(204).end();
++ const data = ev.payload || {};
++ if (!name.startsWith('storage.') && String(data.app_id || '') !== outcomes.MEDIA_APP_ID) return res.status(204).end();
++ if (!outcomes.accepts('events')) {
++ outcomes.stats.dropped.events++;
++ return res.status(204).end();
++ }
++ const subject = ev.subject && ev.subject.type && ev.subject.id != null
++ ? { type: String(ev.subject.type), id: String(ev.subject.id) }
++ : outcomes.subjectOf(name, data);
++ try {
++ outcomes.handle({ via: 'events', event: name, data, eventId: ev.event_id, subject });
++ } catch (err) {
++ console.error('[MediaEvents] apply failed:', err.message);
++ return res.status(500).json({ error: 'apply failed' }); // Events retries
++ }
++ return res.status(204).end();
++}
++
++module.exports = { handler };
ddiff --git a/server/media-proxy/outcomes.js b/server/media-proxy/outcomes.js
nnew file mode 100644
iindex 00000000..5a570630
---- /dev/null
++++ b/server/media-proxy/outcomes.js
@@@ -0,0 +1,225 @@
++'use strict';
++/**
++ * OpenVibe.Live — what Live does when OpenVibe.Media reports an outcome, whichever way it arrives.
++ *
++ * Media reports VOD/clip completions and storage alerts two ways during the Wave 3 transition:
++ * webhook POST /internal/media-webhook (media-proxy/webhook.js), HMAC with MEDIA_WEBHOOK_SECRET
++ * events POST /internal/media-events (media-proxy/media-events.js), an OpenVibe.Events
++ * subscription to media.vod.* / media.clip.* / media.storage.* (scripts/subscribe-media-events.js)
++ *
++ * MEDIA_EVENTS_AUTHORITY picks which one Live acts on:
++ * webhook (default) the webhook acts; Events deliveries are acknowledged and dropped
++ * both either acts; the second copy of an outcome is a no-op
++ * events Events acts; a webhook carrying an event_id is acknowledged and dropped (a
++ * webhook WITHOUT one has no durable twin, so it still acts)
++ *
++ * Each outcome is applied at most once: Media writes the event in the state change's transaction
++ * and puts the same event_id in the webhook body, and every apply claims an inbox receipt (consumer
++ * CONSUMER, key "media:<object type>:<object id>:<event id>") in the same SQLite transaction as
++ * Live's own writes. Side effects outside the database (recorder bookkeeping, AI jobs, the ops
++ * alert) run after that commit, only for the copy that won.
++ */
++const db = require('../db/database');
++
++const CONSUMER = 'live-media-outcomes';
++const MODES = new Set(['webhook', 'both', 'events']);
++const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';
++
++// Webhook event name → the Events event_type Media publishes for it (media/server/events.js TYPES).
++const EVENT_TYPES = {
++ 'vod.ready': 'media.vod.ready',
++ 'vod.failed': 'media.vod.failed',
++ 'clip.ready': 'media.clip.ready',
++ 'clip.failed': 'media.clip.failed',
++ 'storage.alert': 'media.storage.alert',
++ 'storage.recovered': 'media.storage.recovered',
++};
++const WEBHOOK_NAMES = Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [v, k]));
++
++const stats = { applied: { webhook: 0, events: 0 }, duplicate: { webhook: 0, events: 0 }, dropped: { webhook: 0, events: 0 } };
++let inbox = null;
++let warnedNoEventId = false;
++
++function authority() {
++ const v = String(process.env.MEDIA_EVENTS_AUTHORITY || 'webhook').trim().toLowerCase();
++ return MODES.has(v) ? v : 'webhook';
++}
++
++/** Does this path act on outcomes under the current authority? */
++function accepts(via) {
++ const mode = authority();
++ return mode === 'both' || mode === via;
++}
++
++function ensureInbox() {
++ if (inbox) return inbox;
++ const { createInbox } = require('openvibe-sdk/events');
++ inbox = createInbox(db.getDb());
++ inbox.ensureSchema();
++ return inbox;
++}
++
++/** The object an outcome is about, derived exactly as Media derives its event subject. */
++function subjectOf(event, data) {
++ if (String(event).startsWith('storage.')) return { type: 'storage', id: String((data && data.kind) || 'storage') };
++ const type = String(event).startsWith('clip.') ? 'clip' : String(event).startsWith('vod.') ? 'vod' : 'object';
++ const id = data && (data.object_id || data.id);
++ return { type, id: String(id == null ? 'unknown' : id) };
++}
++
++function dedupeKey(eventId, subject) {
++ return `media:${subject.type}:${subject.id}:${eventId}`;
++}
++
++/**
++ * Storage alerts from Media (`storage.alert` / `storage.recovered`). Always logged at
++ * error/warn level; additionally posted to an ops webhook when one is configured —
++ * OPS_ALERT_WEBHOOK_URL in the environment, or the `ops_alert_webhook_url` admin
++ * setting (a Discord-compatible webhook: it receives `{ content }`). This is
++ * deliberately separate from the public go-live Discord webhook.
++ */
++function relayStorageEvent(event, data = {}) {
++ const recovered = event === 'storage.recovered';
++ const line = `[MediaOutcome] ${recovered ? 'STORAGE RECOVERED' : 'STORAGE ALERT'} (${data.kind || 'unknown'}): `
++ + `disk ${data.disk_pct ?? '?'}%, ${data.free_gb ?? '?'} GB free`
++ + (data.hint ? ` — ${data.hint}` : '');
++ (recovered ? console.warn : console.error)(line);
++ if (!recovered && Array.isArray(data.errors) && data.errors.length) {
++ console.error('[MediaOutcome] first errors:', JSON.stringify(data.errors));
++ }
++
++ let url = process.env.OPS_ALERT_WEBHOOK_URL || '';
++ if (!url) { try { url = db.getSetting('ops_alert_webhook_url') || ''; } catch { url = ''; } }
++ if (!/^https:\/\/[^\s]+$/i.test(url)) return;
++
++ const content = (recovered ? '✅ **OpenVibe.Media storage recovered**' : '🚨 **OpenVibe.Media storage alert**')
++ + `\n${data.kind || 'unknown'} — disk ${data.disk_pct ?? '?'}%, ${data.free_gb ?? '?'} GB free`
++ + (data.stalled_passes ? `, ${data.stalled_passes} stalled sweep pass(es)` : '')
++ + (data.hint ? `\n${data.hint}` : '')
++ + (Array.isArray(data.errors) && data.errors.length ? `\n\`${JSON.stringify(data.errors).slice(0, 300)}\`` : '');
++ fetch(url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ content }),
++ signal: AbortSignal.timeout(10000),
++ }).catch(err => console.warn('[MediaOutcome] ops alert webhook failed:', err.message));
++}
++
++/**
++ * Live's database writes for one outcome (synchronous: runs inside the inbox transaction) and the
++ * side effects to run after the commit. Returns { outcome, after? }.
++ */
++function apply(event, data) {
++ data = data || {};
++ switch (event) {
++ case 'vod.ready': {
++ const vodId = data.id;
++ if (!vodId) return { outcome: 'ignored' };
++ // Seed the Live-owned AI state row (queues transcript/overview work).
++ try { db.setVodTranscriptStatus(vodId, 'pending'); } catch { /* */ }
++ // Point the live timeline rows at the VOD so the recording inherits the transcript that
++ // was already built while the stream was running — no second transcription.
++ try {
++ const sid = data.stream_id || data.streamId;
++ if (sid) db.linkTimelineToVod(sid, vodId);
++ } catch { /* */ }
++ return {
++ outcome: 'vod_ready',
++ after: () => {
++ try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ }
++ // Kick the on-finalize AI pass right away (both budget-gated).
++ try {
++ const ai = require('../ai/ai-analysis');
++ const vodMeta = { id: vodId, ...data };
++ if (ai.transcriptionEnabled && ai.transcriptionEnabled()) ai.generateVodTranscript(vodMeta).catch(() => {});
++ if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) ai.generateVodOverview(vodMeta).catch(() => {});
++ } catch { /* backfill poller will pick it up */ }
++ console.log(`[MediaOutcome] VOD ${vodId} ready (${data.duration || data.duration_seconds || '?'}s)`);
++ },
++ };
++ }
++ case 'vod.failed': {
++ const vodId = data.id;
++ if (vodId) { try { db.setVodTranscriptStatus(vodId, 'failed', data.error || 'media reported failure'); } catch { /* */ } }
++ return {
++ outcome: 'vod_failed',
++ after: () => {
++ if (vodId) { try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ } }
++ console.warn(`[MediaOutcome] VOD ${vodId} failed:`, data.error || '(no detail)');
++ },
++ };
++ }
++ case 'clip.ready': {
++ if (!data.id) return { outcome: 'ignored' };
++ // Schedule the chat announce with a grace period (creator titles the clip first); the
++ // clip-notify sweeper fires it (survives restarts).
++ try { require('./clip-notify').scheduleClipNotify(data.id); } catch { /* */ }
++ try { db.setClipTranscriptStatus(data.id, 'pending'); } catch { /* */ }
++ return {
++ outcome: 'clip_ready',
++ after: () => {
++ try {
++ const ai = require('../ai/ai-analysis');
++ if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
++ ai.generateClipOverview({ id: data.id, ...data }).catch(() => {});
++ }
++ } catch { /* */ }
++ },
++ };
++ }
++ case 'clip.failed':
++ if (data.id) { try { db.setClipTranscriptStatus(data.id, 'failed', data.error || 'media reported failure'); } catch { /* */ } }
++ return { outcome: 'clip_failed', after: () => console.warn(`[MediaOutcome] Clip ${data.id} failed:`, data.error || '(no detail)') };
++ case 'storage.alert':
++ case 'storage.recovered':
++ // Media's VOD volume needs a human (drain stalled / disk critical) or is fine again.
++ // Recordings are silently refused while this is unresolved, which is why it is loud
++ // here and forwarded to the ops channel.
++ return { outcome: 'storage', after: () => relayStorageEvent(event, data) };
++ default:
++ return { outcome: 'ignored' };
++ }
++}
++
++/**
++ * Apply one outcome at most once. `via` is 'webhook' or 'events'; `eventId` is Media's event id
++ * (webhooks from a Media without an outbox have none: those apply without a receipt).
++ * Returns { applied, duplicate, outcome }. Throws when Live's writes fail (nothing committed).
++ */
++function handle({ via, event, data, eventId = null, subject = null }) {
++ let after = null;
++ const run = () => { const r = apply(event, data); after = r.after || null; return r.outcome; };
++ let out;
++ if (eventId) {
++ out = ensureInbox().once(CONSUMER, dedupeKey(eventId, subject || subjectOf(event, data)), run);
++ } else {
++ if (via === 'webhook' && !warnedNoEventId) {
++ warnedNoEventId = true;
++ console.warn('[MediaOutcome] a Media webhook carried no event_id (Media without its Events outbox?): applied without dedupe');
++ }
++ out = { duplicate: false, result: db.getDb().transaction(run)() };
++ }
++ if (out.duplicate) {
++ stats.duplicate[via]++;
++ return { applied: false, duplicate: true };
++ }
++ stats.applied[via]++;
++ if (after) { try { after(); } catch (e) { console.warn('[MediaOutcome] post-commit step failed:', e.message); } }
++ return { applied: true, duplicate: false, outcome: out.result };
++}
++
++function status() {
++ return { authority: authority(), consumer: CONSUMER, ...JSON.parse(JSON.stringify(stats)) };
++}
++
++function _reset() {
++ inbox = null;
++ warnedNoEventId = false;
++ for (const k of Object.keys(stats)) stats[k] = { webhook: 0, events: 0 };
++}
++
++module.exports = {
++ CONSUMER, EVENT_TYPES, WEBHOOK_NAMES, EVENT_ID_RE, MEDIA_APP_ID,
++ authority, accepts, subjectOf, dedupeKey, apply, handle, relayStorageEvent, status, stats, _reset,
++};
ddiff --git a/server/media-proxy/webhook.js b/server/media-proxy/webhook.js
iindex bea8532c..91c92c37 100644
---- a/server/media-proxy/webhook.js
++++ b/server/media-proxy/webhook.js
@@@ -1,18 +1,25 @@
/**
-- * OpenVibe.Live — OpenVibe.Media webhook receiver
++ * OpenVibe.Live — OpenVibe.Media webhook receiver (the direct path, being replaced by Events)
*
* POST /internal/media-webhook
-- * Media POSTs { event: 'vod.ready'|'vod.failed'|'clip.ready'|'clip.failed', app_id, data }
-- * with header `X-OVMedia-Signature: sha256=<hmac-sha256(raw body, MEDIA_WEBHOOK_SECRET)>`.
++ * Media POSTs { event: 'vod.ready'|'vod.failed'|'clip.ready'|'clip.failed'|'storage.alert'|…,
++ * app_id, data, event_id? } with header
++ * `X-OVMedia-Signature: sha256=<hmac-sha256(raw body, MEDIA_WEBHOOK_SECRET)>`.
++ * `event_id` is the id of the same outcome's OpenVibe.Events event (Media ≥ cbf8410).
*
-- * This replaces the old in-process recorder completion callbacks:
-- * vod.ready/failed → clear any lingering recording session state
-- * clip.ready → announce the clip in the source channel's live chat
-- * (grace period + per-slot opt-out, like the old clip-notify)
++ * What Live does with an outcome lives in ./outcomes.js, shared with the Events consumer
++ * (./media-events.js). MEDIA_EVENTS_AUTHORITY decides whether this path acts:
++ * webhook (default) or both → applied (once: an outcome that already arrived through Events
++ * is a no-op here, and the other way round)
++ * events → a webhook with an event_id is acknowledged and dropped; one
++ * without (no durable twin exists) is still applied
++ *
++ * Removal: once MEDIA_EVENTS_AUTHORITY=events has run clean, clear Live's webhook_url in Media and
++ * delete this route (docs/architecture.md, "Media outcomes over Events").
*/
'use strict';
const crypto = require('crypto');
--const db = require('../db/database');
++const outcomes = require('./outcomes');
const WEBHOOK_SECRET = process.env.MEDIA_WEBHOOK_SECRET || '';
@@@ -32,113 +39,24 @@ function verifySignature(req) {
}
}
--/**
-- * Storage alerts from Media (`storage.alert` / `storage.recovered`). Always logged at
-- * error/warn level; additionally posted to an ops webhook when one is configured —
-- * OPS_ALERT_WEBHOOK_URL in the environment, or the `ops_alert_webhook_url` admin
-- * setting (a Discord-compatible webhook: it receives `{ content }`). This is
-- * deliberately separate from the public go-live Discord webhook.
-- */
--function relayStorageEvent(event, data = {}) {
-- const recovered = event === 'storage.recovered';
-- const line = `[MediaWebhook] ${recovered ? 'STORAGE RECOVERED' : 'STORAGE ALERT'} (${data.kind || 'unknown'}): `
-- + `disk ${data.disk_pct ?? '?'}%, ${data.free_gb ?? '?'} GB free`
-- + (data.hint ? ` — ${data.hint}` : '');
-- (recovered ? console.warn : console.error)(line);
-- if (!recovered && Array.isArray(data.errors) && data.errors.length) {
-- console.error('[MediaWebhook] first errors:', JSON.stringify(data.errors));
-- }
--
-- let url = process.env.OPS_ALERT_WEBHOOK_URL || '';
-- if (!url) { try { url = db.getSetting('ops_alert_webhook_url') || ''; } catch { url = ''; } }
-- if (!/^https:\/\/[^\s]+$/i.test(url)) return;
--
-- const content = (recovered ? '✅ **OpenVibe.Media storage recovered**' : '🚨 **OpenVibe.Media storage alert**')
-- + `\n${data.kind || 'unknown'} — disk ${data.disk_pct ?? '?'}%, ${data.free_gb ?? '?'} GB free`
-- + (data.stalled_passes ? `, ${data.stalled_passes} stalled sweep pass(es)` : '')
-- + (data.hint ? `\n${data.hint}` : '')
-- + (Array.isArray(data.errors) && data.errors.length ? `\n\`${JSON.stringify(data.errors).slice(0, 300)}\`` : '');
-- fetch(url, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ content }),
-- signal: AbortSignal.timeout(10000),
-- }).catch(err => console.warn('[MediaWebhook] ops alert webhook failed:', err.message));
--}
--
function handler(req, res) {
if (!verifySignature(req)) return res.status(401).json({ error: 'Invalid signature' });
const { event, data } = req.body || {};
if (!event) return res.status(400).json({ error: 'Missing event' });
++ const eventId = outcomes.EVENT_ID_RE.test(String(req.body.event_id || '')) ? String(req.body.event_id) : null;
++ // Events is the authority and this outcome has a durable twin: that copy acts, not this one.
++ if (eventId && !outcomes.accepts('webhook')) {
++ outcomes.stats.dropped.webhook++;
++ return res.json({ ok: true, ignored: 'events_authority' });
++ }
++ let result = null;
try {
-- switch (event) {
-- case 'vod.ready': {
-- const vodId = data?.id;
-- if (vodId) {
-- try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ }
-- // Seed the Live-owned AI state row (queues transcript/overview work)
-- // and kick the on-finalize AI pass right away (both budget-gated).
-- try { db.setVodTranscriptStatus(vodId, 'pending'); } catch { /* */ }
-- // Point the live timeline rows at the VOD so the recording inherits the
-- // transcript that was already built while the stream was running — no
-- // need to transcribe the same audio a second time.
-- try {
-- const sid = data?.stream_id || data?.streamId;
-- if (sid) db.linkTimelineToVod(sid, vodId);
-- } catch { /* */ }
-- try {
-- const ai = require('../ai/ai-analysis');
-- const vodMeta = { id: vodId, ...data };
-- if (ai.transcriptionEnabled && ai.transcriptionEnabled()) ai.generateVodTranscript(vodMeta).catch(() => {});
-- if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) ai.generateVodOverview(vodMeta).catch(() => {});
-- } catch { /* backfill poller will pick it up */ }
-- }
-- console.log(`[MediaWebhook] VOD ${vodId} ready (${data?.duration || data?.duration_seconds || '?'}s)`);
-- break;
-- }
-- case 'vod.failed': {
-- const vodId = data?.id;
-- if (vodId) {
-- try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ }
-- try { db.setVodTranscriptStatus(vodId, 'failed', data?.error || 'media reported failure'); } catch { /* */ }
-- }
-- console.warn(`[MediaWebhook] VOD ${vodId} failed:`, data?.error || '(no detail)');
-- break;
-- }
-- case 'clip.ready': {
-- if (data && data.id) {
-- // Schedule the chat announce with a grace period (creator titles the
-- // clip first); the clip-notify sweeper fires it (survives restarts).
-- try { require('./clip-notify').scheduleClipNotify(data.id); } catch { /* */ }
-- try { db.setClipTranscriptStatus(data.id, 'pending'); } catch { /* */ }
-- try {
-- const ai = require('../ai/ai-analysis');
-- if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
-- ai.generateClipOverview({ id: data.id, ...data }).catch(() => {});
-- }
-- } catch { /* */ }
-- }
-- break;
-- }
-- case 'clip.failed':
-- if (data?.id) { try { db.setClipTranscriptStatus(data.id, 'failed', data?.error || 'media reported failure'); } catch { /* */ } }
-- console.warn(`[MediaWebhook] Clip ${data?.id} failed:`, data?.error || '(no detail)');
-- break;
-- case 'storage.alert':
-- case 'storage.recovered':
-- // Media's VOD volume needs a human (drain stalled / disk critical) or is
-- // fine again. Recordings are silently refused while this is unresolved,
-- // which is why it is loud here and forwarded to the ops channel.
-- relayStorageEvent(event, data);
-- break;
-- default:
-- console.log(`[MediaWebhook] Ignoring event: ${event}`);
-- }
++ result = outcomes.handle({ via: 'webhook', event, data, eventId });
} catch (e) {
console.warn('[MediaWebhook] handler error:', e.message);
}
-- res.json({ ok: true });
++ res.json(result && result.duplicate ? { ok: true, duplicate: true } : { ok: true });
}
module.exports = handler;
ddiff --git a/server/streaming/recorder.js b/server/streaming/recorder.js
iindex df7fbde7..80a6bb6e 100644
---- a/server/streaming/recorder.js
++++ b/server/streaming/recorder.js
@@@ -15,7 +15,7 @@
* TODO(contract): jsmpeg recording is skipped until Media grows an ingest for it.
*
* On stream end: ingestRtpStop / finalizeVod. Media probes, thumbnails, and fires the
-- * vod.ready webhook (see server/media-proxy/webhook.js).
++ * vod.ready outcome (see server/media-proxy/outcomes.js).
*/
'use strict';
const db = require('../db/database');
ddiff --git a/test/media-events.test.js b/test/media-events.test.js
nnew file mode 100644
iindex 00000000..fdc06287
---- /dev/null
++++ b/test/media-events.test.js
@@@ -0,0 +1,162 @@
++'use strict';
++
++// Media outcomes over OpenVibe.Events (roadmap Wave 3 exit). The same completion reaches Live as a
++// signed Media webhook (/internal/media-webhook) and as a signed Events delivery
++// (/internal/media-events); it is applied exactly once whichever arrives first (inbox receipt keyed
++// by Media object + event id), and MEDIA_EVENTS_AUTHORITY=webhook|both|events decides which path
++// acts. Events deliveries need signature v2; other tenants' VODs and non-Media events are ignored.
++
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-events-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.MEDIA_WEBHOOK_SECRET = 'w'.repeat(40);
++process.env.MEDIA_EVENTS_SECRET = 'e'.repeat(64);
++process.env.MEDIA_APP_ID = 'live';
++delete process.env.MEDIA_EVENTS_AUTHORITY;
++delete process.env.OPS_ALERT_WEBHOOK_URL;
++
++let seq = 0;
++const newEventId = () => {
++ const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++ let s = '';
++ for (let i = 0; i < 26; i++) s += A[crypto.randomInt(32)];
++ seq++;
++ return `evt_${s}`;
++};
++
++(async () => {
++ const express = require('express');
++ const db = require('../server/db/database');
++ db.initDb();
++ const outcomes = require('../server/media-proxy/outcomes');
++ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
++
++ // Count Live's writes per outcome: a second application would show up here.
++ const calls = { vod: [], clip: [] };
++ const realVod = db.setVodTranscriptStatus;
++ const realClip = db.setClipTranscriptStatus;
++ db.setVodTranscriptStatus = (id, status, ...rest) => { calls.vod.push([id, status]); return realVod(id, status, ...rest); };
++ db.setClipTranscriptStatus = (id, status, ...rest) => { calls.clip.push([id, status]); return realClip(id, status, ...rest); };
++ const origError = console.error;
++ const storageLines = [];
++ console.error = (...a) => { if (String(a[0]).includes('STORAGE ALERT')) storageLines.push(a.join(' ')); else origError(...a); };
++
++ const app = express();
++ app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
++ app.post('/internal/media-webhook', require('../server/media-proxy/webhook'));
++ app.post('/internal/media-events', require('../server/media-proxy/media-events').handler);
++ const server = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ // Media's two copies of one outcome (server/webhooks.js announce + server/events.js record).
++ const outcome = (event, data, eventId = newEventId()) => {
++ const type = event.startsWith('storage.') ? 'storage' : event.split('.')[0];
++ const id = type === 'storage' ? data.kind : String(data.id);
++ return {
++ eventId,
++ webhook: { event, app_id: data.app_id === undefined ? 'live' : data.app_id, data, ...(eventId ? { event_id: eventId } : {}) },
++ envelope: {
++ event_id: eventId, event_type: `media.${event}`, version: 1, source: 'media', actor: { type: 'service', id: 'media' },
++ timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type, id },
++ payload: { app_id: data.app_id === undefined ? 'live' : data.app_id, ...data },
++ },
++ };
++ };
++ const postWebhook = async (body, { secret = process.env.MEDIA_WEBHOOK_SECRET } = {}) => {
++ const raw = JSON.stringify(body);
++ const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex');
++ const res = await fetch(`${base}/internal/media-webhook`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OVMedia-Signature': sig }, body: raw });
++ return { status: res.status, body: await res.json().catch(() => null) };
++ };
++ const postEvent = async (envelope, { v1Only = false, secret = process.env.MEDIA_EVENTS_SECRET } = {}) => {
++ const raw = JSON.stringify({ event: envelope, seq: ++seq, subscription_id: 'sub_media' });
++ const headers = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret);
++ const res = await fetch(`${base}/internal/media-events`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: raw });
++ return { status: res.status };
++ };
++ const receipts = () => db.getDb().prepare('SELECT event_id FROM idempotency_receipts WHERE consumer = ?').all(outcomes.CONSUMER).map(r => r.event_id);
++
++ // ── authority=webhook (default): the webhook acts; Events deliveries are acknowledged, dropped.
++ assert.strictEqual(outcomes.authority(), 'webhook');
++ const a = outcome('vod.ready', { id: 101, stream_id: null, duration: 60 });
++ assert.deepStrictEqual((await postWebhook(a.webhook)).body, { ok: true });
++ assert.deepStrictEqual(calls.vod, [[101, 'pending']]);
++ assert.deepStrictEqual(receipts(), [`media:vod:101:${a.eventId}`], 'the webhook claims the receipt');
++ assert.strictEqual((await postEvent(a.envelope)).status, 204);
++ const b = outcome('vod.ready', { id: 102 });
++ assert.strictEqual((await postEvent(b.envelope)).status, 204);
++ assert.deepStrictEqual(calls.vod, [[101, 'pending']], 'events do not act under webhook authority');
++
++ // ── authority=both: whichever copy arrives first acts; the other is a no-op.
++ process.env.MEDIA_EVENTS_AUTHORITY = 'both';
++ assert.strictEqual((await postEvent(a.envelope)).status, 204, 'the Events copy of an outcome the webhook applied');
++ assert.deepStrictEqual(calls.vod, [[101, 'pending']], 'applied once');
++ const c = outcome('clip.ready', { id: 7, stream_id: null });
++ assert.strictEqual((await postEvent(c.envelope)).status, 204);
++ assert.deepStrictEqual(calls.clip, [[7, 'pending']]);
++ assert.deepStrictEqual((await postWebhook(c.webhook)).body, { ok: true, duplicate: true }, 'the webhook copy after Events');
++ assert.deepStrictEqual(calls.clip, [[7, 'pending']], 'applied once');
++ assert.strictEqual((await postEvent(c.envelope)).status, 204, 'an Events redelivery');
++ assert.deepStrictEqual(calls.clip, [[7, 'pending']]);
++ // A later outcome for the same clip (retry failed, then succeeded) is a new event and applies.
++ const c2 = outcome('clip.failed', { id: 7, error: 'cut failed' });
++ await postWebhook(c2.webhook);
++ await postEvent(c2.envelope);
++ assert.deepStrictEqual(calls.clip, [[7, 'pending'], [7, 'failed']]);
++
++ // ── authority=events: Events acts; a webhook with an event_id is only acknowledged.
++ process.env.MEDIA_EVENTS_AUTHORITY = 'events';
++ const d = outcome('vod.failed', { id: 103, error: 'short' });
++ assert.deepStrictEqual((await postWebhook(d.webhook)).body, { ok: true, ignored: 'events_authority' });
++ assert.ok(!calls.vod.some(([id]) => id === 103), 'the webhook did not act');
++ assert.strictEqual((await postEvent(d.envelope)).status, 204);
++ assert.deepStrictEqual(calls.vod.filter(([id]) => id === 103), [[103, 'failed']]);
++ // A webhook without event_id (a Media whose outbox is off) has no durable twin: it still acts.
++ const e = outcome('vod.ready', { id: 104 }, null);
++ assert.deepStrictEqual((await postWebhook(e.webhook)).body, { ok: true });
++ assert.deepStrictEqual(calls.vod.filter(([id]) => id === 104), [[104, 'pending']]);
++
++ // Storage alerts are service-wide (app_id null) and apply from Events.
++ const s = outcome('storage.alert', { kind: 'disk_critical', app_id: null, disk_pct: 97, free_gb: 3 });
++ assert.strictEqual((await postEvent(s.envelope)).status, 204);
++ assert.strictEqual(storageLines.length, 1);
++ assert.match(storageLines[0], /disk_critical/);
++ assert.ok(receipts().includes(`media:storage:disk_critical:${s.eventId}`));
++
++ // ── What Live ignores (acknowledged, never applied, never retried).
++ const before = JSON.stringify(calls);
++ const other = outcome('vod.ready', { id: 900, app_id: 'tools' });
++ assert.strictEqual((await postEvent(other.envelope)).status, 204, 'another tenant');
++ const upload = outcome('object.uploaded', { id: 'med_01J0000000000000000000000Z' });
++ upload.envelope.event_type = 'media.object.uploaded';
++ assert.strictEqual((await postEvent(upload.envelope)).status, 204, 'object uploads');
++ const foreign = outcome('vod.ready', { id: 901 });
++ foreign.envelope.source = 'tools';
++ assert.strictEqual((await postEvent(foreign.envelope)).status, 204, 'not from Media');
++ assert.strictEqual(JSON.stringify(calls), before);
++
++ // ── Signatures.
++ const f = outcome('vod.ready', { id: 105 });
++ assert.strictEqual((await postEvent(f.envelope, { v1Only: true })).status, 401, 'v1-only deliveries are refused');
++ assert.strictEqual((await postEvent(f.envelope, { secret: 'x'.repeat(64) })).status, 401);
++ assert.strictEqual((await postWebhook(f.webhook, { secret: 'nope' })).status, 401);
++ delete process.env.MEDIA_EVENTS_SECRET;
++ assert.strictEqual((await postEvent(f.envelope)).status, 503, 'no secret, no endpoint');
++ assert.ok(!calls.vod.some(([id]) => id === 105));
++
++ const st = outcomes.status();
++ assert.strictEqual(st.authority, 'events');
++ assert.ok(st.applied.webhook >= 2 && st.applied.events >= 3 && st.duplicate.webhook === 1 && st.duplicate.events >= 2);
++
++ db.setVodTranscriptStatus = realVod;
++ db.setClipTranscriptStatus = realClip;
++ console.error = origError;
++ server.close();
++ console.log('media events: webhook ⇄ Events transition, applied once per outcome, authority switch, v2 signatures, tenant filter — all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/subscribe-media-events.test.js b/test/subscribe-media-events.test.js
nnew file mode 100644
iindex 00000000..15b73fec
---- /dev/null
++++ b/test/subscribe-media-events.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++
++// scripts/subscribe-media-events.js against a stub Network and Events (fetch is injected): Live's
++// token for openvibe.events, one subscription per Media topic created with MEDIA_EVENTS_SECRET,
++// existing ones reported (not duplicated), --dry-run, --disable/--enable for rollback, and no
++// secret in anything it prints.
++
++const assert = require('assert');
++const { run, parseArgs, TOPICS, DEFAULT_ENDPOINT } = require('../scripts/subscribe-media-events');
++
++const SECRET = 's'.repeat(64);
++const CLIENT_SECRET = 'c'.repeat(48);
++const ENV = { OV_OAUTH_CLIENT_ID: 'live', OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, OV_NETWORK_INTERNAL_URL: 'http://network.test', EVENTS_URL: 'http://events.test/', MEDIA_EVENTS_SECRET: SECRET };
++
++function stub({ subscriptions = [], tokenStatus = 200 } = {}) {
++ const calls = [];
++ const subs = subscriptions.map((s) => ({ ...s }));
++ const reply = (status, body) => ({ status, ok: status < 300, text: async () => JSON.stringify(body) });
++ const fetchImpl = async (url, opts = {}) => {
++ const method = opts.method || 'GET';
++ calls.push({ method, url, body: opts.body || null });
++ if (url === 'http://network.test/oauth/token') {
++ const p = new URLSearchParams(opts.body);
++ assert.strictEqual(p.get('grant_type'), 'client_credentials');
++ assert.strictEqual(p.get('client_id'), 'live');
++ assert.strictEqual(p.get('audience'), 'openvibe.events');
++ return tokenStatus === 200 ? reply(200, { access_token: 'tok-live', token_type: 'Bearer', expires_in: 300 }) : reply(tokenStatus, { error: 'invalid_client' });
++ }
++ assert.strictEqual(opts.headers.Authorization, 'Bearer tok-live');
++ if (url === 'http://events.test/api/v1/subscriptions' && method === 'GET') return reply(200, { subscriptions: subs });
++ if (url === 'http://events.test/api/v1/subscriptions' && method === 'POST') {
++ const b = JSON.parse(opts.body);
++ const row = { id: `sub_${subs.length + 1}`, consumer: 'live', topic_pattern: b.topic_pattern, endpoint: b.endpoint, enabled: true };
++ subs.push(row);
++ return reply(201, { ...row, secret: b.secret });
++ }
++ const m = /^http:\/\/events\.test\/api\/v1\/subscriptions\/([^/]+)\/(enable|disable)$/.exec(url);
++ if (m && method === 'POST') {
++ const s = subs.find((x) => x.id === m[1]);
++ s.enabled = m[2] === 'enable';
++ return reply(200, s);
++ }
++ return reply(404, { code: 'not_found' });
++ };
++ return { fetchImpl, calls, subs };
++}
++const logs = () => { const lines = []; const log = (s) => lines.push(String(s)); log.lines = lines; return log; };
++const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CLIENT_SECRET, 'tok-live']) assert.ok(!l.includes(s), `printed a secret: ${l}`); };
++
++(async () => {
++ assert.deepStrictEqual(TOPICS, ['media.vod.*', 'media.clip.*', 'media.storage.*']);
++ assert.strictEqual(DEFAULT_ENDPOINT, 'http://127.0.0.1:3000/internal/media-events');
++
++ // Create: one per topic, Live's secret, the media-events endpoint; one already there is reported.
++ let s = stub({ subscriptions: [{ id: 'sub_old', topic_pattern: 'media.clip.*', endpoint: DEFAULT_ENDPOINT, enabled: true },
++ { id: 'sub_openre', topic_pattern: 'openre.session.*', endpoint: 'http://127.0.0.1:3000/internal/openre-events', enabled: true }] });
++ let log = logs();
++ let r = await run({ env: ENV, fetchImpl: s.fetchImpl, log });
++ assert.deepStrictEqual(r.subscriptions.map(x => [x.topic, x.created]), [['media.vod.*', true], ['media.clip.*', false], ['media.storage.*', true]]);
++ const posts = s.calls.filter((c) => c.method === 'POST' && c.url.endsWith('/api/v1/subscriptions')).map(c => JSON.parse(c.body));
++ assert.deepStrictEqual(posts, [
++ { topic_pattern: 'media.vod.*', endpoint: DEFAULT_ENDPOINT, secret: SECRET },
++ { topic_pattern: 'media.storage.*', endpoint: DEFAULT_ENDPOINT, secret: SECRET },
++ ]);
++ noSecret(log.lines);
++
++ // Re-running changes nothing.
++ const before = s.subs.length;
++ r = await run({ env: ENV, fetchImpl: s.fetchImpl, log: logs() });
++ assert.ok(r.subscriptions.every(x => x.existed));
++ assert.strictEqual(s.subs.length, before);
++
++ // --dry-run lists only.
++ const s2 = stub();
++ log = logs();
++ r = await run({ action: 'list', env: ENV, fetchImpl: s2.fetchImpl, log });
++ assert.ok(r.subscriptions.every(x => !x.existed));
++ assert.ok(!s2.calls.some(c => c.method === 'POST' && !c.url.endsWith('/oauth/token')));
++ assert.strictEqual(log.lines.filter(l => /would create/.test(l)).length, 3);
++
++ // --disable / --enable (rollback and undo).
++ r = await run({ action: 'disable', env: ENV, fetchImpl: s.fetchImpl, log: logs() });
++ assert.ok(s.subs.filter(x => TOPICS.includes(x.topic_pattern)).every(x => x.enabled === false));
++ assert.ok(s.subs.find(x => x.id === 'sub_openre').enabled, 'other subscriptions untouched');
++ await run({ action: 'enable', env: ENV, fetchImpl: s.fetchImpl, log: logs() });
++ assert.ok(s.subs.every(x => x.enabled));
++
++ // Refusals: no secret, short secret, no client secret, Network refuses the token.
++ await assert.rejects(run({ env: { ...ENV, MEDIA_EVENTS_SECRET: 'short' }, fetchImpl: stub().fetchImpl, log: logs() }), /MEDIA_EVENTS_SECRET must be set/);
++ 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.throws(() => parseArgs(['--bogus']), /unknown argument/);
++ console.log('subscribe-media-events: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });