openvibers/openvibe.live Commits

RobotStreamer is per stream slot only: a stream uses its slot's robotstreamer_integrations row and nothing else. getRobotStreamerIntegrationForStream no longer falls back to the account-level row (which sent every slot without its own row to the same robot); an unmigrated account-level row is logged once per user and skipped, never used and never a crash. validate, login, save and remove answer 400 without managed_stream_id (validate no longer borrows the account token), upserts refuse to write an account-level row, refreshIntegration has no account path, and GET without a slot answers only the server passthrough capability with exists:false. The broadcast page's RobotStreamer settings and viewer poll work on the page's slot, a stream without its slot's config starts no browser publish, and the slot workspace no longer offers to reuse an account token. Run scripts/rs-integrations-to-slots.js on production before deploying this. test/rs-slot-only.test.js

OP OpenVibers committed 24 Sept 2026 · 155f68efd0 · 7 files, +253 −103


Changes

ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 5c6d5ea3..fe64968f 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -1101,11 +1101,11 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
</table>
<h3>RobotStreamer — <code>/api/robotstreamer</code></h3>
--<p>Publish a server-ingested stream to a RobotStreamer robot and (optionally) mirror its chat. Config is per slot with an account-level default. All <code>requireAuth</code>.</p>
++<p>Publish a server-ingested stream to a RobotStreamer robot and (optionally) mirror its chat. Config is per stream slot only: a slot without its own config does not restream to RobotStreamer, and every call except GET needs <code>managed_stream_id</code>. All <code>requireAuth</code>.</p>
<table class="doc-table">
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
--<tr><td>GET</td><td>/api/robotstreamer/integration</td><td>Load RS settings + available robots (<code>?managed_stream_id</code>)</td></tr>
++<tr><td>GET</td><td>/api/robotstreamer/integration</td><td>Load a slot's RS settings + available robots (<code>?managed_stream_id</code>; without it only the server's passthrough capability, <code>exists:false</code>)</td></tr>
<tr><td>POST</td><td>/api/robotstreamer/integration/validate &middot; /login</td><td>Validate token+robot / log in with RS credentials to auto-fetch token+robots</td></tr>
<tr><td>PUT / DELETE</td><td>/api/robotstreamer/integration</td><td>Save config (starts publish + optional chat bridge on live streams) / remove</td></tr>
</tbody>
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 1ebe063d..69efd08b 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -3036,7 +3036,7 @@ function _wsShowRestreamForm(existing) {
// RobotStreamer — saved as a per-slot integration, not an RTMP destination row
if (plat && plat.isRobotStreamer) {
const payload = _wsGetSlotRsFormData();
-- if (!payload.token && !_wsSlotRsIntegration?.has_token && !broadcastState?.robotStreamer?.hasToken) {
++ if (!payload.token && !_wsSlotRsIntegration?.has_token) {
toast('Paste your RobotStreamer token first', 'error');
return;
}
@@@ -3312,11 +3312,10 @@ function _wsRestreamPlatformChanged() {
const enabledEl = document.getElementById('ws-rs-rs-enabled');
const mirrorEl = document.getElementById('ws-rs-rs-mirror-chat');
if (tokenEl) {
++ // Tokens are per slot (there is no account-level token to reuse any more).
tokenEl.placeholder = rs?.has_token
? 'Token saved for this slot — paste a new one only to replace it'
-- : (broadcastState?.robotStreamer?.hasToken
-- ? 'Leave empty to reuse your account token, or paste a slot-specific one'
-- : 'Paste your robotstreamer-token cookie or JWT');
++ : 'Paste your robotstreamer-token cookie or JWT';
}
if (robotEl && rs?.robot_id && !robotEl.value) robotEl.value = rs.robot_id;
if (enabledEl && rs) enabledEl.checked = !!rs.enabled;
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 595d1d55..d7a5b7b1 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -11,8 +11,8 @@
/* ── Per-slot RobotStreamer config ───────────────────────────── */
// Each stream slot (managed stream) can carry its own RobotStreamer
// token + robot, configured in the slot's Restream Destinations panel.
--// Slots with their own config auto-restream independently; slots without
--// fall back to the account-level config + legacy single-slot reservation.
++// RobotStreamer is per slot only: a slot without its own config does not
++// restream to RobotStreamer (there is no account-level fallback).
const _rsSlotIntegrationCache = new Map(); // managed_stream_id → { fetchedAt, integration|null }
async function fetchSlotRsIntegration(managedStreamId, { force = false } = {}) {
@@@ -297,28 +297,19 @@ function updateRsRestreamSlotUI() {
panel.innerHTML = html;
}
--/** Conditionally start RS restream — slot-specific config first, legacy account slot second */
++/** Conditionally start RS restream — only from the stream's own slot config */
async function maybeStartRsRestream(streamId) {
const ss = getStreamState(streamId);
const msId = ss?.streamData?.managed_stream_id || null;
const slotRs = await fetchSlotRsIntegration(msId);
-- if (slotRs) {
-- // This slot has its own RS config — it restreams independently of the
-- // legacy single-slot reservation and of other slots.
-- if (ss) ss._rsSlotIntegration = slotRs;
-- if (rsSlotIntegrationUsable(slotRs)) {
-- startRobotStreamerRestream(streamId).catch(() => {});
-- updateRsRestreamSlotUI();
-- }
-- return;
++ // No slot, or a slot without its own RobotStreamer config: nothing to restream.
++ if (!slotRs) return;
++ // This slot has its own RS config — it restreams independently of other slots.
++ if (ss) ss._rsSlotIntegration = slotRs;
++ if (rsSlotIntegrationUsable(slotRs)) {
++ startRobotStreamerRestream(streamId).catch(() => {});
++ updateRsRestreamSlotUI();
}
-- // Legacy account-level behavior
-- if (!isRsRestreamAssigned(streamId)) return;
-- // Auto-assign the slot on first live stream
-- const currentSlot = getRsRestreamSlotStreamId();
-- if (!currentSlot) setRsRestreamSlot(streamId);
-- startRobotStreamerRestream(streamId).catch(() => {});
-- updateRsRestreamSlotUI();
}
function sendBroadcastSignal(ss, msg) {
@@@ -703,9 +694,11 @@ function syncRobotStreamerUI() {
? 'Saved token on file — paste a new one only if you want to replace it'
: 'Paste your robotstreamer-token cookie or JWT';
-- const summary = rs.ownerName || rs.streamName
-- ? `Configured for ${rs.streamName || `Robot ${rs.robotId}`} as ${rs.ownerName || rs.ownerId || 'unknown owner'}.`
-- : 'RobotStreamer restreaming works with browser WebRTC broadcasting. Paste your RobotStreamer token, validate a robot, then go live.';
++ const summary = !rs.managedStreamId
++ ? 'Pick a stream slot: RobotStreamer is set up per stream slot.'
++ : rs.ownerName || rs.streamName
++ ? `Configured for ${rs.streamName || `Robot ${rs.robotId}`} as ${rs.ownerName || rs.ownerId || 'unknown owner'}.`
++ : 'RobotStreamer restreaming works with browser WebRTC broadcasting. Paste your RobotStreamer token, validate a robot, then go live.';
setRobotStreamerStatus(summary, rs.hasToken ? 'success' : 'info', 'bc-rsStatus');
// Update live status based on current state
@@@ -739,10 +732,14 @@ function syncRobotStreamerUI() {
async function loadRobotStreamerIntegration() {
const doLoad = async () => {
try {
-- const data = await api('/robotstreamer/integration');
++ // The settings here are the RobotStreamer config of the slot this page is on. Without a
++ // slot the server answers only whether it relays the video (passthrough), exists:false.
++ const managedStreamId = _restreamPanelSlotId();
++ const data = await api(managedStreamId ? `/robotstreamer/integration?managed_stream_id=${managedStreamId}` : '/robotstreamer/integration');
const integration = data.integration || {};
broadcastState.robotStreamer = {
loaded: true,
++ managedStreamId: managedStreamId || null,
enabled: !!integration.enabled,
mirrorChat: integration.mirror_chat !== false,
hasToken: !!integration.has_token,
@@@ -772,11 +769,20 @@ function getRobotStreamerFormData() {
mirror_chat: !!document.getElementById('bc-rsMirrorChat')?.checked,
token: document.getElementById('bc-rsToken')?.value.trim() || '',
robot_input: selectRobot || inputRobot,
++ managed_stream_id: _restreamPanelSlotId() || null,
};
}
++/** RobotStreamer settings belong to a stream slot; say so instead of calling the server without one. */
++function _rsNeedsSlot(payload) {
++ if (payload.managed_stream_id) return false;
++ setRobotStreamerStatus('Pick a stream slot first: RobotStreamer is set up per stream slot.', 'error');
++ return true;
++}
++
async function validateRobotStreamerIntegration() {
const payload = getRobotStreamerFormData();
++ if (_rsNeedsSlot(payload)) return;
setRobotStreamerStatus('Validating RobotStreamer settings…', 'info');
try {
const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: payload });
@@@ -802,6 +808,7 @@ async function validateRobotStreamerIntegration() {
async function saveRobotStreamerIntegration() {
const payload = getRobotStreamerFormData();
++ if (_rsNeedsSlot(payload)) return;
setRobotStreamerStatus('Saving RobotStreamer settings…', 'info');
try {
const data = await api('/robotstreamer/integration', { method: 'PUT', body: payload });
@@@ -822,9 +829,15 @@ async function saveRobotStreamerIntegration() {
if (tokenEl) tokenEl.value = '';
syncRobotStreamerUI();
setRobotStreamerStatus('RobotStreamer settings saved.', 'success');
++ // The saved config is this slot's: refresh its cache and restart only its streams.
++ _rsSlotIntegrationCache.delete(payload.managed_stream_id);
const restreamTargets = [...broadcastState.streams.entries()]
-- .filter(([, state]) => state?.localStream)
++ .filter(([, state]) => state?.localStream && state.streamData?.managed_stream_id === payload.managed_stream_id)
.map(([streamId]) => streamId);
++ for (const streamId of restreamTargets) {
++ const ss = getStreamState(streamId);
++ if (ss) ss._rsSlotIntegration = null;
++ }
for (const streamId of restreamTargets) {
if (broadcastState.robotStreamer.enabled && isRsRestreamAssigned(streamId)) {
stopRobotStreamerRestream(streamId, { quiet: true })
@@@ -3511,21 +3524,18 @@ async function startRobotStreamerRestream(streamId) {
// Wait for integration settings to load first (fixes race condition on page load)
await ensureRobotStreamerLoaded();
-- // Slot-specific config wins over the account-level integration
++ // Only the stream's own slot config counts: no slot config, no RobotStreamer.
const slotRs = ss._rsSlotIntegration
|| await fetchSlotRsIntegration(ss.streamData?.managed_stream_id || null);
if (slotRs) ss._rsSlotIntegration = slotRs;
-- if (slotRs) {
-- if (!rsSlotIntegrationUsable(slotRs)) {
-- console.log('[RS Restream] Slot config not usable — enabled:', slotRs.enabled, 'hasToken:', slotRs.has_token, 'robotId:', slotRs.robot_id);
-- if (!slotRs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled for this stream slot.', 'info', 'bc-rsLiveStatus');
-- return null;
-- }
-- } else if (!canUseRobotStreamerRestream()) {
-- const rs = broadcastState.robotStreamer;
-- console.log('[RS Restream] Cannot start — enabled:', rs.enabled, 'hasToken:', rs.hasToken, 'robotId:', rs.robotId);
-- if (!rs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled.', 'info', 'bc-rsLiveStatus');
++ if (!slotRs) {
++ console.log('[RS Restream] No RobotStreamer config on this stream slot — skipping');
++ return null;
++ }
++ if (!rsSlotIntegrationUsable(slotRs)) {
++ console.log('[RS Restream] Slot config not usable — enabled:', slotRs.enabled, 'hasToken:', slotRs.has_token, 'robotId:', slotRs.robot_id);
++ if (!slotRs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled for this stream slot.', 'info', 'bc-rsLiveStatus');
return null;
}
if (ss.robotStreamer?.active) return ss.robotStreamer;
@@@ -3533,7 +3543,7 @@ async function startRobotStreamerRestream(streamId) {
// Server-side RAW passthrough owns this robot — the server forwards our already-encoded
// stream to RobotStreamer with zero re-encode, so the browser must NOT publish a second
// (double) encode. Skip the client publish entirely.
-- if (broadcastState.robotStreamer?.passthrough) {
++ if (slotRs.passthrough || broadcastState.robotStreamer?.passthrough) {
console.log('[RS Restream] Server passthrough active — skipping browser publish for stream', streamId);
setRobotStreamerStatus('Restreaming to RobotStreamer via server (raw passthrough, no re-encode).', 'success', 'bc-rsLiveStatus');
return null;
@@@ -4609,10 +4619,10 @@ async function _startRsViewerPoll() {
try { await _robotStreamerIntegrationPromise; } catch {}
}
const rs = broadcastState.robotStreamer;
-- if (!rs?.enabled || !rs?.robotId) return;
++ if (!rs?.enabled || !rs?.robotId || !rs.managedStreamId) return;
const poll = async () => {
try {
-- const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId } });
++ const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId, managed_stream_id: rs.managedStreamId } });
const robots = data.integration?.available_robots || [];
const robot = robots.find(r => String(r.robot_id) === String(rs.robotId));
_rsViewerCount = robot ? Number(robot.viewers || 0) : 0;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex a50fe4d6..6bd17a28 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -4329,7 +4329,8 @@ function isStreamClipRecordingEnabled(stream) {
// ── RobotStreamer integration helpers ───────────────────────
function getRobotStreamerIntegrationByUserId(userId) {
-- // Account-level default row (no slot binding)
++ // A legacy account-level row (no slot binding). It applies to no stream any more:
++ // scripts/rs-integrations-to-slots.js moves these onto slots. Read only to report them.
return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id IS NULL', [userId]);
}
@@@ -4338,10 +4339,28 @@ function getRobotStreamerIntegrationBySlot(userId, managedStreamId) {
return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id = ?', [userId, managedStreamId]);
}
++// Users already warned about an unmigrated account-level row (once per process each).
++const _rsAccountRowWarned = new Set();
++
++/**
++ * The RobotStreamer config a stream on this slot uses: the slot's own row, and nothing else.
++ * No slot (a legacy session) or a slot without a row means no RobotStreamer. An account-level
++ * row that has not been moved onto a slot yet is logged and skipped, never used: that fallback
++ * sent every slot without its own row to the same robot.
++ */
function getRobotStreamerIntegrationForStream(userId, managedStreamId) {
-- // Slot-specific config wins; fall back to the account-level default row
-- return (managedStreamId ? getRobotStreamerIntegrationBySlot(userId, managedStreamId) : null)
-- || getRobotStreamerIntegrationByUserId(userId);
++ const row = managedStreamId ? getRobotStreamerIntegrationBySlot(userId, managedStreamId) : null;
++ if (row) return row;
++ try {
++ if (!_rsAccountRowWarned.has(userId)) {
++ const legacy = getRobotStreamerIntegrationByUserId(userId);
++ if (legacy) {
++ _rsAccountRowWarned.add(userId);
++ console.warn(`[RS] User ${userId} has an account-level RobotStreamer row (${legacy.id}) that applies to no stream; skipped. Bind it to a slot with scripts/rs-integrations-to-slots.js.`);
++ }
++ }
++ } catch { /* reporting only */ }
++ return null;
}
function deleteRobotStreamerIntegrationForSlot(userId, managedStreamId) {
@@@ -4363,10 +4382,10 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null)
'owner_name',
'last_validated_at',
]);
++ // RobotStreamer is configured per stream slot; account-level rows are no longer written.
const slotId = managedStreamId || null;
-- const existing = slotId
-- ? getRobotStreamerIntegrationBySlot(userId, slotId)
-- : getRobotStreamerIntegrationByUserId(userId);
++ if (!slotId) throw new Error('RobotStreamer settings belong to a stream slot (managed_stream_id is required)');
++ const existing = getRobotStreamerIntegrationBySlot(userId, slotId);
const filtered = Object.entries(fields || {}).filter(([key, val]) => allowed.has(key) && val !== undefined);
if (!filtered.length) return existing;
@@@ -4391,9 +4410,7 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null)
);
}
-- return slotId
-- ? getRobotStreamerIntegrationBySlot(userId, slotId)
-- : getRobotStreamerIntegrationByUserId(userId);
++ return getRobotStreamerIntegrationBySlot(userId, slotId);
}
// ── Restream Destination helpers ─────────────────────────────
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex fb8abccc..0751748e 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -185,6 +185,9 @@ class RobotStreamerService {
last_validated_at: null,
managed_stream_id: null,
available_robots: [],
++ // Whether the server relays the video (a property of this server, not of a row):
++ // the broadcast page needs it even before a slot has a RobotStreamer row.
++ passthrough: this._passthroughEnabledFor(null),
...extras,
};
}
@@@ -220,14 +223,9 @@ class RobotStreamerService {
} catch { return false; }
}
-- getClientIntegration(userId) {
-- return this.sanitizeIntegration(db.getRobotStreamerIntegrationByUserId(userId));
-- }
--
/**
-- * Resolve the effective integration for a stream: the slot-specific row
-- * (matching the stream's managed_stream_id) wins, otherwise the
-- * account-level default row is used.
++ * The integration for a stream: its slot's row, or null (no slot, or no row on the slot).
++ * There is no account-level fallback; see db.getRobotStreamerIntegrationForStream.
*/
getIntegrationForStream(stream) {
if (!stream?.user_id) return null;
@@@ -452,13 +450,8 @@ class RobotStreamerService {
async upsertIntegration(userId, payload = {}, managedStreamId = null) {
const slotId = managedStreamId || null;
-- const existing = slotId
-- ? db.getRobotStreamerIntegrationBySlot(userId, slotId)
-- : db.getRobotStreamerIntegrationByUserId(userId);
-- // When creating a slot-specific config, allow token/robot to fall back to
-- // the account-level default row so users don't have to re-paste the token
-- // for every slot.
-- const fallback = existing || (slotId ? db.getRobotStreamerIntegrationByUserId(userId) : null);
++ if (!slotId) throw new Error('Choose a stream slot: RobotStreamer is set up per stream slot');
++ const existing = db.getRobotStreamerIntegrationBySlot(userId, slotId);
const updates = {
enabled: normalizeBoolean(payload.enabled, existing ? !!existing.enabled : false) ? 1 : 0,
mirror_chat: normalizeBoolean(payload.mirror_chat, existing ? existing.mirror_chat !== 0 : true) ? 1 : 0,
@@@ -474,22 +467,11 @@ class RobotStreamerService {
let availableRobots = [];
if (needsValidation) {
const validated = await this.validateConfiguration({
-- token: providedToken || fallback?.token,
-- robotInput: providedRobot || fallback?.robot_id,
++ token: providedToken || existing?.token,
++ robotInput: providedRobot || existing?.robot_id,
});
Object.assign(updates, validated.fields);
availableRobots = validated.availableRobots;
-- } else if (!existing && fallback) {
-- // Creating a slot row without re-validating — copy config from the default row
-- updates.token = fallback.token;
-- updates.robot_id = fallback.robot_id;
-- updates.owner_id = fallback.owner_id;
-- updates.chat_url = fallback.chat_url;
-- updates.control_url = fallback.control_url;
-- updates.rtc_sfu_url = fallback.rtc_sfu_url;
-- updates.stream_name = fallback.stream_name;
-- updates.owner_name = fallback.owner_name;
-- updates.last_validated_at = fallback.last_validated_at;
}
const row = db.upsertRobotStreamerIntegration(userId, updates, slotId);
@@@ -501,9 +483,8 @@ class RobotStreamerService {
async refreshIntegration(userId, managedStreamId = null) {
const slotId = managedStreamId || null;
-- const existing = slotId
-- ? db.getRobotStreamerIntegrationBySlot(userId, slotId)
-- : db.getRobotStreamerIntegrationByUserId(userId);
++ if (!slotId) return null; // per slot only: there is no account-level row to refresh
++ const existing = db.getRobotStreamerIntegrationBySlot(userId, slotId);
if (!existing?.token || !existing?.robot_id) return existing;
const validated = await this.validateConfiguration({ token: existing.token, robotInput: existing.robot_id });
return db.upsertRobotStreamerIntegration(userId, {
ddiff --git a/server/integrations/routes.js b/server/integrations/routes.js
iindex b4e94e4b..b6a2b34e 100644
---- a/server/integrations/routes.js
++++ b/server/integrations/routes.js
@@@ -28,24 +28,33 @@ function resolveSlotId(req, res) {
}
/**
-- * Live streams whose effective RS config is the given row (slot or default).
-- * For a slot row: only streams on that slot. For the default row: only streams
-- * that do NOT have their own slot-specific config.
++ * RobotStreamer is configured per stream slot only: the writes (validate, login, save, remove)
++ * need a slot. Returns the slot id, or false with a 400 already sent.
*/
++function requireSlotId(req, res) {
++ const slotId = resolveSlotId(req, res);
++ if (slotId === false) return false;
++ if (!slotId) {
++ res.status(400).json({ error: 'Choose a stream slot: RobotStreamer is set up per stream slot (managed_stream_id is required)' });
++ return false;
++ }
++ return slotId;
++}
++
++/** Live streams on the slot whose RS config this is (a slot's row applies to that slot only). */
function liveStreamsForConfig(userId, slotId) {
const liveStreams = db.getLiveStreamsByUserId(userId) || [];
-- if (slotId) return liveStreams.filter(s => s.managed_stream_id === slotId);
-- return liveStreams.filter(s => !s.managed_stream_id || !db.getRobotStreamerIntegrationBySlot(userId, s.managed_stream_id));
++ return liveStreams.filter(s => s.managed_stream_id === slotId);
}
++// GET without a slot answers what does not depend on one (the server passthrough capability),
++// with exists:false — never the legacy account-level row, which applies to no stream.
router.get('/integration', requireAuth, async (req, res) => {
try {
const slotId = resolveSlotId(req, res);
if (slotId === false) return;
-- const row = slotId
-- ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId)
-- : db.getRobotStreamerIntegrationByUserId(req.user.id);
++ const row = slotId ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId) : null;
let availableRobots = [];
// If a saved token + robot exists, re-fetch available robots so the dropdown populates on reload
@@@ -69,11 +78,10 @@ router.get('/integration', requireAuth, async (req, res) => {
router.post('/integration/validate', requireAuth, async (req, res) => {
try {
-- const slotId = resolveSlotId(req, res);
++ const slotId = requireSlotId(req, res);
if (slotId === false) return;
-- const existing = (slotId ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId) : null)
-- || db.getRobotStreamerIntegrationByUserId(req.user.id);
++ const existing = db.getRobotStreamerIntegrationBySlot(req.user.id, slotId);
const token = typeof req.body.token === 'string' && req.body.token.trim()
? req.body.token.trim()
: existing?.token;
@@@ -106,7 +114,7 @@ router.post('/integration/validate', requireAuth, async (req, res) => {
// as an easier alternative to pasting the token manually. The password is never stored.
router.post('/integration/login', requireAuth, async (req, res) => {
try {
-- const slotId = resolveSlotId(req, res);
++ const slotId = requireSlotId(req, res);
if (slotId === false) return;
const login = await robotStreamerService.loginWithCredentials(req.body?.user_name, req.body?.password);
@@@ -131,9 +139,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {
}
}
if (!integration) {
-- const row = slotId
-- ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId)
-- : db.getRobotStreamerIntegrationByUserId(req.user.id);
++ const row = db.getRobotStreamerIntegrationBySlot(req.user.id, slotId);
integration = robotStreamerService.sanitizeIntegration(row, { available_robots: login.robots });
}
@@@ -151,7 +157,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {
router.put('/integration', requireAuth, async (req, res) => {
try {
-- const slotId = resolveSlotId(req, res);
++ const slotId = requireSlotId(req, res);
if (slotId === false) return;
const result = await robotStreamerService.upsertIntegration(req.user.id, req.body || {}, slotId);
@@@ -222,12 +228,11 @@ router.post('/restream/stop', requireAuth, (req, res) => {
}
});
--// Remove a slot-specific RS config (the slot falls back to the account default)
++// Remove a slot's RS config (the slot then has no RobotStreamer)
router.delete('/integration', requireAuth, (req, res) => {
try {
-- const slotId = resolveSlotId(req, res);
++ const slotId = requireSlotId(req, res);
if (slotId === false) return;
-- if (!slotId) return res.status(400).json({ error: 'managed_stream_id is required' });
for (const stream of liveStreamsForConfig(req.user.id, slotId)) {
robotStreamerService.stopForStream(stream.id);
ddiff --git a/test/rs-slot-only.test.js b/test/rs-slot-only.test.js
nnew file mode 100644
iindex 00000000..bb18b309
---- /dev/null
++++ b/test/rs-slot-only.test.js
@@@ -0,0 +1,138 @@
++/**
++ * RobotStreamer is configured per stream slot only.
++ *
++ * - A stream uses its slot's robotstreamer_integrations row and nothing else. An account-level
++ * row (no managed_stream_id, 5 of 8 in production before scripts/rs-integrations-to-slots.js)
++ * is logged and skipped, never used and never a crash: the old fallback sent every slot
++ * without its own row to the same robot.
++ * - validate / login / save / remove need a slot (400 without one); GET without a slot answers
++ * only the passthrough capability, never the account-level row; no account-level row is
++ * written any more.
++ * (scripts/rs-integrations-to-slots.js, tested in rs-integrations-to-slots.test.js, moves the
++ * account-level rows onto slots before this ships.)
++ *
++ * node test/rs-slot-only.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-rs-slot-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++const warnings = [];
++console.log = () => {};
++console.warn = (...a) => { warnings.push(a.join(' ')); };
++
++const db = require('../server/db/database');
++db.initDb();
++const raw = db.getDb();
++
++const auth = require('../server/auth/auth');
++const signIn = (req) => {
++ const id = Number(req.headers['x-test-user'] || 0);
++ const u = id ? db.getUserById(id) : null;
++ if (u) { req.user = u; req.authSource = 'network'; }
++ return u;
++};
++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
++
++const addUser = (id, name) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', 'streamer')`).run(id, name, name, `${name}@x`);
++const slot = (userId, slug) => Number(db.createManagedStream({ user_id: userId, slug, title: slug, stream_key: `key-${userId}-${slug}` }).lastInsertRowid);
++const rsRow = (userId, slotId, fields = {}) => Number(raw.prepare(
++ `INSERT INTO robotstreamer_integrations (user_id, managed_stream_id, enabled, token, robot_id, stream_name, chat_url)
++ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(userId, slotId, fields.enabled ?? 1, fields.token ?? 'tok', fields.robot_id ?? null, fields.stream_name ?? null, 'wss://chat.example').lastInsertRowid);
++const accountRow = (userId) => raw.prepare('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id IS NULL').get(userId);
++
++// User 1 has three slots (like production's user 1: slots 1/60/85), an enabled account-level row
++// and robot 777 configured on slot "second" only.
++addUser(1, 'alex');
++const a1 = slot(1, 'main'), a2 = slot(1, 'second'), a3 = slot(1, 'third');
++const r1 = rsRow(1, null, { robot_id: '777', stream_name: 'Robot' });
++const r1slot = rsRow(1, a2, { robot_id: '777' });
++// User 2 owns slot b1 (for the ownership check).
++addUser(2, 'solo');
++const b1 = slot(2, 'only');
++
++let server;
++(async () => {
++ // ── Runtime: slot-only, account rows skipped (not crashing) ──────────────────────
++ const rs = require('../server/integrations/robotstreamer-service');
++ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, a2).id, r1slot, 'a slot uses its own row');
++ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, a1), null, 'a slot without a row does NOT fall back to the account-level row');
++ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, null), null, 'a slot-less stream gets no RobotStreamer');
++ assert.strictEqual(warnings.filter((w) => /account-level RobotStreamer row/.test(w)).length, 1, 'the unmigrated row is logged once per user');
++ assert.ok(warnings.some((w) => w.includes(`(${r1})`) && w.includes('rs-integrations-to-slots')), 'the log names the row and the script');
++ assert.strictEqual(rs.getIntegrationForStream({ id: 1, user_id: 1, managed_stream_id: a3 }), null);
++ assert.strictEqual(await rs.startForStream({ id: 99, user_id: 1, managed_stream_id: a1, protocol: 'webrtc' }), null, 'going live on a slot without a row starts nothing');
++ assert.strictEqual(await rs.refreshIntegration(1, null), null, 'no account-level refresh');
++ assert.throws(() => db.upsertRobotStreamerIntegration(1, { enabled: 1 }, null), /stream slot/, 'no account-level row is written');
++ await assert.rejects(rs.upsertIntegration(1, { enabled: 1 }, null), /stream slot/);
++
++ // ── Routes ───────────────────────────────────────────────────────────────────────
++ rs.validateConfiguration = async ({ token, robotInput }) => {
++ if (!token) throw new Error('RobotStreamer token is required');
++ return { fields: { token, robot_id: String(robotInput), stream_name: `Robot ${robotInput}`, last_validated_at: new Date().toISOString() }, availableRobots: [{ robot_id: String(robotInput), viewers: 3 }] };
++ };
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/robotstreamer', require('../server/integrations/routes'));
++ server = http.createServer(app).listen(0);
++ const call = (method, p, user, body) => new Promise((resolve, reject) => {
++ const data = body ? JSON.stringify(body) : null;
++ const req = http.request({ port: server.address().port, path: p, method, headers: { 'content-type': 'application/json', 'x-test-user': String(user) } }, (res) => {
++ let text = '';
++ res.on('data', (c) => { text += c; });
++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });
++ });
++ req.on('error', reject);
++ if (data) req.write(data);
++ req.end();
++ });
++
++ let r = await call('GET', '/api/robotstreamer/integration', 1);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.exists, false, 'GET without a slot does not return the account-level row');
++ assert.strictEqual(r.json.integration.robot_id, '', 'the account-level robot is not shown as if it applied');
++ assert.ok('passthrough' in r.json.integration, 'GET without a slot still says whether the server relays the video');
++ r = await call('GET', `/api/robotstreamer/integration?managed_stream_id=${a2}`, 1);
++ assert.strictEqual(r.json.exists, true);
++ assert.strictEqual(r.json.integration.robot_id, '777');
++
++ for (const [method, p, body] of [
++ ['POST', '/api/robotstreamer/integration/validate', { robot_input: '777' }],
++ ['POST', '/api/robotstreamer/integration/login', { user_name: 'u', password: 'p' }],
++ ['PUT', '/api/robotstreamer/integration', { enabled: true, token: 't', robot_input: '1' }],
++ ['DELETE', '/api/robotstreamer/integration', null],
++ ]) {
++ r = await call(method, p, 1, body);
++ assert.strictEqual(r.status, 400, `${method} ${p} without a slot is refused`);
++ assert.match(r.json.error, /stream slot/);
++ }
++ // The old validate-without-slot path read the account token; with a slot it uses only the slot's.
++ r = await call('POST', '/api/robotstreamer/integration/validate', 1, { robot_input: '888', managed_stream_id: a1 });
++ assert.strictEqual(r.status, 400, 'slot a1 has no token of its own, and the account token is not borrowed');
++ r = await call('POST', '/api/robotstreamer/integration/validate', 1, { robot_input: '777', managed_stream_id: a2 });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.integration.managed_stream_id, a2);
++ r = await call('PUT', '/api/robotstreamer/integration', 1, { enabled: true, token: 'fresh', robot_input: '888', managed_stream_id: a1 });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(db.getRobotStreamerIntegrationBySlot(1, a1).robot_id, '888', 'saving with a slot writes that slot\'s row');
++ assert.strictEqual(accountRow(1).robot_id, '777', 'and leaves the account-level row alone');
++ r = await call('PUT', '/api/robotstreamer/integration', 1, { enabled: true, robot_input: '1', managed_stream_id: b1 });
++ assert.strictEqual(r.status, 403, 'another user\'s slot is refused');
++ server.close();
++
++ fs.rmSync(tmp, { recursive: true, force: true });
++ quiet('rs-slot-only: ok');
++ process.exit(0);
++})().catch((err) => {
++ quiet(err);
++ try { server && server.close(); } catch { /* */ }
++ process.exit(1);
++});