openvibers/openvibe.live Commits

WHIP: open CORS so a backend-less web page can publish; docs/whip.md + browser publisher

OP OpenVibers committed 28 Aug 2026 · d10a5cf332 · 9 files, +734 −30

Answers "can a frontend-only site stream to WHIP?" — it could not: /whip/* sat
behind the credentialed CORS allowlist in server/index.js, so any page hosted
outside openvibe.* failed the preflight with 403 before it could POST an offer.

- server/index.js: mount whipHandler.whipCors on /whip and exempt /whip/* from
  the allowlisted, credentialed cors() (which still guards /api/*). Add the WHIP
  public origin to CSP connect-src so the hosted publisher can reach
  whip.openvibe.live.
- whip-handler.js: whipCors middleware — Access-Control-Allow-Origin: *, allows
  POST/PATCH/DELETE + Authorization, exposes Location and X-WHIP-ERROR so a
  browser can read the resource URL and error codes. WHIP is keyed by the
  stream key, never a cookie, so an open origin is safe.
- whip-handler.js: keep rejected (port 0) m-sections out of the BUNDLE group in
  the SDP answer (RFC 8843 §7.3.3) — OBS tolerated it, browsers refuse the
  whole answer; echo the offered format list on rejected sections.
- public/whip-publisher.html: self-contained single-file browser publisher
  (camera/mic or screen, bitrate presets, stats, keepalive DELETE on pagehide,
  window.OpenVibeWhip API). Hosted at /whip-publisher.html, works from any host.
- docs/whip.md: WHIP ingest API reference (auth forms, lifecycle, codec
  requirements, error table, CORS) with a paste-ready browser section; linked
  from docs index, broadcasting guide, README and AGENTS.md.
- test/whip-handler.test.js: CORS + BUNDLE regressions.

Verified end-to-end with headless Chrome on a foreign origin (127.0.0.1:8099 →
:3000): preflight 204, 201 + Location, ICE/DTLS connected, VP8 + Opus producers.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex a1212cec..c0eac89a 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -65,6 +65,7 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
++- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)
ddiff --git a/README.md b/README.md
iindex ff5685f3..9b7f4770 100644
---- a/README.md
++++ b/README.md
@@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c
- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).
- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).
- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).
--- WHIP/HTTP ingestion support (`whip.openvibe.live`).
++- WHIP/HTTP ingestion support (`whip.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).
- real-time broadcast and control channels.
### Media (VODs / clips / pastes / thumbnails)
ddiff --git a/docs/README.md b/docs/README.md
iindex 2bd8fa83..bd588360 100644
---- a/docs/README.md
++++ b/docs/README.md
@@@ -2,7 +2,8 @@
## Getting Started
- [Onboarding Guide](onboarding.md) — New user walkthrough
--- [Broadcasting Guide](broadcasting.md) — Stream setup for WebRTC, RTMP, and JSMPEG
++- [Broadcasting Guide](broadcasting.md) — Stream setup for WebRTC, RTMP, WHIP, and JSMPEG
++- [WHIP Ingest API](whip.md) — WHIP endpoint reference, CORS, error codes · [Publishing from a browser](whip.md#publishing-from-a-browser) (static site, no backend)
## Features
- [Chat System](chat-system.md) — Chat features, moderation, popout chat, TTS
ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 1cf42fe9..0d68412a 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -1,6 +1,6 @@
# Broadcasting Guide
--OpenVibe.Live supports three live broadcast protocols, each suited to different use cases.
++OpenVibe.Live supports four live broadcast methods, each suited to different use cases.
## Broadcast Methods
@@@ -16,6 +16,12 @@ OpenVibe.Live supports three live broadcast protocols, each suited to different
- **Setup**: Copy Server URL and Stream Key into OBS
- **Features**: Full OBS feature set, live thumbnail preview on broadcast page
++### WHIP (OBS, FFmpeg, GStreamer — or any web page)
++- **Best for**: WebRTC-native encoders and custom publishers, including browser-only sites
++- **Latency**: Sub-second
++- **Setup**: Choose *Streaming method: WHIP*, copy the WHIP URL into OBS (Service: WHIP) or `ffmpeg -f whip`
++- **Features**: VP8/H.264 + Opus, auto-creates the live session on first POST, open CORS so a static page can publish with `RTCPeerConnection` + `fetch()` — see the [WHIP Ingest API](whip.md) / [Publishing from a browser](whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html)
++
### JSMPEG (FFmpeg)
- **Best for**: Headless/embedded streaming (Raspberry Pi, IP cameras, 3D printers)
- **Latency**: ~1 second
ddiff --git a/docs/whip.md b/docs/whip.md
nnew file mode 100644
iindex 00000000..e65ed326
---- /dev/null
++++ b/docs/whip.md
@@@ -0,0 +1,167 @@
++# WHIP Ingest API
++
++OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol, [RFC 9725](https://www.rfc-editor.org/rfc/rfc9725)). Anything that can produce a WebRTC offer can go live with a single HTTP request — OBS 30+, FFmpeg 7+, GStreamer, hardware encoders, **and a plain web page**.
++
++> **Can a frontend-only site stream to WHIP?** Yes. The endpoint is open to every origin (CORS `*`) and authenticates with your stream key, so a static page with no backend can publish straight from the browser using only `RTCPeerConnection` + `fetch()`. Jump to [Publishing from a browser](#publishing-from-a-browser), or try the hosted single-file demo at **<https://openvibe.live/whip-publisher.html>** (view-source to copy it).
++
++## Endpoint
++
++```
++POST https://whip.openvibe.live/whip/{streamKey}
++Content-Type: application/sdp
++```
++
++| URL form | Credential | Use it when |
++|---|---|---|
++| `/whip/{streamKey}` | the key is in the path (optionally also as `Authorization: Bearer {streamKey}` — OBS sends both) | **Default.** Browsers, FFmpeg, GStreamer, OBS. |
++| `/whip/{slotId}` | `Authorization: Bearer {streamKey}` or `?key={streamKey}` | Tools that want a stable, non-secret URL with the secret in a header. |
++| `/whip/{streamSessionId}` | `Authorization: Bearer {openvibe.network JWT}` | Internal / legacy. Requires an already-live session; `hbt_` API tokens are **not** accepted here. |
++
++`{streamKey}` is a 32-char hex string that belongs to one **stream slot** (a "managed stream"). Posting to it auto-creates a live session on that slot if none exists — you do not need to click *Go Live* first. The slot's streaming method must be **Browser** or **WHIP** (protocol `webrtc`); RTMP/JSMPEG slots answer `409 wrong_protocol`.
++
++### Getting your WHIP URL
++
++- **UI:** *Broadcast → your stream → Streaming method: WHIP* → copy the WHIP URL. (Same URL the OBS instructions show.)
++- **API:** `GET /api/streams/managed/{slotId}/profile` (JWT or `hbt_` token) returns `stream_key` and `whip_url_base`; the URL is `${whip_url_base}/whip/${stream_key}`.
++
++Treat the URL as a password: anyone holding it can broadcast to your channel. Regenerate the key from the dashboard if it leaks.
++
++## Session lifecycle
++
++1. **`POST /whip/{streamKey}`** with the SDP offer as the body.
++ `201 Created` · body is the SDP answer (`Content-Type: application/sdp`) · `Location` header is the **session resource URL** — keep it.
++2. Apply the answer, and ICE/DTLS connect. The stream is shown as live once media flows (the *Live* state in your app corresponds to `RTCPeerConnection.connectionState === 'connected'`).
++3. **`PATCH {Location}`** (`Content-Type: application/trickle-ice-sdpfrag`) — accepted and acknowledged with `204`, but **not required**: the server is an ICE-lite agent and includes all of its candidates in the answer, so a client never needs to trickle. Browsers can post the offer immediately after `setLocalDescription()` without waiting for gathering.
++4. **`DELETE {Location}`** — ends the session. When the last session on a stream is gone, the stream ends (VOD finalises, restreams stop, viewers are notified). Sessions also end on their own if ICE fails, or 15 s after ICE reports `disconnected` without recovering.
++
++Posting a second offer to the same slot replaces the previous session (useful for reconnecting after a network change).
++
++## Media requirements
++
++| | Accepted | Notes |
++|---|---|---|
++| Video | **VP8**, **H.264** (Constrained Baseline, `profile-level-id=42e01f`, `packetization-mode=1`) | The server takes the **first** codec in your offer that it supports. AV1 / VP9 / HEVC are skipped; if nothing else is offered you get `406 no_compatible_codecs`. |
++| Audio | **Opus** 48 kHz (mono or stereo) | |
++| Encodings | one per m-section (SSRC or a single RID) | No simulcast / SVC — offer a single encoding. RTX (`a=ssrc-group:FID`) is supported. |
++| Transport | BUNDLE, `rtcp-mux`, DTLS-SRTP | Offer `a=setup:actpass`; the answer is `a=setup:passive` + `a=ice-lite`. A data-channel m-section is rejected (`port 0`) but does not fail the offer. |
++
++No STUN/TURN is needed: the server sits on a public address and the client connects straight to the candidates in the answer. Outbound UDP must be allowed.
++
++## Errors
++
++Errors are JSON (`{ "error": "...", "error_code": "..." }`) and also carry the code in an `X-WHIP-ERROR` header, which is exposed to cross-origin callers.
++
++| Status | `X-WHIP-ERROR` | Meaning |
++|---|---|---|
++| 400 | `missing_sdp` / `invalid_sdp` / `invalid_rtp_encoding` / `invalid_stream_id` | Body is not a usable SDP offer (e.g. a video section with no SSRC or RID). |
++| 401 | `invalid_stream_key` / `bearer_mismatch` / `invalid_key` / `invalid_token` / `authentication_required` / `user_not_found` | Key or token not recognised, or path key ≠ bearer key. |
++| 403 | `user_banned` / `not_your_stream` | |
++| 404 | `slot_not_found` / `stream_not_found` / `session_not_found` | The last one is a `PATCH`/`DELETE` to a resource that already ended. |
++| 406 | `no_compatible_codecs` | Offer only VP8 / H.264 / Opus. |
++| 409 | `wrong_protocol` / `stream_not_live` | Slot is set to RTMP/JSMPEG (change the streaming method), or (JWT form) session is not live. |
++| 502 | `transport_creation_failed` / `dtls_negotiation_failed` / `producer_creation_failed` | SFU could not set up the session — retry. |
++| 503 | `sfu_unavailable` / `whip_unavailable` | Server-side WebRTC is down. |
++
++## Cross-origin (CORS)
++
++Every `/whip/*` response, including errors, carries:
++
++```
++Access-Control-Allow-Origin: *
++Access-Control-Allow-Methods: POST, PATCH, DELETE, OPTIONS
++Access-Control-Allow-Headers: Content-Type, Authorization, If-Match
++Access-Control-Expose-Headers: Location, X-WHIP-ERROR
++```
++
++There are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell:
++
++```bash
++curl -si -X OPTIONS https://whip.openvibe.live/whip/x \
++ -H 'Origin: https://my-static-site.example' \
++ -H 'Access-Control-Request-Method: POST' \
++ -H 'Access-Control-Request-Headers: content-type'
++# → HTTP/2 204 with the headers above
++```
++
++## Publishing from a browser
++
++> **Direct link to this section:** <https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser>
++> Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes.
++
++**Spec in five lines (paste-ready):**
++
++```text
++OpenVibe.Live WHIP ingest — browser publisher requirements
++1. POST the SDP offer to https://whip.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).
++2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS).
++3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored.
++4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional.
++5. Stop with DELETE <Location> (use fetch keepalive on pagehide). Errors: JSON + X-WHIP-ERROR header (see table above).
++```
++
++A complete publisher is about forty lines. This is the core of [`whip-publisher.html`](../public/whip-publisher.html) (hosted at <https://openvibe.live/whip-publisher.html>), which adds a UI, screen-share, bitrate stats and clean-up on tab close:
++
++```html
++<video id="preview" autoplay muted playsinline></video>
++<button id="go">Go live</button>
++<script>
++const WHIP_URL = 'https://whip.openvibe.live/whip/<your-stream-key>';
++let pc, resourceUrl;
++
++async function goLive() {
++ const stream = await navigator.mediaDevices.getUserMedia({
++ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
++ audio: true,
++ });
++ document.getElementById('preview').srcObject = stream;
++
++ pc = new RTCPeerConnection({ iceServers: [] }); // ICE-lite server: no STUN needed
++ for (const track of stream.getTracks()) {
++ pc.addTransceiver(track, {
++ direction: 'sendonly',
++ sendEncodings: track.kind === 'video' ? [{ maxBitrate: 2_500_000 }] : undefined,
++ });
++ }
++ pc.onconnectionstatechange = () => console.log('connection:', pc.connectionState);
++
++ await pc.setLocalDescription(await pc.createOffer()); // no need to wait for ICE gathering
++
++ const res = await fetch(WHIP_URL, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/sdp' },
++ body: pc.localDescription.sdp,
++ });
++ if (res.status !== 201) {
++ throw new Error(`WHIP ${res.status} ${res.headers.get('X-WHIP-ERROR') || ''}`);
++ }
++ resourceUrl = new URL(res.headers.get('Location'), WHIP_URL).href;
++ await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
++}
++
++async function stop() {
++ if (resourceUrl) await fetch(resourceUrl, { method: 'DELETE', keepalive: true });
++ pc?.close();
++}
++
++document.getElementById('go').onclick = () => goLive().catch(console.error);
++addEventListener('pagehide', () => resourceUrl && fetch(resourceUrl, { method: 'DELETE', keepalive: true }));
++</script>
++```
++
++Things worth knowing:
++
++- **Secure context.** `getUserMedia` only works on `https://`, `localhost`, or a local `file://` — plain `http://` hosting will not get camera access.
++- **Screen share.** Swap `getUserMedia` for `navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })`. Listen for the video track's `ended` event — that is the browser's own *Stop sharing* button.
++- **Codec choice.** Chrome and Firefox offer VP8 first, Safari offers H.264 first; both are accepted as-is. If you use `RTCRtpTransceiver.setCodecPreferences()`, keep VP8 or H.264 at the front — the server negotiates the first supported codec it sees.
++- **Bitrate.** Set `maxBitrate` in `sendEncodings` (or later via `sender.setParameters()`); 2.5 Mbps for 720p30 and 4.5 Mbps for 1080p30 are good defaults.
++- **Reconnecting.** On `connectionState === 'failed'`, build a fresh `RTCPeerConnection` and `POST` again — the new session replaces the old one on the same slot, and viewers pick up automatically.
++- **Ending cleanly.** Always `DELETE` the resource (use `keepalive: true` so it survives tab close). If you don't, the stream lingers until the 15-second ICE grace period expires.
++- **The key is visible in the page.** That is inherent to a backend-less publisher and the same trust model as OBS; keep the page private (or have the streamer paste their own key at runtime, as the hosted demo does) rather than committing a key into a public repo.
++
++## Other clients
++
++- **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key.
++- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://whip.openvibe.live/whip/<streamKey>"`
++- **GStreamer:** `... ! whipsink whip-endpoint="https://whip.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).
++
++The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods.
ddiff --git a/public/whip-publisher.html b/public/whip-publisher.html
nnew file mode 100644
iindex 00000000..cf3430d8
---- /dev/null
++++ b/public/whip-publisher.html
@@@ -0,0 +1,378 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++ <meta charset="UTF-8">
++ <meta name="viewport" content="width=device-width, initial-scale=1.0">
++ <title>Browser WHIP Publisher — OpenVibe.Live</title>
++ <!--
++ OpenVibe.Live — browser-only WHIP publisher.
++
++ This page has NO backend and NO dependencies. It is one self-contained HTML file
++ that publishes your camera + mic (or your screen) to an OpenVibe.Live WHIP endpoint
++ using nothing but RTCPeerConnection and fetch(). Save it, host it anywhere
++ (GitHub Pages, Cloudflare Pages, Netlify, a local file://), or lift the script
++ into your own site.
++
++ Protocol walkthrough + API reference:
++ https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md
++
++ Programmatic use: window.OpenVibeWhip.start({ url, token, source, maxBitrate })
++ window.OpenVibeWhip.stop()
++ window.OpenVibeWhip.state → { phase, connectionState, resourceUrl, error }
++ -->
++ <style>
++ :root {
++ --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --bg-input: #12121a;
++ --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;
++ --accent: #8b5cf6; --accent-light: #a78bfa; --accent-dark: #6d28d9;
++ --ok: #2ecc71; --warn: #f1c40f; --bad: #e74c3c; --border: #2a2a32;
++ }
++ * { box-sizing: border-box; }
++ html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++ a { color: var(--accent-light); }
++ main { max-width: 900px; margin: 0 auto; padding: 24px 16px 48px; }
++ h1 { font-size: 1.35rem; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }
++ h1 .logo { width: 28px; height: 28px; border-radius: 7px; background: linear-gradient(135deg, var(--accent), var(--accent-dark)); display: inline-block; }
++ .sub { color: var(--text-2); margin: 0 0 20px; font-size: 0.92rem; }
++ .card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
++ label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 0.04em; }
++ input, select { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text); font: inherit; }
++ input:focus, select:focus { outline: none; border-color: var(--accent); }
++ .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
++ @media (max-width: 600px) { .row { grid-template-columns: 1fr; } }
++ .actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
++ button { padding: 10px 18px; border-radius: 8px; border: 1px solid transparent; font: inherit; font-weight: 600; cursor: pointer; }
++ button:disabled { opacity: 0.45; cursor: not-allowed; }
++ .btn-go { background: var(--accent); color: #fff; }
++ .btn-go:not(:disabled):hover { background: var(--accent-dark); }
++ .btn-stop { background: transparent; color: var(--bad); border-color: var(--bad); }
++ .btn-stop:not(:disabled):hover { background: rgba(231, 76, 60, 0.12); }
++ .pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: var(--bg-3); font-size: 0.85rem; color: var(--text-2); }
++ .pill::before { content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
++ .pill[data-phase="connecting"]::before, .pill[data-phase="starting"]::before { background: var(--warn); }
++ .pill[data-phase="live"]::before { background: var(--ok); box-shadow: 0 0 0 4px rgba(46, 204, 113, 0.18); }
++ .pill[data-phase="error"]::before { background: var(--bad); }
++ video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; display: block; }
++ .stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 10px; font-size: 0.85rem; color: var(--text-2); font-variant-numeric: tabular-nums; }
++ .stats b { color: var(--text); font-weight: 600; }
++ pre.log { margin: 0; max-height: 220px; overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
++ pre.log .err { color: var(--bad); } pre.log .ok { color: var(--ok); }
++ .hint { font-size: 0.82rem; color: var(--muted); margin-top: 6px; }
++ .hint code { background: var(--bg-3); padding: 1px 5px; border-radius: 4px; color: var(--text-2); }
++ </style>
++</head>
++<body>
++<main>
++ <h1><span class="logo"></span> Browser WHIP Publisher</h1>
++ <p class="sub">Go live on OpenVibe.Live from any web page — no OBS, no backend, no libraries. Just this file.</p>
++
++ <div class="card">
++ <label for="whip-url">WHIP URL</label>
++ <input id="whip-url" type="url" placeholder="https://whip.openvibe.live/whip/&lt;your-stream-key&gt;" autocomplete="off" spellcheck="false">
++ <p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
++
++ <label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
++ <input id="whip-token" type="password" placeholder="stream key" autocomplete="off">
++
++ <div class="row">
++ <div>
++ <label for="whip-source">Source</label>
++ <select id="whip-source">
++ <option value="camera">Camera + microphone</option>
++ <option value="screen">Screen / window (+ system audio if offered)</option>
++ </select>
++ </div>
++ <div>
++ <label for="whip-quality">Quality</label>
++ <select id="whip-quality">
++ <option value="1280x720@2500">720p · 2.5 Mbps</option>
++ <option value="1920x1080@4500">1080p · 4.5 Mbps</option>
++ <option value="854x480@1200">480p · 1.2 Mbps</option>
++ </select>
++ </div>
++ </div>
++
++ <div class="actions">
++ <button id="whip-start" class="btn-go">Go live</button>
++ <button id="whip-stop" class="btn-stop" disabled>Stop</button>
++ <span id="whip-status" class="pill" data-phase="idle">Idle</span>
++ </div>
++ </div>
++
++ <div class="card">
++ <video id="whip-preview" autoplay muted playsinline></video>
++ <div class="stats">
++ <span>Bitrate <b id="st-bitrate">–</b></span>
++ <span>FPS <b id="st-fps">–</b></span>
++ <span>Resolution <b id="st-res">–</b></span>
++ <span>Codec <b id="st-codec">–</b></span>
++ <span>Uptime <b id="st-uptime">–</b></span>
++ </div>
++ </div>
++
++ <div class="card">
++ <pre id="whip-log" class="log">Ready.</pre>
++ </div>
++
++ <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md">docs/whip.md</a>. View-source this page to copy it.</p>
++</main>
++
++<script>
++(() => {
++ 'use strict';
++
++ const $ = (id) => document.getElementById(id);
++ const els = {
++ url: $('whip-url'), token: $('whip-token'), source: $('whip-source'), quality: $('whip-quality'),
++ start: $('whip-start'), stop: $('whip-stop'), status: $('whip-status'), preview: $('whip-preview'), log: $('whip-log'),
++ bitrate: $('st-bitrate'), fps: $('st-fps'), res: $('st-res'), codec: $('st-codec'), uptime: $('st-uptime'),
++ };
++ const STORAGE_KEY = 'openvibe-whip-url';
++
++ const state = {
++ phase: 'idle', // idle | starting | connecting | live | error
++ connectionState: null,
++ resourceUrl: null, // WHIP resource (Location header) — DELETE it to end the session
++ error: null,
++ pc: null,
++ stream: null,
++ startedAt: null,
++ statsTimer: null,
++ lastStats: null,
++ };
++
++ // ── UI helpers ─────────────────────────────────────────────
++ function log(message, cls) {
++ const line = document.createElement('div');
++ if (cls) line.className = cls;
++ line.textContent = `${new Date().toLocaleTimeString()} ${message}`;
++ els.log.appendChild(line);
++ els.log.scrollTop = els.log.scrollHeight;
++ }
++
++ function setPhase(phase, label) {
++ state.phase = phase;
++ els.status.dataset.phase = phase;
++ els.status.textContent = label || phase[0].toUpperCase() + phase.slice(1);
++ const busy = phase === 'starting' || phase === 'connecting' || phase === 'live';
++ els.start.disabled = busy;
++ els.stop.disabled = !busy;
++ for (const el of [els.url, els.token, els.source, els.quality]) el.disabled = busy;
++ }
++
++ function resetStats() {
++ for (const el of [els.bitrate, els.fps, els.res, els.codec, els.uptime]) el.textContent = '–';
++ }
++
++ // ── Media ──────────────────────────────────────────────────
++ async function getSource(kind, width, height) {
++ if (kind === 'screen') {
++ return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
++ }
++ return navigator.mediaDevices.getUserMedia({
++ video: { width: { ideal: width }, height: { ideal: height }, frameRate: { ideal: 30 } },
++ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
++ });
++ }
++
++ // The server negotiates the FIRST offered codec it supports (VP8, H.264 or Opus), so
++ // put the ones it can take at the front. Browsers already default to VP8/H.264 first,
++ // this just makes it explicit and future-proof against AV1/VP9-first defaults.
++ function preferCodecs(transceiver, kind) {
++ try {
++ if (!transceiver.setCodecPreferences || !RTCRtpSender.getCapabilities) return;
++ const caps = RTCRtpSender.getCapabilities(kind);
++ if (!caps) return;
++ const wanted = kind === 'video' ? ['video/vp8', 'video/h264'] : ['audio/opus'];
++ const rank = (c) => { const i = wanted.indexOf(c.mimeType.toLowerCase()); return i === -1 ? wanted.length : i; };
++ transceiver.setCodecPreferences([...caps.codecs].sort((a, b) => rank(a) - rank(b)));
++ } catch { /* optional optimisation */ }
++ }
++
++ // ── WHIP ───────────────────────────────────────────────────
++ async function start(opts = {}) {
++ if (state.phase !== 'idle' && state.phase !== 'error') throw new Error('Already publishing');
++
++ const url = String(opts.url ?? els.url.value).trim();
++ const token = String(opts.token ?? els.token.value).trim();
++ const source = opts.source || els.source.value;
++ const [dims, kbps] = (opts.quality || els.quality.value).split('@');
++ const [width, height] = dims.split('x').map(Number);
++ const maxBitrate = Number(opts.maxBitrate) || Number(kbps) * 1000;
++
++ if (!/^https?:\/\/[^/]+\/whip\/[^/?#]+\/?$/.test(url)) {
++ throw new Error('WHIP URL should look like https://whip.openvibe.live/whip/<stream-key>');
++ }
++ try { localStorage.setItem(STORAGE_KEY, url); } catch { /* private mode */ }
++
++ state.error = null;
++ state.resourceUrl = null;
++ resetStats();
++ setPhase('starting', 'Requesting media…');
++
++ try {
++ const stream = await getSource(source, width, height);
++ state.stream = stream;
++ els.preview.srcObject = stream;
++ for (const track of stream.getTracks()) {
++ log(`${track.kind} track: ${track.label || 'unnamed'}`);
++ // "Stop sharing" in the browser's screen-share bar ends the track → end the session.
++ track.addEventListener('ended', () => { if (state.phase === 'live' || state.phase === 'connecting') stop('source ended'); });
++ }
++
++ // OpenVibe's SFU is an ICE-lite server on a public address, so no STUN/TURN is
++ // needed: the browser connects straight to the candidates in the SDP answer.
++ const pc = new RTCPeerConnection({ iceServers: opts.iceServers || [] });
++ state.pc = pc;
++
++ for (const track of stream.getTracks()) {
++ const init = { direction: 'sendonly', streams: [stream] };
++ if (track.kind === 'video') init.sendEncodings = [{ maxBitrate }]; // one encoding — no simulcast
++ const transceiver = pc.addTransceiver(track, init);
++ preferCodecs(transceiver, track.kind);
++ }
++
++ pc.addEventListener('connectionstatechange', () => {
++ state.connectionState = pc.connectionState;
++ log(`connection: ${pc.connectionState}`);
++ if (pc.connectionState === 'connected') {
++ if (!state.startedAt) state.startedAt = Date.now();
++ setPhase('live', 'Live');
++ log('Media is flowing — you are live.', 'ok');
++ } else if (pc.connectionState === 'failed') {
++ stop('connection failed');
++ fail(new Error('WebRTC connection failed (ICE/DTLS). Check that UDP is not blocked by a firewall.'));
++ } else if (pc.connectionState === 'disconnected' && state.phase === 'live') {
++ setPhase('connecting', 'Reconnecting…');
++ }
++ });
++
++ setPhase('connecting', 'Negotiating…');
++ const offer = await pc.createOffer();
++ await pc.setLocalDescription(offer);
++
++ const headers = { 'Content-Type': 'application/sdp' };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ log(`POST ${url}`);
++ const resp = await fetch(url, { method: 'POST', headers, body: pc.localDescription.sdp });
++
++ if (resp.status !== 201) {
++ const code = resp.headers.get('X-WHIP-ERROR');
++ let detail = '';
++ try { detail = (await resp.json()).error || ''; } catch { /* non-JSON */ }
++ throw new Error(`WHIP endpoint answered HTTP ${resp.status}${code ? ` (${code})` : ''}${detail ? `: ${detail}` : ''}`);
++ }
++
++ // Location is the session resource. It may be absolute or relative — resolve it
++ // against the endpoint URL so DELETE always hits the right host.
++ const location = resp.headers.get('Location');
++ state.resourceUrl = location ? new URL(location, url).toString() : null;
++ log(`session created${state.resourceUrl ? `: ${state.resourceUrl}` : ''}`);
++
++ const answer = await resp.text();
++ await pc.setRemoteDescription({ type: 'answer', sdp: answer });
++ log('answer applied, waiting for ICE/DTLS…');
++ startStats();
++ } catch (err) {
++ await stop('start failed', { silent: true });
++ fail(err);
++ throw err;
++ }
++ }
++
++ async function stop(reason = 'user', { silent = false } = {}) {
++ const { pc, stream, resourceUrl } = state;
++ stopStats();
++ state.pc = null; state.stream = null; state.resourceUrl = null; state.startedAt = null;
++
++ if (resourceUrl) {
++ // keepalive lets the DELETE complete even if the tab is closing.
++ try { await fetch(resourceUrl, { method: 'DELETE', keepalive: true }); log('session deleted'); }
++ catch (err) { log(`DELETE failed: ${err.message}`, 'err'); }
++ }
++ if (pc) { try { pc.close(); } catch { /* */ } }
++ if (stream) for (const t of stream.getTracks()) t.stop();
++ els.preview.srcObject = null;
++ state.connectionState = null;
++
++ if (!silent) {
++ setPhase('idle', 'Idle');
++ log(`stopped (${reason})`);
++ }
++ }
++
++ function fail(err) {
++ state.error = err?.message || String(err);
++ setPhase('error', 'Error');
++ log(state.error, 'err');
++ if (/Permission|NotAllowed/i.test(state.error)) log('Camera/mic access was denied — check the browser permission prompt.', 'err');
++ if (!window.isSecureContext) log('getUserMedia needs a secure context: serve this page over https:// (or open it as a local file / localhost).', 'err');
++ }
++
++ // ── Stats ──────────────────────────────────────────────────
++ function startStats() {
++ stopStats();
++ state.statsTimer = setInterval(async () => {
++ const pc = state.pc;
++ if (!pc) return;
++ let report;
++ try { report = await pc.getStats(); } catch { return; }
++ const now = Date.now();
++ let video = null, codecName = null;
++ const codecs = new Map();
++ for (const s of report.values()) {
++ if (s.type === 'codec') codecs.set(s.id, s.mimeType);
++ if (s.type === 'outbound-rtp' && s.kind === 'video') video = s;
++ }
++ if (video) {
++ if (state.lastStats && video.bytesSent != null) {
++ const kbps = ((video.bytesSent - state.lastStats.bytesSent) * 8) / ((now - state.lastStats.at) / 1000) / 1000;
++ els.bitrate.textContent = `${Math.round(kbps)} kbps`;
++ }
++ state.lastStats = { bytesSent: video.bytesSent, at: now };
++ if (video.framesPerSecond != null) els.fps.textContent = String(Math.round(video.framesPerSecond));
++ if (video.frameWidth) els.res.textContent = `${video.frameWidth}×${video.frameHeight}`;
++ codecName = codecs.get(video.codecId);
++ if (codecName) els.codec.textContent = codecName.replace('video/', '');
++ }
++ if (state.startedAt) {
++ const s = Math.floor((now - state.startedAt) / 1000);
++ els.uptime.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
++ }
++ }, 2000);
++ }
++ function stopStats() {
++ if (state.statsTimer) clearInterval(state.statsTimer);
++ state.statsTimer = null;
++ state.lastStats = null;
++ }
++
++ // ── Wiring ─────────────────────────────────────────────────
++ els.start.addEventListener('click', () => start().catch(() => { /* already surfaced via fail() */ }));
++ els.stop.addEventListener('click', () => stop('user'));
++ window.addEventListener('pagehide', () => {
++ if (state.resourceUrl) fetch(state.resourceUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
++ });
++
++ // Prefill: ?url=… wins, then the last URL used in this browser.
++ const params = new URLSearchParams(location.search);
++ let saved = null;
++ try { saved = localStorage.getItem(STORAGE_KEY); } catch { /* */ }
++ els.url.value = params.get('url') || saved || '';
++ if (params.get('token')) els.token.value = params.get('token');
++
++ if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {
++ fail(new Error('This browser does not support WebRTC publishing.'));
++ }
++
++ window.OpenVibeWhip = {
++ start,
++ stop,
++ get state() {
++ return { phase: state.phase, connectionState: state.connectionState, resourceUrl: state.resourceUrl, error: state.error };
++ },
++ };
++})();
++</script>
++</body>
++</html>
ddiff --git a/server/index.js b/server/index.js
iindex 5dd303df..de05590e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -212,6 +212,11 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node
// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed
++// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the
++// dedicated WHIP host when one is configured (whip.openvibe.live), which is a different
++// origin from the page itself and would otherwise be blocked by connect-src 'self'.
++const whipConnectOrigin = (config.whip?.enabled && normalizeOrigin(config.whip?.publicUrl)) || null;
++
app.use(helmet({
contentSecurityPolicy: {
directives: {
@@@ -224,7 +229,7 @@ app.use(helmet({
// every queued item renders as a broken image, which is what the media
// request tab was doing for every YouTube link.
imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com"],
-- connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com"],
++ connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", ...(whipConnectOrigin ? [whipConnectOrigin] : [])],
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// itself redirect to presigned B2/R2 object-store URLs — all must be allowed
// or the browser blocks the media element.
@@@ -260,7 +265,12 @@ function isAllowedOrigin(origin) {
} catch { return false; }
}
--app.use(cors({
++// WHIP ingest is keyed by the stream key, not by a cookie, so it is open to every origin —
++// that is what lets a static, backend-less site publish from the browser (docs/whip.md).
++// It must therefore bypass the credentialed allowlist below, which would 403 the preflight.
++app.use('/whip', whipHandler.whipCors);
++
++const allowlistedCors = cors({
origin(origin, callback) {
if (!origin) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
@@@ -268,7 +278,11 @@ app.use(cors({
return callback(new Error('Origin not allowed by CORS'));
},
credentials: true,
--}));
++});
++app.use((req, res, next) => {
++ if (req.path === '/whip' || req.path.startsWith('/whip/')) return next();
++ return allowlistedCors(req, res, next);
++});
app.use(express.json({ limit: '1mb', verify: (req, res, buf) => { req.rawBody = buf; } }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(cookieParser());
ddiff --git a/server/streaming/whip-handler.js b/server/streaming/whip-handler.js
iindex 1aff69e5..369be0de 100644
---- a/server/streaming/whip-handler.js
++++ b/server/streaming/whip-handler.js
@@@ -54,10 +54,53 @@ function buildWhipResourceUrl(req, streamId, resourceId) {
}
}
++// ── CORS ─────────────────────────────────────────────────────
++// WHIP is authenticated by the stream key (path / Bearer / ?key=) and never by a
++// cookie or browser session, so the endpoint can safely be opened to *every* origin.
++// That is what lets a purely static site (GitHub Pages, a local index.html, a
++// Cloudflare Pages app…) publish straight from `RTCPeerConnection` + `fetch()` with
++// no backend of its own — see docs/whip.md. The global CORS middleware in
++// server/index.js is credentialed and allowlisted (it protects the cookie-authenticated
++// /api routes), so /whip/* is exempted from it and handled here instead.
++//
++// `Location` must be exposed or a browser cannot read the resource URL it needs for
++// PATCH/DELETE; `X-WHIP-ERROR` is exposed so a client can show the machine-readable
++// error code instead of a bare status.
++const WHIP_CORS_HEADERS = Object.freeze({
++ 'Access-Control-Allow-Origin': '*',
++ 'Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS',
++ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',
++ 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR',
++ 'Access-Control-Max-Age': '86400',
++ // Helmet defaults CORP to same-origin; a CORS-mode fetch is not subject to it,
++ // but be explicit so nothing upstream can turn the response opaque.
++ 'Cross-Origin-Resource-Policy': 'cross-origin',
++});
++
++function applyWhipCorsHeaders(res) {
++ for (const [name, value] of Object.entries(WHIP_CORS_HEADERS)) {
++ res.set(name, value);
++ }
++ return res;
++}
++
++/**
++ * Express middleware for everything under /whip. Stamps the open CORS headers on the
++ * response up-front (so error responses from the handlers carry them too) and answers
++ * preflights directly.
++ */
++function whipCors(req, res, next) {
++ applyWhipCorsHeaders(res);
++ if (req.method === 'OPTIONS') {
++ return res.status(204).end();
++ }
++ next();
++}
++
function buildWhipResponseHeaders(req, streamId, resourceId) {
return {
Location: buildWhipResourceUrl(req, streamId, resourceId),
-- 'Access-Control-Expose-Headers': 'Location',
++ 'Access-Control-Expose-Headers': WHIP_CORS_HEADERS['Access-Control-Expose-Headers'],
};
}
@@@ -403,7 +446,11 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
const { iceParameters, iceCandidates, dtlsParameters } = transport;
const fingerprint = selectDtlsFingerprint(dtlsParameters.fingerprints);
const setup = getDtlsSetupAttribute(offerSdp) === 'passive' ? 'active' : 'passive';
-- const mids = [];
++ // Only *accepted* m-sections go in the BUNDLE group. RFC 8843 §7.3.3 forbids listing a
++ // rejected (port 0) section there, and browsers enforce it — Chrome/Firefox refuse the
++ // whole answer, which is fatal for a browser publisher whose extra m-section (e.g. a
++ // data channel, or a codec the router lacks) we cannot accept. OBS never checked.
++ const bundledMids = [];
const serverAddress = iceCandidates?.[0]?.ip || config.mediasoup.announcedIp || '127.0.0.1';
const sdpObj = {
@@@ -417,9 +464,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
media: [],
};
-- for (const offerMedia of offerSdp.media || []) {
-- const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(mids.length);
-- mids.push(mid);
++ for (const [mediaIndex, offerMedia] of (offerSdp.media || []).entries()) {
++ const mid = offerMedia.mid != null ? String(offerMedia.mid) : String(mediaIndex);
const producer = producersByKind[offerMedia.type];
if (!producer) {
@@@ -427,12 +473,16 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
type: offerMedia.type,
port: 0,
protocol: offerMedia.protocol || 'UDP/TLS/RTP/SAVPF',
-- payloads: '0',
++ // Echo the offered format list: a rejected section must still be
++ // syntactically complete, and for m=application the "payload" is the
++ // literal `webrtc-datachannel` token, not a number.
++ payloads: String(offerMedia.payloads || '0'),
mid,
direction: 'inactive',
});
continue;
}
++ bundledMids.push(mid);
const answerMedia = {
type: offerMedia.type,
@@@ -498,8 +548,8 @@ function buildSdpAnswer(transport, offerSdp, producersByKind) {
sdpObj.media.push(answerMedia);
}
-- if (mids.length > 0) {
-- sdpObj.groups.push({ type: 'BUNDLE', mids: mids.join(' ') });
++ if (bundledMids.length > 0) {
++ sdpObj.groups.push({ type: 'BUNDLE', mids: bundledMids.join(' ') });
}
return sdpTransform.write(sdpObj);
@@@ -1030,11 +1080,7 @@ function handleWhipDelete(req, res) {
* OPTIONS /whip/* — CORS/discovery
*/
function handleWhipOptions(req, res) {
-- res.status(204)
-- .set('Access-Control-Allow-Methods', 'POST, PATCH, DELETE, OPTIONS')
-- .set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
-- .set('Access-Control-Expose-Headers', 'Location')
-- .end();
++ applyWhipCorsHeaders(res.status(204)).end();
}
/**
@@@ -1101,6 +1147,9 @@ module.exports = {
handleWhipPatch,
handleWhipDelete,
handleWhipOptions,
++ whipCors,
++ applyWhipCorsHeaders,
++ WHIP_CORS_HEADERS,
buildWhipResourceUrl,
buildWhipResponseHeaders,
hasSfuProducers,
ddiff --git a/test/whip-handler.test.js b/test/whip-handler.test.js
iindex 262fd8b3..3ffab3c2 100644
---- a/test/whip-handler.test.js
++++ b/test/whip-handler.test.js
@@@ -41,7 +41,7 @@ assert.notDeepStrictEqual(rtpParameters.encodings[0], {}, 'Encoding object must
console.log('✅ WHIP handler RTP encoding regression test passed');
--const { buildWhipResponseHeaders, handleWhipOptions } = require('../server/streaming/whip-handler');
++const { buildWhipResponseHeaders, handleWhipOptions, whipCors, WHIP_CORS_HEADERS } = require('../server/streaming/whip-handler');
const req = {
protocol: 'https',
@@@ -50,26 +50,66 @@ const req = {
const headers = buildWhipResponseHeaders(req, '123', 'resource-abc');
assert.strictEqual(headers.Location, 'http://localhost:3000/whip/123/resource-abc');
--assert.strictEqual(headers['Access-Control-Expose-Headers'], 'Location');
++// A browser can only read the resource URL (and our error code) if both are exposed.
++assert.strictEqual(headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');
assert.ok(!Object.prototype.hasOwnProperty.call(headers, 'Link'));
--const res = {
-- statusCode: null,
-- headers: {},
-- ended: false,
-- status(code) { this.statusCode = code; return this; },
-- set(key, value) { this.headers[key] = value; return this; },
-- end() { this.ended = true; },
--};
++function makeRes() {
++ return {
++ statusCode: null,
++ headers: {},
++ ended: false,
++ status(code) { this.statusCode = code; return this; },
++ set(key, value) { this.headers[key] = value; return this; },
++ end() { this.ended = true; },
++ };
++}
++const res = makeRes();
handleWhipOptions({}, res);
assert.strictEqual(res.statusCode, 204);
--assert.strictEqual(res.headers['Access-Control-Expose-Headers'], 'Location');
++assert.strictEqual(res.headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');
assert.ok(!('Link' in res.headers));
assert.strictEqual(res.ended, true);
console.log('✅ WHIP handler response header regression test passed');
++// ── Browser (cross-origin) publishing: /whip must be open to every origin ──
++// A static site with no backend can only publish if the preflight and the POST both
++// come back with Access-Control-Allow-Origin: * — the stream key is the credential.
++assert.strictEqual(WHIP_CORS_HEADERS['Access-Control-Allow-Origin'], '*');
++for (const method of ['POST', 'PATCH', 'DELETE']) {
++ assert.ok(WHIP_CORS_HEADERS['Access-Control-Allow-Methods'].includes(method), `CORS must allow ${method}`);
++}
++assert.ok(/Authorization/.test(WHIP_CORS_HEADERS['Access-Control-Allow-Headers']), 'Bearer auth needs Authorization allowed');
++assert.ok(/Content-Type/.test(WHIP_CORS_HEADERS['Access-Control-Allow-Headers']), 'application/sdp needs Content-Type allowed');
++
++// Preflight from a foreign origin is answered directly with the open headers.
++const preflightRes = makeRes();
++let preflightNext = false;
++whipCors({ method: 'OPTIONS', headers: { origin: 'https://static.example' } }, preflightRes, () => { preflightNext = true; });
++assert.strictEqual(preflightRes.statusCode, 204);
++assert.strictEqual(preflightRes.ended, true);
++assert.strictEqual(preflightNext, false, 'preflight must not fall through to the route handlers');
++assert.strictEqual(preflightRes.headers['Access-Control-Allow-Origin'], '*');
++assert.strictEqual(preflightRes.headers['Access-Control-Allow-Methods'], WHIP_CORS_HEADERS['Access-Control-Allow-Methods']);
++assert.strictEqual(preflightRes.headers['Access-Control-Allow-Headers'], WHIP_CORS_HEADERS['Access-Control-Allow-Headers']);
++
++// The actual POST/PATCH/DELETE gets the headers stamped up-front and continues, so even
++// an error response from the handler (401 invalid key, 406 no codecs…) is readable
++// cross-origin instead of surfacing as an opaque "TypeError: Failed to fetch".
++for (const method of ['POST', 'PATCH', 'DELETE']) {
++ const r = makeRes();
++ let nextCalled = false;
++ whipCors({ method, headers: { origin: 'https://static.example' } }, r, () => { nextCalled = true; });
++ assert.strictEqual(nextCalled, true, `${method} must reach the handler`);
++ assert.strictEqual(r.ended, false);
++ assert.strictEqual(r.headers['Access-Control-Allow-Origin'], '*');
++ assert.strictEqual(r.headers['Access-Control-Expose-Headers'], 'Location, X-WHIP-ERROR');
++}
++
++console.log('✅ WHIP cross-origin (browser publisher) CORS regression test passed');
++
const actpassDtls = _extractDtlsParameters({
version: 0,
origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },
@@@ -162,3 +202,51 @@ assert.ok(answerSdp.includes('sha-256'));
assert.ok(answerSdp.includes('AA:BB:CC:DD'));
console.log('✅ WHIP handler DTLS role and fingerprint regression test passed');
++
++// ── Browser publishers: rejected m-sections must stay out of the BUNDLE group ──
++// A browser offer can carry an m-section we cannot accept (a data channel, or a codec
++// the router lacks). RFC 8843 §7.3.3 says a rejected section must not be listed in
++// BUNDLE and Chrome/Firefox refuse the whole answer if it is; OBS never checked.
++const mixedAnswer = _buildSdpAnswer(
++ {
++ iceParameters: { usernameFragment: 'ufrag', password: 'pwd' },
++ iceCandidates: [
++ { foundation: '1', protocol: 'udp', priority: 2130706432, ip: '1.2.3.4', port: 1234, type: 'host' },
++ ],
++ dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB:CC:DD' }] },
++ },
++ {
++ version: 0,
++ origin: { username: '-', sessionId: '1', sessionVersion: 1, netType: 'IN', ipVer: 4, address: '127.0.0.1' },
++ name: 'test',
++ timing: { start: 0, stop: 0 },
++ media: [
++ {
++ type: 'audio', protocol: 'UDP/TLS/RTP/SAVPF', payloads: '111', mid: '0', setup: 'actpass',
++ connection: { ip: '127.0.0.1', version: 4 },
++ rtp: [{ payload: 111, codec: 'opus', rate: 48000, encoding: 2 }], fmtp: [], rtcpFb: [], ext: [],
++ ssrcs: [{ id: '1234', attribute: 'cname' }],
++ },
++ {
++ type: 'video', protocol: 'UDP/TLS/RTP/SAVPF', payloads: '96 97', mid: '1', setup: 'actpass',
++ connection: { ip: '127.0.0.1', version: 4 },
++ rtp: [{ payload: 96, codec: 'VP8', rate: 90000 }, { payload: 97, codec: 'rtx', rate: 90000 }], fmtp: [], rtcpFb: [], ext: [],
++ ssrcs: [{ id: '5678', attribute: 'cname' }],
++ },
++ {
++ type: 'application', protocol: 'UDP/DTLS/SCTP', payloads: 'webrtc-datachannel', mid: '2', setup: 'actpass',
++ connection: { ip: '127.0.0.1', version: 4 },
++ },
++ ],
++ },
++ {
++ audio: { rtpParameters: { codecs: [{ payloadType: 111, mimeType: 'audio/opus', clockRate: 48000, channels: 2 }], headerExtensions: [], encodings: [{ ssrc: 1234 }] } },
++ video: { rtpParameters: { codecs: [{ payloadType: 96, mimeType: 'video/VP8', clockRate: 90000 }], headerExtensions: [], encodings: [{ ssrc: 5678 }] } },
++ }
++);
++assert.ok(mixedAnswer.includes('a=group:BUNDLE 0 1\r\n'), `BUNDLE must list only accepted mids, got: ${mixedAnswer.match(/a=group:[^\r\n]*/)?.[0]}`);
++assert.ok(mixedAnswer.includes('m=application 0 UDP/DTLS/SCTP webrtc-datachannel'), 'rejected section keeps its offered format list');
++assert.ok(mixedAnswer.includes('a=mid:2'), 'rejected section keeps its mid so the m-line count still matches the offer');
++assert.strictEqual((mixedAnswer.match(/^m=/gm) || []).length, 3, 'answer must have one m-line per offered m-line');
++
++console.log('✅ WHIP answer BUNDLE group excludes rejected m-sections (browser publisher) test passed');