openvibers/openvibe.live Commits

Restream setup: sign in with the platform, or paste a key — and make the buttons tappable

OP OpenVibers committed 16 Sept 2026 · ab9973d112 · 6 files, +154 −12

Two reports, both from the same screen.

**Next could not be tapped.** As a bottom sheet the guide card's bottom edge is the screen's
bottom edge, so its footer buttons sat in the phone's home-indicator strip, where the system takes
the first touch — measured 15px of clearance at 390x844. `max-height: 94vh` made it worse by
measuring the *large* viewport, so with the browser toolbar showing the sheet ran below the
visible area entirely. The sheet now sizes with dvh and the footer reserves
env(safe-area-inset-bottom).

**"Add Destination" could not be tapped** for the same reason in a different place: on mobile
.broadcast-layout pins its bottom padding to exactly 0, so .broadcast-main scrolled to the very
edge of the viewport and whatever sat last in it landed in the same strip. That scroller now
carries the inset as padding and scroll-padding, so the end of the list can be brought clear.

**The guide only knew how to paste keys.** The OAuth flow already existed — /restream/oauth/* has
handled Twitch, YouTube and Kick for a while, and all three return the ingest key — but the
guided setup never offered it, so it sent people off to find a stream key by hand. Picking a
platform now shows "Sign in with X" first, backed by the same three-channel popup wait the
broadcast desk uses (postMessage, BroadcastChannel, then polling, because any one of them can be
lost to COOP). The manual key field sits directly under it, not behind a disclosure: plenty of
people would rather not link an account, and Any RTMP has no OAuth at all. Where the server has
no OAuth app configured for a platform, it says so and shows only the manual path. The tiles now
report which way in each platform offers, and whether you are already signed in. RobotStreamer's
login-and-pick-a-robot flow is unchanged.

Also fixes the OAuth slot binding from the audit: managed_stream_id rides in the signed state, so
it is authentic but not authorised — the signature proves we issued it, not that the slot belongs
to the caller. The callback now confirms ownership before provisioning, and links the account
without touching anyone else's slot if it does not. Regression test added (14 checks pass;
confirmed failing with the check reverted).

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

Changes

ddiff --git a/public/css/broadcast.css b/public/css/broadcast.css
iindex e67ce586..32c41c7a 100644
---- a/public/css/broadcast.css
++++ b/public/css/broadcast.css
@@@ -1737,6 +1737,12 @@
border-radius: 0;
border: none;
overflow-y: auto;
++ /* .broadcast-layout pins its bottom padding to 0, so this scroller ran to the very edge of
++ the viewport and whatever sat last in it — "Add Destination", at the end of the settings
++ panel — ended up inside the home-indicator strip, where the system takes the touch. The
++ inset plus a little room means the end of the list can always be scrolled clear of it. */
++ padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
++ scroll-padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
}
.bc-video-container {
ddiff --git a/public/css/ov-guide.css b/public/css/ov-guide.css
iindex 61fa165d..90caed76 100644
---- a/public/css/ov-guide.css
++++ b/public/css/ov-guide.css
@@@ -92,12 +92,21 @@ body.ovg-lock { overflow: hidden; }
.ovg-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: ovgFall linear forwards; }
@keyframes ovgFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
@media (max-width: 640px) {
++ /* As a bottom sheet the card's own bottom edge is the screen's bottom edge, so the footer
++ buttons landed in the phone's home-indicator strip — the system swallows the first touch
++ there, which is why Next could not be tapped. 94vh also measured the *large* viewport, so
++ with the browser toolbar showing the sheet ran below the visible area entirely. dvh tracks
++ the viewport that is actually on screen, and the footer reserves the safe-area inset. */
.ovg-card { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: 100%; max-height: 94vh; border-radius: 20px 20px 0 0; animation: ovgSheet .35s cubic-bezier(.2,1,.3,1); }
++ @supports (height: 100dvh) {
++ .ovg-card { max-height: min(94dvh, calc(100dvh - 6px)); }
++ }
++ .ovg-foot { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
@keyframes ovgSheet { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
.ovg-grid, .ovg-grid--3 { grid-template-columns: 1fr; }
.ovg-steps { grid-template-columns: repeat(4, 1fr); } .ovg-step-label { font-size: 0.6rem; }
.ovg-kv { grid-template-columns: 1fr; } .ovg-kv span { font-size: 0.7rem; }
-- .ovg-foot { padding: 10px 14px 14px; }
++ .ovg-foot { padding: 10px 14px calc(14px + env(safe-area-inset-bottom, 0px)); }
}
@media (prefers-reduced-motion: reduce) { .ovg-card, .ovg-panel, .ovg-pick, .ovg-box, .ovg-sum-row, .ovg-big, .ovg-radar i, .ovg-live-badge { animation: none !important; } }
@@@ -140,3 +149,27 @@ body.ovg-lock { overflow: hidden; }
.ovg-spot-card h4 { margin: 0 0 6px; font-size: 1.05rem; font-weight: 900; } .ovg-spot-card p { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0; }
.ovg-spot-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 640px) { .ovg-hub-top { grid-template-columns: 76px 1fr; } .ovg-ring { width: 76px; height: 76px; } .ovg-ring span { font-size: 1.2rem; } .ovg-profile { grid-template-columns: 1fr; justify-items: center; } .ovg-tasks li { grid-template-columns: 30px 1fr; } .ovg-tasks li .btn { grid-column: 2; justify-self: start; } }
++
++/* ── Restream: the guided way in, and the manual one ──────────────────────────
++ Adding a destination used to mean leaving the page, finding a stream key in someone else's
++ dashboard, and pasting it back. Where we have an OAuth app for the platform, signing in does
++ all of that in one tap. Both paths live in the same box, with the sign-in first because it is
++ the one most people want — but the key field is right there under it, not hidden behind a
++ disclosure, because plenty of people would rather not link an account at all. */
++.ovg-connect { margin: 0 0 4px; }
++.ovg-connect-go { width: 100%; justify-content: center; gap: 10px; }
++.ovg-connect-go i { font-size: 1.05em; }
++.ovg-connect-go[disabled] { opacity: 0.75; cursor: progress; }
++.ovg-connected {
++ display: flex; align-items: center; gap: 8px; margin: 0 0 8px;
++ padding: 8px 10px; border-radius: 10px; font-size: 0.8rem; font-weight: 700;
++ background: color-mix(in srgb, #06d6a0 12%, var(--bg-tertiary));
++ border: 1px solid color-mix(in srgb, #06d6a0 40%, var(--border));
++}
++.ovg-connected i { color: #06d6a0; }
++/* A labelled rule between the two methods — neither is a footnote to the other. */
++.ovg-or { display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; }
++.ovg-or::before, .ovg-or::after { content: ''; height: 1px; flex: 1; background: var(--border); }
++.ovg-or span { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
++.ovg-nooauth { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 10px; }
++.ovg-nooauth i { color: var(--accent); margin-top: 2px; }
ddiff --git a/public/index.html b/public/index.html
iindex 431f9a0f..81a2086b 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -94,18 +94,18 @@
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
-- <link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/broadcast.css?v=12" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/cosmetics.css?v=2" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">
-- <link rel="stylesheet" href="/css/ov-guide.css?v=1" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/ov-guide.css?v=3" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/context-cards.css?v=4" media="print" onload="this.media='all';this.onload=null">
<noscript>
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
-- <link rel="stylesheet" href="/css/broadcast.css?v=11">
++ <link rel="stylesheet" href="/css/broadcast.css?v=12">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
-- <link rel="stylesheet" href="/css/ov-guide.css?v=1">
++ <link rel="stylesheet" href="/css/ov-guide.css?v=3">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/context-cards.css?v=4">
</noscript>
@@@ -5181,7 +5181,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-tour.js?v=9" defer></script>
<script src="/js/ov-guide.js?v=2" defer></script>
--<script src="/js/guides-live.js?v=7" defer></script>
++<script src="/js/guides-live.js?v=8" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js?v=1" defer></script>
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex 08c55f6f..7562d29e 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -24,10 +24,51 @@
const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes', mods: 'fa-user-shield', requests: 'fa-list-ol', moderation: 'fa-gavel', controls: 'fa-gamepad', aibot: 'fa-robot', paste: 'fa-code' };
// ── Shared stream state (slot / endpoint / restreams) ─────
-- const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, method: null, mode: 'camera', poll: null };
++ const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, oauth: {}, method: null, mode: 'camera', poll: null };
async function loadSlots() { try { const d = await api('/streams/managed'); D.slots = d.managed_streams || []; } catch { D.slots = []; } return D.slots; }
async function loadEndpoint() { if (!D.slot) return; try { const d = await api(`/streams/managed/${D.slot.id}/profile`); D.key = d.stream_key || D.slot.stream_key || null; D.rtmpUrl = d.rtmp_url || 'rtmp://openvibe.live/live'; D.whipBase = (d.whip_url_base || location.origin).replace(/\/$/, ''); } catch { D.key = D.slot.stream_key || null; D.rtmpUrl = 'rtmp://openvibe.live/live'; D.whipBase = location.origin; } }
-- async function loadDests() { if (!D.slot) return; try { const d = await api(`/restream/destinations?managed_stream_id=${D.slot.id}`); D.dests = (d.destinations || []).filter(x => !x.managed_stream_id || x.managed_stream_id === D.slot.id); } catch { D.dests = []; } try { const r = await api(`/robotstreamer/integration?managed_stream_id=${D.slot.id}`); D.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { D.rs = null; } }
++ async function loadDests() { if (!D.slot) return; try { const d = await api(`/restream/destinations?managed_stream_id=${D.slot.id}`); D.dests = (d.destinations || []).filter(x => !x.managed_stream_id || x.managed_stream_id === D.slot.id); } catch { D.dests = []; } try { const r = await api(`/robotstreamer/integration?managed_stream_id=${D.slot.id}`); D.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { D.rs = null; } await loadOAuth(); }
++ /** Which platforms this server can do OAuth for, and which the reader has already linked. */
++ async function loadOAuth() { try { const d = await api('/restream/oauth/status'); D.oauth = {}; for (const p of (d.platforms || [])) D.oauth[p.platform] = p; } catch { D.oauth = {}; } return D.oauth; }
++
++ /**
++ * Open a platform's OAuth popup and wait for it to come back.
++ *
++ * Three channels, because any one of them can be lost: postMessage dies when the browser
++ * severs window.opener across a cross-origin navigation (COOP), BroadcastChannel is
++ * same-origin but not everywhere, and polling the server is slow but always true. Whichever
++ * arrives first wins; the rest are torn down. Same approach the broadcast desk uses — this
++ * copy is free of that page's DOM so the guide can use it anywhere.
++ */
++ function connectPlatform(platform, onDone) {
++ const url = `/api/restream/oauth/${platform}/start${D.slot ? `?managed_stream_id=${D.slot.id}` : ''}`;
++ const popup = window.open(url, 'ov-oauth-' + platform, 'width=620,height=760');
++ if (!popup) { say('Allow pop-ups for openvibe.live to connect this way, or paste a key instead.', 'error'); return; }
++ const before = (D.oauth && D.oauth[platform]) || {};
++ let done = false, polls = 0, timer = null, bc = null;
++ const cleanup = () => { try { window.removeEventListener('message', onMsg); } catch { /* */ } try { if (bc) bc.close(); } catch { /* */ } if (timer) clearInterval(timer); };
++ const finish = async (payload) => {
++ if (done) return; done = true; cleanup();
++ if (payload && payload.ok === false) { say(payload.error || 'Connection failed', 'error'); onDone(false); return; }
++ await loadDests();
++ const st = (D.oauth && D.oauth[platform]) || {};
++ const dest = D.dests.find(x => x.platform === platform);
++ const needsKey = payload ? payload.needsManualKey : !!(dest && !dest.stream_key);
++ say(`Connected ${(PLAT[platform] || PLAT.custom)[0]}${st.username ? ' as ' + st.username : ''}${needsKey ? ' — one key still needed' : ''}`, needsKey ? 'info' : 'success');
++ onDone(true, needsKey);
++ };
++ const onMsg = (e) => { if (e.origin !== location.origin) return; const d = e.data; if (d && d.type === 'restream-oauth' && d.platform === platform) finish(d); };
++ window.addEventListener('message', onMsg);
++ try { bc = new BroadcastChannel('restream-oauth'); bc.onmessage = (e) => { const d = e.data; if (d && d.type === 'restream-oauth' && d.platform === platform) finish(d); }; } catch { bc = null; }
++ timer = setInterval(async () => {
++ polls++;
++ await loadOAuth();
++ const now = (D.oauth && D.oauth[platform]) || {};
++ if (now.connected && (!before.connected || now.username !== before.username)) return finish(null);
++ if (popup.closed && polls >= 2 && !done) { cleanup(); done = true; onDone(false); return; }
++ if (polls >= 120 && !done) { cleanup(); done = true; onDone(false); }
++ }, 2000);
++ }
async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }
async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }
const onBroadcast = () => location.pathname.startsWith('/broadcast');
@@@ -144,12 +185,20 @@
id: 'restream', title: 'Restream', icon: 'fa-satellite-dish', heading: 'Mirror it everywhere (optional)', optional: true, skipLabel: 'Skip for now',
render: async () => {
if (!D.slot) await pickDefaultSlot(); if (!D.slot) return ui.lead('Create a stream first.');
++ if (!D.oauth || !Object.keys(D.oauth).length) await loadOAuth();
const added = [...(D.rs ? [`<li class="on"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> RobotStreamer · ${esc(D.rs.stream_name || ('robot ' + D.rs.robot_id))}<i class="fa-solid fa-check ovg-ok"></i></li>`] : []), ...D.dests.map(d => `<li class="on"><i class="${(PLAT[d.platform] || PLAT.custom)[1]}" style="color:${(PLAT[d.platform] || PLAT.custom)[2]}"></i> ${esc(d.name || (PLAT[d.platform] || PLAT.custom)[0])}<i class="fa-solid fa-check ovg-ok"></i></li>`)];
return `${ui.lead(`Go live here once and the same stream goes out to every platform you add — while your chat, emotes, sound commands and controls stay on OpenVibe.${added.length ? ` <b>${added.length} already set up for "${esc(D.slot.title)}"</b> — add more, or move on.` : ' Add what you want, or skip.'}`)}
${added.length ? `<ul class="ovg-added">${added.join('')}</ul>` : ''}
${ui.grid([
ui.pick({ id: 'robotstreamer', icon: 'fa-solid fa-robot', title: 'RobotStreamer', sub: 'log in once, pick your robot', color: '#4a9eff' }),
-- ...Object.entries(PLAT).map(([id, [name, icon, color]]) => ui.pick({ id, icon, title: name, sub: id === 'custom' ? 'server URL + key' : 'paste your stream key', color })),
++ ...Object.entries(PLAT).map(([id, [name, icon, color]]) => {
++ const o = (D.oauth && D.oauth[id]) || null;
++ const sub = id === 'custom' ? 'server URL + key'
++ : (o && o.connected) ? `signed in as ${esc(o.username || name)}`
++ : (o && o.configured) ? 'sign in — or paste a key'
++ : 'paste your stream key';
++ return ui.pick({ id, icon, title: name, sub, color });
++ }),
], 3)}<div class="ovg-detail" id="ovg-plat-form"></div>`;
},
mount: (el) => {
@@@ -176,8 +225,39 @@
return;
}
const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';
-- f.innerHTML = ui.box(name, `<p>${plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.' : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.' : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.' : 'Paste the RTMP server URL and stream key from the service you want to mirror to.'}</p>${needsUrl ? '<input class="form-input" id="ovg-p-url" placeholder="rtmps://… server URL">' : ''}<input class="form-input" id="ovg-p-key" type="password" placeholder="${name} stream key" autocomplete="off"><label class="ovg-check"><input type="checkbox" id="ovg-p-auto" checked> Start automatically whenever I go live</label><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div>`, icon.replace('fa-brands ', '').replace('fa-solid ', ''));
++ const oauth = (D.oauth && D.oauth[plat]) || null;
++ const canConnect = !!(oauth && oauth.configured);
++ const manualHelp = plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.'
++ : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.'
++ : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.'
++ : 'Paste the RTMP server URL and stream key from the service you want to mirror to.';
++
++ // Two ways in, and the reader picks. Signing in is one tap and fills the key in for them;
++ // pasting a key is still here for anyone who would rather not link an account, whose
++ // platform we have no OAuth app for, or who is mirroring to some other RTMP service.
++ const connectBlock = canConnect ? `
++ <div class="ovg-connect">
++ ${oauth.connected ? `<p class="ovg-connected"><i class="fa-solid fa-circle-check"></i> Already signed in as <b>${esc(oauth.username || name)}</b> — reconnect to refresh the key.</p>` : ''}
++ <button type="button" class="btn btn-primary btn-lg ovg-connect-go" id="ovg-oauth-go">
++ <i class="${icon}"></i> ${oauth.connected ? 'Reconnect' : 'Sign in with'} ${esc(name)}
++ </button>
++ <p class="muted ovg-fine">Opens ${esc(name)} in a window. We read your channel and ${oauth.providesKey ? 'stream key' : 'channel details'} and fill this in — nothing to copy across.</p>
++ <div class="ovg-or"><span>or do it by hand</span></div>
++ </div>` : `<p class="muted ovg-fine ovg-nooauth"><i class="fa-solid fa-circle-info"></i> One-tap sign-in isn't set up for ${esc(name)} on this server yet — paste a key instead.</p>`;
++
++ f.innerHTML = ui.box(name, `${connectBlock}<p>${manualHelp}</p>${needsUrl ? '<input class="form-input" id="ovg-p-url" placeholder="rtmps://… server URL">' : ''}<input class="form-input" id="ovg-p-key" type="password" placeholder="${name} stream key" autocomplete="off"><label class="ovg-check"><input type="checkbox" id="ovg-p-auto" checked> Start automatically whenever I go live</label><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div>`, icon.replace('fa-brands ', '').replace('fa-solid ', ''));
f.querySelector('.ovg-box-head i').className = icon;
++ const oauthBtn = f.querySelector('#ovg-oauth-go');
++ if (oauthBtn) oauthBtn.onclick = () => {
++ oauthBtn.disabled = true;
++ oauthBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for ' + esc(name) + '…';
++ connectPlatform(plat, (ok, needsKey) => {
++ if (ok && !needsKey) return rerender(); // done — back to the list
++ if (ok && needsKey) { rerender(); return; } // linked, but the key still needs pasting
++ oauthBtn.disabled = false;
++ oauthBtn.innerHTML = `<i class="${icon}"></i> Sign in with ${esc(name)}`;
++ });
++ };
f.querySelector('#ovg-p-add').onclick = async () => {
const key = f.querySelector('#ovg-p-key').value.trim(); const url = needsUrl ? f.querySelector('#ovg-p-url').value.trim() : '';
if (!key) return say('Paste the stream key first', 'error'); if (needsUrl && !url) return say('The server URL is needed too', 'error');
ddiff --git a/server/streaming/restream-routes.js b/server/streaming/restream-routes.js
iindex 0e890437..4d14c065 100644
---- a/server/streaming/restream-routes.js
++++ b/server/streaming/restream-routes.js
@@@ -570,8 +570,19 @@ router.get('/oauth/:platform/callback', async (req, res) => {
scope: tokens.scope,
});
-- // Provision / update the restream destination for the target slot
-- const managedStreamId = stateData.managedStreamId || null;
++ // Provision / update the restream destination for the target slot.
++ // managed_stream_id arrives from the query at /start and rides along in the signed state,
++ // so it is authentic but not authorised — the signature proves we issued it, not that the
++ // slot belongs to this account. Without the ownership check a user could start the flow
++ // against someone else's slot id and have a destination provisioned onto their stream.
++ let managedStreamId = stateData.managedStreamId || null;
++ if (managedStreamId) {
++ const slot = db.getManagedStreamById(managedStreamId);
++ if (!slot || slot.user_id !== userId) {
++ console.warn(`[Restream OAuth] ${platform}: user ${userId} tried to provision onto slot ${managedStreamId}`);
++ managedStreamId = null; // link the account, but touch nobody else's slot
++ }
++ }
let destProvisioned = false;
if (managedStreamId) {
const existing = db.getRestreamDestinationsByManagedStream(managedStreamId)
ddiff --git a/test/security-redaction.test.js b/test/security-redaction.test.js
iindex b2c85c22..e5676181 100644
---- a/test/security-redaction.test.js
++++ b/test/security-redaction.test.js
@@@ -167,4 +167,16 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
}
}
++// ── A signed OAuth state proves we issued it, not that the slot is yours ─────
++{
++ const src = fs.readFileSync(path.join(ROOT, 'server/streaming/restream-routes.js'), 'utf8');
++ const cb = src.match(/const userId = stateData\.userId;[\s\S]*?destProvisioned = true;/);
++ assert(cb, 'the OAuth callback should still provision a destination');
++ assert(/slot\.user_id !== userId/.test(cb[0]),
++ 'the callback must confirm the state\'s managed_stream_id belongs to the authenticating user');
++ assert(cb[0].indexOf('slot.user_id !== userId') < cb[0].indexOf('createRestreamDestination'),
++ 'ownership must be checked before a destination is created');
++ ok('restream OAuth callback will not provision onto another account\'s slot');
++}
++
console.log(`\n${pass} checks passed`);