openvibers/openvibe.live Commits
Live: Media outcomes arrive only over OpenVibe.Events; the direct media webhook and its authority switch are deleted (#68)
Changes
@@@ -96,15 +96,11 @@ MEDIA_PUBLIC_URL=https://openvibe.mediaMEDIA_APP_ID=live# Media (VOD/clip/paste) calls authenticate with Live's service token from# OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (audience openvibe.media, namespace live).--# 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 outcomes (vod/clip ready or failed, storage alerts) arrive over OpenVibe.Events: the 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). Without it Live receives no Media outcomes.#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@@@ -4,7 +4,7 @@Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **Cosmetics:** OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's `user_cosmetics`/`user_equipped`/`user_equipped_tag` are read by nothing (`test/cosmetics-tables-gone.test.js`) and are dropped by a contract migration.++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (the only path; the direct webhook was retired 2026-10-10). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **Cosmetics:** OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's `user_cosmetics`/`user_equipped`/`user_equipped_tag` are read by nothing (`test/cosmetics-tables-gone.test.js`) and are dropped by a contract migration.## Commands@@@ -43,7 +43,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 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)).++- Media reports `vod.ready` / `clip.ready` (and failures, storage alerts) as `media.*` OpenVibe.Events events, delivered to `POST /internal/media-events`, driving recording state, AI jobs and clip chat announcements. Each outcome is applied once (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's PostgreSQL database.### Authentication & currencies@@@ -150,7 +150,7 @@ cp .env.example .env # then edit — see SETUP.md (no DATABASE_URL: an embeddenpm run dev```--Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_WEBHOOK_SECRET`. Media calls use Live's service token from that OAuth client (audience `openvibe.media`, namespace `live`, grants `media.object.read`/`list`/`upload`/`delete`).++Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_EVENTS_SECRET`. Media calls use Live's service token from that OAuth client (audience `openvibe.media`, namespace `live`, grants `media.object.read`/`list`/`upload`/`delete`).---@@@ -7,7 +7,7 @@ This document describes the OpenVibe.Live runtime setup, local development, andOpenVibe.Live (port **3000**) is the streaming front — ingest (RTMP / WHIP / WebRTC / JSMPEG), chat, channels, monetization (Vibes), moderation, and the SPA. Two sibling services do heavy lifting:- **OpenVibe.Network** (`openvibe.network`, port **4000**) — SSO/OAuth2 identity provider (client id `live`), RS256 JWTs, URL registry, notifications, and the network-wide **OpenCoins wallet**. Live calls the wallet server-to-server via `OV_NETWORK_INTERNAL_URL` with its service token (client credentials).--- **OpenVibe.Media** (`openvibe.media`, port **4100**) — owns **VODs, clips, pastes, thumbnails, and file storage** (Media API v1). Live records streams by pointing Media at its ingest (RTMP pull / RTP ports 12000-12199), proxies the SPA's `/api/vods`, `/api/clips`, `/api/pastes`, `/api/thumbnails` calls to it (`server/media-client.js`), 302-redirects file payloads to `MEDIA_PUBLIC_URL`, and receives `vod.ready` / `clip.ready` webhooks at `POST /internal/media-webhook`.++- **OpenVibe.Media** (`openvibe.media`, port **4100**) — owns **VODs, clips, pastes, thumbnails, and file storage** (Media API v1). Live records streams by pointing Media at its ingest (RTMP pull / RTP ports 12000-12199), proxies the SPA's `/api/vods`, `/api/clips`, `/api/pastes`, `/api/thumbnails` calls to it (`server/media-client.js`), 302-redirects file payloads to `MEDIA_PUBLIC_URL`, and receives `vod.ready` / `clip.ready` outcomes as OpenVibe.Events deliveries at `POST /internal/media-events`.Live keeps locally, in its PostgreSQL database: users/streams/channel state, Vibes (PayPal tipping/cashout), channel points, comments, stream memories + AI state for Media-hosted vods/clips (`vod_ai_state` / `clip_ai_state`), ephemeral live thumbnails, and the song-request queue.@@@ -48,7 +48,7 @@ cp .env.example .env- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).- `MEDIA_APP_ID` (`live`) — Live's Media tenant. Media calls authenticate with Live's service token (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`, namespace `live`), which needs the `media.object.read`, `media.object.list`, `media.object.upload` and `media.object.delete` grants.--- `MEDIA_WEBHOOK_SECRET` — HMAC secret for Media → Live webhooks.++- `MEDIA_EVENTS_SECRET` — the secret of Live's OpenVibe.Events subscriptions to Media's outcomes (`scripts/subscribe-media-events.js`).#### Public key for token verification@@@ -96,7 +96,6 @@ OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pemMEDIA_URL=http://127.0.0.1:4100MEDIA_PUBLIC_URL=http://127.0.0.1:4100MEDIA_APP_ID=live--MEDIA_WEBHOOK_SECRET=dev-webhook-secret```3. Run `npm run dev` (without `DATABASE_URL`, Live migrates an embedded PGlite database under `DATA_DIR`).@@@ -123,7 +122,7 @@ Server-side recording is delegated to OpenVibe.Media:- **Browser MediaRecorder** — chunk uploads to `/api/vods/stream/:id/chunk` are forwarded to Media's chunks endpoints.- **JSMPEG** — not recorded (no Media ingest for mpeg-ts push).--Completion arrives via the `vod.ready` / `clip.ready` webhooks.++Completion arrives as `media.vod.ready` / `media.clip.ready` events over OpenVibe.Events.### WebRTC / mediasoup@@@ -155,7 +154,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.- **Auth fails**: `OV_NETWORK_PUBLIC_KEY` missing/invalid, or Network unreachable.- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down, `MEDIA_URL` is wrong, or Live's service token lacks the `media.object.*` grants.--- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.++- **VODs never leave "processing"**: Live's Events subscriptions to `media.*` are missing or `MEDIA_EVENTS_SECRET` does not match them (Events shows the deliveries failing with 401).- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.- **WebRTC fails**: `mediasoup` could not initialize or TURN is not configured.@@@ -231,22 +231,15 @@ through the SDK inbox (`idempotency_receipts`):#### 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`).++Media reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` as `media.*` events. It++writes each event in the state change's transaction, and Events delivers it to `POST /internal/media-events`++(signature v2, `MEDIA_EVENTS_SECRET`). [server/media-proxy/outcomes.js](../server/media-proxy/outcomes.js)++applies each outcome once (receipt `media:<object type>:<object id>:<event id>`, consumer++`live-media-outcomes`), so a redelivery is a no-op. Only Live's tenant (`MEDIA_APP_ID`) counts, and++`media.object.uploaded` is ignored.++++This is the only path. The direct webhook (`POST /internal/media-webhook`, `MEDIA_WEBHOOK_SECRET`) carried the++same outcomes during the transition. It was retired on 2026-10-10, after Events had delivered every one of++Live's 123 media outcomes with no dead letters: Live's `webhook_url` was cleared in Media's `apps` row and seed,++and the route, `server/media-proxy/webhook.js`, `MEDIA_WEBHOOK_SECRET` and `MEDIA_EVENTS_AUTHORITY` were++deleted.@@@ -22,10 +22,9 @@* 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.++ * These subscriptions are how Media's outcomes reach Live (the direct webhook is gone). A new++ * subscription gets no history; replay with POST /api/v1/deliveries/replay (events.delivery.admin)++ * if wanted — outcomes Live already applied are no-ops.*/const fs = require('fs');@@@ -658,13 +658,10 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),}));--// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …), two ways during the Wave 3--// transition, applied once whichever arrives first; MEDIA_EVENTS_AUTHORITY=webhook|both|events--// picks the one that acts (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router--// because they authenticate with HMAC signatures, not X-Internal-Key.--// direct webhook (MEDIA_WEBHOOK_SECRET), to be removed once Events is proven--app.post('/internal/media-webhook', require('./media-proxy/webhook'));--// OpenVibe.Events delivery of media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET)++// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …): the OpenVibe.Events delivery of++// media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET), applied once per outcome++// (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router because it authenticates++// with an HMAC signature, not X-Internal-Key.app.post('/internal/media-events', require('./media-proxy/media-events').handler);// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.app.post('/internal/network-events', require('./auth/network-events').handler);@@@ -7,10 +7,8 @@* (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).++ * This is the only way Media's outcomes reach Live (the direct webhook is gone). ./outcomes.js++ * applies each one once (inbox receipt keyed by Media object + event id), so a redelivery is a no-op.** 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@@@ -30,19 +28,15 @@ async function handler(req, res) {// 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;++ const name = ev.source === 'media' ? outcomes.OUTCOME_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 {-- await outcomes.handle({ via: 'events', event: name, data, eventId: ev.event_id, subject });++ await outcomes.handle({ 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@@@ -1,32 +1,24 @@'use strict';/**-- * OpenVibe.Live — what Live does when OpenVibe.Media reports an outcome, whichever way it arrives.++ * OpenVibe.Live — what Live does when OpenVibe.Media reports an outcome.*-- * 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 reports VOD/clip completions and storage alerts as OpenVibe.Events events: Live subscribes to media.vod.*,++ * media.clip.* and media.storage.* (scripts/subscribe-media-events.js) and Events delivers them to++ * POST /internal/media-events (media-proxy/media-events.js). The direct webhook (/internal/media-webhook) that carried++ * the same outcomes during the Wave 3 transition is gone (2026-10-10): Events had delivered every one of them.*-- * 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.++ * Each outcome is applied at most once: every apply claims an inbox receipt (consumer CONSUMER, key++ * "media:<object type>:<object id>:<event id>") in the same transaction as Live's own writes, so an Events redelivery++ * is a no-op. Side effects outside the database (recorder bookkeeping, AI jobs, the ops alert) run after that commit,++ * only for the delivery that applied.*/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).++// The outcome name Live acts on → 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',@@@ -35,22 +27,10 @@ const EVENT_TYPES = {'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 OUTCOME_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 } };++const stats = { applied: 0, duplicate: 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;@@@ -183,28 +163,18 @@ async function apply(event, data) {}/**-- * 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).++ * Apply one Events delivery's outcome at most once (`eventId` is the Events event id, `subject` the object it is++ * about). Returns { applied, duplicate, outcome }. Throws when Live's writes fail (nothing committed; Events retries).*/--async function handle({ via, event, data, eventId = null, subject = null }) {++async function handle({ event, data, eventId, subject = null }) {let after = null;const run = async () => { const r = await apply(event, data); after = r.after || null; return r.outcome; };-- let out;-- if (eventId) {-- out = await 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: await db.getDb().tx(run) };-- }++ const out = await ensureInbox().once(CONSUMER, dedupeKey(eventId, subject || subjectOf(event, data)), run);if (out.duplicate) {-- stats.duplicate[via]++;++ stats.duplicate++;return { applied: false, duplicate: true };}-- stats.applied[via]++;++ stats.applied++;if (after) { try { await after(); } catch (e) { console.warn('[MediaOutcome] post-commit step failed:', e.message); } }// A VOD or clip that became ready (or failed) changes what OpenVibe.Search should hold for its page.const kind = /^(vod|clip)\.(ready|failed)$/.exec(String(event));@@@ -213,16 +183,16 @@ async function handle({ via, event, data, eventId = null, subject = null }) {}function status() {-- return { authority: authority(), consumer: CONSUMER, ...JSON.parse(JSON.stringify(stats)) };++ return { consumer: CONSUMER, ...stats };}function _reset() {inbox = null;-- warnedNoEventId = false;-- for (const k of Object.keys(stats)) stats[k] = { webhook: 0, events: 0 };++ stats.applied = 0;++ stats.duplicate = 0;}module.exports = {-- CONSUMER, EVENT_TYPES, WEBHOOK_NAMES, EVENT_ID_RE, MEDIA_APP_ID,-- authority, accepts, subjectOf, dedupeKey, apply, handle, relayStorageEvent, status, stats, _reset,++ CONSUMER, EVENT_TYPES, OUTCOME_NAMES, EVENT_ID_RE, MEDIA_APP_ID,++ subjectOf, dedupeKey, apply, handle, relayStorageEvent, status, stats, _reset,};ddeleted file mode 100644@@@ -1,62 +0,0 @@--/**-- * 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'|'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).-- *-- * 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 outcomes = require('./outcomes');----const WEBHOOK_SECRET = process.env.MEDIA_WEBHOOK_SECRET || '';----function verifySignature(req) {-- if (!WEBHOOK_SECRET) {-- console.warn('[MediaWebhook] MEDIA_WEBHOOK_SECRET not set — rejecting webhook');-- return false;-- }-- const header = String(req.headers['x-ovmedia-signature'] || '');-- const m = header.match(/^sha256=([0-9a-f]+)$/i);-- if (!m || !req.rawBody) return false;-- const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.rawBody).digest('hex');-- try {-- return crypto.timingSafeEqual(Buffer.from(m[1].toLowerCase(), 'hex'), Buffer.from(expected, 'hex'));-- } catch {-- return false;-- }--}----async 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 {-- result = await outcomes.handle({ via: 'webhook', event, data, eventId });-- } catch (e) {-- console.warn('[MediaWebhook] handler error:', e.message);-- }-- res.json(result && result.duplicate ? { ok: true, duplicate: true } : { ok: true });--}----module.exports = handler;@@@ -260,7 +260,7 @@ const safe = { DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };const writes = [['POST', '/api/streams', { title: 'x' }], ['PUT', '/api/streams/1', { title: 'y' }], ['PATCH', '/api/streams/1', { title: 'y' }],['DELETE', '/api/streams/1', null], ['POST', '/api/chat/send', { message: 'hi' }], ['POST', '/api/auth/logout', null],-- ['POST', '/internal/media-events', { type: 'media.vod.ready' }], ['POST', '/internal/media-webhook', {}], ['POST', '/whip/1', null],++ ['POST', '/internal/media-events', { type: 'media.vod.ready' }], ['POST', '/whip/1', null],['POST', '/banned/continue', null], ['POST', '/api/csp-report', {}], ['POST', '/release-metrics', { counts: { applied: { style: 1 } } }],];for (const [m, p, body] of writes) {@@@ -1,19 +1,16 @@'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.++// Media outcomes over OpenVibe.Events: a signed delivery to /internal/media-events is applied exactly++// once (inbox receipt keyed by Media object + event id), so a redelivery is a no-op. Deliveries need++// signature v2; other tenants' VODs and non-Media events are acknowledged and ignored. The direct++// webhook (/internal/media-webhook) is gone: nothing answers there.const assert = require('assert');const crypto = require('crypto');const path = require('path');--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;@@@ -44,18 +41,16 @@ const newEventId = () => {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).++ // One Media outcome as Events delivers it (media/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 },@@@ -63,12 +58,6 @@ const newEventId = () => {},};};-- 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);@@@ -77,45 +66,27 @@ const newEventId = () => {};const receipts = async () => (await 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');++ // ── An outcome applies once; a redelivery is a no-op.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(await 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']]);++ assert.deepStrictEqual(await receipts(), [`media:vod:101:${a.eventId}`], 'the delivery claims the receipt');++ assert.strictEqual((await postEvent(a.envelope)).status, 204, 'an Events redelivery');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.strictEqual((await postEvent(c2.envelope)).status, 204);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']]);++++ // ── The direct webhook is gone.++ const gone = await fetch(`${base}/internal/media-webhook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });++ assert.strictEqual(gone.status, 404, 'nothing answers at /internal/media-webhook');// 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 });@@@ -140,19 +111,18 @@ const newEventId = () => {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);++ assert.strictEqual(st.consumer, outcomes.CONSUMER);++ assert.ok(st.applied >= 5 && st.duplicate >= 1, JSON.stringify(st));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');++ console.log('media events: applied once per outcome, redeliveries no-ops, the webhook gone, v2 signatures, tenant filter — all checks passed');process.exit(0);})().catch((err) => { console.error(err); process.exit(1); });@@@ -38,7 +38,6 @@ const ENV = {OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client',INTERNAL_API_KEY: 'sentinel-not-a-secret-internal-api-key',OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal-key',-- MEDIA_WEBHOOK_SECRET: 'sentinel-not-a-secret-media-webhook',MEDIA_EVENTS_SECRET: 'sentinel-not-a-secret-media-events',LIVE_EVENTS_SECRET: 'sentinel-not-a-secret-live-events',OPENRE_EVENTS_SECRET: 'sentinel-not-a-secret-openre-events',
@@ -96,15 +96,11 @@ MEDIA_PUBLIC_URL=https://openvibe.media
@@ -96,15 +96,11 @@ MEDIA_PUBLIC_URL=https://openvibe.media
MEDIA_APP_ID=live
MEDIA_APP_ID=live
# Media (VOD/clip/paste) calls authenticate with Live's service token from
# Media (VOD/clip/paste) calls authenticate with Live's service token from
# OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (audience openvibe.media, namespace live).
# OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (audience openvibe.media, namespace live).
-# 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 outcomes (vod/clip ready or failed, storage alerts) arrive over OpenVibe.Events: the 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). Without it Live receives no Media outcomes.
#MEDIA_EVENTS_SECRET=
#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
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
-**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **Cosmetics:** OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's `user_cosmetics`/`user_equipped`/`user_equipped_tag` are read by nothing (`test/cosmetics-tables-gone.test.js`) and are dropped by a contract migration.
+**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (the only path; the direct webhook was retired 2026-10-10). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **Cosmetics:** OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's `user_cosmetics`/`user_equipped`/`user_equipped_tag` are read by nothing (`test/cosmetics-tables-gone.test.js`) and are dropped by a contract migration.
## Commands
## Commands
@@ -43,7 +43,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -43,7 +43,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 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)).
+- Media reports `vod.ready` / `clip.ready` (and failures, storage alerts) as `media.*` OpenVibe.Events events, delivered to `POST /internal/media-events`, driving recording state, AI jobs and clip chat announcements. Each outcome is applied once (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's PostgreSQL database.
- Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state` / `clip_ai_state` in Live's PostgreSQL database.
### Authentication & currencies
### Authentication & currencies
@@ -150,7 +150,7 @@ cp .env.example .env # then edit — see SETUP.md (no DATABASE_URL: an embedde
@@ -150,7 +150,7 @@ cp .env.example .env # then edit — see SETUP.md (no DATABASE_URL: an embedde
npm run dev
npm run dev
```
```
-Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_WEBHOOK_SECRET`. Media calls use Live's service token from that OAuth client (audience `openvibe.media`, namespace `live`, grants `media.object.read`/`list`/`upload`/`delete`).
+Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_PUBLIC_KEY`, `MEDIA_URL`, `MEDIA_PUBLIC_URL`, `MEDIA_EVENTS_SECRET`. Media calls use Live's service token from that OAuth client (audience `openvibe.media`, namespace `live`, grants `media.object.read`/`list`/`upload`/`delete`).
---
---
@@ -7,7 +7,7 @@ This document describes the OpenVibe.Live runtime setup, local development, and
@@ -7,7 +7,7 @@ This document describes the OpenVibe.Live runtime setup, local development, and
OpenVibe.Live (port **3000**) is the streaming front — ingest (RTMP / WHIP / WebRTC / JSMPEG), chat, channels, monetization (Vibes), moderation, and the SPA. Two sibling services do heavy lifting:
OpenVibe.Live (port **3000**) is the streaming front — ingest (RTMP / WHIP / WebRTC / JSMPEG), chat, channels, monetization (Vibes), moderation, and the SPA. Two sibling services do heavy lifting:
- **OpenVibe.Network** (`openvibe.network`, port **4000**) — SSO/OAuth2 identity provider (client id `live`), RS256 JWTs, URL registry, notifications, and the network-wide **OpenCoins wallet**. Live calls the wallet server-to-server via `OV_NETWORK_INTERNAL_URL` with its service token (client credentials).
- **OpenVibe.Network** (`openvibe.network`, port **4000**) — SSO/OAuth2 identity provider (client id `live`), RS256 JWTs, URL registry, notifications, and the network-wide **OpenCoins wallet**. Live calls the wallet server-to-server via `OV_NETWORK_INTERNAL_URL` with its service token (client credentials).
-- **OpenVibe.Media** (`openvibe.media`, port **4100**) — owns **VODs, clips, pastes, thumbnails, and file storage** (Media API v1). Live records streams by pointing Media at its ingest (RTMP pull / RTP ports 12000-12199), proxies the SPA's `/api/vods`, `/api/clips`, `/api/pastes`, `/api/thumbnails` calls to it (`server/media-client.js`), 302-redirects file payloads to `MEDIA_PUBLIC_URL`, and receives `vod.ready` / `clip.ready` webhooks at `POST /internal/media-webhook`.
+- **OpenVibe.Media** (`openvibe.media`, port **4100**) — owns **VODs, clips, pastes, thumbnails, and file storage** (Media API v1). Live records streams by pointing Media at its ingest (RTMP pull / RTP ports 12000-12199), proxies the SPA's `/api/vods`, `/api/clips`, `/api/pastes`, `/api/thumbnails` calls to it (`server/media-client.js`), 302-redirects file payloads to `MEDIA_PUBLIC_URL`, and receives `vod.ready` / `clip.ready` outcomes as OpenVibe.Events deliveries at `POST /internal/media-events`.
Live keeps locally, in its PostgreSQL database: users/streams/channel state, Vibes (PayPal tipping/cashout), channel points, comments, stream memories + AI state for Media-hosted vods/clips (`vod_ai_state` / `clip_ai_state`), ephemeral live thumbnails, and the song-request queue.
Live keeps locally, in its PostgreSQL database: users/streams/channel state, Vibes (PayPal tipping/cashout), channel points, comments, stream memories + AI state for Media-hosted vods/clips (`vod_ai_state` / `clip_ai_state`), ephemeral live thumbnails, and the song-request queue.
@@ -48,7 +48,7 @@ cp .env.example .env
@@ -48,7 +48,7 @@ cp .env.example .env
- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).
- `MEDIA_URL` — internal Media API base (default `http://127.0.0.1:4100`).
- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).
- `MEDIA_PUBLIC_URL` — public Media host (default `https://openvibe.media`).
- `MEDIA_APP_ID` (`live`) — Live's Media tenant. Media calls authenticate with Live's service token (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`, namespace `live`), which needs the `media.object.read`, `media.object.list`, `media.object.upload` and `media.object.delete` grants.
- `MEDIA_APP_ID` (`live`) — Live's Media tenant. Media calls authenticate with Live's service token (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`, namespace `live`), which needs the `media.object.read`, `media.object.list`, `media.object.upload` and `media.object.delete` grants.
-- `MEDIA_WEBHOOK_SECRET` — HMAC secret for Media → Live webhooks.
+- `MEDIA_EVENTS_SECRET` — the secret of Live's OpenVibe.Events subscriptions to Media's outcomes (`scripts/subscribe-media-events.js`).
#### Public key for token verification
#### Public key for token verification
@@ -96,7 +96,6 @@ OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
@@ -96,7 +96,6 @@ OV_NETWORK_PUBLIC_KEY=./data/keys/openvibe-network-public.pem
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
MEDIA_APP_ID=live
MEDIA_APP_ID=live
-MEDIA_WEBHOOK_SECRET=dev-webhook-secret
```
```
3. Run `npm run dev` (without `DATABASE_URL`, Live migrates an embedded PGlite database under `DATA_DIR`).
3. Run `npm run dev` (without `DATABASE_URL`, Live migrates an embedded PGlite database under `DATA_DIR`).
@@ -123,7 +122,7 @@ Server-side recording is delegated to OpenVibe.Media:
@@ -123,7 +122,7 @@ Server-side recording is delegated to OpenVibe.Media:
- **Browser MediaRecorder** — chunk uploads to `/api/vods/stream/:id/chunk` are forwarded to Media's chunks endpoints.
- **Browser MediaRecorder** — chunk uploads to `/api/vods/stream/:id/chunk` are forwarded to Media's chunks endpoints.
- **JSMPEG** — not recorded (no Media ingest for mpeg-ts push).
- **JSMPEG** — not recorded (no Media ingest for mpeg-ts push).
-Completion arrives via the `vod.ready` / `clip.ready` webhooks.
+Completion arrives as `media.vod.ready` / `media.clip.ready` events over OpenVibe.Events.
### WebRTC / mediasoup
### WebRTC / mediasoup
@@ -155,7 +154,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
@@ -155,7 +154,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
- **Auth fails**: `OV_NETWORK_PUBLIC_KEY` missing/invalid, or Network unreachable.
- **Auth fails**: `OV_NETWORK_PUBLIC_KEY` missing/invalid, or Network unreachable.
- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.
- **Invalid redirect_uri**: local callback URIs not registered on the `live` OAuth client in Network.
- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down, `MEDIA_URL` is wrong, or Live's service token lacks the `media.object.*` grants.
- **VOD/clip/paste endpoints return 502**: OpenVibe.Media is down, `MEDIA_URL` is wrong, or Live's service token lacks the `media.object.*` grants.
-- **Webhook 401s in Media logs**: `MEDIA_WEBHOOK_SECRET` mismatch.
+- **VODs never leave "processing"**: Live's Events subscriptions to `media.*` are missing or `MEDIA_EVENTS_SECRET` does not match them (Events shows the deliveries failing with 401).
- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.
- **OpenCoins balance always 0 / spends fail**: `OV_NETWORK_INTERNAL_URL`/`OV_OAUTH_CLIENT_SECRET` wrong, or the user has no linked Network account.
- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.
- **CORS rejects browser traffic**: `BASE_URL` set to localhost in production.
- **WebRTC fails**: `mediasoup` could not initialize or TURN is not configured.
- **WebRTC fails**: `mediasoup` could not initialize or TURN is not configured.
@@ -231,22 +231,15 @@ through the SDK inbox (`idempotency_receipts`):
@@ -231,22 +231,15 @@ through the SDK inbox (`idempotency_receipts`):
#### Media outcomes over Events
#### 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`).
+Media reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` as `media.*` events. It
+writes each event in the state change's transaction, and Events delivers it to `POST /internal/media-events`
+(signature v2, `MEDIA_EVENTS_SECRET`). [server/media-proxy/outcomes.js](../server/media-proxy/outcomes.js)
+applies each outcome once (receipt `media:<object type>:<object id>:<event id>`, consumer
+`live-media-outcomes`), so a redelivery is a no-op. Only Live's tenant (`MEDIA_APP_ID`) counts, and
+`media.object.uploaded` is ignored.
+
+This is the only path. The direct webhook (`POST /internal/media-webhook`, `MEDIA_WEBHOOK_SECRET`) carried the
+same outcomes during the transition. It was retired on 2026-10-10, after Events had delivered every one of
+Live's 123 media outcomes with no dead letters: Live's `webhook_url` was cleared in Media's `apps` row and seed,
+and the route, `server/media-proxy/webhook.js`, `MEDIA_WEBHOOK_SECRET` and `MEDIA_EVENTS_AUTHORITY` were
+deleted.
@@ -22,10 +22,9 @@
@@ -22,10 +22,9 @@
* OV_NETWORK_INTERNAL_URL (default http://127.0.0.1:4000), EVENTS_URL (default http://127.0.0.1:4300)
* 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
* 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.
* 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.
+ * These subscriptions are how Media's outcomes reach Live (the direct webhook is gone). A new
+ * subscription gets no history; replay with POST /api/v1/deliveries/replay (events.delivery.admin)
+ * if wanted — outcomes Live already applied are no-ops.
*/
*/
const fs = require('fs');
const fs = require('fs');
@@ -658,13 +658,10 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
@@ -658,13 +658,10 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),
setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),
}));
}));
-// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …), two ways during the Wave 3
-// transition, applied once whichever arrives first; MEDIA_EVENTS_AUTHORITY=webhook|both|events
-// picks the one that acts (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router
-// because they authenticate with HMAC signatures, not X-Internal-Key.
-// direct webhook (MEDIA_WEBHOOK_SECRET), to be removed once Events is proven
-app.post('/internal/media-webhook', require('./media-proxy/webhook'));
-// OpenVibe.Events delivery of media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET)
+// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …): the OpenVibe.Events delivery of
+// media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET), applied once per outcome
+// (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router because it authenticates
+// with an HMAC signature, not X-Internal-Key.
app.post('/internal/media-events', require('./media-proxy/media-events').handler);
app.post('/internal/media-events', require('./media-proxy/media-events').handler);
// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.
// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.
app.post('/internal/network-events', require('./auth/network-events').handler);
app.post('/internal/network-events', require('./auth/network-events').handler);
@@ -7,10 +7,8 @@
@@ -7,10 +7,8 @@
* (scripts/subscribe-media-events.js), signed with
* (scripts/subscribe-media-events.js), signed with
* MEDIA_EVENTS_SECRET, signature v2 only.
* 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).
+ * This is the only way Media's outcomes reach Live (the direct webhook is gone). ./outcomes.js
+ * applies each one once (inbox receipt keyed by Media object + event id), so a redelivery is a no-op.
*
*
* Only Live's own tenant counts: Events carries every Media tenant's VODs and clips, and a
* 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
* vod/clip event whose payload.app_id is not MEDIA_APP_ID is acknowledged and ignored. Storage
@@ -30,19 +28,15 @@ async function handler(req, res) {
@@ -30,19 +28,15 @@ async function handler(req, res) {
// Signed but unusable: acknowledge so it is not redelivered forever.
// 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();
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;
+ const name = ev.source === 'media' ? outcomes.OUTCOME_NAMES[ev.event_type] : null;
if (!name) return res.status(204).end();
if (!name) return res.status(204).end();
const data = ev.payload || {};
const data = ev.payload || {};
if (!name.startsWith('storage.') && String(data.app_id || '') !== outcomes.MEDIA_APP_ID) return res.status(204).end();
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
const subject = ev.subject && ev.subject.type && ev.subject.id != null
? { type: String(ev.subject.type), id: String(ev.subject.id) }
? { type: String(ev.subject.type), id: String(ev.subject.id) }
: outcomes.subjectOf(name, data);
: outcomes.subjectOf(name, data);
try {
try {
- await outcomes.handle({ via: 'events', event: name, data, eventId: ev.event_id, subject });
+ await outcomes.handle({ event: name, data, eventId: ev.event_id, subject });
} catch (err) {
} catch (err) {
console.error('[MediaEvents] apply failed:', err.message);
console.error('[MediaEvents] apply failed:', err.message);
return res.status(500).json({ error: 'apply failed' }); // Events retries
return res.status(500).json({ error: 'apply failed' }); // Events retries
@@ -1,32 +1,24 @@
@@ -1,32 +1,24 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Live — what Live does when OpenVibe.Media reports an outcome, whichever way it arrives.
+ * OpenVibe.Live — what Live does when OpenVibe.Media reports an outcome.
*
*
- * 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 reports VOD/clip completions and storage alerts as OpenVibe.Events events: Live subscribes to media.vod.*,
+ * media.clip.* and media.storage.* (scripts/subscribe-media-events.js) and Events delivers them to
+ * POST /internal/media-events (media-proxy/media-events.js). The direct webhook (/internal/media-webhook) that carried
+ * the same outcomes during the Wave 3 transition is gone (2026-10-10): Events had delivered every one of them.
*
*
- * 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.
+ * Each outcome is applied at most once: every apply claims an inbox receipt (consumer CONSUMER, key
+ * "media:<object type>:<object id>:<event id>") in the same transaction as Live's own writes, so an Events redelivery
+ * is a no-op. Side effects outside the database (recorder bookkeeping, AI jobs, the ops alert) run after that commit,
+ * only for the delivery that applied.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const CONSUMER = 'live-media-outcomes';
const CONSUMER = 'live-media-outcomes';
-const MODES = new Set(['webhook', 'both', 'events']);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';
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).
+// The outcome name Live acts on → the Events event_type Media publishes for it (media/server/events.js TYPES).
const EVENT_TYPES = {
const EVENT_TYPES = {
'vod.ready': 'media.vod.ready',
'vod.ready': 'media.vod.ready',
'vod.failed': 'media.vod.failed',
'vod.failed': 'media.vod.failed',
@@ -35,22 +27,10 @@ const EVENT_TYPES = {
@@ -35,22 +27,10 @@ const EVENT_TYPES = {
'storage.alert': 'media.storage.alert',
'storage.alert': 'media.storage.alert',
'storage.recovered': 'media.storage.recovered',
'storage.recovered': 'media.storage.recovered',
};
};
-const WEBHOOK_NAMES = Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [v, k]));
+const OUTCOME_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 } };
+const stats = { applied: 0, duplicate: 0 };
let inbox = null;
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() {
function ensureInbox() {
if (inbox) return inbox;
if (inbox) return inbox;
@@ -183,28 +163,18 @@ async function apply(event, data) {
@@ -183,28 +163,18 @@ async function apply(event, data) {
}
}
/**
/**
- * 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).
+ * Apply one Events delivery's outcome at most once (`eventId` is the Events event id, `subject` the object it is
+ * about). Returns { applied, duplicate, outcome }. Throws when Live's writes fail (nothing committed; Events retries).
*/
*/
-async function handle({ via, event, data, eventId = null, subject = null }) {
+async function handle({ event, data, eventId, subject = null }) {
let after = null;
let after = null;
const run = async () => { const r = await apply(event, data); after = r.after || null; return r.outcome; };
const run = async () => { const r = await apply(event, data); after = r.after || null; return r.outcome; };
- let out;
- if (eventId) {
- out = await 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: await db.getDb().tx(run) };
- }
+ const out = await ensureInbox().once(CONSUMER, dedupeKey(eventId, subject || subjectOf(event, data)), run);
if (out.duplicate) {
if (out.duplicate) {
- stats.duplicate[via]++;
+ stats.duplicate++;
return { applied: false, duplicate: true };
return { applied: false, duplicate: true };
}
}
- stats.applied[via]++;
+ stats.applied++;
if (after) { try { await after(); } catch (e) { console.warn('[MediaOutcome] post-commit step failed:', e.message); } }
if (after) { try { await after(); } catch (e) { console.warn('[MediaOutcome] post-commit step failed:', e.message); } }
// A VOD or clip that became ready (or failed) changes what OpenVibe.Search should hold for its page.
// A VOD or clip that became ready (or failed) changes what OpenVibe.Search should hold for its page.
const kind = /^(vod|clip)\.(ready|failed)$/.exec(String(event));
const kind = /^(vod|clip)\.(ready|failed)$/.exec(String(event));
@@ -213,16 +183,16 @@ async function handle({ via, event, data, eventId = null, subject = null }) {
@@ -213,16 +183,16 @@ async function handle({ via, event, data, eventId = null, subject = null }) {
}
}
function status() {
function status() {
- return { authority: authority(), consumer: CONSUMER, ...JSON.parse(JSON.stringify(stats)) };
+ return { consumer: CONSUMER, ...stats };
}
}
function _reset() {
function _reset() {
inbox = null;
inbox = null;
- warnedNoEventId = false;
- for (const k of Object.keys(stats)) stats[k] = { webhook: 0, events: 0 };
+ stats.applied = 0;
+ stats.duplicate = 0;
}
}
module.exports = {
module.exports = {
- CONSUMER, EVENT_TYPES, WEBHOOK_NAMES, EVENT_ID_RE, MEDIA_APP_ID,
- authority, accepts, subjectOf, dedupeKey, apply, handle, relayStorageEvent, status, stats, _reset,
+ CONSUMER, EVENT_TYPES, OUTCOME_NAMES, EVENT_ID_RE, MEDIA_APP_ID,
+ subjectOf, dedupeKey, apply, handle, relayStorageEvent, status, stats, _reset,
};
};
deleted file mode 100644
deleted file mode 100644
@@ -1,62 +0,0 @@
@@ -1,62 +0,0 @@
-/**
- * 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'|'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).
- *
- * 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 outcomes = require('./outcomes');
-
-const WEBHOOK_SECRET = process.env.MEDIA_WEBHOOK_SECRET || '';
-
-function verifySignature(req) {
- if (!WEBHOOK_SECRET) {
- console.warn('[MediaWebhook] MEDIA_WEBHOOK_SECRET not set — rejecting webhook');
- return false;
- }
- const header = String(req.headers['x-ovmedia-signature'] || '');
- const m = header.match(/^sha256=([0-9a-f]+)$/i);
- if (!m || !req.rawBody) return false;
- const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.rawBody).digest('hex');
- try {
- return crypto.timingSafeEqual(Buffer.from(m[1].toLowerCase(), 'hex'), Buffer.from(expected, 'hex'));
- } catch {
- return false;
- }
-}
-
-async 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 {
- result = await outcomes.handle({ via: 'webhook', event, data, eventId });
- } catch (e) {
- console.warn('[MediaWebhook] handler error:', e.message);
- }
- res.json(result && result.duplicate ? { ok: true, duplicate: true } : { ok: true });
-}
-
-module.exports = handler;
@@ -260,7 +260,7 @@ const safe = { DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
@@ -260,7 +260,7 @@ const safe = { DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
const writes = [
const writes = [
['POST', '/api/streams', { title: 'x' }], ['PUT', '/api/streams/1', { title: 'y' }], ['PATCH', '/api/streams/1', { title: 'y' }],
['POST', '/api/streams', { title: 'x' }], ['PUT', '/api/streams/1', { title: 'y' }], ['PATCH', '/api/streams/1', { title: 'y' }],
['DELETE', '/api/streams/1', null], ['POST', '/api/chat/send', { message: 'hi' }], ['POST', '/api/auth/logout', null],
['DELETE', '/api/streams/1', null], ['POST', '/api/chat/send', { message: 'hi' }], ['POST', '/api/auth/logout', null],
- ['POST', '/internal/media-events', { type: 'media.vod.ready' }], ['POST', '/internal/media-webhook', {}], ['POST', '/whip/1', null],
+ ['POST', '/internal/media-events', { type: 'media.vod.ready' }], ['POST', '/whip/1', null],
['POST', '/banned/continue', null], ['POST', '/api/csp-report', {}], ['POST', '/release-metrics', { counts: { applied: { style: 1 } } }],
['POST', '/banned/continue', null], ['POST', '/api/csp-report', {}], ['POST', '/release-metrics', { counts: { applied: { style: 1 } } }],
];
];
for (const [m, p, body] of writes) {
for (const [m, p, body] of writes) {
@@ -1,19 +1,16 @@
@@ -1,19 +1,16 @@
'use strict';
'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.
+// Media outcomes over OpenVibe.Events: a signed delivery to /internal/media-events is applied exactly
+// once (inbox receipt keyed by Media object + event id), so a redelivery is a no-op. Deliveries need
+// signature v2; other tenants' VODs and non-Media events are acknowledged and ignored. The direct
+// webhook (/internal/media-webhook) is gone: nothing answers there.
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const path = require('path');
const path = require('path');
-process.env.MEDIA_WEBHOOK_SECRET = 'w'.repeat(40);
process.env.MEDIA_EVENTS_SECRET = 'e'.repeat(64);
process.env.MEDIA_EVENTS_SECRET = 'e'.repeat(64);
process.env.MEDIA_APP_ID = 'live';
process.env.MEDIA_APP_ID = 'live';
-delete process.env.MEDIA_EVENTS_AUTHORITY;
delete process.env.OPS_ALERT_WEBHOOK_URL;
delete process.env.OPS_ALERT_WEBHOOK_URL;
let seq = 0;
let seq = 0;
@@ -44,18 +41,16 @@ const newEventId = () => {
@@ -44,18 +41,16 @@ const newEventId = () => {
const app = express();
const app = express();
app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
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);
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 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}`;
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).
+ // One Media outcome as Events delivers it (media/server/events.js record).
const outcome = (event, data, eventId = newEventId()) => {
const outcome = (event, data, eventId = newEventId()) => {
const type = event.startsWith('storage.') ? 'storage' : event.split('.')[0];
const type = event.startsWith('storage.') ? 'storage' : event.split('.')[0];
const id = type === 'storage' ? data.kind : String(data.id);
const id = type === 'storage' ? data.kind : String(data.id);
return {
return {
eventId,
eventId,
- webhook: { event, app_id: data.app_id === undefined ? 'live' : data.app_id, data, ...(eventId ? { event_id: eventId } : {}) },
envelope: {
envelope: {
event_id: eventId, event_type: `media.${event}`, version: 1, source: 'media', actor: { type: 'service', id: 'media' },
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 },
timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type, id },
@@ -63,12 +58,6 @@ const newEventId = () => {
@@ -63,12 +58,6 @@ const newEventId = () => {
},
},
};
};
};
};
- 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 postEvent = async (envelope, { v1Only = false, secret = process.env.MEDIA_EVENTS_SECRET } = {}) => {
const raw = JSON.stringify({ event: envelope, seq: ++seq, subscription_id: 'sub_media' });
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 headers = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret);
@@ -77,45 +66,27 @@ const newEventId = () => {
@@ -77,45 +66,27 @@ const newEventId = () => {
};
};
const receipts = async () => (await db.getDb().prepare('SELECT event_id FROM idempotency_receipts WHERE consumer = ?').all(outcomes.CONSUMER)).map(r => r.event_id);
const receipts = async () => (await 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');
+ // ── An outcome applies once; a redelivery is a no-op.
const a = outcome('vod.ready', { id: 101, stream_id: null, duration: 60 });
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(await receipts(), [`media:vod:101:${a.eventId}`], 'the webhook claims the receipt');
assert.strictEqual((await postEvent(a.envelope)).status, 204);
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']]);
+ assert.deepStrictEqual(await receipts(), [`media:vod:101:${a.eventId}`], 'the delivery claims the receipt');
+ assert.strictEqual((await postEvent(a.envelope)).status, 204, 'an Events redelivery');
assert.deepStrictEqual(calls.vod, [[101, 'pending']], 'applied once');
assert.deepStrictEqual(calls.vod, [[101, 'pending']], 'applied once');
const c = outcome('clip.ready', { id: 7, stream_id: null });
const c = outcome('clip.ready', { id: 7, stream_id: null });
assert.strictEqual((await postEvent(c.envelope)).status, 204);
assert.strictEqual((await postEvent(c.envelope)).status, 204);
assert.deepStrictEqual(calls.clip, [[7, 'pending']]);
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.
// 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' });
const c2 = outcome('clip.failed', { id: 7, error: 'cut failed' });
- await postWebhook(c2.webhook);
- await postEvent(c2.envelope);
+ assert.strictEqual((await postEvent(c2.envelope)).status, 204);
assert.deepStrictEqual(calls.clip, [[7, 'pending'], [7, 'failed']]);
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' });
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.strictEqual((await postEvent(d.envelope)).status, 204);
assert.deepStrictEqual(calls.vod.filter(([id]) => id === 103), [[103, 'failed']]);
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']]);
+
+ // ── The direct webhook is gone.
+ const gone = await fetch(`${base}/internal/media-webhook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ assert.strictEqual(gone.status, 404, 'nothing answers at /internal/media-webhook');
// Storage alerts are service-wide (app_id null) and apply from Events.
// 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 });
const s = outcome('storage.alert', { kind: 'disk_critical', app_id: null, disk_pct: 97, free_gb: 3 });
@@ -140,19 +111,18 @@ const newEventId = () => {
@@ -140,19 +111,18 @@ const newEventId = () => {
const f = outcome('vod.ready', { id: 105 });
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, { v1Only: true })).status, 401, 'v1-only deliveries are refused');
assert.strictEqual((await postEvent(f.envelope, { secret: 'x'.repeat(64) })).status, 401);
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;
delete process.env.MEDIA_EVENTS_SECRET;
assert.strictEqual((await postEvent(f.envelope)).status, 503, 'no secret, no endpoint');
assert.strictEqual((await postEvent(f.envelope)).status, 503, 'no secret, no endpoint');
assert.ok(!calls.vod.some(([id]) => id === 105));
assert.ok(!calls.vod.some(([id]) => id === 105));
const st = outcomes.status();
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);
+ assert.strictEqual(st.consumer, outcomes.CONSUMER);
+ assert.ok(st.applied >= 5 && st.duplicate >= 1, JSON.stringify(st));
db.setVodTranscriptStatus = realVod;
db.setVodTranscriptStatus = realVod;
db.setClipTranscriptStatus = realClip;
db.setClipTranscriptStatus = realClip;
console.error = origError;
console.error = origError;
server.close();
server.close();
- console.log('media events: webhook ⇄ Events transition, applied once per outcome, authority switch, v2 signatures, tenant filter — all checks passed');
+ console.log('media events: applied once per outcome, redeliveries no-ops, the webhook gone, v2 signatures, tenant filter — all checks passed');
process.exit(0);
process.exit(0);
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -38,7 +38,6 @@ const ENV = {
@@ -38,7 +38,6 @@ const ENV = {
OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client',
OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client',
INTERNAL_API_KEY: 'sentinel-not-a-secret-internal-api-key',
INTERNAL_API_KEY: 'sentinel-not-a-secret-internal-api-key',
OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal-key',
OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal-key',
- MEDIA_WEBHOOK_SECRET: 'sentinel-not-a-secret-media-webhook',
MEDIA_EVENTS_SECRET: 'sentinel-not-a-secret-media-events',
MEDIA_EVENTS_SECRET: 'sentinel-not-a-secret-media-events',
LIVE_EVENTS_SECRET: 'sentinel-not-a-secret-live-events',
LIVE_EVENTS_SECRET: 'sentinel-not-a-secret-live-events',
OPENRE_EVENTS_SECRET: 'sentinel-not-a-secret-openre-events',
OPENRE_EVENTS_SECRET: 'sentinel-not-a-secret-openre-events',