openvibers/openrestream Commits
A service acting for a stream's owner switching mirror_to_live on links it to their Live channel like the person (Bot: a desk on my Live channel, a robot's Live switch); a stream naming its slot is never linked again (#117)
Changes
@@@ -209,7 +209,9 @@ sudo deploy/scripts/deploy.sh status # workers.shA stream someone creates on openre.stream also goes live on their OpenVibe Live channel unless they untick "Show itlive on my OpenVibe Live channel too" (the create form, ticked by default; the API's `show_on_live: false` for a--person's own stream; services such as Live and Bot name their own refs and are never linked). `server/live-link.js`++person's own stream; a service's new stream, such as Live's and Bot's, names its own refs and is not linked; switching++`mirror_to_live` on later links it, by the person or by a service acting for them with X-OV-Subject, such as Bot's++"Show this desk on my Live channel", unless the stream already names its Live slot). `server/live-link.js`asks Live for a slot on the owner's channel bound to the stream (`POST /internal/openre/slots` on Live, capability`live.openre.slot.bind`, contracts 0.126.0, with this service's Network token for audience `openvibe.live`). The slotbecomes the stream's `live:managed_stream` ref (its label is the channel URL) and `mirror_to_live` turns on, so Live's@@@ -181,9 +181,12 @@ function createV1Router({ rt, auth, liveLink = null }) {if (!d) return;const b = req.body || {};// A person switching mirror_to_live on gets their Live slot first (it stays off, with the reason, when Live refuses).-- // A record-only stream is refused instead (openre.record_only): it never goes live.++ // So does a first-party service acting for the stream's owner (X-OV-Subject), at that person's request: Bot's++ // "Show this desk on my Live channel", a robot's Live switch. A stream that already names its Live slot (Live's++ // own) is never linked again. A record-only stream is refused instead (openre.record_only): it never goes live.const recordOnly = b.record_to !== undefined ? b.record_to === 'videos' : d.record_to === 'videos';-- const linkNow = liveLink && req.caller.kind === 'user' && b.mirror_to_live === true && !liveLink.slotOf(d) && !recordOnly;++ const forOwner = req.caller.kind === 'user' || (req.caller.kind === 'service' && !!req.caller.subject);++ const linkNow = liveLink && forOwner && b.mirror_to_live === true && !liveLink.slotOf(d) && !recordOnly;let updated = await store.definitions.update(d.id, {title: b.title, description: b.description, protocols: b.protocols, recording_mode: b.recording_mode,recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, mirror_to_live: linkNow ? undefined : b.mirror_to_live, state: b.state,@@@ -3,7 +3,8 @@* OpenVibe Live on by default (owner, 2026-10-09; contracts 0.126.0). A person's new stream asks Live for a slot on* their channel (POST /internal/openre/slots with OpenRestream's service token) unless they untick it; the slot becomes* the stream's live:managed_stream ref and mirror_to_live turns on. A refusal keeps the stream with Live off and says-- * why. Services (Live's own slots, Bot's robots) are never linked here. Switching it on later links once.++ * why. A service's new stream (Live's own slots, Bot's robots) is never linked here. Switching it on later links once,++ * by the person or by a service acting for them (X-OV-Subject), unless the stream already names its Live slot.*/const assert = require('assert');const { bootApi, request, userToken, serviceToken, suite, OWNER, OTHER } = require('./helpers');@@@ -122,6 +123,31 @@ t('the API links a person\'s stream by default, never a service\'s', async () =>assert.strictEqual(theirs.body.stream.mirror_to_live, false);});++t('a service acting for the owner switching it on links once (Bot: a desk on my Live channel); never a stream that names its slot', async () => {++ const bot = serviceToken('bot', ['openre.stream.write', 'openre.stream.read']);++ const made = await request(api.base, 'POST', '/api/v1/streams', { token: bot, headers: { 'X-OV-Subject': OWNER }, body: { title: 'desk-pc desk', mirror_to_live: false, playback_visibility: 'public', external_refs: [{ service: 'bot', type: 'machine_live', id: 'dev_01M5YGM39VC2RRGDQPRD6D4SHD' }] } });++ assert.strictEqual(made.status, 201, made.text);++ assert.strictEqual(made.body.live, undefined, 'never at create');++ const id = made.body.stream.id;++ let before = liveCalls.length;++ const on = await request(api.base, 'PATCH', `/api/v1/streams/${id}`, { token: bot, headers: { 'X-OV-Subject': OWNER }, body: { mirror_to_live: true } });++ assert.strictEqual(on.status, 200, on.text);++ assert.deepStrictEqual(on.body.live, { linked: true, channel_url: 'https://openvibe.live/@owner' });++ assert.strictEqual(on.body.stream.mirror_to_live, true);++ assert.strictEqual(liveCalls.length, before + 1);++ assert.strictEqual(liveCalls[liveCalls.length - 1].subject, OWNER, 'the owner\'s channel');++ await request(api.base, 'PATCH', `/api/v1/streams/${id}`, { token: bot, headers: { 'X-OV-Subject': OWNER }, body: { mirror_to_live: true } });++ assert.strictEqual(liveCalls.length, before + 1, 'already linked');++ // Live's own slot stream names its slot: switching mirror_to_live on never asks Live for another one.++ const live = serviceToken('live', ['openre.stream.write', 'openre.stream.read']);++ const slot = await request(api.base, 'POST', '/api/v1/streams', { token: live, headers: { 'X-OV-Subject': OWNER }, body: { title: 'A Live slot', external_refs: [{ service: 'live', type: 'managed_stream', id: '9002' }] } });++ before = liveCalls.length;++ const relink = await request(api.base, 'PATCH', `/api/v1/streams/${slot.body.stream.id}`, { token: live, headers: { 'X-OV-Subject': OWNER }, body: { mirror_to_live: true } });++ assert.strictEqual(relink.status, 200, relink.text);++ assert.strictEqual(relink.body.live, undefined);++ assert.strictEqual(liveCalls.length, before);++});++t('teardown', async () => { await api.close(); });t.run();
@@ -209,7 +209,9 @@ sudo deploy/scripts/deploy.sh status # workers.sh
@@ -209,7 +209,9 @@ sudo deploy/scripts/deploy.sh status # workers.sh
A stream someone creates on openre.stream also goes live on their OpenVibe Live channel unless they untick "Show it
A stream someone creates on openre.stream also goes live on their OpenVibe Live channel unless they untick "Show it
live on my OpenVibe Live channel too" (the create form, ticked by default; the API's `show_on_live: false` for a
live on my OpenVibe Live channel too" (the create form, ticked by default; the API's `show_on_live: false` for a
-person's own stream; services such as Live and Bot name their own refs and are never linked). `server/live-link.js`
+person's own stream; a service's new stream, such as Live's and Bot's, names its own refs and is not linked; switching
+`mirror_to_live` on later links it, by the person or by a service acting for them with X-OV-Subject, such as Bot's
+"Show this desk on my Live channel", unless the stream already names its Live slot). `server/live-link.js`
asks Live for a slot on the owner's channel bound to the stream (`POST /internal/openre/slots` on Live, capability
asks Live for a slot on the owner's channel bound to the stream (`POST /internal/openre/slots` on Live, capability
`live.openre.slot.bind`, contracts 0.126.0, with this service's Network token for audience `openvibe.live`). The slot
`live.openre.slot.bind`, contracts 0.126.0, with this service's Network token for audience `openvibe.live`). The slot
becomes the stream's `live:managed_stream` ref (its label is the channel URL) and `mirror_to_live` turns on, so Live's
becomes the stream's `live:managed_stream` ref (its label is the channel URL) and `mirror_to_live` turns on, so Live's
@@ -181,9 +181,12 @@ function createV1Router({ rt, auth, liveLink = null }) {
@@ -181,9 +181,12 @@ function createV1Router({ rt, auth, liveLink = null }) {
if (!d) return;
if (!d) return;
const b = req.body || {};
const b = req.body || {};
// A person switching mirror_to_live on gets their Live slot first (it stays off, with the reason, when Live refuses).
// A person switching mirror_to_live on gets their Live slot first (it stays off, with the reason, when Live refuses).
- // A record-only stream is refused instead (openre.record_only): it never goes live.
+ // So does a first-party service acting for the stream's owner (X-OV-Subject), at that person's request: Bot's
+ // "Show this desk on my Live channel", a robot's Live switch. A stream that already names its Live slot (Live's
+ // own) is never linked again. A record-only stream is refused instead (openre.record_only): it never goes live.
const recordOnly = b.record_to !== undefined ? b.record_to === 'videos' : d.record_to === 'videos';
const recordOnly = b.record_to !== undefined ? b.record_to === 'videos' : d.record_to === 'videos';
- const linkNow = liveLink && req.caller.kind === 'user' && b.mirror_to_live === true && !liveLink.slotOf(d) && !recordOnly;
+ const forOwner = req.caller.kind === 'user' || (req.caller.kind === 'service' && !!req.caller.subject);
+ const linkNow = liveLink && forOwner && b.mirror_to_live === true && !liveLink.slotOf(d) && !recordOnly;
let updated = await store.definitions.update(d.id, {
let updated = await store.definitions.update(d.id, {
title: b.title, description: b.description, protocols: b.protocols, recording_mode: b.recording_mode,
title: b.title, description: b.description, protocols: b.protocols, recording_mode: b.recording_mode,
recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, mirror_to_live: linkNow ? undefined : b.mirror_to_live, state: b.state,
recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, mirror_to_live: linkNow ? undefined : b.mirror_to_live, state: b.state,
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* OpenVibe Live on by default (owner, 2026-10-09; contracts 0.126.0). A person's new stream asks Live for a slot on
* OpenVibe Live on by default (owner, 2026-10-09; contracts 0.126.0). A person's new stream asks Live for a slot on
* their channel (POST /internal/openre/slots with OpenRestream's service token) unless they untick it; the slot becomes
* their channel (POST /internal/openre/slots with OpenRestream's service token) unless they untick it; the slot becomes
* the stream's live:managed_stream ref and mirror_to_live turns on. A refusal keeps the stream with Live off and says
* the stream's live:managed_stream ref and mirror_to_live turns on. A refusal keeps the stream with Live off and says
- * why. Services (Live's own slots, Bot's robots) are never linked here. Switching it on later links once.
+ * why. A service's new stream (Live's own slots, Bot's robots) is never linked here. Switching it on later links once,
+ * by the person or by a service acting for them (X-OV-Subject), unless the stream already names its Live slot.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { bootApi, request, userToken, serviceToken, suite, OWNER, OTHER } = require('./helpers');
const { bootApi, request, userToken, serviceToken, suite, OWNER, OTHER } = require('./helpers');
@@ -122,6 +123,31 @@ t('the API links a person\'s stream by default, never a service\'s', async () =>
@@ -122,6 +123,31 @@ t('the API links a person\'s stream by default, never a service\'s', async () =>
assert.strictEqual(theirs.body.stream.mirror_to_live, false);
assert.strictEqual(theirs.body.stream.mirror_to_live, false);
});
});
+t('a service acting for the owner switching it on links once (Bot: a desk on my Live channel); never a stream that names its slot', async () => {
+ const bot = serviceToken('bot', ['openre.stream.write', 'openre.stream.read']);
+ const made = await request(api.base, 'POST', '/api/v1/streams', { token: bot, headers: { 'X-OV-Subject': OWNER }, body: { title: 'desk-pc desk', mirror_to_live: false, playback_visibility: 'public', external_refs: [{ service: 'bot', type: 'machine_live', id: 'dev_01M5YGM39VC2RRGDQPRD6D4SHD' }] } });
+ assert.strictEqual(made.status, 201, made.text);
+ assert.strictEqual(made.body.live, undefined, 'never at create');
+ const id = made.body.stream.id;
+ let before = liveCalls.length;
+ const on = await request(api.base, 'PATCH', `/api/v1/streams/${id}`, { token: bot, headers: { 'X-OV-Subject': OWNER }, body: { mirror_to_live: true } });
+ assert.strictEqual(on.status, 200, on.text);
+ assert.deepStrictEqual(on.body.live, { linked: true, channel_url: 'https://openvibe.live/@owner' });
+ assert.strictEqual(on.body.stream.mirror_to_live, true);
+ assert.strictEqual(liveCalls.length, before + 1);
+ assert.strictEqual(liveCalls[liveCalls.length - 1].subject, OWNER, 'the owner\'s channel');
+ await request(api.base, 'PATCH', `/api/v1/streams/${id}`, { token: bot, headers: { 'X-OV-Subject': OWNER }, body: { mirror_to_live: true } });
+ assert.strictEqual(liveCalls.length, before + 1, 'already linked');
+ // Live's own slot stream names its slot: switching mirror_to_live on never asks Live for another one.
+ const live = serviceToken('live', ['openre.stream.write', 'openre.stream.read']);
+ const slot = await request(api.base, 'POST', '/api/v1/streams', { token: live, headers: { 'X-OV-Subject': OWNER }, body: { title: 'A Live slot', external_refs: [{ service: 'live', type: 'managed_stream', id: '9002' }] } });
+ before = liveCalls.length;
+ const relink = await request(api.base, 'PATCH', `/api/v1/streams/${slot.body.stream.id}`, { token: live, headers: { 'X-OV-Subject': OWNER }, body: { mirror_to_live: true } });
+ assert.strictEqual(relink.status, 200, relink.text);
+ assert.strictEqual(relink.body.live, undefined);
+ assert.strictEqual(liveCalls.length, before);
+});
+
t('teardown', async () => { await api.close(); });
t('teardown', async () => { await api.close(); });
t.run();
t.run();