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
Changes
@@@ -77,6 +77,13 @@ MEDIA_APP_ID=liveMEDIA_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@@@ -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@@@ -169,3 +169,48 @@ SSO and wallet come from OpenVibe.Network; media storage and processing from Opeinter-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`).nnew file mode 100644@@@ -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 };@@@ -544,9 +544,14 @@ app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATHsetHeaders: (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 provenapp.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);nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};@@@ -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;@@@ -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');nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
@@ -77,6 +77,13 @@ MEDIA_APP_ID=live
@@ -77,6 +77,13 @@ MEDIA_APP_ID=live
MEDIA_API_KEY=
MEDIA_API_KEY=
# Shared secret for verifying Media → Live webhooks (X-OVMedia-Signature HMAC)
# Shared secret for verifying Media → Live webhooks (X-OVMedia-Signature HMAC)
MEDIA_WEBHOOK_SECRET=
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 ingest ───────────────────────────────────────────────────────────────
RTMP_PORT=1935
RTMP_PORT=1935
@@ -44,7 +44,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -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.
- 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`.
- 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.
- Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state` / `clip_ai_state` in live.db.
### Authentication & currencies
### Authentication & currencies
@@ -169,3 +169,48 @@ SSO and wallet come from OpenVibe.Network; media storage and processing from Ope
@@ -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:
inter-service contracts are in `../CONTRACTS.md` (OpenVibers workspace). Deployment:
[deploy.md](deploy.md). Security posture: [../SECURITY_AUDIT.md](../SECURITY_AUDIT.md). Measurements:
[deploy.md](deploy.md). Security posture: [../SECURITY_AUDIT.md](../SECURITY_AUDIT.md). Measurements:
[performance-audit.md](performance-audit.md).
[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`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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 };
@@ -544,9 +544,14 @@ app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH
@@ -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'),
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'));
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,
// 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,53 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,225 @@
@@ -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,
+};
@@ -1,18 +1,25 @@
@@ -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
* 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';
'use strict';
const crypto = require('crypto');
const crypto = require('crypto');
-const db = require('../db/database');
+const outcomes = require('./outcomes');
const WEBHOOK_SECRET = process.env.MEDIA_WEBHOOK_SECRET || '';
const WEBHOOK_SECRET = process.env.MEDIA_WEBHOOK_SECRET || '';
@@ -32,113 +39,24 @@ function verifySignature(req) {
@@ -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) {
function handler(req, res) {
if (!verifySignature(req)) return res.status(401).json({ error: 'Invalid signature' });
if (!verifySignature(req)) return res.status(401).json({ error: 'Invalid signature' });
const { event, data } = req.body || {};
const { event, data } = req.body || {};
if (!event) return res.status(400).json({ error: 'Missing event' });
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 {
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) {
} catch (e) {
console.warn('[MediaWebhook] handler error:', e.message);
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;
module.exports = handler;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
* TODO(contract): jsmpeg recording is skipped until Media grows an ingest for it.
* TODO(contract): jsmpeg recording is skipped until Media grows an ingest for it.
*
*
* On stream end: ingestRtpStop / finalizeVod. Media probes, thumbnails, and fires the
* 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';
'use strict';
const db = require('../db/database');
const db = require('../db/database');
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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); });