openvibers/openvibe.live Commits

Live: Media outcomes arrive only over OpenVibe.Events; the direct media webhook and its authority switch are deleted (#68)

OP OpenVibers committed 10 Oct 2026 · d7fb500916 · 13 files, +75 −220

Plan T4 / C-83: Media's outcomes reach Live only over OpenVibe.Events now, and the direct webhook is deleted (convert, verify, delete).

**Verified before deleting (production, 2026-10-10):** Events has delivered all 123 of Live's media outcomes (79 `media.vod.*`, 44 `media.clip.*`) to `/internal/media-events`, every one `delivered`, with no failures or dead letters. `MEDIA_EVENTS_AUTHORITY=events` has been the authority, so the webhook copies were only acknowledged. The latest outcome's receipt was claimed by the Events delivery.

**Already done in production:** Live's `webhook_url` and `webhook_secret` are cleared in Media's `apps` row and in `MEDIA_APPS_SEED` (backup kept). Media reads apps per send, so it has stopped posting to Live.

**This PR deletes:**
- `POST /internal/media-webhook` and `server/media-proxy/webhook.js`;
- the `MEDIA_EVENTS_AUTHORITY` switch with its `webhook`/`both` modes;
- `MEDIA_WEBHOOK_SECRET`.

`outcomes.js` is Events-only: one receipt per outcome, so a redelivery is a no-op. `WEBHOOK_NAMES` is renamed `OUTCOME_NAMES`, and stats are `{ applied, duplicate }`.

**Docs:** README, SETUP, AGENTS.md and `docs/architecture.md` ("Media outcomes over Events" now records the retirement). **Tests:** `media-events.test.js` is rewritten for the one path (once per outcome, redelivery no-op, the webhook 404s, v2 signatures, tenant filter). `drill-mode` and `security-secrets` drop the webhook. `ov test`: 23 passed.

After the deploy, `MEDIA_WEBHOOK_SECRET` and `MEDIA_EVENTS_AUTHORITY` come out of live.env.

Changes

ddiff --git a/.env.example b/.env.example
iindex badf9d4b..826a07f5 100644
---- a/.env.example
++++ b/.env.example
@@@ -96,15 +96,11 @@ MEDIA_PUBLIC_URL=https://openvibe.media
MEDIA_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
ddiff --git a/AGENTS.md b/AGENTS.md
iindex a07c6275..2e59b56a 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 9ad3dc88..fe0361fb 100644
---- a/README.md
++++ b/README.md
@@@ -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 embedde
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`).
---
ddiff --git a/SETUP.md b/SETUP.md
iindex d5688d37..20f59647 100644
---- a/SETUP.md
++++ b/SETUP.md
@@@ -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.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.pem
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=http://127.0.0.1:4100
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`).
@@@ -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.
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 2cd6b6dc..9bc0c736 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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.
ddiff --git a/scripts/subscribe-media-events.js b/scripts/subscribe-media-events.js
iindex dda79e17..89b0e293 100644
---- a/scripts/subscribe-media-events.js
++++ b/scripts/subscribe-media-events.js
@@@ -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');
ddiff --git a/server/index.js b/server/index.js
iindex 6d89b320..e5998492 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/server/media-proxy/media-events.js b/server/media-proxy/media-events.js
iindex 19c4664a..4e60c003 100644
---- a/server/media-proxy/media-events.js
++++ b/server/media-proxy/media-events.js
@@@ -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
ddiff --git a/server/media-proxy/outcomes.js b/server/media-proxy/outcomes.js
iindex 1becf421..702d30f5 100644
---- a/server/media-proxy/outcomes.js
++++ b/server/media-proxy/outcomes.js
@@@ -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,
};
ddiff --git a/server/media-proxy/webhook.js b/server/media-proxy/webhook.js
ddeleted file mode 100644
iindex 0051e179..00000000
---- a/server/media-proxy/webhook.js
++++ /dev/null
@@@ -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;
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex 00404d1c..fb8e06ee 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -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) {
ddiff --git a/test/media-events.test.js b/test/media-events.test.js
iindex 36994b3a..7b6ed62c 100644
---- a/test/media-events.test.js
++++ b/test/media-events.test.js
@@@ -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); });
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex e99f2097..d9801b64 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -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',