openvibers/openvibe.network Commits

Link hub: the owner's machine switches, live screen, screen links and desks (#769)

OP OpenVibers committed 3 days ago · 8b6bcdd7ef · 9 files, +868 −20

* Link hub: the owner's machine switches, live screen, screen links and desks

The machine card on openvibe.link/machines now shows what Bot reports: Let my agent use this computer and Let my
watches check things from this computer (off unless Bot says on), Show my screen / Watch my screen with Stop and
Take over, the screen links with Revoke, and the machine's desks (Watch and take over, Stop, Show live on my
channel). Each control calls Bot through Network as the signed-in person; the switches and the screen are the
person's own session only, and another person's machine is 404.

The openvibe.link vhost now passes /api/v1/machines and /api/v1/machine-pools to Network, so the hub's calls stay
on this host's own session instead of leaving for the openvibe.network catch-all. The CSP allows the mediasoup
client and OpenRestream signaling for the live screen.

Checked: test/machines-hub.test.js (each control's Bot route, the person in X-OV-Subject, another person's machine
404, switches read from Bot), test/my-machines.test.js (the controls in the page), link-home (the vhost),
home-image-csp (the screen sources); ov test changed-file selection green; gitleaks clean.

* Use a glyph this site draws for Take over

fa-computer-mouse is not in openvibe-shared's glyphs, so no-font-awesome refused it; fa-keyboard is.

Changes

ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex 8a9dbe11..f489471e 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -202,6 +202,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "no-store" always;
}
++ # The machine hub on openvibe.link/machines is Network's page, so its API is Network's too, on the host-only
++ # session this host signs in with: the machines list, the owner's controls and the live screen, plus machine pools.
++ location ~ ^/api/v1/(machines|machine-pools)(/|$) {
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ add_header Cache-Control "no-store" always;
++ }
location ~ ^/(|install|add-computer(/complete)?|machines(/.*)?|pair|index\.md|send\.js|egress-settings\.js|locker\.mjs|llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|\.well-known/agent\.json)$ {
limit_except GET HEAD { deny all; }
proxy_pass http://127.0.0.1:4000;
ddiff --git a/public/css/my.css b/public/css/my.css
iindex 6765b649..6cef0a0b 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -698,3 +698,18 @@ html[data-ov-motion=off] .me-act:hover{transform: none}
.my-email-row { display: flex; gap: 8px; align-items: stretch; }
.my-email-row input { flex: 1; min-width: 0; }
#account-emails-message:empty { display: none; }
++
++/* The machine card's owner controls (my/machines.js, the Link hub): the two switches share .mc-role, the live
++ screen, its links and the machine's desks. Plain: the picture fills the card, a row wraps on a phone. */
++.mc-screen-block, .mc-shares, .mc-desks { display: grid; gap: 8px; min-width: 0; }
++.mc-screen { margin: 0; display: grid; gap: 8px; min-width: 0; border-radius: 10px; }
++.mc-screen:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++.mc-screen video { width: 100%; max-height: 70vh; border-radius: 10px; background: #000; object-fit: contain; }
++.mc-screen video[hidden] { display: none; }
++.mc-shares-list, .mc-desks-list { display: grid; gap: 6px; }
++.mc-share-row, .mc-desk-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card); }
++.mc-share-row small, .mc-desk-row small { display: block; color: var(--text-muted); font-size: 12px; overflow-wrap: anywhere; }
++.mc-desk-live { flex-basis: 100%; }
++.mc-desk-live summary { display: inline-block; cursor: pointer; }
++.mc-desk-live[open] { display: grid; gap: 8px; }
++.mc-takeover { margin-top: 4px; }
ddiff --git a/public/js/my/machines.js b/public/js/my/machines.js
iindex 4fac5dad..952b7ad9 100644
---- a/public/js/my/machines.js
++++ b/public/js/my/machines.js
@@@ -51,7 +51,7 @@ function mcCard(m) {
}
// The roles: a switch each; a change saves the whole set at once.
-- const roles = mcEl('fieldset', 'mc-roles', [mcEl('legend', '', ['What it does for you'])]);
++ const roles = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['What it does for you'])]);
(mcRoles || []).forEach((role) => {
const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;
const box = mcEl('input', '', [], { type: 'checkbox', id, value: role.id });
@@@ -61,6 +61,15 @@ function mcCard(m) {
});
card.append(roles);
++ const own = mcOwnerSwitches(m);
++ if (own) card.append(own);
++ const screen = mcScreenBlock(card, m);
++ if (screen) card.append(screen);
++ const links = mcSharesBlock(m);
++ if (links) card.append(links);
++ const desks = mcDesksBlock(card, m);
++ if (desks) card.append(desks);
++
const acts = mcEl('div', 'mc-acts');
const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });
rename.addEventListener('click', () => mcRename(card, m, name));
@@@ -71,18 +80,389 @@ function mcCard(m) {
return card;
}
++/* ── The owner's switches, the live screen, its links, and the machine's desks ───────────────────────────────
++ Everything here answers from what Bot reports (the machine's controls) and calls the hub's routes, which call
++ Bot as the person. A switch is the person's own click; an app's token cannot reach these routes (Network refuses
++ it), so nothing turns on what an agent may do by itself. */
++
++/** A labelled switch row: a checkbox, an icon, a bold label and the sentence that says what it allows. */
++function mcSwitchRow(id, label, note, checked, onChange) {
++ const box = mcEl('input', '', [], { type: 'checkbox', id });
++ box.checked = checked === true;
++ box.addEventListener('change', () => onChange(box));
++ return mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(id.endsWith('-agent') ? 'fa-robot' : 'fa-tower-broadcast')]),
++ mcEl('span', 'mc-role-t', [mcEl('b', '', [label]), mcEl('small', '', [note])])], { for: id });
++}
++
++/** The two switches the removed page had for the person's agent and watches, off unless Bot says they are on. */
++function mcOwnerSwitches(m) {
++ const c = m.controls || null;
++ if (!c || !c.computer) return null;
++ const field = mcEl('fieldset', 'mc-roles mc-switches', [mcEl('legend', '', ['For your agent and your watches'])]);
++ field.append(mcSwitchRow(`mc-${m.id}-agent`, 'Let my agent use this computer',
++ 'Your OpenVibe.Actor agent may use its terminal, files and screen as you, at the level set on the computer. Off until you turn it on.',
++ c.agent_computer, (box) => mcSaveSwitch(m, box, 'agent-computer', `Saved. ${m.name || 'The machine'} ${box.checked ? 'may be used by your agent.' : 'is off limits to your agent now.'}`)));
++ if (c.probe) {
++ field.append(mcSwitchRow(`mc-${m.id}-watch`, 'Let my watches check things from this computer',
++ 'OpenVibe.Watch may probe your own network from here for your watches: one GET or one TCP connection, to private addresses only.',
++ c.watch_probe, (box) => mcSaveSwitch(m, box, 'watch-probe', `Saved. Your watches ${box.checked ? 'may check from this computer.' : 'no longer check from here.'}`)));
++ }
++ return field;
++}
++
++async function mcSaveSwitch(m, box, route, done) {
++ const before = !box.checked;
++ box.disabled = true;
++ try {
++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/${route}`, { method: 'POST', body: JSON.stringify({ on: box.checked }) });
++ if (out && out.machine && out.machine.controls) m.controls = out.machine.controls;
++ mcSay(done);
++ } catch (e) {
++ box.checked = before;
++ mcSay(e.message || 'The switch could not be saved.', true);
++ } finally { box.disabled = false; }
++}
++
++/** The live screen: Show/Stop, ten minutes by Bot's default, the picture, and Take over. */
++function mcScreenBlock(card, m) {
++ const c = m.controls || null;
++ const screen = c && c.screen;
++ if (!screen || !screen.available) return null;
++ // Offline with nothing showing: there is no picture to start; the links below can still be revoked.
++ if (!m.online && !screen.live) return null;
++ const block = mcEl('div', 'mc-screen-block', [], { 'data-screen-block': m.id });
++ const acts = mcEl('div', 'mc-acts');
++ const show = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-display'), screen.live ? ' Watch my screen' : ' Show my screen'], { type: 'button' });
++ show.addEventListener('click', async () => {
++ show.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'POST', body: JSON.stringify({ desk: 'main' }) });
++ mcSay('Showing the screen: the picture appears in a few seconds.');
++ mcMountPlayer(card, block, m, 'main', true);
++ show.hidden = true;
++ } catch (e) { mcSay(e.message || 'The screen could not be shown.', true); }
++ finally { show.disabled = false; }
++ });
++ const stop = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-stop'), ' Stop showing my screen'], { type: 'button' });
++ stop.addEventListener('click', async () => {
++ stop.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'DELETE' });
++ mcUnmountPlayer(card);
++ mcSay('Your screen is no longer shown.');
++ show.hidden = false;
++ } catch (e) { mcSay(e.message || 'The screen could not be stopped.', true); }
++ finally { stop.disabled = false; }
++ });
++ acts.append(show, stop);
++ block.append(acts, mcEl('p', 'mc-confirm', ['Live to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.']));
++ // A screen already showing when the card was drawn: connect without starting a second one.
++ if (screen.live && m.online) mcMountPlayer(card, block, m, 'main', false);
++ return block;
++}
++
++/** The links made for this machine's screen: list them, and revoke one for good. */
++function mcSharesBlock(m) {
++ const c = m.controls || {};
++ if (!c.computer || !((c.screen && c.screen.available) || (c.desk && c.desk.available))) return null;
++ const block = mcEl('div', 'mc-shares');
++ const button = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-link'), ' Screen links'], { type: 'button' });
++ const list = mcEl('div', 'mc-shares-list');
++ list.hidden = true;
++ const draw = (shares) => {
++ if (!shares.length) {
++ list.replaceChildren(mcEl('p', 'mc-confirm', ['No screen links. Your agent makes one when it shows you a desk.']));
++ return;
++ }
++ list.replaceChildren(...shares.map((s) => {
++ const meta = ` · ${s.control ? 'watch and use' : 'watch only'}${s.desk && s.desk !== 'main' ? ` · ${s.desk}` : ''}${s.expires_at ? ` · until ${new Date(s.expires_at).toLocaleString()}` : ''}`;
++ const row = mcEl('div', 'mc-share-row', [mcEl('span', '', [mcEl('b', '', [s.label || 'A screen link']), mcEl('small', '', [meta])])]);
++ const revoke = mcEl('button', 'btn btn-small mc-revoke', ['Revoke'], { type: 'button' });
++ revoke.addEventListener('click', async () => {
++ revoke.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/shares/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
++ mcSay('The screen link is revoked.');
++ row.remove();
++ } catch (e) { mcSay(e.message || 'The link could not be revoked.', true); revoke.disabled = false; }
++ });
++ row.append(revoke);
++ return row;
++ }));
++ };
++ button.addEventListener('click', async () => {
++ button.disabled = true;
++ try {
++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/shares`);
++ draw((out && out.shares) || []);
++ list.hidden = false;
++ } catch (e) { mcSay(e.message || 'The screen links could not be read.', true); }
++ finally { button.disabled = false; }
++ });
++ block.append(button, list);
++ return block;
++}
++
++/** The desks running on this machine: watched/taken over, stopped, or put on the owner's Live channel. */
++function mcDesksBlock(card, m) {
++ const d = m.controls && m.controls.desk;
++ if (!d || !d.available || !m.online) return null;
++ const block = mcEl('div', 'mc-desks');
++ const button = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-display'), ' See its desks'], { type: 'button' });
++ const list = mcEl('div', 'mc-desks-list');
++ list.hidden = true;
++ const draw = (desks, maxDesks) => {
++ if (!desks.length) {
++ list.replaceChildren(mcEl('p', 'mc-confirm', ['No desks are running. An agent\'s task starts a desk of its own here, and stops it when it is done.']));
++ return;
++ }
++ list.replaceChildren(...desks.map((desk) => {
++ const meta = ` · ${desk.kind || 'desk'}${Number.isInteger(desk.width) ? ` · ${desk.width}×${desk.height}` : ''}`;
++ const row = mcEl('div', 'mc-desk-row', [mcEl('span', '', [mcEl('b', '', [desk.desk]), mcEl('small', '', [meta])])]);
++ const acts = mcEl('div', 'mc-acts');
++ const watch = mcEl('button', 'btn btn-small', ['Watch and take over'], { type: 'button' });
++ watch.addEventListener('click', async () => {
++ watch.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'POST', body: JSON.stringify({ desk: desk.desk }) });
++ mcMountPlayer(card, block, m, desk.desk, true);
++ mcSay(`Showing ${desk.desk}.`);
++ } catch (e) { mcSay(e.message || 'The desk could not be shown.', true); }
++ finally { watch.disabled = false; }
++ });
++ const stop = mcEl('button', 'btn btn-outline btn-small', ['Stop'], { type: 'button' });
++ stop.addEventListener('click', async () => {
++ stop.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks/${encodeURIComponent(desk.desk)}/stop`, { method: 'POST', body: '{}' });
++ mcSay(`${desk.desk} is stopped.`);
++ } catch (e) { mcSay(e.message || 'The desk could not be stopped.', true); stop.disabled = false; }
++ });
++ const live = mcEl('details', 'mc-desk-live', [mcEl('summary', 'btn btn-outline btn-small', ['Show live on my channel']),
++ mcEl('p', 'mc-confirm', ['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.']),
++ (() => {
++ const go = mcEl('button', 'btn btn-small mc-revoke', ['Go live with this desk'], { type: 'button' });
++ go.addEventListener('click', async () => {
++ go.disabled = true;
++ try {
++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks/${encodeURIComponent(desk.desk)}/live`, { method: 'POST', body: '{}' });
++ mcSay(out && out.channel_url ? `Live on ${out.channel_url} for up to 30 minutes.` : `${desk.desk} is live on your OpenVibe Live channel for up to 30 minutes.`);
++ if (out && out.channel_url) list.append(mcEl('p', 'mc-confirm', [mcEl('a', '', [out.channel_url], { href: out.channel_url })]));
++ } catch (e) { mcSay(e.message || 'The desk could not go live.', true); go.disabled = false; }
++ });
++ return go;
++ })()]);
++ acts.append(watch, stop, live);
++ row.append(acts);
++ return row;
++ }).concat(maxDesks ? [mcEl('p', 'mc-confirm', [`At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.`])] : []));
++ };
++ button.addEventListener('click', async () => {
++ button.disabled = true;
++ try {
++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks`);
++ draw((out && out.desks) || [], out && out.max_desks);
++ list.hidden = false;
++ } catch (e) { mcSay(e.message || 'The machine\'s desks could not be read.', true); }
++ finally { button.disabled = false; }
++ });
++ block.append(button, list);
++ return block;
++}
++
++/* ── The picture: OpenRestream's viewer signaling, the same mediasoup flow the Bot page used ───────────────── */
++
++const MC_MEDIASOUP_URL = 'https://esm.sh/[email protected]';
++let mcMediasoup = null;
++const mcLoadMediasoup = () => (mcMediasoup ||= import(MC_MEDIASOUP_URL).then(
++ (m) => m.Device || (m.default && m.default.Device),
++ (e) => { mcMediasoup = null; throw e; }));
++
++function mcViewerWait(ws, type, timeoutMs = 15000) {
++ return new Promise((resolve, reject) => {
++ if (ws.readyState !== WebSocket.OPEN) return reject(new Error('the screen signaling socket closed'));
++ let timer;
++ const done = (finish) => { clearTimeout(timer); ws.removeEventListener('message', onMessage); ws.removeEventListener('close', onClose); finish(); };
++ const onMessage = (e) => {
++ let m; try { m = JSON.parse(e.data); } catch { return; }
++ if (m.type === type) done(() => resolve(m));
++ else if (m.type === 'watch-queued') done(() => reject(new Error('nothing is publishing yet')));
++ else if (m.type === 'sfu-error' || m.type === 'sfu-viewer-error') done(() => reject(new Error(m.error || m.type)));
++ };
++ const onClose = () => done(() => reject(new Error('the screen signaling socket closed')));
++ timer = setTimeout(() => done(() => reject(new Error(`no ${type} from the screen`))), timeoutMs);
++ ws.addEventListener('message', onMessage);
++ ws.addEventListener('close', onClose);
++ });
++}
++
++/** Play a live screen's viewer URL (the mediasoup sfu-viewer flow) in `video`; stop() ends it for good. */
++function mcPlayStream(video, url, say) {
++ let current = null, timer = null, attempt = 0, stopped = false;
++ function teardown() {
++ const session = current; current = null;
++ video.srcObject = null;
++ if (!session) return;
++ for (const c of session.consumers) { try { c.close(); } catch { /* gone */ } }
++ if (session.transport) { try { session.transport.close(); } catch { /* gone */ } }
++ if (session.ws) { try { session.ws.close(); } catch { /* gone */ } }
++ }
++ function retry() {
++ if (timer || stopped) return;
++ video.hidden = true; teardown();
++ timer = setTimeout(() => { timer = null; connect(); }, Math.min(10000, 500 * 2 ** attempt++));
++ }
++ async function connect() {
++ const session = { ws: null, transport: null, consumers: [] };
++ const stale = () => current !== session;
++ current = session;
++ try {
++ const ws = new WebSocket(url);
++ session.ws = ws;
++ await new Promise((open, fail) => { ws.addEventListener('open', open, { once: true }); ws.addEventListener('close', () => fail(new Error('the screen signaling socket closed')), { once: true }); });
++ if (stale()) return;
++ ws.send(JSON.stringify({ type: 'watch' }));
++ const ready = await mcViewerWait(ws, 'sfu-viewer-ready');
++ if (stale()) return;
++ if (!ready.producers || !ready.producers.length) throw new Error('the screen has no live track yet');
++ const Device = await mcLoadMediasoup();
++ if (stale()) return;
++ const device = new Device();
++ await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
++ if (stale()) return;
++ ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
++ const created = await mcViewerWait(ws, 'sfu-viewer-transport-created');
++ if (stale()) return;
++ const transport = device.createRecvTransport({ id: created.id, iceParameters: created.iceParameters, iceCandidates: created.iceCandidates, dtlsParameters: created.dtlsParameters, iceServers: created.iceServers || [] });
++ session.transport = transport;
++ transport.on('connect', ({ dtlsParameters }, cb, errb) => {
++ if (stale()) return errb(new Error('the screen session is gone'));
++ ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
++ mcViewerWait(ws, 'sfu-viewer-transport-connected').then(() => cb(), () => errb(new Error('the screen transport did not connect')));
++ });
++ const media = new MediaStream();
++ for (const producer of ready.producers) {
++ ws.send(JSON.stringify({ type: 'sfu-viewer-consume', transportId: transport.id, producerId: producer.id, rtpCapabilities: device.rtpCapabilities }));
++ const consumed = await mcViewerWait(ws, 'sfu-viewer-consumed');
++ if (stale()) return;
++ const consumer = await transport.consume({ id: consumed.id, producerId: consumed.producerId, kind: consumed.kind, rtpParameters: consumed.rtpParameters });
++ session.consumers.push(consumer);
++ media.addTrack(consumer.track);
++ }
++ if (stale()) return;
++ video.srcObject = media;
++ video.hidden = false;
++ say('Live.');
++ attempt = 0;
++ await video.play().catch(() => { /* autoplay holds the track anyway */ });
++ ws.addEventListener('close', () => { if (!stale()) retry(); });
++ } catch (e) { if (!stale()) retry(); }
++ }
++ connect();
++ return { stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null; } teardown(); video.hidden = true; } };
++}
++
++/** Mount one player on the card (stopping any other) and poll the screen's state for its viewer URL. */
++function mcMountPlayer(card, block, m, desk, takeOver) {
++ mcUnmountPlayer(card);
++ const figure = mcEl('figure', 'mc-screen', [], { tabindex: '0', 'data-desk': desk });
++ const video = mcEl('video', '', [], { muted: '', autoplay: '', playsinline: '' });
++ video.hidden = true;
++ const status = mcEl('p', 'mc-confirm', ['Waiting for the machine…'], { role: 'status' });
++ const use = mcEl('input', '', [], { type: 'checkbox', id: `mc-${m.id}-${desk.replace(/\W/g, '-')}-use` });
++ const useRow = mcEl('label', 'mc-role mc-takeover', [use, mcEl('span', 'mc-role-ic', [mcIcon('fa-keyboard')]),
++ mcEl('span', 'mc-role-t', [mcEl('b', '', ['Use the mouse and keyboard']), mcEl('small', '', [desk === 'main' ? 'Clicks and keys on the picture go to the computer.' : 'While you use it, the agent waits on this desk.'])])], { for: use.getAttribute('id') });
++ const stop = mcEl('button', 'btn btn-outline btn-small', ['Stop showing it'], { type: 'button' });
++ let player = null, timer = null, stopped = false;
++ const say = (t) => { status.textContent = t || ''; };
++ const poll = async () => {
++ if (stopped) return;
++ let s = null;
++ try { s = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`); } catch (e) { say(e.message || 'The screen could not be read.'); }
++ if (s && s.live && s.signaling_url && !player) { say('Connecting to the screen…'); player = mcPlayStream(video, s.signaling_url, say); }
++ else if (player && s && s.state === 'off') { player.stop(); player = null; say('The screen is no longer shown.'); }
++ else if (!player) say(s && s.online === false ? 'The machine is offline.' : 'Waiting for the machine…');
++ if (!stopped) timer = setTimeout(poll, player ? 5000 : 1500);
++ };
++ stop.addEventListener('click', async () => {
++ stop.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'DELETE' });
++ say('Stopped.');
++ } catch (e) { say(e.message || 'The screen could not be stopped.'); }
++ stopped = true; clearTimeout(timer);
++ if (player) { player.stop(); player = null; }
++ stop.disabled = false;
++ });
++ mcAttachTakeOver(figure, video, m, use);
++ figure.append(video, status, useRow, stop);
++ block.append(figure);
++ card._mcPlayer = { stop() { stopped = true; clearTimeout(timer); if (player) player.stop(); }, figure, use };
++ poll();
++ if (takeOver) figure.focus();
++ return card._mcPlayer;
++}
++
++function mcUnmountPlayer(card) {
++ const p = card && card._mcPlayer;
++ if (!p) return;
++ p.stop();
++ if (p.figure && p.figure.parent) p.figure.remove();
++ card._mcPlayer = null;
++}
++
++/** The owner's mouse and keyboard on the picture, as the removed page's "Take over": positions in picture pixels. */
++function mcAttachTakeOver(figure, video, m, use) {
++ const send = (value) => apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen/input`, { method: 'POST', body: JSON.stringify(value) }).catch((e) => mcSay(e.message || 'The computer did not do it.', true));
++ const at = (ev) => {
++ if (!video.videoWidth) return null;
++ const r = video.getBoundingClientRect();
++ const scale = Math.min(r.width / video.videoWidth, r.height / video.videoHeight);
++ const ox = (r.width - video.videoWidth * scale) / 2, oy = (r.height - video.videoHeight * scale) / 2;
++ const x = Math.round((ev.clientX - r.left - ox) / scale), y = Math.round((ev.clientY - r.top - oy) / scale);
++ return x < 0 || y < 0 || x > video.videoWidth || y > video.videoHeight ? null : { x, y, width: video.videoWidth, height: video.videoHeight };
++ };
++ const on = () => !!(use && use.checked);
++ let clickTimer = null;
++ figure.addEventListener('click', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); clearTimeout(clickTimer); clickTimer = setTimeout(() => send({ action: 'click', ...p }), 220); });
++ figure.addEventListener('dblclick', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); clearTimeout(clickTimer); send({ action: 'double_click', ...p }); });
++ figure.addEventListener('contextmenu', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); send({ action: 'right_click', ...p }); });
++ let wheel = 0, wheelAt = null, wheelTimer = null;
++ figure.addEventListener('wheel', (ev) => {
++ if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault();
++ wheel += ev.deltaY; wheelAt = p; clearTimeout(wheelTimer);
++ wheelTimer = setTimeout(() => { const dy = Math.max(-20, Math.min(20, Math.round(wheel / 60))); wheel = 0; if (dy) send({ action: 'scroll', ...wheelAt, dy }); }, 150);
++ }, { passive: false });
++ const NAMES = { Enter: 'Return', Backspace: 'BackSpace', Tab: 'Tab', Escape: 'Escape', Delete: 'Delete', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', Home: 'Home', End: 'End', PageUp: 'Prior', PageDown: 'Next', Insert: 'Insert', ' ': 'space' };
++ let text = '', textTimer = null;
++ const flush = () => { if (text) { const t = text; text = ''; send({ action: 'type', text: t }); } };
++ figure.addEventListener('keydown', (ev) => {
++ if (!on()) return;
++ const mods = [ev.ctrlKey && 'ctrl', ev.altKey && 'alt', ev.metaKey && 'super'].filter(Boolean);
++ if (['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) return;
++ ev.preventDefault();
++ if (!mods.length && ev.key.length === 1) { text += ev.key; clearTimeout(textTimer); textTimer = setTimeout(flush, 120); return; }
++ flush();
++ const name = NAMES[ev.key] || (/^F\d{1,2}$/.test(ev.key) ? ev.key : (ev.key.length === 1 ? ev.key.toLowerCase() : null));
++ if (!name || !/^[A-Za-z0-9_]+$/.test(name)) return;
++ if (ev.shiftKey && mods.length) mods.push('shift');
++ send({ action: 'key', keys: [...mods, name].join('+') });
++ });
++}
++
++
async function mcSaveRoles(card, m) {
-- const roles = [...card.querySelectorAll('.mc-role input')].filter((b) => b.checked).map((b) => b.value);
-- card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = true; });
++ const roles = [...card.querySelectorAll('.mc-role-set input')].filter((b) => b.checked).map((b) => b.value);
++ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = true; });
try {
const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles }) });
m.roles = (out && out.machine && out.machine.roles) || roles;
mcSay(`Saved. ${m.name || 'The machine'} picks this up at its next check-in.`);
} catch (e) {
mcSay(e.message || 'The roles could not be saved.', true);
-- card.querySelectorAll('.mc-role input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });
++ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });
} finally {
-- card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = false; });
++ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = false; });
}
}
ddiff --git a/server/index.js b/server/index.js
iindex 5a09ad1a..812dc6d4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -124,7 +124,9 @@ app.use(helmet({
defaultSrc: ["'self'"],
// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may
// measure performance; script-src loads the beacon, connect-src is where it reports.
-- scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com"],
++ scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com",
++ // The Link hub's live screen (/machines): mediasoup-client's pinned ESM build, the same one the Bot page loads.
++ "https://esm.sh"],
styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com", "fonts.gstatic.com"],
fontSrc: ["'self'", "fonts.gstatic.com"],
// Avatars and media live on openvibe.media; the home page's live-stream thumbnails come from openvibe.live and
@@@ -146,6 +148,8 @@ app.use(helmet({
// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17)
"https://openvibe.events",
"https://openvibe.link", // Link's page on its own origin
++ // The machine's live screen on the Link hub: OpenRestream's viewer signaling socket.
++ "wss://ingest.openre.stream",
],
frameSrc: ["'none'"],
scriptSrcAttr: ["'unsafe-inline'"],
ddiff --git a/server/registry/machines.js b/server/registry/machines.js
iindex 367d7acd..e0c675ca 100644
---- a/server/registry/machines.js
++++ b/server/registry/machines.js
@@@ -13,12 +13,22 @@
* PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)
* PUT /api/v1/machines/:id/roles network.machines.manage → { machine } (the whole set)
* POST /api/v1/machines/:id/revoke network.machines.manage → { machine }
++ * POST /api/v1/machines/:id/agent-computer, /watch-probe → { machine } (the owner's own session)
++ * POST /api/v1/machines/:id/screen network.machines.manage → 201 { machine_id, … } (the live screen)
++ * GET /api/v1/machines/:id/screen network.machines.read → { live, signaling_url, … }
++ * DELETE /api/v1/machines/:id/screen network.machines.manage → { stopped }
++ * POST /api/v1/machines/:id/screen/input → { done } (mouse and keyboard)
++ * GET /api/v1/machines/:id/desks network.machines.read → { desks, max_desks }
++ * POST /api/v1/machines/:id/desks/:desk/stop, /live → { stopped } / 201 the screen
++ * GET /api/v1/machines/:id/shares network.machines.read → { shares } (screen links)
++ * DELETE /api/v1/machines/:id/shares/:share → { revoked }
* POST /internal/machines/status network.link.manage → 202 (Bot's report)
*
* Who may call: the person, with their own session; or an app holding the capability with the person's
* consent — the token names the person in on_behalf_of, and the person's grant for the capability must still be
* approved. A machine that is not that person's is not there at all (404, never 403), so nobody can probe whose
-- * machine an id is.
++ * machine an id is. The owner's switches, the live screen and its links, and the desks are their own session only:
++ * no app or run token turns on what an agent may do with the computer, opens its screen or hands out its desks.
*
* The events (through OpenVibe.Events, server/developer/event-relay.js): network.machine.online and
* .offline (Bot's report of a machine's state, once per change), .paired (the machine redeemed its code),
@@@ -103,6 +113,32 @@ function machineOf(m) {
if (typeof m.last_seen_at === 'string' && m.last_seen_at) out.last_seen_at = m.last_seen_at;
if (typeof m.node_version === 'string' && m.node_version) out.node_version = m.node_version;
if (typeof m.node_id === 'string' && PRINCIPAL.test(m.node_id)) out.principal_id = m.node_id;
++ // The owner's controls (the Link hub's machine card): each switch the machine itself reported, plus what its
++ // computer plugin offers. The values are Bot's, never an assumption: a machine that has not said is off. Only
++ // the shape is narrowed here (no file roots, tools or paths), as with the rest of the hub's view.
++ const computer = m.computer && typeof m.computer === 'object' ? m.computer : null;
++ out.controls = {
++ agent_computer: m.agent_computer === true,
++ watch_probe: m.watch_probe === true,
++ computer: !!computer,
++ ...(computer ? {
++ level: typeof computer.level === 'string' ? computer.level : null,
++ probe: !!(computer.probe && typeof computer.probe === 'object'),
++ screen: {
++ available: !!(computer.screen && computer.screen.stream === true),
++ live: computer.screen_stream === 'live',
++ },
++ desk: computer.desk && typeof computer.desk === 'object' ? {
++ available: true,
++ screen: computer.desk.screen === true,
++ named: computer.desk.named === true,
++ sandboxed: computer.desk.isolation === 'sandbox',
++ running: Number.isInteger(computer.desks_running) ? computer.desks_running : null,
++ ...(Number.isInteger(computer.desk.max_desks) ? { max: computer.desk.max_desks } : {}),
++ ...(Array.isArray(computer.desk.kinds) ? { kinds: computer.desk.kinds.filter((k) => typeof k === 'string').slice(0, 5) } : {}),
++ } : { available: false },
++ } : {}),
++ };
return out;
}
@@@ -157,6 +193,19 @@ function createBotClient({ baseUrl, privateKey, issuer, fetchImpl = globalThis.f
roles: (subject, id, roles) => call('PUT', `/api/v1/machines/${encodeURIComponent(id)}/roles`, { roles }, subject),
revoke: (subject, id) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/revoke`, {}, subject),
pairing: (subject) => call('POST', '/api/v1/machines/pairings', {}, subject),
++ // The owner's switches and the live screen (the Link hub's machine card). Bot still owns every check:
++ // a machine that is not the person's is 404 before any of these do anything.
++ agentComputer: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/agent-computer`, { on: !!on }, subject),
++ watchProbe: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/watch-probe`, { on: !!on }, subject),
++ screenStart: (subject, id, body) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/screen`, body || {}, subject),
++ screenState: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),
++ screenStop: (subject, id) => call('DELETE', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),
++ screenInput: (subject, id, value) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/screen/input`, value || {}, subject),
++ desks: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/desks`, null, subject),
++ deskStop: (subject, id, desk) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/desks/${encodeURIComponent(desk)}/stop`, {}, subject),
++ deskLive: (subject, id, desk) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/desks/${encodeURIComponent(desk)}/live`, {}, subject),
++ shares: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/shares`, null, subject),
++ revokeShare: (subject, id, share) => call('DELETE', `/api/v1/machines/${encodeURIComponent(id)}/shares/${encodeURIComponent(share)}`, null, subject),
};
}
@@@ -285,7 +334,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const ownSession = (req, res, next) => {
if (req.hub) {
return http.sendProblem(res, 403, 'capability.owner_denied', {
-- detail: 'pair a machine and choose its roles from your own session on openvibe.network',
++ detail: 'only your own session on openvibe.network changes what a machine does',
});
}
return next();
@@@ -426,6 +475,158 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
res.json({ machine });
}));
++ // ── The owner's own controls (the Link hub's machine card) ─────────────────────────────────────
++ // Bot owns every check (the machine is the person's own, 404 otherwise; a switch is the owner's). The hub
++ // answers only from the person's own session: an app or a run acting for them may read and rename and revoke,
++ // but it may not turn on what an agent may do with the computer, show its screen, or hand its desks out.
++ const booleanOn = (req) => {
++ const on = (req.body || {}).on;
++ if (typeof on !== 'boolean') throw new MachineError(422, 'registry.invalid_switch', 'on is true or false');
++ return on;
++ };
++ hub.post('/:id/agent-computer', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const on = booleanOn(req);
++ const out = await bot.agentComputer(subject, machineId(req), on);
++ res.json({ machine: machineOf(out && out.machine) });
++ }));
++ hub.post('/:id/watch-probe', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const on = booleanOn(req);
++ const out = await bot.watchProbe(subject, machineId(req), on);
++ res.json({ machine: machineOf(out && out.machine) });
++ }));
++
++ // The live screen, to the owner only: Start (10 minutes by Bot's default), the state the page polls, Stop, and
++ // the owner's own mouse and keyboard while it is shown. `signaling_url` is a viewer credential, so it never
++ // leaves the person's own session.
++ const DESK = /^(main|private|desk-[a-z0-9][a-z0-9-]{0,23})$/;
++ const screenOf = (id, out) => {
++ const desk = DESK.test(String((out && out.desk) || '')) ? String(out.desk) : 'main';
++ const channel = out && out.live && typeof out.live.channel_url === 'string' && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(out.live.channel_url)
++ ? out.live.channel_url : null;
++ return {
++ machine_id: id,
++ streaming: !!(out && out.streaming === true),
++ seconds: Number.isInteger(out && out.seconds) ? out.seconds : null,
++ until: typeof (out && out.until) === 'string' ? out.until : null,
++ desk,
++ ...(channel ? { channel_url: channel } : {}),
++ };
++ };
++ hub.post('/:id/screen', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const body = req.body || {};
++ const desk = body.desk == null || body.desk === '' ? 'main' : String(body.desk);
++ if (!DESK.test(desk)) throw new MachineError(422, 'registry.invalid_desk', 'desk is main, private or desk-<name>');
++ const out = await bot.screenStart(subject, id, { desk });
++ res.status(201).json(screenOf(id, out));
++ }));
++ hub.get('/:id/screen', read, ownSession, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const out = (await bot.screenState(subject, id)) || {};
++ const url = typeof out.signaling_url === 'string' && /^wss?:\/\//.test(out.signaling_url) ? out.signaling_url : null;
++ res.json({
++ machine_id: id, live: !!url, signaling_url: url,
++ state: out.state === 'live' ? 'live' : 'off', online: out.online === true,
++ desk: DESK.test(String(out.desk || '')) ? out.desk : 'main',
++ });
++ }));
++ hub.delete('/:id/screen', manage, ownSession, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const out = await bot.screenStop(subject, id);
++ res.json({ machine_id: id, stopped: !!(out && out.stopped === true) });
++ }));
++ hub.post('/:id/screen/input', manage, ownSession, express.json({ limit: '4kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const out = await bot.screenInput(subject, id, req.body && typeof req.body === 'object' ? req.body : {});
++ res.json({ machine_id: id, done: !!(out && out.done === true), desk: DESK.test(String((out && out.desk) || '')) ? out.desk : 'main' });
++ }));
++
++ // The desks running on the machine: read on demand (never polled), then stopped or put on the owner's Live
++ // channel. Watching one is the screen route with its name.
++ const deskOf = (req) => {
++ const desk = String((req.params && req.params.desk) || '');
++ if (!DESK.test(desk) || desk === 'main') throw new MachineError(422, 'registry.invalid_desk', 'desk is private or desk-<name>');
++ return desk;
++ };
++ hub.get('/:id/desks', read, ownSession, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const out = (await bot.desks(subject, id)) || {};
++ const desks = (Array.isArray(out.desks) ? out.desks : []).flatMap((d) => {
++ const desk = d && DESK.test(String(d.desk || '')) && d.desk !== 'main' ? String(d.desk) : null;
++ if (!desk) return [];
++ return [{
++ desk,
++ ...(typeof d.kind === 'string' ? { kind: d.kind.slice(0, 20) } : {}),
++ ...(Number.isInteger(d.since) ? { since: d.since } : {}),
++ ...(Number.isInteger(d.width) ? { width: d.width } : {}),
++ ...(Number.isInteger(d.height) ? { height: d.height } : {}),
++ }];
++ }).slice(0, 50);
++ res.json({ machine_id: id, desks, max_desks: Number.isInteger(out.max_desks) ? out.max_desks : null });
++ }));
++ hub.post('/:id/desks/:desk/stop', manage, ownSession, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const desk = deskOf(req);
++ await bot.deskStop(subject, id, desk);
++ res.json({ machine_id: id, stopped: desk });
++ }));
++ hub.post('/:id/desks/:desk/live', manage, ownSession, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const desk = deskOf(req);
++ const out = await bot.deskLive(subject, id, desk);
++ res.status(201).json(screenOf(id, { ...(out || {}), desk }));
++ }));
++
++ // The screen links the owner made (their agent makes them through Bot's watch-links route): listed and revoked,
++ // never shown again. Only the person in their own session sees the list.
++ hub.get('/:id/shares', read, ownSession, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const out = (await bot.shares(subject, id)) || {};
++ const shares = (Array.isArray(out.shares) ? out.shares : []).flatMap((s) => {
++ const share = s && typeof s.id === 'string' && /^shr_[0-9A-Za-z]{1,64}$/.test(s.id) ? s.id : null;
++ if (!share) return [];
++ return [{
++ id: share,
++ ...(typeof s.label === 'string' && s.label ? { label: s.label.slice(0, 80) } : {}),
++ control: s.control === true,
++ desk: DESK.test(String(s.desk || '')) ? s.desk : 'main',
++ ...(typeof s.created_at === 'string' ? { created_at: s.created_at } : {}),
++ ...(typeof s.expires_at === 'string' ? { expires_at: s.expires_at } : {}),
++ ...(typeof s.last_used_at === 'string' ? { last_used_at: s.last_used_at } : {}),
++ }];
++ });
++ res.json({ machine_id: id, shares });
++ }));
++ hub.delete('/:id/shares/:share', manage, ownSession, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const share = String((req.params && req.params.share) || '');
++ if (!/^shr_[0-9A-Za-z]{1,64}$/.test(share)) throw new MachineError(404, 'registry.share_not_found', 'there is no such screen link');
++ const out = await bot.revokeShare(subject, id, share);
++ res.json({ machine_id: id, revoked: !!(out && out.revoked === true) });
++ }));
++
return { hub, compute };
}
ddiff --git a/test/home-image-csp.test.js b/test/home-image-csp.test.js
iindex d415ddad..cac62050 100644
---- a/test/home-image-csp.test.js
++++ b/test/home-image-csp.test.js
@@@ -14,7 +14,12 @@ const { bootServer } = require('./helpers/boot-server');
for (const origin of ['https://openvibe.media', 'https://openvibe.live', 'https://openvibe.community']) {
assert.ok(img.includes(origin), `img-src lets ${origin} through: ${img.join(' ')}`);
}
-- console.log('home image CSP: media, live and community pictures allowed');
++ // The machine hub's live screen (/machines) imports mediasoup-client's pinned ESM build and opens
++ // OpenRestream's viewer signaling socket: exactly those two, in the two directives that gate them.
++ const directive = (name) => csp.split(';').map((d) => d.trim()).find((d) => d.startsWith(name)) || '';
++ assert.ok(directive('script-src').includes('https://esm.sh'), 'script-src lets the mediasoup client through');
++ assert.ok(directive('connect-src').includes('wss://ingest.openre.stream'), 'connect-src lets the screen signaling through');
++ console.log('CSP: home images, and the machine hub\'s live screen sources');
await srv.stop();
process.exit(0);
} finally {
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex faefa5d9..5f64128d 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -68,6 +68,9 @@ const SHAPED = {
assert.ok(vhost.includes('add-computer(/complete)?'), 'the add-computer return flow reaches Link before the openvibe.network catch-all');
const sendApi = vhost.slice(vhost.indexOf('location ~ ^/api/v1/me/nodes/(send-keys|send)$ {'));
assert.ok(sendApi.length < vhost.length && /client_max_body_size 40m;/.test(sendApi.slice(0, sendApi.indexOf('\n }'))), 'the Link Send API has its own location with a body limit that fits a file');
++ // …the machine hub's own API (the machines list, the owner's controls, the live screen, pools), which the page
++ // asks for on this host: without it those calls left for openvibe.network's catch-all and the hub showed nothing.
++ assert.ok(/location ~ \^\/api\/v1\/\(machines\|machine-pools\)\(\/\|\$\) \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(vhost), 'the machine hub API is proxied on openvibe.link');
// …and /auth/* to Link's own sign-in (Network#664), or openvibe.link never signs anyone in (11-08 audit).
const linkBlock = vhost.slice(vhost.indexOf(' server_name openvibe.link;'));
assert.ok(/location \^~ \/auth\/ \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(linkBlock.slice(0, linkBlock.indexOf('\n}\n'))), 'the openvibe.link vhost proxies /auth/ to Network');
ddiff --git a/test/machines-hub.test.js b/test/machines-hub.test.js
iindex 2debf637..cd0d607c 100644
---- a/test/machines-hub.test.js
++++ b/test/machines-hub.test.js
@@@ -38,7 +38,20 @@ const bob = await mk('hubbob');
// ── The fake Bot: the registry underneath. It answers per person, and it records every call. ──
const calls = [];
const owned = new Map(); // subject → [machine]
--const machineOf = (id) => ({ id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null });
++const flags = new Map(); // machine id → the owner's switches and the screen state the fake Link reports
++const stateOf = (id) => flags.get(id) || {};
++const machineOf = (id) => {
++ const s = stateOf(id);
++ return {
++ id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null,
++ agent_computer: s.agent_computer === true, watch_probe: s.watch_probe === true,
++ computer: {
++ level: 'ask', terminal: { level: 'ask' }, files: { level: 'ask' }, screen: { level: 'ask', stream: true },
++ probe: { level: 'observe' }, desk: { level: 'ask', max_desks: 2, kinds: ['browser'], isolation: 'sandbox', screen: true, named: true },
++ waiting: 0, desks_running: 0, screen_stream: s.screen_live ? 'live' : 'off',
++ },
++ };
++};
const botServer = http.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
@@@ -47,26 +60,73 @@ const botServer = http.createServer(async (req, res) => {
calls.push({ method: req.method, url: req.url, subject, body, auth: String(req.headers.authorization || '') });
const answer = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const list = () => (owned.get(subject) || []).map(machineOf);
++ const mineOf = (id) => list().find((m) => m.id === id);
++ const setFlag = (id, key, value) => { flags.set(id, { ...stateOf(id), [key]: value }); return machineOf(id); };
if (req.method === 'GET' && req.url === '/api/v1/machines') return answer(200, { machines: list() });
-- if (req.method === 'GET' && req.url.startsWith('/api/v1/machines/')) {
-- const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
-- const mine = list().find((m) => m.id === id);
-- return mine ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });
-- }
if (req.method === 'POST' && req.url === '/api/v1/machines/pairings') {
return answer(201, { pairing: { code: 'ABCD-EFGH', expires_at: new Date(Date.now() + 600000).toISOString(), pairing_id: `pair_${ids.ulid()}`, installer: 'curl -fsSL https://openvibe.link/install | sh -s -- --pairing pair_1 --code ABCD-EFGH --worker' } });
}
++ // The owner's own controls (the card): each as Bot checks it, the machine's own or 404.
++ let m;
++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/(agent-computer|watch-probe)$/.exec(req.url))) {
++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
++ const key = m[2] === 'agent-computer' ? 'agent_computer' : 'watch_probe';
++ if (typeof body.on !== 'boolean') return answer(422, { code: 'bot.invalid_input' });
++ return answer(200, { machine: setFlag(decodeURIComponent(m[1]), key, body.on) });
++ }
++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {
++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
++ return answer(201, { machine_id: decodeURIComponent(m[1]), streaming: true, seconds: 600, fps: 2, until: new Date(Date.now() + 600000).toISOString(), desk: body.desk || 'main' });
++ }
++ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {
++ const id = decodeURIComponent(m[1]);
++ if (!mineOf(id)) return answer(404, { code: 'bot.device_not_found' });
++ const live = stateOf(id).screen_live === true;
++ return answer(200, { machine_id: id, live, signaling_url: live ? 'wss://ingest.openre.stream/w/abc' : null, state: live ? 'live' : 'off', online: true, desk: 'main' });
++ }
++ if (req.method === 'DELETE' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {
++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
++ setFlag(decodeURIComponent(m[1]), 'screen_live', false);
++ return answer(200, { stopped: true });
++ }
++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen\/input$/.exec(req.url))) {
++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
++ return answer(200, { done: true, desk: 'main' });
++ }
++ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/desks$/.exec(req.url))) {
++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
++ return answer(200, { machine_id: decodeURIComponent(m[1]), desks: [{ desk: 'desk-a', kind: 'browser', since: 1700000000, width: 1280, height: 720 }], max_desks: 2 });
++ }
++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/desks\/([^/]+)\/(stop|live)$/.exec(req.url))) {
++ const id = decodeURIComponent(m[1]);
++ if (!mineOf(id)) return answer(404, { code: 'bot.device_not_found' });
++ if (m[3] === 'stop') return answer(200, { stopped: decodeURIComponent(m[2]) });
++ setFlag(id, 'screen_live', true);
++ return answer(201, { machine_id: id, streaming: true, seconds: 1800, until: new Date(Date.now() + 1800000).toISOString(), desk: decodeURIComponent(m[2]), live: { channel_url: 'https://openvibe.live/@ana' } });
++ }
++ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/shares$/.exec(req.url))) {
++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
++ return answer(200, { machine_id: decodeURIComponent(m[1]), shares: [{ id: 'shr_abc', label: 'Staff room', control: false, desk: 'main', created_at: new Date().toISOString(), expires_at: new Date(Date.now() + 86400000).toISOString() }] });
++ }
++ if (req.method === 'DELETE' && (m = /^\/api\/v1\/machines\/([^/]+)\/shares\/([^/]+)$/.exec(req.url))) {
++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
++ return answer(200, { revoked: decodeURIComponent(m[2]) === 'shr_abc' });
++ }
if (req.method === 'PATCH' && req.url.startsWith('/api/v1/machines/')) {
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
-- const m = machineOf(id); m.name = body.name; return answer(200, { machine: m });
++ const row = machineOf(id); row.name = body.name; return answer(200, { machine: row });
}
if (req.method === 'PUT' && req.url.endsWith('/roles')) {
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/roles'.length));
-- const m = machineOf(id); m.roles = body.roles; return answer(200, { machine: m });
++ const row = machineOf(id); row.roles = body.roles; return answer(200, { machine: row });
}
if (req.method === 'POST' && req.url.endsWith('/revoke')) {
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/revoke'.length));
-- const m = machineOf(id); m.online = false; return answer(200, { machine: m });
++ const row = machineOf(id); row.online = false; return answer(200, { machine: row });
++ }
++ if (req.method === 'GET' && req.url.startsWith('/api/v1/machines/')) {
++ const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
++ return mineOf(id) ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });
}
answer(404, { code: 'bot.not_found' });
});
@@@ -180,6 +240,106 @@ try {
const botCalls = calls.filter((c) => c.method !== 'GET');
for (const c of botCalls) assert.strictEqual(c.subject, ana.subject_id, 'every change reaches Bot as the person');
++ // ── The owner's controls on the machine card: Bot's report is the switch's state, and each control is a call
++ // to Bot as the person. Nothing here is assumed on: the fake Link has both switches off until asked. ──
++ const card = (await call('GET', '/api/v1/machines', { user: ana })).body.machines[0];
++ assert.deepStrictEqual([card.controls.agent_computer, card.controls.watch_probe], [false, false], 'off because Bot said off');
++ assert.deepStrictEqual(card.controls.screen, { available: true, live: false }, 'the screen it may show, not shown');
++ assert.strictEqual(card.controls.computer, true);
++ assert.strictEqual(card.controls.probe, true);
++ assert.strictEqual(card.controls.desk.available, true);
++
++ const agentOn = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: true } });
++ assert.strictEqual(agentOn.status, 200, JSON.stringify(agentOn.body));
++ assert.strictEqual(agentOn.body.machine.controls.agent_computer, true);
++ let sent = calls.at(-1);
++ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/agent-computer`, ana.subject_id, { on: true }]);
++ assert.strictEqual((await call('GET', '/api/v1/machines', { user: ana })).body.machines[0].controls.agent_computer, true, 'the switch now reads on from Bot');
++ const agentOff = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: false } });
++ assert.strictEqual(agentOff.body.machine.controls.agent_computer, false);
++
++ const watchOn = await call('POST', `/api/v1/machines/${anaMachine}/watch-probe`, { user: ana, body: { on: true } });
++ assert.strictEqual(watchOn.status, 200, JSON.stringify(watchOn.body));
++ assert.strictEqual(watchOn.body.machine.controls.watch_probe, true);
++ sent = calls.at(-1);
++ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/watch-probe`, ana.subject_id, { on: true }]);
++ // A body that is not true/false never reaches Bot.
++ const beforeBadSwitch = calls.length;
++ const badSwitch = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: 'yes' } });
++ assert.strictEqual(badSwitch.status, 422, JSON.stringify(badSwitch.body));
++ assert.strictEqual(calls.length, beforeBadSwitch, 'Bot never saw it');
++
++ // The live screen: 10 minutes by Bot's default (the hub sends no seconds), the state, input, stop.
++ const started = await call('POST', `/api/v1/machines/${anaMachine}/screen`, { user: ana, body: { desk: 'main' } });
++ assert.strictEqual(started.status, 201, JSON.stringify(started.body));
++ assert.deepStrictEqual([started.body.streaming, started.body.seconds, started.body.desk], [true, 600, 'main']);
++ sent = calls.at(-1);
++ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/screen`, ana.subject_id, { desk: 'main' }]);
++ const state = await call('GET', `/api/v1/machines/${anaMachine}/screen`, { user: ana });
++ assert.strictEqual(state.status, 200, JSON.stringify(state.body));
++ assert.deepStrictEqual([state.body.live, state.body.state], [false, 'off']);
++ const input = await call('POST', `/api/v1/machines/${anaMachine}/screen/input`, { user: ana, body: { action: 'click', x: 1, y: 2, width: 100, height: 100 } });
++ assert.strictEqual(input.status, 200, JSON.stringify(input.body));
++ assert.strictEqual(input.body.done, true);
++ assert.strictEqual(calls.at(-1).url, `/api/v1/machines/${anaMachine}/screen/input`);
++ const stopped = await call('DELETE', `/api/v1/machines/${anaMachine}/screen`, { user: ana });
++ assert.deepStrictEqual([stopped.status, stopped.body.stopped], [200, true]);
++
++ // The screen links: listed without their token, then revoked by id.
++ const shares = await call('GET', `/api/v1/machines/${anaMachine}/shares`, { user: ana });
++ assert.strictEqual(shares.status, 200, JSON.stringify(shares.body));
++ assert.deepStrictEqual(shares.body.shares.map((s) => [s.id, s.label, s.control, s.desk]), [['shr_abc', 'Staff room', false, 'main']]);
++ assert.ok(!JSON.stringify(shares.body).includes('token'), 'never the token');
++ const revokeShare = await call('DELETE', `/api/v1/machines/${anaMachine}/shares/shr_abc`, { user: ana });
++ assert.deepStrictEqual([revokeShare.status, revokeShare.body.revoked], [200, true]);
++ assert.strictEqual(calls.at(-1).url, `/api/v1/machines/${anaMachine}/shares/shr_abc`);
++
++ // The desks: read on demand, stopped, and put on the person's Live channel for 30 minutes.
++ const desks = await call('GET', `/api/v1/machines/${anaMachine}/desks`, { user: ana });
++ assert.strictEqual(desks.status, 200, JSON.stringify(desks.body));
++ assert.deepStrictEqual(desks.body.desks.map((d) => [d.desk, d.kind, d.width, d.height]), [['desk-a', 'browser', 1280, 720]]);
++ assert.strictEqual(desks.body.max_desks, 2);
++ const deskStop = await call('POST', `/api/v1/machines/${anaMachine}/desks/desk-a/stop`, { user: ana, body: {} });
++ assert.deepStrictEqual([deskStop.status, deskStop.body.stopped], [200, 'desk-a']);
++ const live = await call('POST', `/api/v1/machines/${anaMachine}/desks/desk-a/live`, { user: ana, body: {} });
++ assert.strictEqual(live.status, 201, JSON.stringify(live.body));
++ assert.deepStrictEqual([live.body.desk, live.body.seconds, live.body.channel_url], ['desk-a', 1800, 'https://openvibe.live/@ana']);
++ const afterLive = await call('GET', `/api/v1/machines/${anaMachine}/screen`, { user: ana });
++ assert.deepStrictEqual([afterLive.body.live, afterLive.body.state, afterLive.body.signaling_url], [true, 'live', 'wss://ingest.openre.stream/w/abc']);
++
++ // Another person's machine is not there for any of them (404, never 403).
++ const otherPaths = [
++ ['POST', `/api/v1/machines/${bobMachine}/agent-computer`, { on: true }],
++ ['POST', `/api/v1/machines/${bobMachine}/watch-probe`, { on: true }],
++ ['POST', `/api/v1/machines/${bobMachine}/screen`, { desk: 'main' }],
++ ['GET', `/api/v1/machines/${bobMachine}/screen`],
++ ['GET', `/api/v1/machines/${bobMachine}/desks`],
++ ['GET', `/api/v1/machines/${bobMachine}/shares`],
++ ];
++ for (const [method, url, body] of otherPaths) {
++ const theirs = await call(method, url, { user: ana, body });
++ assert.strictEqual(theirs.status, 404, `${method} ${url}: ${JSON.stringify(theirs.body)}`);
++ assert.strictEqual(theirs.body.code, 'bot.device_not_found');
++ }
++
++ // An app or a run acting for the person may not reach the owner's own controls: only their own session does.
++ const beforeDelegatedControls = calls.length;
++ for (const [name, tokenFor] of [['an app', appToken], ['a run', runToken]]) {
++ for (const [method, url, body, cap] of [
++ ['POST', `/api/v1/machines/${anaMachine}/agent-computer`, { on: true }, 'network.machines.manage'],
++ ['POST', `/api/v1/machines/${anaMachine}/watch-probe`, { on: true }, 'network.machines.manage'],
++ ['POST', `/api/v1/machines/${anaMachine}/screen`, { desk: 'main' }, 'network.machines.manage'],
++ ['GET', `/api/v1/machines/${anaMachine}/screen`, undefined, 'network.machines.read'],
++ ['GET', `/api/v1/machines/${anaMachine}/desks`, undefined, 'network.machines.read'],
++ ['GET', `/api/v1/machines/${anaMachine}/shares`, undefined, 'network.machines.read'],
++ ]) {
++ const refused = await call(method, url, { token: tokenFor(cap), body });
++ assert.strictEqual(refused.status, 403, `${name} ${method} ${url}: ${JSON.stringify(refused.body)}`);
++ assert.strictEqual(refused.body.code, 'capability.owner_denied');
++ }
++ }
++ assert.strictEqual(calls.length, beforeDelegatedControls, 'Bot was not called for a delegated control');
++
// ── A role outside the catalogue is refused, and Bot never sees it. ──
const before = calls.length;
const bad = await call('PUT', `/api/v1/machines/${bobMachine}/roles`, { user: ana, body: { roles: ['nope.nope'] } });
@@@ -210,7 +370,7 @@ try {
// ── Pairing a machine and choosing its roles are the person's own session, and nothing else. ──
// A token that acts for the person (an app's, or a run's) may read, rename and revoke; it may never add a
// machine or a role: a machine paired that way would carry the person's jobs and their desks.
-- const OWN_SESSION = 'pair a machine and choose its roles from your own session on openvibe.network';
++ const OWN_SESSION = 'only your own session on openvibe.network changes what a machine does';
const beforeDelegated = calls.length;
for (const [name, token] of [['an app', appToken('network.machines.manage')], ['a run', runToken('network.machines.manage')]]) {
const pairing = await call('POST', '/api/v1/machines/pairings', { token, body: {} });
ddiff --git a/test/my-machines.test.js b/test/my-machines.test.js
iindex dda28d1f..74581d39 100644
---- a/test/my-machines.test.js
++++ b/test/my-machines.test.js
@@@ -21,15 +21,17 @@ class Node {
append(...kids) { for (const k of kids) { const n = typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k; n.parent = this; this.children.push(n); } }
replaceChildren(...kids) { this.children = []; this.append(...kids); }
replaceWith(n) { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); p.children[i] = n; n.parent = p; }
++ remove() { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); if (i >= 0) p.children.splice(i, 1); }
get childNodes() { return this.children.slice(); }
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
fire(t, ev = {}) { (this.listeners[t] || []).forEach((fn) => fn(ev)); }
-- focus() {} select() {}
++ focus() {} select() {} play() { return Promise.resolve(); }
get classList() { const n = this; return { toggle: (c, on) => { n.className = on ? `${n.className} ${c}` : n.className.replace(c, ''); }, contains: (c) => n.className.split(' ').includes(c) }; }
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
set textContent(v) { this._text = String(v); this.children = []; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
querySelectorAll(sel) {
++ if (sel === '.mc-role-set input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role' && n.parent.parent && n.parent.parent.className.split(' ').includes('mc-role-set'));
if (sel === '.mc-role input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role');
return [];
}
@@@ -45,6 +47,7 @@ function page({ answers }) {
console, Date, Number, String, Set, Math, JSON, Promise, URLSearchParams, location: { search: '' },
navigator: { clipboard: { writeText: async () => {} } },
setInterval: (fn) => { timers.push(fn); return timers.length; }, clearInterval: () => { timers = []; },
++ setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout: () => {},
apiFetch: async (p, o = {}) => { calls.push([o.method || 'GET', p, o.body ? JSON.parse(o.body) : null]); const a = answers(o.method || 'GET', p, o.body ? JSON.parse(o.body) : null); if (a instanceof Error) throw a; return a; },
};
vm.runInNewContext(SRC, sandbox);
@@@ -109,6 +112,71 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
await flush();
assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_A/revoke'), 'revoked on the second');
++ // ── The owner's controls (the removed page's): two switches that read Bot's state, the live screen, its
++ // links, and the machine's desks. Each is a call to Network's hub route, which calls Bot as the person. ──
++ const switchMachine = {
++ id: 'dev_C', kind: 'onboard', online: true, name: 'Desk PC', roles: [], running: [],
++ controls: {
++ computer: true, agent_computer: true, watch_probe: false, level: 'ask', probe: true,
++ screen: { available: true, live: false },
++ desk: { available: true, screen: true, named: true, running: 1, max: 2, kinds: ['browser'] },
++ },
++ };
++ p = page({ answers: (m, url, body) => {
++ if (url === '/api/v1/machines/roles') return { roles: ROLES };
++ if (m === 'GET' && url === '/api/v1/machines') return { machines: [switchMachine] };
++ if (m === 'POST' && /\/agent-computer$/.test(url)) { switchMachine.controls.agent_computer = body.on; return { machine: switchMachine }; }
++ if (m === 'POST' && /\/watch-probe$/.test(url)) { switchMachine.controls.watch_probe = body.on; return { machine: switchMachine }; }
++ if (m === 'POST' && /\/screen$/.test(url)) return { machine_id: 'dev_C', streaming: true, seconds: 600, desk: body.desk };
++ if (m === 'GET' && /\/screen$/.test(url)) return { machine_id: 'dev_C', live: false, state: 'off', online: true };
++ if (m === 'DELETE' && /\/screen$/.test(url)) return { stopped: true };
++ if (m === 'GET' && /\/shares$/.test(url)) return { machine_id: 'dev_C', shares: [{ id: 'shr_abc', label: 'Staff room', control: false, desk: 'main' }] };
++ if (m === 'DELETE' && /\/shares\/shr_abc$/.test(url)) return { revoked: true };
++ if (m === 'GET' && /\/desks$/.test(url)) return { machine_id: 'dev_C', desks: [{ desk: 'desk-a', kind: 'browser', width: 1280, height: 720 }], max_desks: 2 };
++ if (m === 'POST' && /\/desks\/desk-a\/stop$/.test(url)) return { stopped: 'desk-a' };
++ if (m === 'POST' && /\/desks\/desk-a\/live$/.test(url)) return { machine_id: 'dev_C', desk: 'desk-a', seconds: 1800, channel_url: 'https://openvibe.live/@ana' };
++ return {};
++ } });
++ await p.sandbox.loadMachines();
++ const card = p.ids['mc-list'].children[0];
++ assert.match(card.textContent, /Let my agent use this computer/);
++ assert.match(card.textContent, /Let my watches check things from this computer/);
++ const switches = card.all((n) => n.tagName === 'INPUT' && /-(agent|watch)$/.test(n.getAttribute('id') || ''));
++ assert.deepStrictEqual(switches.map((b) => b.checked), [true, false], 'the switches start from Bot\'s own state');
++ switches[0].checked = false; switches[0].fire('change');
++ await flush();
++ assert.deepStrictEqual(p.calls.find((c) => /\/agent-computer$/.test(c[1])), ['POST', '/api/v1/machines/dev_C/agent-computer', { on: false }], 'the agent switch saves on the person\'s click');
++
++ const button = (text) => card.all((n) => n.tagName === 'BUTTON' && new RegExp(text).test(n.textContent))[0];
++ button('Show my screen').fire('click');
++ await flush();
++ assert.deepStrictEqual(p.calls.find((c) => c[0] === 'POST' && /\/screen$/.test(c[1])), ['POST', '/api/v1/machines/dev_C/screen', { desk: 'main' }], 'ten minutes by the server\'s default');
++ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/screen$/.test(c[1])), 'the picture waits for the machine to publish');
++ button('Stop showing my screen').fire('click');
++ await flush();
++ assert.ok(p.calls.some((c) => c[0] === 'DELETE' && /\/screen$/.test(c[1])), 'Stop reaches the machine');
++
++ button('Screen links').fire('click');
++ await flush();
++ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/shares$/.test(c[1])));
++ assert.match(card.textContent, /Staff room/);
++ const shareRevoke = card.all((n) => n.tagName === 'BUTTON' && n.parent && n.parent.className === 'mc-share-row')[0];
++ shareRevoke.fire('click');
++ await flush();
++ assert.ok(p.calls.some((c) => c[0] === 'DELETE' && c[1] === '/api/v1/machines/dev_C/shares/shr_abc'), 'revoking one link');
++
++ button('See its desks').fire('click');
++ await flush();
++ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/desks$/.test(c[1])));
++ assert.match(card.textContent, /desk-a.*browser.*1280×720/);
++ card.all((n) => n.tagName === 'BUTTON' && n.textContent === 'Stop')[0].fire('click');
++ await flush();
++ assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_C/desks/desk-a/stop'), 'a desk stops from here');
++ button('Go live with this desk').fire('click');
++ await flush();
++ assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_C/desks/desk-a/live'), 'the Live channel takes the desk with its warning first');
++ assert.match(card.textContent, /openvibe\.live\/@ana/);
++
// ── Pairing: the code and the command, then the machine arriving ──
machines = [];
p = page({ answers: (m, url) => {