openvibers/openvibe.chat Commits

Start shared computer parties on openvibe.party (#416)

OP OpenVibers committed 5 days ago · 1146f52285 · 12 files, +405 −27

* Start shared computer parties on openvibe.party

* Merge origin/main into party desktop branch

* Show stopped party desktop state

* Keep party TURN credentials valid for share lifetime

* Explain unavailable OpenVibe party computers on start screen

* Party home: where the shared computer runs is two plain choices, not a dropdown

Changes

ddiff --git a/.env.example b/.env.example
iindex 0df6b7b..1a5afb0 100644
---- a/.env.example
++++ b/.env.example
@@@ -43,6 +43,8 @@ OV_OAUTH_CLIENT_SECRET=written-by-service-principal-js
#CHAT_PARTY_HOST=openvibe.party
#CHAT_PARTY_URL=https://openvibe.party
#CHAT_PARTY_OAUTH_REDIRECT_URI=https://openvibe.party/auth/callback
++# The same random value must be set as CONNECT_PARTY_SHARED_SECRET in Connect.
++#CHAT_PARTY_CONNECT_SECRET=
# OpenVibe.Live — every Live-owned read and side effect (server/live-context.js)
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
@@@ -100,7 +102,8 @@ SOUNDS_PATH=./data/sounds
# ICE for calls (GET /api/chat/ice-servers; openvibe.chat's call rooms): STUN always, TURN when set.
# With TURN_AUTH_SECRET (coturn use-auth-secret, the same value as Live's) credentials expire after an
# hour; otherwise the static pair. Production: the same TURN_URL / TURN_AUTH_SECRET as live.env.
--#TURN_URL=turn:turn.openvibe.live
++# Party desktops require turns: over TLS on port 443 and TURN_AUTH_SECRET.
++#TURN_URL=turns:turn.openvibe.live:443
#TURN_AUTH_SECRET=
#TURN_USERNAME=
#TURN_CREDENTIAL=
ddiff --git a/migrations/0047_party_desktop.sql b/migrations/0047_party_desktop.sql
nnew file mode 100644
iindex 0000000..2995d4c
---- /dev/null
++++ b/migrations/0047_party_desktop.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- A shared computer is a private party room with its existing control lease.
++-- A Link machine supplies the browser; its Connect share is closed when the party ends.
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS desktop_hosting text COLLATE "C";
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS desktop_state text COLLATE "C";
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS desktop_layout text COLLATE "C" DEFAULT 'watch';
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS desktop_node_id text COLLATE "C";
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS desktop_share_id text COLLATE "C";
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS desktop_share_url text COLLATE "C";
++ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_allowed;
++ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_allowed CHECK (party_kind IN ('agent_stream', 'media', 'radio', 'desktop'));
++ALTER TABLE room_parties ADD CONSTRAINT room_parties_desktop_layout CHECK (desktop_layout IN ('watch', 'listen'));
++ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_source;
++ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_source CHECK (
++ (party_kind = 'agent_stream' AND agent_stream_id IS NOT NULL AND media IS NULL)
++ OR (party_kind IN ('media', 'radio') AND agent_stream_id IS NULL AND media IS NOT NULL)
++ OR (party_kind = 'desktop' AND agent_stream_id IS NULL AND media IS NULL
++ AND desktop_hosting IN ('link', 'openvibe') AND desktop_state IN ('waiting', 'ready', 'stopped'))
++);
ddiff --git a/public/web/party-home.css b/public/web/party-home.css
iindex 6600e0a..a19f62a 100644
---- a/public/web/party-home.css
++++ b/public/web/party-home.css
@@@ -5,8 +5,10 @@
.pty-hero h1 { margin: 0; font-size: clamp(2.1rem, 7vw, 3.4rem); line-height: 1.05; letter-spacing: -.025em; font-weight: 850; text-wrap: balance; }
.pty-hero .oc-lede { font-size: clamp(1.02rem, 2.6vw, 1.2rem); }
--.pty-paste { display: grid; gap: 8px; margin-top: 8px; }
--.pty-paste label { font-weight: 700; font-size: 14px; }
++.pty-paste, .pty-desktop-start { display: grid; gap: 8px; margin-top: 8px; }
++.pty-paste label, .pty-desktop-start label { font-weight: 700; font-size: 14px; }
++.pty-desktop-start select { flex: 1 1 240px; min-width: 0; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; }
++.pty-other { margin-top: 28px; }
.pty-paste-row { display: flex; flex-wrap: wrap; gap: 8px; }
.pty-paste input { flex: 1 1 240px; min-width: 0; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; }
.pty-paste input::placeholder { color: var(--oc-muted); opacity: .8; }
@@@ -62,3 +64,12 @@
.pty-radio-list li span, .pty-radio-list li small { color: var(--oc-muted); overflow-wrap: anywhere; }
.pty-radio-list li a.oc-button { flex: none; min-height: 44px; display: inline-flex; align-items: center; padding: 0 16px; }
@media (max-width: 600px) { .pty-radio { padding: 16px; } .pty-radio-list li { align-items: stretch; flex-direction: column; } .pty-radio-list li a.oc-button { align-self: flex-start; } }
++
++/* Where the shared computer runs: two plain choices (11-08). */
++.pty-start-h { margin: 0 0 10px; font-size: 1.15rem; }
++.pty-host-choice { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
++.pty-host { display: grid; gap: 4px; text-align: left; padding: 14px 16px; min-height: 72px; border: 1px solid var(--border, rgba(255,255,255,.14)); border-radius: 14px; background: var(--bg-secondary, transparent); color: inherit; font: inherit; cursor: pointer; }
++.pty-host strong { font-size: 1.02rem; }
++.pty-host span { font-size: .86rem; color: var(--text-secondary, #9aa3b2); }
++.pty-host:hover, .pty-host:focus-visible { border-color: var(--accent, #6d5dfc); }
++@media (max-width: 520px) { .pty-host-choice { grid-template-columns: minmax(0, 1fr); } }
ddiff --git a/public/web/party.css b/public/web/party.css
iindex c660726..f5142b0 100644
---- a/public/web/party.css
++++ b/public/web/party.css
@@@ -46,6 +46,12 @@ main[data-party] [hidden] { display: none !important; }
box-shadow: 0 18px 50px rgba(0, 0, 0, .35);
}
.pt-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
++.pt-frame.is-desktop { width: 100%; aspect-ratio: 16 / 9; }
++.pt-frame.is-desktop.is-listening { aspect-ratio: auto; height: min(44vh, 360px); min-height: 230px; }
++.pt-desktop-mode { display: flex; justify-content: flex-end; align-items: center; min-height: 32px; color: var(--pt-muted); font-size: .85rem; }
++.pt-desktop-layout { border: 1px solid var(--pt-line); border-radius: 999px; color: inherit; background: var(--pt-panel); padding: 5px 12px; cursor: pointer; }
++.pt-frame.is-desktop .pt-desktop { display: block; width: 100%; height: 100%; border: 0; background: #000; }
++.pt-frame.is-desktop .pt-veil { z-index: 1; }
.pt-provider { position: absolute; inset: 0; width: 100%; height: 100%; }
.pt-provider iframe { width: 100%; height: 100%; border: 0; }
.pt-provider-refusal { margin: 0; padding: 10px 14px; color: var(--pt-muted); }
ddiff --git a/public/web/party.js b/public/web/party.js
iindex 00e11f3..69d1b5d 100644
---- a/public/web/party.js
++++ b/public/web/party.js
@@@ -23,7 +23,7 @@
if (data && data.party) {
var v = data.party;
data = {
-- slug: v.id, title: v.title, kind: v.kind, radio: v.radio || null, media: v.media, source_url: v.source_url, member: v.member,
++ slug: v.id, title: v.title, kind: v.kind, radio: v.radio || null, desktop: v.desktop || null, ended_at: v.ended_at || null, media: v.media, source_url: v.source_url, member: v.member,
server_time_ms: data.server_time_ms,
host: { subject: v.host, name: v.host_name || null },
members: (v.members || []).map(function (m) { return { subject: m.subject, name: m.name }; }),
@@@ -44,7 +44,7 @@
(data.members || []).forEach(function (m) { if (m && m.subject) names[m.subject] = m.name || 'Someone'; });
if (data.host && data.host.subject) names[data.host.subject] = data.host.name || names[data.host.subject] || 'The host';
if (self && self.subject) names[self.subject] = self.name || names[self.subject] || 'You';
-- var isRadio = data.kind === 'radio', radio = data.radio || null, guestListening = false;
++ var isRadio = data.kind === 'radio', isDesktop = data.kind === 'desktop', radio = data.radio || null, guestListening = false;
var serverOffset = Number(data.server_time_ms) ? Number(data.server_time_ms) - Date.now() : 0;
var bestClockRtt = Infinity;
var catalogue = [];
@@@ -106,6 +106,16 @@
var floats = el('div', 'pt-floats'); // reactions rising over the picture
floats.setAttribute('aria-hidden', 'true');
frame.appendChild(veil); frame.appendChild(drift); frame.appendChild(floats);
++ var desktopFrame = null, desktopTicketFor = null;
++ if (isDesktop) {
++ if (video) video.hidden = true;
++ frame.classList.add('is-desktop');
++ desktopFrame = el('iframe', 'pt-desktop');
++ desktopFrame.title = 'Shared party computer';
++ desktopFrame.setAttribute('allow', 'autoplay; fullscreen; clipboard-read; clipboard-write');
++ desktopFrame.setAttribute('referrerpolicy', 'no-referrer');
++ frame.insertBefore(desktopFrame, veil);
++ }
var refusal = el('p', 'pt-provider-refusal'); refusal.hidden = true;
if (providerInfo) {
refusal.appendChild(el('span', '', 'This player could not play the link.'));
@@@ -136,6 +146,35 @@
if (invite) { var h1 = head.querySelector('h1'); if (h1 && h1.nextSibling) head.insertBefore(invite, h1.nextSibling); else head.appendChild(invite); }
stage.appendChild(head);
stage.appendChild(frame);
++ var desktopMode = null;
++ if (isDesktop) {
++ desktopMode = el('div', 'pt-desktop-mode');
++ stage.insertBefore(desktopMode, frame);
++ if (!guest && self && self.subject === owner) {
++ var layoutButton = el('button', 'pt-desktop-layout');
++ layoutButton.type = 'button';
++ layoutButton.addEventListener('click', function () {
++ var next = data.desktop && data.desktop.layout === 'listen' ? 'watch' : 'listen';
++ var headers = { 'Content-Type': 'application/json' }, t = token();
++ if (t) headers.Authorization = 'Bearer ' + t;
++ layoutButton.disabled = true;
++ fetch('/api/chat/parties/' + encodeURIComponent(slug) + '/desktop/layout', {
++ method: 'POST', headers: headers, credentials: 'same-origin', body: JSON.stringify({ layout: next })
++ }).then(function (r) { return r.ok ? r.json() : null; }).then(function (v) {
++ if (v && v.party) { data.desktop = v.party.desktop; paintDesktopLayout(); }
++ }).catch(function () { /* keep the current layout */ }).finally(function () { layoutButton.disabled = false; });
++ });
++ desktopMode.appendChild(layoutButton);
++ }
++ }
++ function paintDesktopLayout() {
++ if (!desktopMode) return;
++ var listening = !!(data.desktop && data.desktop.layout === 'listen');
++ frame.classList.toggle('is-listening', listening);
++ if (desktopMode.firstChild) desktopMode.firstChild.textContent = listening ? 'Watch together' : 'Listen together';
++ else desktopMode.textContent = listening ? 'Listening together' : 'Watching together';
++ }
++ paintDesktopLayout();
if (providerInfo) stage.appendChild(refusal);
if (!guest) stage.appendChild(bar);
if (isRadio) {
@@@ -165,6 +204,27 @@
function holder() { return lease && lease.holder ? lease.holder : { kind: 'nobody', subject: null }; }
function iHold() { var h = holder(); return !!(self && h.kind === 'person' && h.subject === self.subject); }
function paintVeil() {
++ if (isDesktop) {
++ frame.classList.toggle('is-guest', guest);
++ veil.textContent = '';
++ if (guest) { veil.appendChild(el('span', 'pt-veil-line', 'Sign in to join the shared computer.')); veil.appendChild(joinLink('pt-join', 'Sign in to join')); }
++ else if (data.ended_at || data.desktop && data.desktop.state === 'stopped') veil.appendChild(el('span', 'pt-veil-line', 'This shared computer has stopped.'));
++ else if (!data.desktop || !data.desktop.url) {
++ veil.appendChild(el('span', 'pt-veil-line', self && self.subject === owner ? 'Run openvibe-link party host ' + slug + ' on your computer to bring the desktop online.' : 'The host is bringing the shared computer online.'));
++ }
++ veil.hidden = !guest && !!desktopTicketFor && !data.ended_at && data.desktop && data.desktop.state === 'ready';
++ frame.classList.toggle('is-waiting', !veil.hidden);
++ if (desktopFrame && !guest && data.desktop && data.desktop.url && desktopTicketFor !== data.desktop.url) {
++ desktopTicketFor = data.desktop.url;
++ var headers = {}, t = token();
++ if (t) headers.Authorization = 'Bearer ' + t;
++ fetch('/api/chat/parties/' + encodeURIComponent(slug) + '/desktop/ticket', { headers: headers, credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (v) { if (v && v.url) desktopFrame.src = v.url; else desktopTicketFor = null; })
++ .catch(function () { desktopTicketFor = null; });
++ }
++ return;
++ }
if (isRadio && guest) {
frame.classList.add('is-guest');
veil.textContent = '';
@@@ -496,16 +556,16 @@
if (m.type === 'room_joined') {
joined = true;
if (m.subject && !self) self = { subject: m.subject, name: names[m.subject] || 'You' };
-- if (!sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });
++ if (!isDesktop && !sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });
catchUp();
paintRadio();
-- } else if (m.type === 'sync' && m.state) {
++ } else if (!isDesktop && m.type === 'sync' && m.state) {
clock = m.state;
if (!clock.paused || clock.position_ms > 0) everPlayed = true;
if (video && (!isRadio || radio && radio.current) && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
if (provider) providerClock(clock, false);
if (isRadio && guest) applyGuestClock();
-- } else if (m.type === 'radio' && m.state) {
++ } else if (!isDesktop && m.type === 'radio' && m.state) {
radio = m.state;
if (!radio.current && video) { video.pause(); video.removeAttribute('src'); src = null; }
else if (radio.current && video && clock && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
@@@ -560,6 +620,14 @@
radioAction('skip', { expected_id: radio.current.id });
});
if (video) video.addEventListener('loadedmetadata', applyGuestClock);
++ if (isDesktop && !guest) setInterval(function () {
++ var headers = {}, t = token();
++ if (t) headers.Authorization = 'Bearer ' + t;
++ fetch('/api/chat/parties/' + encodeURIComponent(slug), { headers: headers, credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (v) { if (v && v.party) { data.desktop = v.party.desktop; data.ended_at = v.party.ended_at; paintDesktopLayout(); paintVeil(); } })
++ .catch(function () { /* the page keeps its last known state */ });
++ }, 4000);
if (isRadio && guest) {
setInterval(function () {
var key = null;
ddiff --git a/server/net/turn.js b/server/net/turn.js
iindex c95d183..bdd1d1b 100644
---- a/server/net/turn.js
++++ b/server/net/turn.js
@@@ -36,9 +36,9 @@ function turnConfig(env = process.env) {
};
}
--function turnCredentials(tag = 'anon', cfg = turnConfig(), now = Date.now()) {
++function turnCredentials(tag = 'anon', cfg = turnConfig(), now = Date.now(), ttlSeconds = TTL_SECONDS) {
if (cfg.secret) {
-- const username = `${Math.floor(now / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;
++ const username = `${Math.floor(now / 1000) + ttlSeconds}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;
return { username, credential: crypto.createHmac('sha1', cfg.secret).update(username).digest('base64'), ephemeral: true };
}
if (cfg.username && cfg.credential) return { username: cfg.username, credential: cfg.credential, ephemeral: false };
@@@ -46,11 +46,11 @@ function turnCredentials(tag = 'anon', cfg = turnConfig(), now = Date.now()) {
}
/** The list for a viewer (tag: u<id> or anon). */
--function iceServers(tag = 'anon', cfg = turnConfig(), now = Date.now()) {
++function iceServers(tag = 'anon', cfg = turnConfig(), now = Date.now(), ttlSeconds = TTL_SECONDS) {
const servers = [STUN];
let ephemeral = false;
if (cfg.url) {
-- const c = turnCredentials(tag, cfg, now);
++ const c = turnCredentials(tag, cfg, now, ttlSeconds);
if (c) {
const tcp = cfg.url.includes('?') ? `${cfg.url}&transport=tcp` : `${cfg.url}?transport=tcp`;
servers.push({ urls: cfg.url, username: c.username, credential: c.credential }, { urls: tcp, username: c.username, credential: c.credential });
ddiff --git a/server/rooms/parties.js b/server/rooms/parties.js
iindex f38d6f7..8ba9653 100644
---- a/server/rooms/parties.js
++++ b/server/rooms/parties.js
@@@ -77,7 +77,7 @@ function mediaFromRow(value) {
try { return JSON.parse(value); } catch { return value.startsWith('https://') ? value : null; }
}
--const kindOfParty = (party) => (party && ['media', 'radio'].includes(party.party_kind) ? party.party_kind : 'agent_stream');
++const kindOfParty = (party) => (party && ['media', 'radio', 'desktop'].includes(party.party_kind) ? party.party_kind : 'agent_stream');
/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */
function syncOf(party, now = Date.now()) {
@@@ -94,7 +94,7 @@ function syncOf(party, now = Date.now()) {
function joinUrl(row, kind, rawSecret = null) {
const slug = encodeURIComponent(row.slug);
-- const base = kind === 'media' || kind === 'radio' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;
++ const base = kind === 'media' || kind === 'radio' || kind === 'desktop' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;
return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
}
@@@ -107,6 +107,10 @@ function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
vod_id: party.vod_id == null ? null : String(party.vod_id),
...(kind === 'media' || kind === 'radio' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),
...(kind === 'radio' ? { radio_track: mediaFromRow(party.radio_track) } : {}),
++ ...(kind === 'desktop' ? { desktop: {
++ hosting: party.desktop_hosting, state: party.desktop_state, layout: party.desktop_layout || 'watch',
++ url: party.desktop_state === 'ready' ? party.desktop_share_url : null,
++ } } : {}),
host: party.host_subject,
title: row.name,
...(kind === 'agent_stream' ? { sync: syncOf(party, now) } : {}),
@@@ -134,9 +138,9 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
host_name: host ? (host.display_name || host.username) : null,
member,
};
-- if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio') {
++ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio' || kindOfParty(party) === 'desktop') {
const room = { id: row.id, slug: row.slug };
-- out.clock = await syncClock.get(room);
++ if (kindOfParty(party) !== 'desktop') out.clock = await syncClock.get(room);
if (member) {
out.lease = await controlLease.state(room);
out.members = (await rooms.members(row, { subjects: true }))
@@@ -153,11 +157,29 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
* Returns the party and the one time its raw join secret exists.
*/
async function create(user, input = {}) {
++ if (input && input.desktop_hosting) return await createDesktop(user, input);
if (input && input.radio_track_id) return await require('./radio').start(user, input);
if (input && input.media !== undefined && input.media !== null && input.media !== '') return await createMedia(user, input);
return await createAgentStream(user, input);
}
++async function createDesktop(user, { title = 'Shared computer', desktop_hosting: hosting } = {}) {
++ if (!user) fail(401, 'party.sign_in', 'Sign in to start a shared computer');
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');
++ if (hosting !== 'link' && hosting !== 'openvibe') fail(422, 'party.hosting', 'Choose your computer or an OpenVibe computer');
++ if (hosting === 'openvibe') fail(503, 'party.no_openvibe_machine', 'No OpenVibe machine is free right now');
++ const name = titleOf(title);
++ const raw = secret();
++ const slug = `party-${crypto.randomBytes(4).toString('hex')}`;
++ const room = await rooms.create(user, { name: name.slice(0, 40), slug, kind: 'party', visibility: 'private', topic: name, party: true });
++ await db.run(`INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash, party_kind, desktop_hosting, desktop_state)
++ VALUES (?, NULL, NULL, ?, ?, 'live', false, false, ?, 'desktop', ?, 'waiting')`,
++ [room.id, user.id, user.subject_id, sha256(raw), hosting]);
++ await controlLease.take({ id: room.id, slug }, user.subject_id, { watchers: [] });
++ const row = await bySlug(slug);
++ return { party: view(row, partyOf(row), { rawSecret: raw }), roomId: room.id, join_secret: raw };
++}
++
async function createAgentStream(user, { agent_stream_id: agentStreamId, vod_id: vodId = null, title, listed = false } = {}) {
if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');
@@@ -211,7 +233,7 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
}
async function bySlug(slug) {
-- const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url, p.radio_track FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
++ const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url, p.radio_track, p.desktop_hosting, p.desktop_state, p.desktop_layout, p.desktop_node_id, p.desktop_share_id, p.desktop_share_url FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
return row || null;
}
@@@ -222,6 +244,8 @@ function partyOf(row) {
position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,
join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,
party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url, radio_track: row.radio_track,
++ desktop_hosting: row.desktop_hosting, desktop_state: row.desktop_state, desktop_layout: row.desktop_layout, desktop_node_id: row.desktop_node_id,
++ desktop_share_id: row.desktop_share_id, desktop_share_url: row.desktop_share_url,
} : null;
}
@@@ -241,7 +265,7 @@ async function mine(user, { limit = 6 } = {}) {
const since = new Date(Date.now() - 14 * 86400_000).toISOString();
const rows = await db.all(`SELECT r.slug, r.name, p.host_user_id, p.created_at
FROM room_members m JOIN rooms r ON r.id = m.room_id JOIN room_parties p ON p.room_id = r.id
-- WHERE m.user_id = ? AND p.party_kind IN ('media', 'radio') AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?
++ WHERE m.user_id = ? AND p.party_kind IN ('media', 'radio', 'desktop') AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?
ORDER BY p.created_at DESC LIMIT ?`, [user.id, since, Math.max(1, Math.min(12, Number(limit) || 6))]);
return (rows || []).map((x) => ({
id: x.slug, title: x.name, hosting: Number(x.host_user_id) === Number(user.id),
@@@ -293,12 +317,21 @@ async function count(agentStreamId) {
async function end(row, party, user) {
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host ends the party');
-- await db.run('UPDATE room_parties SET ended_at = now(), playing = false WHERE room_id = ? AND ended_at IS NULL', [party.room_id]);
++ await db.run("UPDATE room_parties SET ended_at = now(), playing = false, desktop_state = CASE WHEN party_kind = 'desktop' THEN 'stopped' ELSE desktop_state END WHERE room_id = ? AND ended_at IS NULL", [party.room_id]);
++ const fresh = await bySlug(row.slug);
++ return view(fresh, partyOf(fresh));
++}
++
++async function setDesktopLayout(row, party, user, layout) {
++ if (!row || !party || party.party_kind !== 'desktop' || party.ended_at) fail(404, 'rooms.not_found', 'No such room');
++ if (!user || user.subject_id !== party.host_subject) fail(403, 'party.not_host', 'Only the host changes the party layout');
++ if (layout !== 'watch' && layout !== 'listen') fail(422, 'party.layout', 'Choose Watch or Listen');
++ await db.run('UPDATE room_parties SET desktop_layout = ? WHERE room_id = ? AND ended_at IS NULL', [layout, party.room_id]);
const fresh = await bySlug(row.slug);
return view(fresh, partyOf(fresh));
}
module.exports = {
AGENT_STREAM_RE, MAX_TITLE, PartyError,
-- create, createMedia, createAgentStream, bySlug, partyOf, view, detail, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches, mine,
++ create, createMedia, createDesktop, createAgentStream, bySlug, partyOf, view, detail, syncOf, mayView, joinWithLink, setSync, setDesktopLayout, count, end, keyMatches, mine,
};
ddiff --git a/server/rooms/party-desktop.js b/server/rooms/party-desktop.js
nnew file mode 100644
iindex 0000000..cde74e3
---- /dev/null
++++ b/server/rooms/party-desktop.js
@@@ -0,0 +1,112 @@
++'use strict';
++// Link's node token starts and maintains a party desktop. A short-lived signed
++// grant lets Connect create only this party's share, bound to that same node.
++const crypto = require('crypto');
++const { verifyServiceToken } = require('openvibe-sdk/auth');
++const contracts = require('openvibe-contracts');
++const config = require('../config');
++const db = require('../db/database');
++const { keys } = require('../net/service-auth');
++const { iceServers } = require('../net/turn');
++const controlLease = require('./control-lease');
++const rooms = require('./rooms');
++const parties = require('./parties');
++
++const NODE = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
++const SHARE = /^shr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++async function nodeOf(req) {
++ const header = String(req.get('authorization') || '');
++ if (!header.startsWith('Bearer ')) return null;
++ const r = await verifyServiceToken(header.slice(7).trim(), {
++ ...keys().verifyOptions, issuer: config.networkUrl, audience: 'openvibe.chat', contracts,
++ });
++ if (!r.ok || r.claims.actor_type !== 'node') return null;
++ const m = NODE.exec(String(r.claims.sub || ''));
++ const owner = String(r.claims.on_behalf_of || '');
++ return m && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ? { id: m[1], owner } : null;
++}
++
++function grant(data, secret = process.env.CHAT_PARTY_CONNECT_SECRET) {
++ if (!secret || secret.length < 32) throw new Error('CHAT_PARTY_CONNECT_SECRET is not configured');
++ const payload = Buffer.from(JSON.stringify(data)).toString('base64url');
++ const signature = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
++ return `${payload}.${signature}`;
++}
++
++async function authorized(req) {
++ const node = await nodeOf(req);
++ const row = await parties.bySlug(req.params.slug);
++ const party = parties.partyOf(row);
++ if (!node || !row || !party || party.party_kind !== 'desktop' || party.host_subject !== node.owner) return null;
++ if (party.desktop_node_id && party.desktop_node_id !== node.id) return null;
++ return { node, row, party };
++}
++
++async function claim({ node, row, party }, port) {
++ if (party.ended_at || party.desktop_state === 'stopped') throw new parties.PartyError(410, 'party.ended', 'This party has ended');
++ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new parties.PartyError(422, 'party.port', 'Name a local port');
++ if (party.desktop_hosting === 'openvibe') throw new parties.PartyError(503, 'party.no_openvibe_machine', 'No OpenVibe machine is free right now');
++ // neko reads ICE credentials at container start. Match the Connect share's
++ // 24-hour expiry; ordinary call rooms retain their one-hour credentials.
++ const turn = iceServers(`p${row.id}`, undefined, Date.now(), 24 * 3600);
++ const ice = turn.iceServers;
++ if (!turn.ephemeral) throw new parties.PartyError(503, 'party.turn_unavailable', 'The party TURN relay needs short-lived credentials');
++ if (!ice.some((x) => String(x.urls).startsWith('turns:') && String(x.urls).includes(':443'))) {
++ throw new parties.PartyError(503, 'party.turn_unavailable', 'The party TURN relay is not available over TLS on port 443');
++ }
++ const host = await db.get('SELECT username FROM ctx_users WHERE subject_id = ? LIMIT 1', [party.host_subject]);
++ if (!host || !host.username) throw new parties.PartyError(409, 'party.host_username', 'The host needs a username');
++ await db.run('UPDATE room_parties SET desktop_node_id = ? WHERE room_id = ? AND desktop_node_id IS NULL AND ended_at IS NULL', [node.id, party.room_id]);
++ const fresh = parties.partyOf(await parties.bySlug(row.slug));
++ if (fresh.desktop_node_id !== node.id) throw new parties.PartyError(409, 'party.host_taken', 'Another machine is hosting this party');
++ return {
++ ice_servers: ice,
++ share_grant: grant({ type: 'create', party: row.slug, owner: party.host_subject, username: host.username, node: node.id, port, exp: Math.floor(Date.now() / 1000) + 90 }),
++ };
++}
++
++async function access({ node, row, party }, subject) {
++ const members = (await rooms.members(row, { subjects: true }))
++ .filter((m) => m.subject_id && m.role !== 'blocked')
++ .map((m) => ({ subject: m.subject_id, username: m.username }));
++ const lease = await controlLease.state({ id: row.id, slug: row.slug });
++ return {
++ active: !party.ended_at && party.desktop_state !== 'stopped' && party.desktop_node_id === node.id,
++ member: members.some((m) => m.subject === subject),
++ members,
++ holder: lease && lease.holder && lease.holder.kind === 'person' ? lease.holder.subject : null,
++ };
++}
++
++async function ready({ node, row, party }, { share_id: shareId, url }) {
++ if (party.ended_at || party.desktop_node_id !== node.id) throw new parties.PartyError(409, 'party.host', 'This machine cannot host the party');
++ if (!SHARE.test(String(shareId || ''))) throw new parties.PartyError(422, 'party.share', 'Invalid Connect share');
++ let u;
++ try { u = new URL(url); } catch { u = null; }
++ if (!u || u.protocol !== 'https:' || u.username || u.password || u.port || u.search || u.hash || u.pathname !== '/' ||
++ !u.hostname.startsWith(`${row.slug}--`) || !u.hostname.endsWith('.openvibe.link')) {
++ throw new parties.PartyError(422, 'party.share_url', 'Invalid Connect share address');
++ }
++ await db.run("UPDATE room_parties SET desktop_state = 'ready', desktop_share_id = ?, desktop_share_url = ? WHERE room_id = ? AND desktop_node_id = ? AND ended_at IS NULL", [shareId, u.toString().replace(/\/$/, ''), party.room_id, node.id]);
++ const fresh = await parties.bySlug(row.slug);
++ return parties.view(fresh, parties.partyOf(fresh));
++}
++
++async function ticket(row, party, user) {
++ if (!row || !party || party.party_kind !== 'desktop' || party.ended_at || party.desktop_state !== 'ready' || !party.desktop_share_id || !party.desktop_share_url)
++ throw new parties.PartyError(404, 'party.desktop_unavailable', 'The shared computer is not ready');
++ const access = await rooms.access(row, user);
++ if (!access.read || !user || !user.subject_id) throw new parties.PartyError(404, 'rooms.not_found', 'No such room');
++ const username = String(user.username || '');
++ if (!username) throw new parties.PartyError(409, 'party.username', 'Your account needs a username');
++ const value = grant({ type: 'access', share: party.desktop_share_id, subject: user.subject_id,
++ username, exp: Math.floor(Date.now() / 1000) + 60 });
++ return { url: `${party.desktop_share_url}/__ovc/party#${value}` };
++}
++
++async function stopped({ node, party }) {
++ await db.run("UPDATE room_parties SET desktop_state = 'stopped', desktop_share_url = NULL WHERE room_id = ? AND desktop_node_id = ?", [party.room_id, node.id]);
++}
++
++module.exports = { nodeOf, authorized, grant, claim, access, ready, stopped, ticket };
ddiff --git a/server/rooms/party-routes.js b/server/rooms/party-routes.js
iindex ae379d9..0c3f328 100644
---- a/server/rooms/party-routes.js
++++ b/server/rooms/party-routes.js
@@@ -17,6 +17,7 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const parties = require('../rooms/parties');
const radio = require('../rooms/radio');
++const desktop = require('../rooms/party-desktop');
const router = express.Router();
@@@ -40,6 +41,56 @@ router.get('/radio/tracks', async (_req, res) => {
res.set('Cache-Control', 'public, max-age=30').json({ tracks: await radio.catalogue() });
});
++// Link presents its Network node token for Chat, acting for the party owner.
++// These routes never accept the invite secret or a browser's session as machine authority.
++router.post('/:slug/desktop/claim', express.json({ limit: '2kb' }), async (req, res) => {
++ try {
++ const hosted = await desktop.authorized(req);
++ if (!hosted) return res.status(404).json({ code: 'rooms.not_found' });
++ res.set('Cache-Control', 'no-store').json(await desktop.claim(hosted, Number(req.body && req.body.port)));
++ } catch (err) { send(res, err); }
++});
++
++router.get('/:slug/desktop/access', async (req, res) => {
++ try {
++ const hosted = await desktop.authorized(req);
++ if (!hosted) return res.status(404).json({ code: 'rooms.not_found' });
++ res.set('Cache-Control', 'no-store').json(await desktop.access(hosted, String(req.query.subject || '')));
++ } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/desktop/ready', express.json({ limit: '2kb' }), async (req, res) => {
++ try {
++ const hosted = await desktop.authorized(req);
++ if (!hosted) return res.status(404).json({ code: 'rooms.not_found' });
++ res.set('Cache-Control', 'no-store').json({ party: await desktop.ready(hosted, req.body || {}) });
++ } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/desktop/stopped', async (req, res) => {
++ try {
++ const hosted = await desktop.authorized(req);
++ if (!hosted) return res.status(404).json({ code: 'rooms.not_found' });
++ await desktop.stopped(hosted);
++ res.status(204).end();
++ } catch (err) { send(res, err); }
++});
++
++router.get('/:slug/desktop/ticket', requireAuth, async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug);
++ const party = parties.partyOf(row);
++ res.set('Cache-Control', 'no-store').json(await desktop.ticket(row, party, req.user));
++ } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/desktop/layout', requireAuth, express.json({ limit: '1kb' }), async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
++ res.set('Cache-Control', 'no-store').json({ party: await parties.setDesktopLayout(row, party, req.user, req.body && req.body.layout) });
++ } catch (err) { send(res, err); }
++});
++
router.get('/:slug/radio', optionalAuth, async (req, res) => {
try {
const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex a6b6565..b3c96f5 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -93,7 +93,7 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat', partyPro
// GETs and only with the person's own Bearer (the Actor API's CORS route).
`connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.actor https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,
// A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url).
-- `frame-src 'self' https://openvibe.bot${partyProviders ? ' https://www.youtube-nocookie.com https://player.twitch.tv https://embed.twitch.tv' : ''}`,
++ `frame-src 'self' https://openvibe.bot https://*.openvibe.link${partyProviders ? ' https://www.youtube-nocookie.com https://player.twitch.tv https://embed.twitch.tv' : ''}`,
"frame-ancestors 'self'",
"base-uri 'self'",
"object-src 'none'",
ddiff --git a/server/web/party-pages.js b/server/web/party-pages.js
iindex 355afac..8dad4c0 100644
---- a/server/web/party-pages.js
++++ b/server/web/party-pages.js
@@@ -152,13 +152,22 @@ function createPartyRoutes({ config }) {
<ul class="pty-mine-list">${mine.map((x) => `<li><a href="/p/${encodeURIComponent(x.id)}"><strong>${esc(x.title)}</strong><span>${x.hosting ? 'You started it' : 'You joined'} · ${esc(since(x.started_at))}</span></a></li>`).join('')}</ul>
</section>` : '';
const startHref = (x) => `/new?media=${encodeURIComponent(x.page_url)}&title=${encodeURIComponent(x.title)}`;
++ // Two plain choices, not a dropdown (Opus's design pass): where the shared computer runs.
++ const desktopStart = signedIn ? `<form class="pty-desktop-start" method="get" action="/new">
++<h2 class="pty-start-h">Start a shared computer</h2>
++<div class="pty-host-choice">
++<button type="submit" name="desktop" value="link" class="pty-host"><strong>On my computer</strong><span>Free. Runs in the Link app on your computer.</span></button>
++<button type="submit" name="desktop" value="openvibe" class="pty-host"><strong>On OpenVibe</strong><span id="pty-openvibe-availability">No OpenVibe machine is free right now.</span></button>
++</div>
++<p class="pty-hint">Open a browser or a music service together. One person has the remote.</p>
++</form>` : `<p class="pty-cta"><a class="oc-button" href="/auth/login?next=%2F">Sign in to start a shared computer</a></p>`;
const start = signedIn
? `<form class="pty-paste" method="get" action="/new">
<label for="party-media">Paste a video link</label>
<div class="pty-paste-row"><input id="party-media" name="media" type="url" inputmode="url" autocomplete="off" required placeholder="https://openvibe.video/watch?v=…"><button type="submit" class="oc-button">Start a party</button></div>
<p class="pty-hint">A recording, a clip or an upload on OpenVibe.Video, or a video file on OpenVibe.</p>
</form>`
-- : `<p class="pty-cta"><a class="oc-button" href="/auth/login?next=%2F">Sign in to start a party</a><span class="pty-hint">Free with your OpenVibe account.</span></p>`;
++ : '';
const grid = picks.length ? `<section class="pty-pick" aria-labelledby="pty-pick-h">
<h2 id="pty-pick-h">Pick something to watch</h2>
<ul class="pty-grid">${picks.map((x) => `<li class="pty-card"><a href="${esc(startHref(x))}">
@@@ -173,15 +182,16 @@ ${x.channel ? `<span class="pty-by">@${esc(String(x.channel).slice(0, 40))}</spa
description: DESCRIPTION,
body: `<header class="pty-hero">
<p class="pty-eyebrow">OpenVibe.Party</p>
--<h1>Watch together</h1>
--<p class="oc-lede">Everyone sees the same moment, with the chat beside it. One person holds the remote, and can pass it on.</p>
++<h1>Share a computer with friends</h1>
++<p class="oc-lede">Open a browser or music service together. Everyone sees and hears the same desktop, with one remote and a chat.</p>
${error}
--${start}
++${desktopStart}
</header>
${yours}
++<section class="pty-other" aria-labelledby="pty-other-h"><h2 id="pty-other-h">Other ways to watch</h2>${start}</section>
<section class="pty-radio" aria-labelledby="pty-radio-h"><h2 id="pty-radio-h">OpenVibe Radio</h2><p>Listen together using music its creator has shared for streaming. Every track shows its source and licence.</p>
${tracks.length ? `<ul class="pty-radio-list">${tracks.slice(0, 12).map((t) => `<li><div><strong>${esc(t.title)}</strong><span>${esc(t.artist)}${t.ai_made ? ' · AI-made music' : ''}</span><small>Source: ${esc(radioSource(t.source))} · <a href="${esc(t.licence_url)}">${esc(radioLicence(t.licence))}</a>${t.source_url ? ` · <a href="${esc(t.source_url)}">Original source</a>` : ''}</small></div><a class="oc-button" href="/new?radio=${encodeURIComponent(t.id)}">Listen together</a></li>`).join('')}</ul>` : '<p>No Radio tracks are published yet. Creators can mark their own audio uploads as music on OpenVibe.Download.</p>'}
--<p>Spotify, Apple Music and YouTube Music listening is coming after those services approve it.</p></section>
++<p>To listen to Spotify or YouTube Music together, start a shared computer and open the service inside it.</p></section>
${grid}
<section class="pty-how" aria-labelledby="pty-how-h">
<h2 id="pty-how-h">How it works</h2>
@@@ -199,6 +209,14 @@ ${grid}
router.get('/new', async (req, res) => {
const actor = await viewerOf(req);
if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ if (req.query.desktop) {
++ const hosting = String(req.query.desktop);
++ if (hosting !== 'link' && hosting !== 'openvibe') return res.redirect(303, '/');
++ if (hosting === 'openvibe') return page(req, res, 503, { actor, title: 'No OpenVibe computer is free', path: '/new', robots: 'noindex, nofollow', footer: 'compact', partyHome: true,
++ body: '<section class="oc-party-confirm pty-confirm"><h1>No OpenVibe machine is free right now</h1><p>You can host this party on your own computer with Link.</p><p><a class="oc-button" href="/new?desktop=link">Host it on my computer</a></p></section>' });
++ return page(req, res, 200, { actor, title: 'Start a shared computer', path: '/new', robots: 'noindex, nofollow', footer: 'compact', partyHome: true,
++ body: `<section class="oc-party-confirm pty-confirm"><h1>Start a shared computer?</h1><p>Link runs a temporary browser in a container on your computer. People you invite can watch, and you can pass the remote.</p><form method="post" action="/new"><input type="hidden" name="desktop_hosting" value="link"><button class="oc-button" type="submit">Start the party</button></form></section>` });
++ }
if (req.query.radio) {
let chosen;
try { chosen = await radio.track(String(req.query.radio)); } catch { return res.redirect(303, '/?error=That%20Radio%20track%20is%20not%20available'); }
@@@ -239,6 +257,14 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
const origin = String(req.get('origin') || '');
const fetchSite = String(req.get('sec-fetch-site') || '');
if ((origin && origin !== base) || (fetchSite && fetchSite !== 'same-origin')) return res.status(403).type('text/plain').send('Start a party from openvibe.party itself.');
++ if (req.body && req.body.desktop_hosting) {
++ try {
++ const out = await parties.createDesktop(actor.user, { desktop_hosting: String(req.body.desktop_hosting), title: 'Shared computer' });
++ return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);
++ } catch (err) {
++ return res.redirect(303, `/?error=${encodeURIComponent(err instanceof parties.PartyError ? err.message : 'The shared computer could not be started')}`);
++ }
++ }
if (req.body && req.body.radio_track_id) {
try {
const out = await radio.start(actor.user, { radio_track_id: String(req.body.radio_track_id) });
ddiff --git a/test/party-media.test.js b/test/party-media.test.js
iindex 4590ae5..030dce3 100644
---- a/test/party-media.test.js
++++ b/test/party-media.test.js
@@@ -14,6 +14,7 @@
const assert = require('assert');
const http = require('http');
const { ids } = require('openvibe-contracts');
++const { serviceAuth } = require('openvibe-contracts');
const { boot, suite } = require('./helpers');
const t = suite('party media');
@@@ -46,7 +47,8 @@ const dataOf = (html) => {
};
t('boot', async () => {
-- h = await boot({ env: { CHAT_PARTY_URL: 'https://openvibe.party', CHAT_PARTY_OAUTH_REDIRECT_URI: 'https://openvibe.party/auth/callback' } });
++ h = await boot({ env: { CHAT_PARTY_URL: 'https://openvibe.party', CHAT_PARTY_OAUTH_REDIRECT_URI: 'https://openvibe.party/auth/callback',
++ CHAT_PARTY_CONNECT_SECRET: 'party-test-shared-secret-01234567890123456789', TURN_URL: 'turns:turn.openvibe.live:443', TURN_AUTH_SECRET: 'party-test-turn-secret' } });
ann = h.addUser('ann', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
cat = h.addUser('cat', { subject: ids.newId('user') });
@@@ -149,6 +151,51 @@ t('agent-stream parties are unchanged', async () => {
assert.strictEqual((await api('PUT', `/rooms/${party.id}/party`, ann, { position_s: 10 })).body.code, 'party.clock');
});
++t('a desktop party gates members, claims one Link machine, and issues member tickets', async () => {
++ assert.strictEqual((await api('POST', '/parties', bob, { desktop_hosting: 'openvibe' })).status, 503);
++ const made = await api('POST', '/parties', bob, { desktop_hosting: 'link', title: 'Shared computer' });
++ assert.strictEqual(made.status, 201, made.text);
++ const slug = made.body.party.id;
++ assert.strictEqual(made.body.party.kind, 'desktop');
++ assert.strictEqual(made.body.party.desktop.state, 'waiting');
++ assert.strictEqual(made.body.party.desktop.layout, 'watch');
++ const seen = await api('GET', `/parties/${slug}`, bob);
++ assert.strictEqual(seen.body.party.lease.holder.subject, bob.subject_id);
++ assert.strictEqual((await api('GET', `/parties/${slug}/desktop/ticket`, ann)).status, 404);
++ const nodeId = ids.newId('node');
++ const now = Math.floor(Date.now() / 1000);
++ const nodeToken = (owner) => serviceAuth.signServiceToken({ iss: h.ISS, sub: `node:${nodeId}`, actor_type: 'node', on_behalf_of: owner,
++ aud: ['openvibe.chat'], cap: [], iat: now, exp: now + 300, jti: `party_${owner}` }, h.keys.privateKey);
++ const wrong = await h.http('POST', `/api/chat/parties/${slug}/desktop/claim`, { token: nodeToken(ann.subject_id), body: { port: 18180 } });
++ assert.strictEqual(wrong.status, 404);
++ const claim = await h.http('POST', `/api/chat/parties/${slug}/desktop/claim`, { token: nodeToken(bob.subject_id), body: { port: 18180 } });
++ assert.strictEqual(claim.status, 200, claim.text);
++ assert.ok(claim.body.share_grant);
++ assert.ok(claim.body.ice_servers.some((s) => String(s.urls).startsWith('turns:')));
++ const relay = claim.body.ice_servers.find((s) => String(s.urls).startsWith('turns:'));
++ assert.ok(Number(relay.username.split(':')[0]) >= now + 23 * 3600);
++ const access = await h.http('GET', `/api/chat/parties/${slug}/desktop/access?subject=${bob.subject_id}`, { token: nodeToken(bob.subject_id) });
++ assert.strictEqual(access.status, 200, access.text);
++ assert.strictEqual(access.body.member, true);
++ assert.strictEqual(access.body.holder, bob.subject_id);
++ const share = ids.newId('share');
++ const ready = await h.http('POST', `/api/chat/parties/${slug}/desktop/ready`, { token: nodeToken(bob.subject_id),
++ body: { share_id: share, url: `https://${slug}--bob.openvibe.link` } });
++ assert.strictEqual(ready.status, 200, ready.text);
++ const ticket = await api('GET', `/parties/${slug}/desktop/ticket`, bob);
++ assert.strictEqual(ticket.status, 200, ticket.text);
++ assert.match(ticket.body.url, new RegExp(`^https://${slug}--bob\\.openvibe\\.link/__ovc/party#`));
++ assert.ok(!ticket.body.url.includes(made.body.join_secret));
++ assert.strictEqual((await api('POST', `/parties/${slug}/desktop/layout`, ann, { layout: 'listen' })).status, 403);
++ const listen = await api('POST', `/parties/${slug}/desktop/layout`, bob, { layout: 'listen' });
++ assert.strictEqual(listen.status, 200, listen.text);
++ assert.strictEqual(listen.body.party.desktop.layout, 'listen');
++ assert.strictEqual((await api('GET', `/parties/${slug}`, bob)).body.party.desktop.layout, 'listen');
++ const end = await api('POST', `/rooms/${slug}/party/end`, bob, {});
++ assert.strictEqual(end.status, 200, end.text);
++ assert.strictEqual((await api('GET', `/parties/${slug}/desktop/ticket`, bob)).status, 404);
++});
++
t('openvibe.party: the start form, sign-in, and /new', async () => {
const signedOut = await raw('GET', '/');
assert.strictEqual(signedOut.status, 200, signedOut.text.slice(0, 200));
@@@ -267,6 +314,8 @@ t('the home offers OpenVibe.Video\'s latest, and a pasted watch page plays its f
assert.ok(!home.text.includes('evil.example'), 'a page on another site is never offered');
assert.match(home.text, /\/web\/party-home\.css/, 'the home\'s styles');
assert.match(home.text, /How it works/);
++ assert.match(home.text, /No OpenVibe machine is free right now/);
++ assert.match(home.text, /name="desktop" value="link" class="pty-host"><strong>On my computer<\/strong>/, 'hosting on my computer is one of two plain choices');
// A pasted watch page: the confirmation names it, and the party plays its file with the page as its source.
const asks = await raw('GET', `/new?media=${encodeURIComponent(PAGE)}`, { cookie: cookieOf(ann) });