openvibers/openvibe.bot Commits

A desk on your OpenVibe Live channel, when you choose (Desks plan D8): a public stream of the machine's own, linked to your channel through OpenRestream, for up to 30 minutes; never your own screen, never the private screen stream (#196)

OP OpenVibers committed 17 days ago · f98f28aa1e · 10 files, +132 −8

Changes

ddiff --git a/public/panel.css b/public/panel.css
iindex 6d9959d..71e1429 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -217,6 +217,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
++/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */
++.desk-live { flex-basis: 100%; }
++.desk-live > summary { list-style: none; display: inline-flex; }
++.desk-live > summary::-webkit-details-marker { display: none; }
++.desk-live form { display: grid; gap: 0.5rem; margin-top: 0.5rem; max-width: 36rem; }
++.desk-live form p { margin: 0; }
@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }
.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }
.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex dcb6263..7bfce81 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -489,7 +489,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
};
r.post('/machines/:id/screen', manage, wrap(async (req, res) => {
const { device, subject } = await screenOwner(req);
-- const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk });
++ const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk, live: req.body?.live === true });
res.status(201).json({ machine_id: device.id, ...out });
}));
r.get('/machines/:id/screen', wrap(async (req, res) => {
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex f2dd906..7dfc59d 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -720,6 +720,41 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key) };
}
++ /**
++ * A desk shown on the owner's OpenVibe Live channel, at their request (the Desks plan's D8) → { streamId, key, whipUrl,
++ * live: { linked, channel_url?, code?, reason? } }. A second stream of the machine's (external ref
++ * bot:machine_live:<id>), public, never the private screen stream: switching its mirror_to_live on has OpenRestream
++ * link it to a slot on their channel (once; Live then shows each session at openvibe.live/@them). Each start rotates
++ * the key. Throws 503 without OpenRestream or a WHIP ingest.
++ */
++ async function liveScreenKey(device, owner) {
++ if (!openre || !whipUrl('x')) fail(503, 'bot.openre_unavailable', 'OpenRestream is not configured here, so a desk cannot go live');
++ const found = await openre.find(`bot:machine_live:${device.id}`, owner);
++ let out = null;
++ if (found && found.id) {
++ const r = await openre.rotate(found.id, owner, { grace_seconds: 0, end_sessions: true });
++ out = r && { streamId: found.id, key: r.key };
++ }
++ if (!out) {
++ const label = String(device.name || device.id).slice(0, 100);
++ const created = await openre.create({
++ title: `${label}: my agent at work`, protocols: ['webrtc'], recording_mode: 'none', recording_visibility: 'public',
++ playback_visibility: 'public', mirror_to_live: false, show_on_live: false,
++ external_refs: [{ service: 'bot', type: 'machine_live', id: device.id, label }],
++ }, owner);
++ out = created && created.stream && { streamId: created.stream.id, key: created.key };
++ }
++ if (!out || typeof out.streamId !== 'string' || !out.key || typeof out.key.key !== 'string' || !out.key.key) {
++ fail(503, 'bot.openre_unavailable', 'OpenRestream answered no ingest key for the desk');
++ }
++ const shown = await openre.showOnLive(out.streamId, owner);
++ const slot = shown && shown.stream && (shown.stream.external_refs || []).find((r) => r && r.service === 'live' && r.type === 'managed_stream');
++ const channel = (shown && shown.live && shown.live.channel_url) || (slot && slot.label) || null;
++ const live = shown && shown.live && shown.live.linked === false
++ ? { linked: false, code: shown.live.code || null, reason: shown.live.reason || null }
++ : { linked: !!(shown && shown.stream && shown.stream.mirror_to_live && channel), channel_url: channel };
++ return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key), live };
++ }
/** The viewer signaling URL of the machine's open screen session, as its owner, or null (none is live yet). */
async function screenLive(device, owner) {
if (!openre || !device.screen_stream_id) return null;
@@@ -1253,7 +1288,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,
-- screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
++ screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
ddiff --git a/server/machine-screen.js b/server/machine-screen.js
iindex ffe576f..fd5f44c 100644
---- a/server/machine-screen.js
++++ b/server/machine-screen.js
@@@ -78,7 +78,14 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {
throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');
}
-- const key = await domain.machines.screen.key(device, subject);
++ // On the owner's OpenVibe Live channel (the Desks plan's D8): a desk only, never their own screen, on a public
++ // stream of its own that OpenRestream links to their channel; refused with Live's reason when it cannot be.
++ const live = opts.live === true;
++ if (live && desk === 'main') throw new BotError(422, 'bot.live_desk_only', 'only a desk goes on your Live channel, never your own screen');
++ const key = live ? await domain.machines.screen.liveKey(device, subject) : await domain.machines.screen.key(device, subject);
++ if (live && !key.live.linked) {
++ throw new BotError(409, key.live.code || 'bot.live_refused', key.live.reason || 'OpenVibe Live did not take the desk: sign in to openvibe.live once, then try again');
++ }
const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk !== 'main' ? { desk } : {}) });
if (r.result !== 'ack') {
const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');
@@@ -88,7 +95,7 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
const data = r.data && typeof r.data === 'object' ? r.data : {};
const until = Number.isFinite(data.until_ms) ? new Date(data.until_ms).toISOString() : new Date(now() + seconds * 1000).toISOString();
shown.set(device.id, desk);
-- return { streaming: true, seconds, fps, until, desk };
++ return { streaming: true, seconds, fps, until, desk, ...(live ? { live: { channel_url: key.live.channel_url } } : {}) };
},
async state(device, subject) {
const sensors = hub ? ((hub.deviceState(device.id).telemetry || {}).sensors || {}) : {};
ddiff --git a/server/openre/client.js b/server/openre/client.js
iindex 34130f5..997debb 100644
---- a/server/openre/client.js
++++ b/server/openre/client.js
@@@ -19,6 +19,8 @@
* get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404
* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
* update(id, fields, owner) PATCH /api/v1/streams/:id → the updated stream, or null on 404
++ * showOnLive(id, owner) PATCH /api/v1/streams/:id { mirror_to_live: true } as the owner: OpenRestream
++ * links it to a slot on their Live channel once → { stream, live? }, or null on 404
* rotate(id, owner, { grace_seconds, end_sessions })
* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
* archive(id, owner) DELETE /api/v1/streams/:id (OpenRestream refuses a live stream: 409)
@@@ -119,6 +121,10 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
const out = await call('PATCH', stream(id), { owner, body, named: true });
return (out && out.stream) || null;
},
++ async showOnLive(id, owner) {
++ const out = await call('PATCH', stream(id), { owner, body: { mirror_to_live: true }, named: true });
++ return out && out.stream ? { stream: out.stream, live: out.live || null } : null;
++ },
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 75b41d0..3445db9 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -871,7 +871,7 @@ ${control ? `<div class="share-controls">
* /machines/:id/desks: the desks running on the owner's machine (Desks plan), each a computer of its own for an agent:
* its kind, size and start, Watch and take over (the screen page on that desk), and Stop.
*/
--function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null }) {
++function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null, live = null }) {
const name = machine.name || machine.id;
const id = esc(machine.id);
const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };
@@@ -884,7 +884,12 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
const rows = desks.map((d) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span><code>${esc(d.desk)}</code></span></span>`
+ `<span class="robot-meta"><span class="pill">${esc(KIND[d.kind] || 'Desk')}</span>${Number.isInteger(d.width) ? ` <span class="muted">${d.width}×${d.height}</span>` : ''}${since(d)}</span>`
+ `<span class="robot-actions"><a class="button" href="/machines/${id}/screen?desk=${encodeURIComponent(d.desk)}">Watch and take over</a>`
-- + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span></li>`).join('');
++ + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span>`
++ // On the owner's OpenVibe Live channel, when they choose (the Desks plan's D8): a second step says what goes public.
++ + `<details class="desk-live"><summary class="button quiet">Show live on my channel</summary>`
++ + `<form method="post" action="/machines/${id}/desks/live"><input type="hidden" name="desk" value="${esc(d.desk)}">`
++ + `<p class="muted">Everyone can watch this desk on your OpenVibe Live channel for up to 30 minutes: what your agent does and every page it opens, signed-in pages included. Stop showing it on its screen page, or stop the desk.</p>`
++ + `<button type="submit" class="button primary">Go live with this desk</button></form></details></li>`).join('');
const list = error ? `<p class="error" role="alert">${esc(error)}</p>`
: rows ? `<ul class="robots">${rows}</ul>`
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;
@@@ -893,6 +898,7 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
<h1>${esc(name)}: desks</h1>
<p class="muted">Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.</p>
${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}
++${live ? `<p class="setting-ok" role="status">${icon('check')}<span><code>${esc(live.desk)}</code> is live${live.channel ? ` on <a href="${esc(live.channel)}">${esc(live.channel.replace(/^https:\/\//, ''))}</a>` : ' on your OpenVibe Live channel'} for up to 30 minutes. <a href="/machines/${id}/screen?desk=${encodeURIComponent(live.desk)}">Its screen page</a> stops it.</span></p>` : ''}
${list}
${Number.isInteger(maxDesks) ? `<p class="muted small">At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.</p>` : ''}
</main>`;
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 3388cad..e037b0d 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -676,7 +676,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
} else error = `The machine did not list its desks: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300);
}
const stopped = typeof req.query.stopped === 'string' && machineScreen.DESK.test(req.query.stopped) ? req.query.stopped : null;
-- res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped }));
++ const liveDesk = typeof req.query.live === 'string' && machineScreen.DESK.test(req.query.live) && req.query.live !== 'main' ? req.query.live : null;
++ const channel = typeof req.query.channel === 'string' && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(req.query.channel) ? req.query.channel : null;
++ res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped, live: liveDesk ? { desk: liveDesk, channel } : null }));
++ }));
++ // A desk on the owner's OpenVibe Live channel for up to 30 minutes (the Desks plan's D8), from the desks page's
++ // "Show live on my channel" and its confirmation.
++ r.post('/machines/:id/desks/live', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
++ const mine = await myMachine(req, me);
++ const desk = req.body && typeof req.body.desk === 'string' ? req.body.desk : '';
++ if (!machineScreen.DESK.test(desk) || desk === 'main') throw new BotError(422, 'bot.invalid_input', 'desk is private or desk-<name>');
++ const out = await machineScreen.start(mine, me.subject, asUser(me), { desk, live: true, seconds: machineScreen.limits.SECONDS.max });
++ const channel = out.live && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(String(out.live.channel_url || '')) ? out.live.channel_url : '';
++ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?live=${encodeURIComponent(desk)}${channel ? `&channel=${encodeURIComponent(channel)}` : ''}`);
}));
r.post('/machines/:id/desks/stop', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 846cff7..dbcd8c6 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -194,6 +194,8 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
const streams = new Map();
const sessions = new Map();
const calls = [];
++ const noLive = new Set(); // owners with no OpenVibe Live account (a Live link is refused)
++ let slotSeq = 40;
let failure = null;
let failuresLeft = 0; // how many further requests failNext answers before it clears
let n = 0;
@@@ -311,6 +313,14 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
if (st && !m[2] && req.method === 'PATCH') {
// Only the streaming toggles are writable here, as OpenRestream's PATCH accepts: the rest stays put.
if (body && body.recording_mode !== undefined) st.recording_mode = body.recording_mode;
++ // Switching mirror_to_live on links a slot on the owner's Live channel once, as OpenRestream does for the
++ // person or a service acting for them (OpenRestream#117); an owner in noLive has no Live account.
++ if (body && body.mirror_to_live === true && !st.refs.some((r) => r.service === 'live')) {
++ if (noLive.has(st.owner)) return send(res, 200, { stream: view(st), live: { linked: false, code: 'live.no_account', reason: 'You have no OpenVibe Live channel yet. Sign in to openvibe.live once, then switch this on again.' } });
++ st.refs.push({ service: 'live', type: 'managed_stream', id: String(++slotSeq), label: `https://openvibe.live/@${st.owner.slice(-6).toLowerCase()}` });
++ st.mirror_to_live = true;
++ return send(res, 200, { stream: view(st), live: { linked: true, channel_url: st.refs.at(-1).label } });
++ }
if (body && body.mirror_to_live !== undefined) st.mirror_to_live = !!body.mirror_to_live;
return send(res, 200, { stream: view(st) });
}
@@@ -333,7 +343,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
});
const url = await listen(server);
return {
-- url, token, streams, sessions, calls, admits, goLive, endLive,
++ url, token, streams, sessions, calls, admits, goLive, endLive, noLive,
failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },
setCaps: (list) => { granted = [...(list || caps)]; },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex 3145ab5..0236a72 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -386,6 +386,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(html.includes('<code>desk-task-a</code>') && html.includes('Browser desk') && html.includes('Whole desk'));
assert.ok(html.includes(`href="/machines/${host.deviceId}/screen?desk=desk-task-a"`), 'Watch and take over that desk');
assert.ok(html.includes('name="desk" value="desk-task-a"'), 'its Stop');
++ assert.ok(html.includes(`action="/machines/${host.deviceId}/desks/live"`) && html.includes('Show live on my channel') && html.includes('signed-in pages included'), 'and, a second step away, live on the owner\'s channel (Desks plan D8)');
assert.ok(html.includes('At most 4 whole desks at once here'));
assert.strictEqual((await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(bob) })).status, 404, 'nobody else');
const stop = (body, headers = {}) => fetch(`${t.base}/machines/${host.deviceId}/desks/stop`, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex), ...headers } });
ddiff --git a/test/machine-screen.test.js b/test/machine-screen.test.js
iindex caf0a98..f020019 100644
---- a/test/machine-screen.test.js
++++ b/test/machine-screen.test.js
@@@ -229,6 +229,45 @@ const { boot, check, done } = require('./helpers/app');
m.c.close();
});
++ await check('a desk on the owner\'s OpenVibe Live channel (Desks plan D8): a public stream of its own, linked to their channel; never their own screen; Live\'s refusal said', async () => {
++ const host = await machine(alex, { ...SCREEN, desk: { screen: true, named: true, kinds: ['desktop', 'browser'] } });
++ const privateBefore = [...t.openre.streams.values()].filter((x) => x.refs.some((r) => r.type === 'machine' && r.id === host.deviceId)).map((x) => [x.id, x.mirror_to_live, x.playback_visibility]);
++ assert.strictEqual((await screen('POST', host.deviceId, { user: alex, body: { live: true } })).json.code, 'bot.live_desk_only', 'never the person\'s own screen');
++ const r = await screen('POST', host.deviceId, { user: alex, body: { desk: 'desk-task-a', live: true, seconds: 1800 } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.match(r.json.live.channel_url, /^https:\/\/openvibe\.live\/@/);
++ const sent = seen.at(-1);
++ assert.deepStrictEqual([sent.kind, sent.value.action, sent.value.desk, sent.value.seconds], ['screen_stream', 'start', 'desk-task-a', 1800]);
++ const live = [...t.openre.streams.values()].find((x) => x.refs.some((ref) => ref.type === 'machine_live' && ref.id === host.deviceId));
++ assert.ok(live, 'a stream of its own for Live');
++ assert.deepStrictEqual([live.playback_visibility, live.mirror_to_live, live.refs.some((ref) => ref.service === 'live')], ['public', true, true], 'public and linked to the channel');
++ assert.ok(t.openre.admits(sent.value.publish_key) && live.keys.some((k) => k.key === sent.value.publish_key && k.status === 'active'), 'the machine publishes to it');
++ const privateAfter = [...t.openre.streams.values()].filter((x) => x.refs.some((r) => r.type === 'machine' && r.id === host.deviceId)).map((x) => [x.id, x.mirror_to_live, x.playback_visibility]);
++ assert.deepStrictEqual(privateAfter, privateBefore, 'the private screen stream is never touched');
++ // Again: the same stream, a new key, linked once.
++ const again = await screen('POST', host.deviceId, { user: alex, body: { desk: 'desk-task-a', live: true } });
++ assert.strictEqual(again.json.live.channel_url, r.json.live.channel_url);
++ assert.strictEqual([...t.openre.streams.values()].filter((x) => x.refs.some((ref) => ref.type === 'machine_live' && ref.id === host.deviceId)).length, 1);
++ // From the desks page: the second step posts the desk, and the page says where it is live.
++ assert.strictEqual((await form(`/machines/${host.deviceId}/desks/live`, alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
++ const posted = await fetch(`${t.base}/machines/${host.deviceId}/desks/live`, { method: 'POST', redirect: 'manual', body: 'desk=desk-task-a', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
++ assert.strictEqual(posted.status, 303);
++ const where = posted.headers.get('location');
++ assert.match(where, /\/desks\?live=desk-task-a&channel=https%3A%2F%2Fopenvibe\.live%2F%40/);
++ const page = await (await fetch(t.base + where, { headers: cookie(alex) })).text();
++ assert.ok(page.includes('<code>desk-task-a</code> is live on <a href="https://openvibe.live/@'), 'the page says where');
++ // Without a Live account: Live's reason, and nothing is sent to the machine.
++ const bobs = await machine(bob, { ...SCREEN, desk: { screen: true, named: true } });
++ t.openre.noLive.add(bob.subject);
++ const before = seen.length;
++ const refused = await screen('POST', bobs.deviceId, { user: bob, body: { desk: 'desk-task-b', live: true } });
++ assert.deepStrictEqual([refused.status, refused.json.code], [409, 'live.no_account'], refused.text);
++ assert.match(refused.json.detail || '', /Sign in to openvibe\.live once/);
++ assert.strictEqual(seen.length, before, 'nothing went to the machine');
++ host.c.close();
++ bobs.c.close();
++ });
++
await check('removing the machine archives its screen stream', async () => {
const r = await form(`/machines/${mine.deviceId}/remove`, alex);
assert.strictEqual(r.status, 303);