openvibers/openvibe.chat Commits
Watch together v1: media parties, their clock, and the openvibe.party host (#392)
Changes
@@@ -37,6 +37,13 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000OV_OAUTH_CLIENT_ID=chatOV_OAUTH_CLIENT_SECRET=written-by-service-principal-js++# openvibe.party, Watch Together's host, served by this process by the Host header++# (server/web/party-pages.js): its own host-only session, on a second redirect URI of the same client++# (<CHAT_PARTY_URL>/auth/callback, seeded in Network). Production: openvibe.party.++#CHAT_PARTY_HOST=openvibe.party++#CHAT_PARTY_URL=https://openvibe.party++#CHAT_PARTY_OAUTH_REDIRECT_URI=https://openvibe.party/auth/callback++# OpenVibe.Live — every Live-owned read and side effect (server/live-context.js)OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000#LIVE_TIMEOUT_MS=4000nnew file mode 100644@@@ -0,0 +1,149 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.party — Nginx Configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.party.conf — keep them in sync and reload nginx++## after changing it.++##++## OpenVibe.Chat (port 4400, systemd unit openvibe-chat) serves this host too, by the Host header++## (server/web/party-pages.js): the party page shell and its start form, openvibe.party's own sign-in++## (server/web/session.js, redirect <this host>/auth/callback on Chat's OAuth client), and the same++## Chat API and /ws/chat the party's chat and clock use. nginx terminates TLS, sets the client address++## from the connection only (Cloudflare → nginx → Node, as Chat's own vhost does), and caches nothing:++## Chat's Cache-Control decides.++##++## The zone names carry the party prefix: nginx refuses a duplicate limit_req_zone across the whole++## configuration, and openvibe.chat.conf already defines ovchat_*.++## Install: sudo ov-vhost-install deploy/nginx/openvibe.party.conf openvibe.party.conf++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovparty_api:10m rate=10r/s;++limit_req_zone $binary_remote_addr zone=ovparty_auth:10m rate=10r/m;++map $request_method $ovparty_write_key { default ""; POST $binary_remote_addr; }++limit_req_zone $ovparty_write_key zone=ovparty_write:10m rate=30r/m;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.party www.openvibe.party;++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.party$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.party;++ ssl_certificate /etc/letsencrypt/live/openvibe.party/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.party/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++ return 301 https://openvibe.party$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.party;++ include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo)++++ ssl_certificate /etc/letsencrypt/live/openvibe.party/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.party/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.party.access.log;++ error_log /var/log/nginx/openvibe.party.error.log;++++ client_max_body_size 1m;++++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml;++++ 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;++ proxy_no_cache 1;++ proxy_cache_bypass 1;++++ # Host-only surfaces (the app refuses them from the proxy too).++ location ~* ^/metrics(/|$) { return 404; }++ location ~* ^/internal(/|$) { return 404; }++ location ~* ^/api/v1/resources(/|$) { return 404; }++++ # The party's socket: its chat, presence, the shared clock and the control lease all ride it.++ # A location's proxy_set_header replaces every server-level one, so the client address is set here++ # again, from the connection only (never a forwarded header from the client).++ location ^~ /ws/chat {++ proxy_pass http://127.0.0.1:4400;++ 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;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection "upgrade";++ proxy_read_timeout 3600s;++ proxy_send_timeout 3600s;++ }++++ # The session probe the shared navbar asks on every page view: on its own budget, as on openvibe.chat.++ location = /auth/me {++ limit_req zone=ovparty_api burst=30 nodelay;++ limit_req_status 429;++ proxy_set_header Connection "";++ 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;++ proxy_pass http://127.0.0.1:4400;++ }++++ location /auth/ {++ limit_req zone=ovparty_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_set_header Connection "";++ 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;++ proxy_pass http://127.0.0.1:4400;++ }++++ location /api/ {++ limit_req zone=ovparty_api burst=40 nodelay;++ client_max_body_size 10m;++ proxy_set_header Connection "";++ 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;++ proxy_pass http://127.0.0.1:4400;++ }++++ location / {++ limit_req zone=ovparty_write burst=20 nodelay;++ proxy_set_header Connection "";++ 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;++ proxy_pass http://127.0.0.1:4400;++ }++}nnew file mode 100644@@@ -0,0 +1,33 @@++-- phase: expand++-- Party watch v1 (plan R1, 2026-11-07): a party may watch OpenVibe media instead of an agent stream.++--++-- Until now a party watched a public agent stream (or its VOD) and the party row carried the stream and the host's++-- position. A media party watches a Media ref or an https URL on an OpenVibe host instead: the room's shared clock++-- (server/rooms/sync-clock.js, migrations/0042_room_shared_state.sql) holds the timeline and the control lease holds++-- the remote, so the party row keeps only the media, the page it was started from and whose party it is.++--++-- agent_stream_id nullable now; an agent-stream party still sets it and keeps working as it did++-- party_kind 'agent_stream' (the default, so existing rows need no rewrite) or 'media'++-- media a Media ref { media_id, role?, variant? } or an https URL on an OpenVibe host, as the clock++-- validates it; agent-stream parties leave it null++-- source_url the OpenVibe page the party was started from (same host rule), or null++--++-- Expand only: one nullable column, two new columns with defaults and new constraints; an older release ignores them++-- and its INSERT (which names neither column) still fits, because party_kind defaults to 'agent_stream'.++ALTER TABLE room_parties ALTER COLUMN agent_stream_id DROP NOT NULL;++++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS party_kind text COLLATE "C" NOT NULL DEFAULT 'agent_stream';++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS media jsonb;++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS source_url text COLLATE "C";++++DO $$ BEGIN++ ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_allowed CHECK (party_kind IN ('agent_stream', 'media'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++++-- A media party names its media and no stream; an agent-stream party names its stream and no media.++DO $$ BEGIN++ 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 = 'media' AND agent_stream_id IS NULL AND media IS NOT NULL)++ );++EXCEPTION WHEN duplicate_object THEN NULL; END $$;@@@ -19,7 +19,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.333.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.143.0","pg": "^8.23.0","ws": "^8.18.0"},@@@ -1601,9 +1601,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.142.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",-- "integrity": "sha512-C+h3qpRCN6Sgwd9qLsAICyzttiPlipk8p8hgaI0e2/gj3nt4/vLFmMxJsIoltYh413yJ4JwynrXAwfM0akSbmA==",++ "version": "4.143.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.143.0",++ "integrity": "sha512-B47vk4OuZDMb6EQRjIJDvhspUJLuwTLDS3ngXVNa+Vh75WSt7Z4B9uZLuGknbLZSjcicdRRtKPRexzCTImqqQg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -31,7 +31,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.333.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.143.0","pg": "^8.23.0","ws": "^8.18.0"},nnew file mode 100644@@@ -0,0 +1,150 @@++/* openvibe.party (Party watch v1, Opus's design). Desktop: the stage at 16:9 on the left, a rail on the right with the++ people over the room's chat. Phone: the stage full width, one row of faces, the chat filling the rest. No tabs.++ Colours follow the network theme (theme-loader.js) through Chat's tokens, with dark defaults. */++main[data-party] { max-width: none; padding: 0; }++main[data-party] .oc-party {++ --pt-bg: var(--oc-bg, var(--bg-primary, #0a0f1c));++ --pt-panel: var(--oc-panel, var(--bg-secondary, #111a2c));++ --pt-panel-2: var(--oc-panel-2, var(--bg-tertiary, #16213a));++ --pt-text: var(--oc-text, var(--text-primary, #e6edf7));++ --pt-muted: var(--oc-muted, var(--text-secondary, #9fb0c8));++ --pt-line: var(--oc-line, var(--border-color, rgba(255, 255, 255, .12)));++ --pt-accent: var(--oc-accent, var(--accent, #60a5fa));++ --pt-on-accent: var(--oc-on-accent, var(--on-accent, #0a0f1c));++ --pt-nav: var(--ovnav-h, 60px);++ display: grid;++ grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);++ grid-template-rows: auto minmax(0, 1fr);++ grid-template-areas: "stage people" "stage chat";++ gap: 16px;++ box-sizing: border-box;++ height: calc(100dvh - var(--pt-nav));++ max-width: 1640px;++ margin: 0 auto;++ padding: 16px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px));++ color: var(--pt-text);++}++main[data-party] * { box-sizing: border-box; }++main[data-party] [hidden] { display: none !important; }++++/* ── The stage ── */++.oc-party .oc-party-head h1 { margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.25; overflow-wrap: anywhere; flex: 1 1 auto; min-width: 0; }++.oc-party .oc-party-head p { margin: 0; flex-basis: 100%; order: 3; font-size: 13px; }++.oc-party .oc-party-head p .pt-from { font-size: inherit; }++[data-party-stage] { grid-area: stage; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 10px; }++.pt-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-width: 0; }++.pt-title { flex: 1 1 auto; min-width: 0; margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.25; overflow-wrap: anywhere; text-wrap: balance; }++.pt-from { flex: none; color: var(--pt-muted); font-size: 13.5px; text-decoration: none; }++.pt-from:hover, .pt-from:focus-visible { color: var(--pt-text); text-decoration: underline; }++[data-party-invite] { flex: none; }++.pt-invite { min-height: 40px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--pt-accent); color: var(--pt-on-accent); font: inherit; font-size: 14px; font-weight: 750; cursor: pointer; }++.pt-invite:hover { filter: brightness(1.08); }++++.pt-frame {++ position: relative; overflow: hidden; border-radius: 16px; background: #000;++ aspect-ratio: 16 / 9; width: min(100%, calc((100dvh - var(--pt-nav) - 200px) * 16 / 9)); max-width: 100%; align-self: center;++ box-shadow: 0 18px 50px rgba(0, 0, 0, .35);++}++.pt-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }++/* Honest states over the picture: waiting for the host, joining, reconnecting. */++.pt-veil {++ position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center;++ background: radial-gradient(ellipse at center, rgba(0, 0, 0, .35), rgba(0, 0, 0, .72));++ color: #fff; font-size: clamp(15px, 2.2vw, 20px); font-weight: 700; text-wrap: balance; pointer-events: none;++}++.pt-drift {++ position: absolute; top: 12px; left: 12px; padding: 4px 10px; border-radius: 999px;++ background: rgba(0, 0, 0, .66); color: #fff; font-size: 12.5px; font-weight: 700; pointer-events: none;++}++.pt-floats { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }++.pt-float { position: absolute; bottom: 12px; font-size: 30px; animation: pt-rise 2.4s cubic-bezier(.2, .7, .3, 1) forwards; }++@keyframes pt-rise { 0% { transform: translateY(0) scale(.7); opacity: 0; } 12% { opacity: 1; transform: translateY(-14px) scale(1.05); } 100% { transform: translateY(-190px) scale(1); opacity: 0; } }++@media (prefers-reduced-motion: reduce) { .pt-float { animation: pt-fade 1.6s ease forwards; } @keyframes pt-fade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } }++++/* The bar under the picture: the remote pill and the reactions. */++.pt-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }++[data-party-remote] { min-width: 0; }++.pt-remote {++ display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 44px; padding: 5px 6px 5px 12px;++ border-radius: 999px; background: var(--pt-panel); border: 1px solid var(--pt-line);++}++.pt-remote.is-menu { border-radius: 18px; }++.pt-remote-ic { display: grid; place-items: center; color: var(--pt-accent); }++.pt-remote-text { font-size: 14px; font-weight: 650; overflow-wrap: anywhere; }++.pt-remote-btn {++ min-height: 34px; padding: 0 14px; border: 0; border-radius: 999px; background: var(--pt-accent); color: var(--pt-on-accent);++ font: inherit; font-size: 13.5px; font-weight: 750; cursor: pointer;++}++.pt-remote-btn.is-quiet { background: transparent; color: var(--pt-text); box-shadow: inset 0 0 0 1px var(--pt-line); }++.pt-remote-btn:hover { filter: brightness(1.08); }++.pt-remote-btn.is-quiet:hover { background: var(--pt-panel-2); filter: none; }++.pt-react { display: flex; gap: 4px; }++.pt-react-btn {++ width: 44px; height: 44px; border: 0; border-radius: 50%; background: var(--pt-panel); font-size: 20px; line-height: 1;++ cursor: pointer; transition: transform .12s ease, background .12s ease;++}++.pt-react-btn:hover { background: var(--pt-panel-2); transform: translateY(-2px); }++.pt-react-btn:active { transform: scale(.92); }++.pt-flash { margin: 0; padding: 8px 12px; border-radius: 10px; background: var(--pt-panel-2); color: var(--pt-text); font-size: 14px; }++++/* ── The people ── */++.pt-h { margin: 0 0 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--pt-muted); }++[data-party-people] { grid-area: people; min-width: 0; padding: 14px; border-radius: 16px; background: var(--pt-panel); border: 1px solid var(--pt-line); }++.pt-faces { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 10px 14px; }++.pt-face { display: flex; align-items: center; gap: 8px; min-width: 0; }++.pt-avatar {++ flex: none; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;++ background: hsl(var(--h, 210) 55% 42%); color: #fff; font-size: 13px; font-weight: 800;++ box-shadow: 0 0 0 2px var(--pt-panel);++}++.pt-face.has-remote .pt-avatar { box-shadow: 0 0 0 2px var(--pt-panel), 0 0 0 4px var(--pt-accent); }++.pt-face.is-away { opacity: .5; }++.pt-face-name { font-size: 14px; font-weight: 650; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.pt-face-tag { padding: 1px 8px; border-radius: 999px; background: color-mix(in srgb, var(--pt-accent) 18%, transparent); color: var(--pt-accent); font-size: 11.5px; font-weight: 750; }++.pt-face-tag.is-quiet { background: var(--pt-panel-2); color: var(--pt-muted); }++++/* ── The chat ── */++[data-party-chat] {++ grid-area: chat; min-width: 0; min-height: 0; display: flex; flex-direction: column;++ padding: 14px; border-radius: 16px; background: var(--pt-panel); border: 1px solid var(--pt-line);++}++.pt-thread { list-style: none; margin: 0; padding: 0 2px 8px 0; flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; overscroll-behavior: contain; }++.pt-empty { margin: auto 0; color: var(--pt-muted); font-size: 14px; text-align: center; }++.pt-msg { font-size: 14.5px; line-height: 1.45; overflow-wrap: anywhere; }++.pt-msg-who { margin-right: 6px; font-weight: 750; }++.pt-msg.is-react .pt-msg-text { font-size: 20px; }++.pt-say { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid var(--pt-line); }++.pt-say-input {++ flex: 1 1 auto; min-width: 0; height: 44px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--pt-line);++ background: var(--pt-bg); color: var(--pt-text); font: inherit; font-size: 15px;++}++.pt-say-btn { flex: none; height: 44px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--pt-accent); color: var(--pt-on-accent); font: inherit; font-weight: 750; cursor: pointer; }++++/* ── Phones and narrow windows: one column, no tabs ── */++@media (max-width: 900px) {++ main[data-party] .oc-party {++ display: flex; flex-direction: column; gap: 10px; height: auto; min-height: calc(100dvh - var(--pt-nav));++ padding: 0 0 max(12px, env(safe-area-inset-bottom, 0px));++ }++ [data-party-stage] { gap: 8px; }++ .pt-head { order: 3; padding: 0 max(14px, env(safe-area-inset-left, 0px)); }++ .pt-frame { order: 1; width: 100%; border-radius: 0; box-shadow: none; position: sticky; top: var(--pt-nav); z-index: 2; }++ .pt-bar { order: 2; padding: 0 max(12px, env(safe-area-inset-left, 0px)); }++ .pt-flash { order: 4; margin: 0 14px; }++ .pt-title { font-size: 1.05rem; }++ [data-party-people] { margin: 0 12px; padding: 10px 12px; }++ [data-party-people] .pt-h { margin-bottom: 6px; }++ /* One row of compact faces: the avatar over the first name, so five or six fit; the ring marks the remote. */++ .pt-faces { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding: 5px 4px 2px; margin: -5px -4px 0; gap: 6px; }++ .pt-faces::-webkit-scrollbar { display: none; }++ .pt-face { flex: none; flex-direction: column; gap: 4px; width: 60px; }++ .pt-face-name { max-width: 60px; font-size: 12px; text-align: center; }++ .pt-face-tag { display: none; }++ .pt-avatar { width: 40px; height: 40px; font-size: 14px; }++ [data-party-chat] { flex: 1 1 auto; margin: 0 12px; min-height: 320px; }++ .pt-thread { max-height: 52dvh; }++}++@media (max-width: 420px) {++ .pt-react-btn { width: 40px; height: 44px; font-size: 19px; }++ .pt-remote { flex: 1 1 100%; }++}nnew file mode 100644@@@ -0,0 +1,361 @@++/* openvibe.party: watch together (plan R1, Party watch v1; Opus's design). The page shell (server/web) gives++ <main data-party> with five empty regions and the party's view as JSON in #party-data. This script builds:++++ - the stage: Shared's player (<video data-ov-player>) kept on the room's timeline by Shared's ov-sync over Chat's++ room socket (common.sync-clock@1), with honest states over it: waiting for the host, catching up (only past a++ second of drift), reconnecting;++ - the remote pill under it (common.control-lease@1): who has it and the one move that makes sense for you (Ask for++ it, Pass it, Take it, and Take it back for the room's owner, which always wins);++ - the people: faces, the one with the remote marked, the away ones dimmed while their seat waits;++ - the room's chat, with reactions that also float over the stage;++ - Invite: Shared's share sheet (the link and a QR code).++++ Everything a person wrote is set with textContent; links are built from nodes. No tabs: on a phone the stage, a row++ of faces and the chat stack in one column. */++(function () {++ 'use strict';++ var root = document.querySelector('main[data-party]');++ var dataEl = document.getElementById('party-data');++ if (!root || !dataEl) return;++ var data;++ try { data = JSON.parse(dataEl.textContent || '{}'); } catch (e) { return; }++ // The shell (server/web/party-pages.js) sends { party: view }: the slug is its id, the host its subject.++ if (data && data.party) {++ var v = data.party;++ data = {++ slug: v.id, title: v.title, media: v.media, source_url: v.source_url, member: v.member,++ host: { subject: v.host, name: v.host_name || null },++ members: (v.members || []).map(function (m) { return { subject: m.subject, name: m.name }; }),++ // The shell's join_url never carries the key (the secret is not echoed into a page): only a URL that has one++ // is an invitation; otherwise the key kept from the link the person came by makes it (inviteUrl).++ clock: v.clock || null, lease: v.lease || null, invite_url: v.join_url && /[?&]k=/.test(v.join_url) ? v.join_url : null,++ };++ }++++ var slug = root.getAttribute('data-party') || data.slug;++ var region = function (name) { return root.querySelector('[data-party-' + name + ']'); };++ var stage = region('stage'), people = region('people'), chat = region('chat'), remote = region('remote'), invite = region('invite');++ var REACTIONS = ['😂', '😮', '🔥', '❤️', '👏'];++ var DRIFT_SHOWN_MS = 1000;++ var self = data.self || null; // { subject, name } for a signed-in member++ var owner = data.host ? data.host.subject : null; // the room's owner: Take it back always works for them++ var names = {};++ (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 lease = data.lease || null, clock = data.clock || null, asked = null, everPlayed = !!(clock && (!clock.paused || clock.position_ms > 0));++ var away = {}; // subject -> when they left (their seat waits a minute)++ var ws = null, sync = null, delay = 1000, latest = 0, joined = false;++++ // ── small helpers ────────────────────────────────────────────────────────────++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++ function nameOf(subject) { return subject === (self && self.subject) ? 'You' : (names[subject] || 'Someone'); }++ function initials(name) { var p = String(name || '?').trim().split(/\s+/); return ((p[0] || '?')[0] + (p[1] ? p[1][0] : '')).toUpperCase(); }++ // FNV-1a over the whole subject, so two subjects that differ in one letter still get far-apart colours.++ function hue(s) { var h = 2166136261; s = String(s || ''); for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return (h % 12) * 30; }++ function realName(subject) { return names[subject] || 'Someone'; }++ function send(frame) { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify(frame)); return true; } catch (e) { /* closed */ } } return false; }++ function token() {++ if (data.token) return data.token;++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');++ return m ? decodeURIComponent(m[1]) : undefined;++ }++ // The invite link carries the party's key. The server shows the key once (when the party is made); a member who++ // came by the link keeps it here, so they can invite others too.++ function inviteUrl() {++ var KEY = 'ov-party-key:' + slug, k = null;++ try { k = new URLSearchParams(location.search).get('k'); } catch (e) { /* old browser */ }++ try { if (k) localStorage.setItem(KEY, k); else k = localStorage.getItem(KEY); } catch (e) { /* storage blocked */ }++ return data.invite_url || ('https://openvibe.party/p/' + encodeURIComponent(slug) + (k ? '?k=' + encodeURIComponent(k) : ''));++ }++++ // ── the stage ────────────────────────────────────────────────────────────────++ var video = el('video');++ video.setAttribute('data-ov-player', '');++ video.setAttribute('playsinline', '');++ video.preload = 'auto';++ video.controls = true;++ var src = typeof (clock && clock.media) === 'string' ? clock.media : (typeof data.media === 'string' ? data.media : null);++ if (src) video.src = src;++ var frame = el('div', 'pt-frame');++ frame.appendChild(video);++ var veil = el('div', 'pt-veil'); // waiting / reconnecting, over the picture++ veil.setAttribute('role', 'status');++ var drift = el('span', 'pt-drift', 'Catching up…');++ drift.hidden = true;++ 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);++ // The shell's own header (the title and who started it) leads; without one, the page writes its own.++ var shellHead = root.querySelector('.oc-party-head');++ var head = shellHead || el('div', 'pt-head');++ if (shellHead) head.classList.add('pt-head');++ else head.appendChild(el('h1', 'pt-title', data.title || 'Watch party'));++ if (data.source_url) {++ var from = el('a', 'pt-from', 'Open on its page ↗');++ from.href = data.source_url; from.target = '_blank'; from.rel = 'noopener';++ // Beside "Started by …" when the shell has that line; otherwise in the header.++ var by = shellHead && shellHead.querySelector('p');++ if (by) { by.appendChild(document.createTextNode(' · ')); by.appendChild(from); } else head.appendChild(from);++ }++ var bar = el('div', 'pt-bar'); // the remote pill and the reactions, under the picture++ // Invite sits right after the title, on its line.++ 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);++ stage.appendChild(bar);++ if (remote) bar.appendChild(remote);++ var react = el('div', 'pt-react');++ react.setAttribute('aria-label', 'React');++ REACTIONS.forEach(function (r) {++ var b = el('button', 'pt-react-btn', r);++ b.type = 'button';++ b.setAttribute('aria-label', 'React ' + r);++ // It rises when the room's echo arrives, for everyone at once, the sender too.++ b.addEventListener('click', function () { send({ type: 'room_message', message: r }); });++ react.appendChild(b);++ });++ bar.appendChild(react);++++ function rise(emoji) {++ var f = el('span', 'pt-float', emoji);++ f.style.left = (12 + Math.random() * 70) + '%';++ floats.appendChild(f);++ setTimeout(function () { f.remove(); }, 2600);++ }++ 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() {++ var text = '';++ if (!ws || ws.readyState !== 1) text = joined ? 'Reconnecting…' : 'Joining the party…';++ else if (!src) text = 'This party has nothing to play yet.';++ else if (!everPlayed && clock && clock.paused) text = iHold() ? 'Press play when everyone is here.' : 'Waiting for ' + (holder().kind === 'person' ? nameOf(holder().subject) : 'the host') + ' to press play';++ veil.textContent = text;++ veil.hidden = !text;++ frame.classList.toggle('is-waiting', !!text);++ }++++ // ── the remote ───────────────────────────────────────────────────────────────++ function pill(text, moves) {++ remote.textContent = '';++ var p = el('div', 'pt-remote');++ var icon = el('span', 'pt-remote-ic');++ icon.setAttribute('aria-hidden', 'true');++ icon.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="7" y="2.5" width="10" height="19" rx="3"/><circle cx="12" cy="8" r="1.6"/><path d="M10 13h4M10 16.5h4"/></svg>';++ p.appendChild(icon);++ p.appendChild(el('span', 'pt-remote-text', text));++ (moves || []).forEach(function (m) {++ var b = el('button', 'pt-remote-btn' + (m.quiet ? ' is-quiet' : ''), m.label);++ b.type = 'button';++ b.addEventListener('click', m.run);++ p.appendChild(b);++ });++ remote.appendChild(p);++ }++ function passMenu() {++ var here = (lease && lease.watchers || []).filter(function (s) { return !self || s !== self.subject; });++ remote.textContent = '';++ var p = el('div', 'pt-remote is-menu');++ p.appendChild(el('span', 'pt-remote-text', here.length ? 'Pass the remote to' : 'Nobody else is here yet'));++ here.forEach(function (s) {++ var b = el('button', 'pt-remote-btn is-quiet', nameOf(s));++ b.type = 'button';++ b.addEventListener('click', function () { send({ type: 'lease.grant', to: { kind: 'person', subject: s } }); });++ p.appendChild(b);++ });++ var back = el('button', 'pt-remote-btn is-quiet', 'Cancel');++ back.type = 'button';++ back.addEventListener('click', paintRemote);++ p.appendChild(back);++ remote.appendChild(p);++ }++ function paintRemote() {++ if (!remote) return;++ var h = holder(), mine = iHold(), isOwner = !!(self && self.subject === owner);++ var takeBack = { label: 'Take it back', run: function () { send({ type: 'lease.take' }); } };++ if (!self) return pill(h.kind === 'person' ? nameOf(h.subject) + ' has the remote' : 'Sign in to share the remote', []);++ if (mine) {++ var moves = [{ label: 'Pass it', run: passMenu }, { label: 'Let go', quiet: true, run: function () { send({ type: 'lease.release' }); } }];++ if (asked && asked !== self.subject) {++ moves.unshift({ label: 'Pass to ' + nameOf(asked), run: function () { send({ type: 'lease.grant', to: { kind: 'person', subject: asked } }); asked = null; } });++ return pill(nameOf(asked) + ' asks for the remote', moves);++ }++ return pill('You have the remote', moves);++ }++ if (h.kind === 'nobody') return pill('Nobody has the remote', [{ label: 'Take it', run: function () { send({ type: 'lease.request' }); } }]);++ if (h.kind === 'ai') return pill((names[h.subject] || 'Vibe') + ' has the remote', isOwner ? [takeBack] : []);++ var label = nameOf(h.subject) + ' has the remote' + (away[h.subject] ? ' (away a moment)' : '');++ var ask = { label: asked === self.subject ? 'Asked' : 'Ask for it', quiet: asked === self.subject, run: function () { if (send({ type: 'lease.request' })) { asked = self.subject; paintRemote(); } } };++ return pill(label, isOwner ? [ask, takeBack] : [ask]);++ }++++ // ── the people ───────────────────────────────────────────────────────────────++ function paintPeople() {++ if (!people) return;++ people.textContent = '';++ var watching = (lease && lease.watchers) || [];++ var all = Object.keys(names);++ watching.forEach(function (s) { if (all.indexOf(s) < 0) all.push(s); });++ // Here first, then the ones whose seat waits, then the members who are not here.++ var rank = function (s) { return watching.indexOf(s) >= 0 ? 0 : away[s] ? 1 : 2; };++ all.sort(function (a, b) { return rank(a) - rank(b) || nameOf(a).localeCompare(nameOf(b)); });++ var h = people.appendChild(el('h2', 'pt-h', watching.length === 1 ? '1 watching' : watching.length + ' watching'));++ h.id = 'pt-people-h';++ var list = el('ul', 'pt-faces');++ list.setAttribute('aria-labelledby', 'pt-people-h');++ all.forEach(function (s) {++ var here = watching.indexOf(s) >= 0, li = el('li', 'pt-face' + (here ? '' : ' is-away'));++ var dot = el('span', 'pt-avatar', initials(realName(s)));++ dot.style.setProperty('--h', String(hue(s)));++ li.appendChild(dot);++ li.appendChild(el('span', 'pt-face-name', nameOf(s)));++ var hd = holder();++ if (hd.kind === 'person' && hd.subject === s) { li.classList.add('has-remote'); li.appendChild(el('span', 'pt-face-tag', 'Remote')); }++ else if (!here && away[s]) li.appendChild(el('span', 'pt-face-tag is-quiet', 'Back soon'));++ if (s === owner) li.title = nameOf(s) + ' made this party';++ list.appendChild(li);++ });++ people.appendChild(list);++ }++++ // ── the chat ─────────────────────────────────────────────────────────────────++ var thread = el('ol', 'pt-thread');++ thread.setAttribute('aria-live', 'polite');++ var empty = el('li', 'pt-empty', 'Say hello. Everyone here sees it.');++ thread.appendChild(empty);++ var form = el('form', 'pt-say');++ var input = el('input', 'pt-say-input');++ input.type = 'text'; input.maxLength = 500; input.placeholder = 'Message the party'; input.setAttribute('aria-label', 'Message the party'); input.autocomplete = 'off';++ var go = el('button', 'pt-say-btn', 'Send');++ go.type = 'submit';++ form.appendChild(input); form.appendChild(go);++ if (chat) {++ chat.appendChild(el('h2', 'pt-h', 'Chat'));++ chat.appendChild(thread);++ chat.appendChild(form);++ }++ form.addEventListener('submit', function (e) {++ e.preventDefault();++ var text = input.value.trim();++ if (!text) return;++ if (send({ type: 'room_message', message: text })) input.value = '';++ });++ function textNodes(parent, text) {++ var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;++ while ((m = re.exec(text))) {++ if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));++ var url = m[0].replace(/[.,!?)]+$/, ''), a = el('a', null, url);++ a.href = url; a.target = '_blank'; a.rel = 'noopener nofollow ugc';++ parent.appendChild(a); last = m.index + url.length; re.lastIndex = last;++ }++ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));++ }++ var ONLY_EMOJI = /^(?:\p{Extended_Pictographic}|️|){1,8}$/u;++ function render(m, live) {++ if (!m || m.id == null || thread.querySelector('[data-id="' + m.id + '"]')) return;++ if (empty.parentNode) empty.remove();++ var stick = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120;++ var text = String(m.message || '');++ var li = el('li', 'pt-msg' + (ONLY_EMOJI.test(text) ? ' is-react' : ''));++ li.dataset.id = String(m.id);++ li.appendChild(el('b', 'pt-msg-who', m.display_name || m.username || 'Someone'));++ var t = el('span', 'pt-msg-text');++ textNodes(t, text);++ li.appendChild(t);++ thread.appendChild(li);++ latest = Math.max(latest, Number(m.id) || 0);++ if (stick) thread.scrollTop = thread.scrollHeight;++ if (live && ONLY_EMOJI.test(text)) rise(text);++ }++ function catchUp() {++ var headers = {};++ var t = token();++ if (t) headers.Authorization = 'Bearer ' + t;++ fetch('/api/chat/rooms/' + encodeURIComponent(slug) + '/messages' + (latest ? '?after=' + latest : ''), { headers: headers, credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) { if (d && d.messages) d.messages.forEach(function (m) { render(m, false); }); })++ .catch(function () { /* offline: the socket brings what comes next */ });++ }++++ // ── the socket ───────────────────────────────────────────────────────────────++ function connect() {++ if (!window.WebSocket) { paintVeil(); return; }++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');++ ws.onopen = function () { delay = 1000; send({ type: 'join_room', room: slug, token: token() }); paintVeil(); };++ ws.onmessage = function (ev) {++ var m;++ try { m = JSON.parse(ev.data); } catch (e) { return; }++ if (m.room && m.room !== slug) return;++ 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 });++ catchUp();++ } else if (m.type === 'sync' && m.state) {++ clock = m.state;++ if (!clock.paused || clock.position_ms > 0) everPlayed = true;++ if (!src && typeof clock.media === 'string') { src = clock.media; video.src = src; }++ } else if (m.type === 'lease' && m.state) {++ var before = lease && lease.watchers ? lease.watchers : [];++ lease = m.state;++ before.forEach(function (s) { if ((lease.watchers || []).indexOf(s) < 0) away[s] = Date.now(); });++ (lease.watchers || []).forEach(function (s) { delete away[s]; });++ if (iHold() || holder().kind !== 'person' || (asked && holder().subject === asked)) asked = null;++ } else if (m.type === 'lease.request' && m.request && m.request.requester) {++ if (iHold()) asked = m.request.requester.subject;++ } else if (m.type === 'room_message' && m.message) {++ render(m.message, true);++ } else if (m.type === 'lease_error' || m.type === 'sync_error' || m.type === 'room_error') {++ flash(m.message || 'That did not work.');++ }++ paintVeil(); paintRemote(); paintPeople();++ };++ ws.onclose = function () {++ if (sync && sync.destroy) { try { sync.destroy(); } catch (e) { /* gone */ } }++ sync = null;++ paintVeil();++ setTimeout(connect, delay);++ delay = Math.min(delay * 2, 30000);++ };++ }++ var flashEl = el('p', 'pt-flash');++ flashEl.setAttribute('role', 'alert');++ flashEl.hidden = true;++ stage.appendChild(flashEl);++ var flashTimer = null;++ function flash(text) { flashEl.textContent = text; flashEl.hidden = false; clearTimeout(flashTimer); flashTimer = setTimeout(function () { flashEl.hidden = true; }, 4000); }++++ // Catching up shows only past a second of drift, and only while the room plays.++ setInterval(function () {++ var d = sync && sync.drift ? Math.abs(Number(sync.drift()) || 0) : 0;++ drift.hidden = !(clock && !clock.paused && d > DRIFT_SHOWN_MS);++ // A seat waits a minute; after it, the person is simply not here.++ var now = Date.now(), changed = false;++ Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });++ if (changed) { paintPeople(); paintRemote(); }++ }, 500);++ video.addEventListener('play', function () { everPlayed = true; paintVeil(); });++++ // ── invite ───────────────────────────────────────────────────────────────────++ if (invite) {++ var ib = el('button', 'pt-invite', 'Invite');++ ib.type = 'button';++ ib.addEventListener('click', function () {++ var url = inviteUrl();++ var open = function () { window.OVShare.open({ url: url, title: data.title || 'Watch with me', kind: 'party', id: slug }); };++ if (window.OVShare) return open();++ var s = document.createElement('script');++ s.src = '/shared/ov-share.js';++ s.onload = function () { if (window.OVShare) open(); };++ s.onerror = function () {++ if (navigator.clipboard) navigator.clipboard.writeText(url).then(function () { flash('Invite link copied'); }, function () { flash(url); });++ else flash(url);++ };++ document.head.appendChild(s);++ });++ invite.appendChild(ib);++ }++++ paintVeil(); paintRemote(); paintPeople();++ connect();++})();@@@ -53,6 +53,8 @@ function getAllowedOrigins() {}allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');allowed.add(normalizeOrigin(config.web.baseUrl) || 'https://openvibe.chat');++ // openvibe.party is Chat's other host (Watch Together): its page's socket is an upgrade from this origin.++ allowed.add(normalizeOrigin(config.party && config.party.baseUrl) || 'https://openvibe.party');allowed.add('https://openvibe.games');allowed.add('https://openvibe.tools');if (!config.isProduction) {@@@ -339,6 +341,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin// The chat plays queue of a room (Agent Streams S6): the votes and the moderators' hand.app.use('/api/chat/rooms', require('./chat/play-queue-routes').member);app.use('/api/chat/rooms', require('./rooms/routes'));++ // Watch parties' own API (server/rooms/party-routes.js): start a media party, read one a viewer may see.++ app.use('/api/chat/parties', require('./rooms/party-routes'));app.use('/api/chat/vibe', require('./rooms/vibe-routes'));// Support inboxes (server/inbox/): the widget's visitor API, and the inbox's people.app.use('/api/chat/widget/inbox/:id', require('./inbox/routes').widget);@@@ -375,6 +379,11 @@ function createApp({ chatServer, relay, events = null, callServer = null, settinlimits.identify(req, res, () => callReads(req, res, () => callRoutes(req, res, next)));});++ // openvibe.party, Watch Together's host: its pages, sign-in and assets (server/web/party-pages.js). Its own host++ // only; every other request falls through to openvibe.chat's site below.++ const party = require('./web/party-pages').createPartyRoutes({ config });++ app.use((req, res, next) => (party.isPartyHost(req) ? party.router(req, res, next) : next()));++// openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).const webRoutes = require('./web/pages').createWebRoutes({ config });app.use(webRoutes);@@@ -90,6 +90,14 @@ module.exports = {// Where a room's Community attachments link (a space is <communityUrl>/forum/<slug>).communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),},++ // openvibe.party, Watch Together's host (Party watch v1, plan R1): Chat serves it too (server/web/party-pages.js),++ // with its own host-only session. Its sign-in is a second redirect URI on Chat's OAuth client (a Network seed).++ party: {++ host: String(process.env.CHAT_PARTY_HOST || 'openvibe.party').toLowerCase(),++ baseUrl: strip(process.env.CHAT_PARTY_URL || (isProduction ? 'https://openvibe.party' : `http://localhost:${port}`)),++ redirectUri: process.env.CHAT_PARTY_OAUTH_REDIRECT_URI || `${strip(process.env.CHAT_PARTY_URL || (isProduction ? 'https://openvibe.party' : `http://localhost:${port}`))}/auth/callback`,++ scope: 'profile theme',++ },// Pictures in rooms and channels (server/rooms/pictures.js): uploaded to OpenVibe.Pics as the person (their own// Network token) through MediaHub on this host, and shown inline. Off without CHAT_PICS_INTERNAL_URL.pics: {@@@ -1,9 +1,13 @@'use strict';/**-- * Watch parties (Agent Streams S7, design 2026-11-06 §4): a Chat room of kind 'party' where friends watch a public-- * agent stream or its VOD in sync, with their own chat beside the stream's.++ * Watch parties (Agent Streams S7, design 2026-11-06 §4; media parties, plan R1 2026-11-07): a Chat room of kind++ * 'party' where friends watch something in sync, with their own chat beside it.*-- * - Sync: on a VOD the position follows the host's; on a live stream it follows the live edge (no position).++ * - An agent-stream party watches a public agent stream or its VOD: on a VOD the position follows the host's; on a++ * live stream it follows the live edge (no position).++ * - A media party watches a Media ref or an https URL on an OpenVibe host, exactly as a room's shared clock accepts++ * media (server/rooms/sync-clock.js). Its timeline is the room's clock (paused at 0 when it starts) and its remote++ * is the room's control lease, granted to the person who started it.* - Joining is by the party's link. The link carries a secret, the row stores its sha256 only, and a stranger* without it sees the same 404 as a missing room.* - Private by default: rooms.visibility is 'private', the party is never in the directory, and `listed` only lets@@@ -12,13 +16,17 @@* The room itself is rooms.js's (name, members, messages, moderation); this module owns only the party facts.*/const crypto = require('crypto');++const config = require('../config');const db = require('../db/database');const rooms = require('./rooms');++const syncClock = require('./sync-clock');++const controlLease = require('./control-lease');const AGENT_STREAM_RE = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;const VOD_RE = /^\d{1,16}$/;const MAX_TITLE = 200;const BASE_URL = () => String(process.env.OV_CHAT_PUBLIC_URL || 'https://openvibe.chat').replace(/\/+$/, '');++const PARTY_BASE_URL = () => String(config.party.baseUrl || 'https://openvibe.party').replace(/\/+$/, '');class PartyError extends Error {constructor(status, code, message) { super(message); this.status = status; this.code = code; }@@@ -42,6 +50,35 @@ function vodOf(value) {function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }++/**++ * A media party's media: a Media ref or an https URL on an OpenVibe host, validated by the shared clock's own rules++ * (server/rooms/sync-clock.js). A refusal is the party API's 422, not the clock's.++ */++function mediaOf(value) {++ try {++ return syncClock._mediaOf(value);++ } catch (err) {++ if (err instanceof syncClock.SyncError) fail(422, 'party.media', err.message);++ throw err;++ }++}++++/** The OpenVibe page a party was started from: an https page on an OpenVibe host, or null. */++function sourceUrlOf(value) {++ if (value == null || value === '') return null;++ if (typeof value !== 'string' || !syncClock.onOpenVibe(value)) fail(422, 'party.source_url', 'source_url is an https page on an OpenVibe site');++ return value;++}++++/** The party row's media, as stored (jsonb comes back as an object; older drivers may give text). */++function mediaFromRow(value) {++ if (value == null) return null;++ if (typeof value !== 'string') return value;++ try { return JSON.parse(value); } catch { return null; }++}++++const kindOfParty = (party) => (party && party.party_kind === 'media' ? 'media' : '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()) {if (!party || party.mode === 'live') return { mode: 'live', playing: true };@@@ -55,32 +92,70 @@ function syncOf(party, now = Date.now()) {};}--function joinUrl(row, rawSecret = null) {-- const base = `${BASE_URL()}/rooms/${encodeURIComponent(row.slug)}`;++function joinUrl(row, kind, rawSecret = null) {++ const slug = encodeURIComponent(row.slug);++ const base = kind === 'media' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;}function view(row, party, { rawSecret = null, now = Date.now() } = {}) {++ const kind = kindOfParty(party);return {id: row.slug,-- agent_stream_id: party.agent_stream_id,++ kind,++ agent_stream_id: party.agent_stream_id || null,vod_id: party.vod_id == null ? null : String(party.vod_id),++ ...(kind === 'media' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),host: party.host_subject,title: row.name,-- sync: syncOf(party, now),++ ...(kind === 'agent_stream' ? { sync: syncOf(party, now) } : {}),visibility: 'private',listed: !!party.listed,-- join_url: joinUrl(row, rawSecret),++ join_url: joinUrl(row, kind, rawSecret),created_at: new Date(party.created_at instanceof Date ? party.created_at.getTime() : Date.parse(String(party.created_at).includes('T') ? party.created_at : `${String(party.created_at).replace(' ', 'T')}Z`)).toISOString(),...(party.ended_at ? { ended_at: new Date(party.ended_at).toISOString() } : {}),};}++/**++ * What a viewer sees of a party: the view, the host's name, and — for a media party — the room's clock. A member also++ * gets the members (their subjects and names) and the remote's state; someone holding only the link's secret gets++ * neither, because the lease's watchers are live presence and the members are the room's people.++ * → null when the caller may not view it at all (the caller answers the missing-room 404).++ */++async function detail(row, party, { user = null, key = null, now = Date.now() } = {}) {++ const access = await rooms.access(row, user || null);++ const member = !!access.read;++ if (!member && !keyMatches(party, key)) return null;++ const host = await db.get('SELECT username, display_name FROM ctx_users WHERE subject_id = ? LIMIT 1', [party.host_subject]);++ const out = {++ ...view(row, party, { now }),++ host_name: host ? (host.display_name || host.username) : null,++ member,++ };++ if (kindOfParty(party) === 'media') {++ const room = { id: row.id, slug: row.slug };++ out.clock = await syncClock.get(room);++ if (member) {++ out.lease = await controlLease.state(room);++ out.members = (await rooms.members(row, { subjects: true }))++ .filter((m) => m.role !== 'blocked')++ .map((m) => ({ subject: m.subject_id || null, name: m.display_name || m.username, role: m.role }));++ }++ }++ return out;++}++/*** Start a party on a public agent stream or its VOD. The room is private; only the link (with its secret) opens it.* Returns the party and the one time its raw join secret exists.*/--async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = null, title, listed = false } = {}) {++async function create(user, input = {}) {++ if (input && input.media !== undefined && input.media !== null && input.media !== '') return await createMedia(user, input);++ return await createAgentStream(user, input);++}++++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');const streamId = String(agentStreamId || '');@@@ -103,8 +178,37 @@ async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = nureturn { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };}++/**++ * Start a party on OpenVibe media: the room, the party row, the room's clock seeded paused at 0 with that media, and++ * the remote granted to the person who started it. The join link is openvibe.party/p/<slug>?k=…, and the secret is++ * in this answer only.++ */++async function createMedia(user, { media, source_url: sourceUrl = 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');++ const chosen = mediaOf(media);++ const source = sourceUrlOf(sourceUrl);++ const name = titleOf(title);++ const raw = secret();++ const roomName = (name.length < 3 ? `Party: ${name}` : name).slice(0, 40);++ const slug = `party-${crypto.randomBytes(4).toString('hex')}`;++ const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });++ const id = room.id;++ 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, media, source_url)++ VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, 'media', ?, ?)`,++ [id, user.id, user.subject_id || '', !!listed, sha256(raw), JSON.stringify(chosen), source]++ );++ const shared = { id, slug: room.slug };++ await syncClock.set(shared, user.subject_id, { media: chosen, position_ms: 0, rate: 1, paused: true });++ await controlLease.take(shared, user.subject_id, { watchers: [] });++ const row = await db.get('SELECT * FROM rooms WHERE id = ?', [id]);++ const party = await db.get('SELECT * FROM room_parties WHERE room_id = ?', [id]);++ return { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };++}++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 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 FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);return row || null;}@@@ -114,6 +218,7 @@ function partyOf(row) {host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,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,} : null;}@@@ -145,6 +250,7 @@ async function joinWithLink(row, party, user, key) {/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */async function setSync(row, party, user, { position_s: position, playing } = {}) {if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');++ if (kindOfParty(party) === 'media') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');let pos = party.position_s;if (position !== undefined) {@@@ -174,5 +280,5 @@ async function end(row, party, user) {module.exports = {AGENT_STREAM_RE, MAX_TITLE, PartyError,-- create, bySlug, partyOf, view, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,++ create, createMedia, createAgentStream, bySlug, partyOf, view, detail, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,};nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++/**++ * /api/chat/parties — Watch Together's parties, the way openvibe.party and the sites that start one reach them.++ * The room routes (server/rooms/routes.js, /api/chat/rooms/parties…) keep serving the agent-stream parties; both++ * go through server/rooms/parties.js, so the secret, the 404 and the clock are decided in one place.++ *++ * POST / { media, source_url?, title } — a signed-in person starts a media party. 201 { party, join_secret }:++ * the join link (openvibe.party/p/<slug>?k=…) carries the secret, and this answer is the only place++ * it exists. The room's clock starts paused at 0 on the media and the remote is the creator's.++ * GET /:slug ?k=<secret> — the party as a viewer sees it: title, media, source_url, the host (subject and name),++ * the clock, and (for a member) the people and the remote. A stranger without the key, or with a++ * wrong one, gets the same 404 as a missing room.++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { requireAuth, optionalAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');++const parties = require('../rooms/parties');++++const router = express.Router();++++// Starting a party: the same budget as making a room (owners keep at most MAX_ROOMS_PER_OWNER).++const createLimit = limits('chat.party.create', { minute: 10, hour: 60 });++++function send(res, err) {++ if (err instanceof parties.PartyError) return res.status(err.status).json({ error: err.message, code: err.code });++ console.error('[Parties]', err && err.message);++ return res.status(500).json({ error: 'Something went wrong' });++}++++router.post('/', requireAuth, createLimit, async (req, res) => {++ try {++ const out = await parties.create(req.user, req.body || {});++ res.status(201).json({ party: out.party, join_secret: out.join_secret });++ } catch (err) { send(res, err); }++});++++router.get('/:slug', optionalAuth, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug);++ const party = parties.partyOf(row);++ const out = row && party ? await parties.detail(row, party, { user: req.user || null, key: req.query.k }) : null;++ if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out });++ } catch (err) { send(res, err); }++});++++module.exports = router;@@@ -141,8 +141,9 @@ router.get('/:slug/party', optionalAuth, async (req, res) => {const row = await parties.bySlug(req.params.slug);const party = parties.partyOf(row);if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });-- if (!(await parties.mayView(row, party, req.user || null, req.query.k))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });-- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: parties.view(row, party) });++ const out = await parties.detail(row, party, { user: req.user || null, key: req.query.k });++ if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out });} catch (err) { send(res, err); }});@@@ -204,6 +204,6 @@ function close() { _cache.clear(); _queues.clear(); }module.exports = {MAX_POSITION_MS, MAX_RATE, SyncError, set, get, evict, close,-- // Exposed for the WS layer and its tests; the validation stays here.-- _mediaOf: mediaOf, _positionOf: positionOf, _rateOf: rateOf, _pausedOf: pausedOf,++ // Exposed for the WS layer, the party row and their tests; the validation stays here.++ onOpenVibe, _mediaOf: mediaOf, _positionOf: positionOf, _rateOf: rateOf, _pausedOf: pausedOf,};@@@ -57,12 +57,12 @@ function links(o) {return LINKS.map((l) => ({ key: l.key, href: l.href, icon: l.icon, more: l.more, label: counts[l.key] > 0 ? `${l.label} (${counts[l.key] > 99 ? '99+' : counts[l.key]})` : l.label }));}--function navConfig(o, config) {++function navConfig(o, config, site = {}, siteLinks = null) {return {-- service: 'chat',++ service: site.service || 'chat',apiBase: NETWORK_URL,-- links: links(o).map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: o.active === l.key })),-- history: { type: 'page', title: o.title || SITE_NAME },++ links: (siteLinks || site.links || links(o)).map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: o.active === l.key })),++ history: { type: 'page', title: o.title || site.name || SITE_NAME },silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,@@@ -78,7 +78,7 @@ function navConfig(o, config) {* pictures, avatars), and connections go to this site, Network (sign-in, the Frame) and Events (release and* notification streams). Checked on production pages with the policy attached before shipping (2026-10-17).*/--function contentSecurityPolicy(nonce) {++function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {return ["default-src 'self'",// 'inline-speculation-rules': the shared navbar's prefetch rules (Shared 4.70) run under the nonce policy too.@@@ -87,7 +87,7 @@ function contentSecurityPolicy(nonce) {"img-src 'self' data: blob: https:","media-src 'self' blob: https:","font-src 'self' data:",-- "connect-src 'self' wss://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com",++ `connect-src 'self' ${wsOrigin} 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","frame-ancestors 'self'",@@@ -110,18 +110,25 @@ function contentSecurityPolicy(nonce) {* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),* inlineStyles (CSS the page needs and the site's stylesheet cannot hold: the rule that shows the picked block of a-- * pick block is written for the page, because there is one per block)++ * pick block is written for the page, because there is one per block), site ({ name, service, links }: a page served++ * on another of Chat's hosts, openvibe.party, in the same Frame under its own name), mainAttrs (attributes for the++ * <main>, e.g. openvibe.party's data-party="<slug>"), party (a watch party's page: Shared's player.css/player.js and++ * ov-sync.js, then public/web/party.css and party.js, Opus's design).*/function renderPage(o) {const { config } = o;if (!o.robots) throw new TypeError('renderPage needs explicit robots');++ const site = o.site || {};++ const siteName = site.name || SITE_NAME;++ const siteService = site.service || 'chat';++ const siteLinks = site.links || links(o);// fullTitle: a page that names the site itself (the front page) takes no suffix.-- const title = o.fullTitle || (o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME);++ const title = o.fullTitle || (o.title ? `${o.title} · ${siteName}` : siteName);let head = sharedSeo.headTags({title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,-- robots: o.robots, siteName: SITE_NAME, type: 'website', jsonLd: o.jsonLd,++ robots: o.robots, siteName, type: 'website', jsonLd: o.jsonLd,// Indexable pages share the site's card (openvibe-shared/social); private ones advertise nothing.-- image: /^index/.test(o.robots) ? social.url('chat') : undefined, imageAlt: /^index/.test(o.robots) ? SITE_NAME : undefined,++ image: /^index/.test(o.robots) ? social.url('chat') : undefined, imageAlt: /^index/.test(o.robots) ? siteName : undefined,});// A public page names its Markdown twin and the site's llms.txt (openvibe-shared/agent-layer); gated and// private pages have no twin and advertise none.@@@ -149,6 +156,7 @@ ${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}${o.app ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}++${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}<script src="${ovServe.url('theme-loader.js')}" defer></script><script src="${ovServe.url('navbar.js')}" defer></script>@@@ -162,14 +170,14 @@ ${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}><a class="oc-skip" href="#main">Skip to content</a><div id="navbar-mount"></div>--${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}--<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.wide ? ' oc-main--wide' : ''}`}">++${frame.noscriptNav({ name: siteName, home: '/', links: siteLinks.map(({ label, href }) => ({ label, href })) })}++<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.wide ? ' oc-main--wide' : ''}`}"${o.mainAttrs ? ` ${o.mainAttrs}` : ''}><noscript><p class="oc-account">${who}</p></noscript>${o.body || ''}</main>--${o.app ? '' : frame.footer({ service: 'chat', variant: footerVariant, updates: '/updates' })}++${o.app ? '' : frame.footer({ service: siteService, variant: footerVariant, updates: '/updates' })}<script>--window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'chat', variant: footerVariant, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};++window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config, site, siteLinks), footer: { service: siteService, variant: footerVariant, mount: '#ov-footer', brandName: siteName, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};document.addEventListener('DOMContentLoaded', function () {try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }@@@ -1200,4 +1200,4 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addrreturn router;}--module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml };++module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml, viewerOf };nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++/**++ * openvibe.party, Watch Together's host (Party watch v1, plan R1): Chat serves it beside openvibe.chat, so a party is++ * a Chat room and its chat, socket, clock and remote already exist (server/rooms/parties.js, sync-clock.js,++ * control-lease.js).++ *++ * GET / "Start a party": one field, paste any OpenVibe link (signed in; sign-in otherwise)++ * GET /new?media=&title= signed in: starts the party and 303s to /p/<slug>?k=<secret>; signed out: sign-in and++ * back here. `source_url` (the page it was started from) is optional, as the API's is.++ * GET /p/:slug?k= the party page shell: the Shared Frame, a <main data-party="<slug>"> and the empty++ * regions the page's own script fills (server/rooms/parties.js keeps the data), with the++ * party view as JSON in #party-data. The link's secret joins the person and is never++ * echoed into the page.++ * /auth/* sign-in with OpenVibe.Network: its own host-only session and its own redirect URI on++ * Chat's OAuth client (<party base>/auth/callback, in Network's seed)++ * /shared/*, /web/* the Frame's scripts and the site's stylesheet, the same files openvibe.chat serves++ *++ * A stranger without the link (or with a wrong secret) gets the missing-room 404, exactly as on openvibe.chat.++ */++const crypto = require('crypto');++const express = require('express');++const path = require('path');++const cache = require('openvibe-shared/cache-policy');++const ovServe = require('openvibe-shared/serve');++const { renderPage, contentSecurityPolicy, esc } = require('./layout');++const { createSessionRoutes } = require('./session');++const { viewerOf } = require('./pages');++const parties = require('../rooms/parties');++++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');++const SITE = Object.freeze({ name: 'OpenVibe.Party', service: 'party', links: [{ key: 'start', label: 'Start a party', href: '/' }] });++const DESCRIPTION = 'Watch together: start a party on any OpenVibe video and everyone watches in step, with the chat beside it.';++++const escJson = (value) => JSON.stringify(value).replace(/</g, '\\u003c');++++/** A title out of the link someone pasted: the last path segment, or the host's name. */++function titleFromMedia(media) {++ try {++ const u = new URL(media);++ const last = decodeURIComponent((u.pathname.split('/').filter(Boolean).pop() || ''));++ const words = last.replace(/[-_]+/g, ' ').replace(/\.(mp4|webm|m3u8|mov|mkv|m4v)$/i, '').replace(/\s+/g, ' ').trim();++ return (words || `${u.hostname} party`).slice(0, 80);++ } catch { return 'Watch party'; }++}++++function createPartyRoutes({ config }) {++ const router = express.Router();++ const base = String(config.party.baseUrl).replace(/\/+$/, '');++ const wsOrigin = base.replace(/^http/, 'ws');++ const partyConfig = {++ ...config,++ baseUrl: base,++ web: { ...config.web, baseUrl: base },++ oauth: { ...config.oauth, redirectUri: config.party.redirectUri, scope: config.party.scope },++ };++ const viewers = { resolve: async (req) => await viewerOf(req) };++++ const hostOf = (req) => String((req.headers && req.headers.host) || '').toLowerCase().replace(/:\d+$/, '');++ const isPartyHost = (req) => hostOf(req) === config.party.host || hostOf(req) === `www.${config.party.host}`;++++ const page = async (req, res, status, o) => {++ const actor = o.actor || await viewerOf(req);++ const nonce = crypto.randomBytes(16).toString('base64');++ res.status(status)++ .set('Cache-Control', cache.htmlHeaders({ private: true }))++ .set('Content-Security-Policy', contentSecurityPolicy(nonce, wsOrigin))++ .type('html')++ .send(renderPage({ config: partyConfig, actor, nonce, site: SITE, ...o }));++ };++++ router.use('/auth', createSessionRoutes({ config: partyConfig, viewers }));++ router.use('/shared', ovServe.handler());++ router.use('/web', express.static(PUBLIC_DIR, { index: false, setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=300'); } }));++++ const notice = (kind, text) => `<p class="oc-notice oc-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${esc(text)}</p>`;++++ router.get('/', async (req, res) => {++ const actor = await viewerOf(req);++ const signedIn = actor.kind === 'user';++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ const form = signedIn++ ? `<form class="oc-party-start" method="get" action="/new">++<label for="party-media">Paste an OpenVibe link</label>++<input id="party-media" name="media" type="url" inputmode="url" autocomplete="off" required placeholder="https://openvibe.live/v/…">++<button type="submit" class="oc-button">Start a party</button>++</form>`++ : `<p class="oc-party-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to start a party.</p>`;++ await page(req, res, 200, {++ actor, title: 'Watch together', path: '/', robots: 'index, follow', footer: 'full',++ description: DESCRIPTION,++ body: `<header class="oc-party-hero">++<h1>Watch together</h1>++<p class="oc-lede">Start a party on any OpenVibe video: everyone watches the same moment, and the conversation is right there.</p>++</header>++${error}++${form}`,++ });++ });++++ // GET /new asks before it starts anything: a link (Watch together on a video page, or one pasted anywhere) must never++ // start a party in a signed-in person's name on its own. The page names what will play; Start is a same-origin POST.++ 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)}`);++ const media = String(req.query.media || '').trim();++ if (!media) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);++ const title = String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200) || titleFromMedia(media);++ const source = req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '';++ let host = '';++ try { host = new URL(media).hostname; } catch { host = ''; }++ await page(req, res, 200, {++ actor, title: 'Start a party', path: '/new', robots: 'noindex, nofollow', footer: 'compact',++ body: `<section class="oc-party-confirm">++<h1>Start a watch party?</h1>++<p class="oc-lede"><strong>${esc(title)}</strong>${host ? ` <span class="oc-muted">from ${esc(host)}</span>` : ''}</p>++<p class="oc-muted">Everyone you invite watches the same moment, with a chat beside it. You hold the remote, and you can pass it on.</p>++<form method="post" action="/new">++<input type="hidden" name="media" value="${esc(media)}">++<input type="hidden" name="title" value="${esc(title)}">++${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''}++<button type="submit" class="oc-button">Start the party</button> <a href="/" class="oc-muted">Not now</a>++</form>++</section>`,++ });++ });++++ router.post('/new', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ const actor = await viewerOf(req);++ if (actor.kind !== 'user') return res.redirect(303, '/auth/login?next=%2F');++ // Started from this site only: a cookie-carrying POST from anywhere else is refused.++ 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.');++ const media = String((req.body && req.body.media) || '').trim();++ if (!media) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);++ const title = String((req.body && req.body.title) || '').replace(/\s+/g, ' ').trim().slice(0, 200) || titleFromMedia(media);++ try {++ const out = await parties.create(actor.user, { media, source_url: (req.body && req.body.source_url) || null, title });++ return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);++ } catch (err) {++ const message = err instanceof parties.PartyError ? err.message : 'That party could not be started';++ return res.redirect(303, `/?error=${encodeURIComponent(message)}`);++ }++ });++++ const notFound = async (req, res) => page(req, res, 404, {++ title: 'Page not found', path: req.path, robots: 'noindex, nofollow', footer: 'compact',++ body: `<h1>This party is not here</h1><p class="oc-muted">The link may be wrong, or the party may have ended.</p><p><a class="oc-button" href="/">Start a party</a></p>`,++ });++++ router.get('/p/:slug', async (req, res, next) => {++ try {++ const actor = await viewerOf(req);++ const user = actor.kind === 'user' ? actor.user : null;++ const row = await parties.bySlug(req.params.slug);++ const party = parties.partyOf(row);++ const key = req.query.k;++ // The link's secret is the invitation: joining it here is the same path the API's join uses. A person who++ // is already a member is simply let in. An ended party (or a banned account) still shows its page.++ if (row && party && user && parties.keyMatches(party, key)) {++ try { await parties.joinWithLink(row, party, user, key); } catch (err) {++ if (!(err instanceof parties.PartyError)) throw err;++ if (err.status === 404) return await notFound(req, res);++ }++ }++ const out = row && party ? await parties.detail(row, party, { user, key }) : null;++ if (!out) return await notFound(req, res);++ await page(req, res, 200, {++ actor, title: out.title, path: `/p/${out.id}`, robots: 'noindex, nofollow', footer: 'compact',++ description: 'Watch together on OpenVibe.',++ mainAttrs: `data-party="${esc(out.id)}"`, party: true, wide: true,++ body: `<article class="oc-party">++<header class="oc-party-head"><h1>${esc(out.title)}</h1><p class="oc-muted">Started by ${esc(out.host_name || 'someone')}</p></header>++<div class="oc-party-stage" data-party-stage aria-label="The video"></div>++<div class="oc-party-people" data-party-people aria-label="People here"></div>++<div class="oc-party-chat" data-party-chat aria-label="Chat"></div>++<div class="oc-party-remote" data-party-remote aria-label="The remote"></div>++<div class="oc-party-invite" data-party-invite aria-label="Invite"></div>++</article>++<script type="application/json" id="party-data">${escJson({ party: out })}</script>`,++ });++ } catch (err) { next(err); }++ });++++ // The party host's own 404 for a browser (the same words as Chat's), and the JSON 404 for anything else.++ router.use((req, res, next) => {++ if ((req.method !== 'GET' && req.method !== 'HEAD') || /^\/(api|internal|ws|auth|shared|web)(\/|$)/.test(req.path)) return next();++ return page(req, res, 404, {++ title: 'Page not found', path: req.path, robots: 'noindex, nofollow', footer: 'compact',++ body: `<h1>Page not found</h1><p><a href="/">Start a party</a></p>`,++ }).catch(next);++ });++++ return { router, isPartyHost };++}++++module.exports = { createPartyRoutes, titleFromMedia, SITE };nnew file mode 100644@@@ -0,0 +1,227 @@++'use strict';++/**++ * Watch Together v1, the media party and its host (plan R1, 2026-11-07):++ *++ * - a media party is a private Chat room whose row carries the media (a Media ref or an OpenVibe https URL) and the++ * page it was started from; the room's clock starts paused at 0 on that media and the creator holds the remote;++ * - the join link is openvibe.party/p/<slug>?k=…, and the secret is in the create answer only;++ * - a stranger without the key (or with a wrong one) gets the missing-room 404; a member gets the members, and a++ * holder of just the link does not;++ * - agent-stream parties keep their stream, VOD position and /rooms/ link;++ * - openvibe.party is served by Host: the start form, /new (sign-in when signed out, the party when signed in) and++ * the page shell with its data hooks and no key in the HTML.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('party media');++let h, ann, bob, cat;++const MEDIA = { media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', role: 'video' };++const MEDIA_URL = 'https://openvibe.media/vod/friday.webm';++const api = (method, path, u, body) => h.http(method, `/api/chat${path}`, { token: u && u.token, body });++++/** A request with its own Host header (the party host is chosen by it, not by the port we reached). */++function raw(method, path, { host = 'openvibe.party', cookie = null, form = null, headers = {} } = {}) {++ return new Promise((resolve, reject) => {++ const body = form ? new URLSearchParams(form).toString() : null;++ const req = http.request({++ host: '127.0.0.1', port: h.port, path, method,++ headers: { host, ...(cookie ? { cookie } : {}), ...(body ? { 'content-type': 'application/x-www-form-urlencoded', 'content-length': Buffer.byteLength(body) } : {}), ...headers },++ }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text }));++ });++ req.on('error', reject);++ req.end(body || undefined);++ });++}++const cookieOf = (u) => `ov_token=${u.token}`;++const dataOf = (html) => {++ const m = /<script type="application\/json" id="party-data">([\s\S]*?)<\/script>/.exec(html);++ assert.ok(m, 'the page carries its party data');++ return JSON.parse(m[1]);++};++++t('boot', async () => {++ h = await boot({ env: { CHAT_PARTY_URL: 'https://openvibe.party', CHAT_PARTY_OAUTH_REDIRECT_URI: 'https://openvibe.party/auth/callback' } });++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ cat = h.addUser('cat', { subject: ids.newId('user') });++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));++});++++let party, key;++++t('a media party starts paused at 0, on the media, with the remote to the person who started it', async () => {++ const r = await api('POST', '/parties', ann, { media: MEDIA, source_url: 'https://openvibe.live/v/abc123', title: 'Friday film' });++ assert.strictEqual(r.status, 201, r.text);++ party = r.body.party;++ key = r.body.join_secret;++ assert.strictEqual(party.kind, 'media');++ assert.strictEqual(party.title, 'Friday film');++ assert.deepStrictEqual(party.media, MEDIA);++ assert.strictEqual(party.source_url, 'https://openvibe.live/v/abc123');++ assert.match(party.id, /^party-[0-9a-f]{8}$/);++ assert.strictEqual(party.join_url, `https://openvibe.party/p/${party.id}?k=${encodeURIComponent(key)}`);++ assert.ok(key && key.length >= 20, 'the secret appears in the create answer');++ assert.ok(!('sync' in party), 'a media party has no VOD position of its own');++ assert.strictEqual(party.visibility, 'private');++++ const seen = await api('GET', `/parties/${party.id}`, ann);++ assert.strictEqual(seen.status, 200, seen.text);++ const view = seen.body.party;++ assert.strictEqual(view.member, true);++ assert.strictEqual(view.host, ann.subject_id);++ assert.strictEqual(view.host_name, 'Ann');++ assert.deepStrictEqual(view.clock && view.clock.media, MEDIA);++ assert.strictEqual(view.clock.position_ms, 0);++ assert.strictEqual(view.clock.paused, true, 'the clock is seeded paused');++ assert.deepStrictEqual(view.lease.holder, { kind: 'person', subject: ann.subject_id }, 'the creator has the remote');++ assert.deepStrictEqual(view.members.map((m) => [m.subject, m.name]), [[ann.subject_id, 'Ann']]);++});++++t('the media must be OpenVibe media, and the source page on an OpenVibe host', async () => {++ const outside = await api('POST', '/parties', ann, { media: 'https://tracker.example.com/film.webm', title: 'Elsewhere' });++ assert.strictEqual(outside.status, 422, outside.text);++ assert.strictEqual(outside.body.code, 'party.media');++ const notHttps = await api('POST', '/parties', ann, { media: { media_id: 'not-a-ref' }, title: 'Bad ref' });++ assert.strictEqual(notHttps.status, 422, notHttps.text);++ assert.strictEqual(notHttps.body.code, 'party.media');++ const source = await api('POST', '/parties', ann, { media: MEDIA, source_url: 'https://example.com/page', title: 'Wrong page' });++ assert.strictEqual(source.status, 422, source.text);++ assert.strictEqual(source.body.code, 'party.source_url');++ assert.strictEqual((await api('POST', '/parties', null, { media: MEDIA, title: 'Signed out' })).status, 401);++});++++t('a stranger without the key, or with a wrong one, sees the missing-room 404', async () => {++ for (const path of [`/parties/${party.id}`, `/parties/${party.id}?k=not-the-link`, `/rooms/${party.id}/party`]) {++ const r = await api('GET', path, bob);++ assert.strictEqual(r.status, 404, `${path}: ${r.text}`);++ assert.strictEqual(r.body.code, 'rooms.not_found');++ }++ // One character off (never the same character: a key that already ends in x would be the right key).++ const wrongKey = key.slice(0, -1) + (key.endsWith('x') ? 'y' : 'x');++ const wrongRoom = await api('GET', `/parties/${party.id}?k=${encodeURIComponent(wrongKey)}`, bob);++ assert.strictEqual(wrongRoom.status, 404);++});++++t('the link invites (the members see the people), and joining keeps the seat', async () => {++ const invited = await api('GET', `/parties/${party.id}?k=${encodeURIComponent(key)}`, cat);++ assert.strictEqual(invited.status, 200, invited.text);++ assert.strictEqual(invited.body.party.member, false, 'holding the link is not membership');++ assert.ok(!('members' in invited.body.party), 'a key holder is not shown the room\'s people');++ assert.ok(!('lease' in invited.body.party), 'nor the remote\'s watchers');++ assert.deepStrictEqual(invited.body.party.clock.media, MEDIA, 'but the clock is there');++++ const joined = await api('POST', `/rooms/${party.id}/party/join`, bob, { k: key });++ assert.strictEqual(joined.status, 200, joined.text);++ assert.strictEqual(joined.body.role, 'member');++ const seen = await api('GET', `/parties/${party.id}`, bob);++ assert.strictEqual(seen.status, 200, seen.text);++ assert.strictEqual(seen.body.party.member, true);++ assert.deepStrictEqual(seen.body.party.members.map((m) => m.subject).sort(), [ann.subject_id, bob.subject_id].sort());++ assert.ok(seen.body.party.lease, 'a member sees the remote');++ const again = await api('POST', `/rooms/${party.id}/party/join`, bob, { k: key });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.body.role, 'member', 'a member keeps their seat');++});++++t('agent-stream parties are unchanged', async () => {++ const AGENT = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';++ const r = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Stream party', listed: true });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.party.kind, 'agent_stream');++ assert.strictEqual(r.body.party.agent_stream_id, AGENT);++ assert.strictEqual(r.body.party.vod_id, '4242');++ assert.match(r.body.party.join_url, /\/rooms\/party-[0-9a-f]{8}\?k=/);++ assert.deepStrictEqual(r.body.party.sync.mode, 'vod');++ assert.strictEqual(r.body.party.sync.playing, false);++ assert.ok(!('media' in r.body.party));++ assert.strictEqual((await api('PUT', `/rooms/${party.id}/party`, ann, { position_s: 10 })).body.code, 'party.clock');++});++++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));++ assert.match(signedOut.text, /Start a party/);++ assert.match(signedOut.text, /\/auth\/login\?next=%2F/);++ assert.ok(!/name="media"/.test(signedOut.text), 'a signed-out visitor gets sign-in, not the form');++++ const signedIn = await raw('GET', '/', { cookie: cookieOf(ann) });++ assert.strictEqual(signedIn.status, 200);++ assert.match(signedIn.text, /action="\/new"/);++ assert.match(signedIn.text, /name="media"/);++++ const signIn = await raw('GET', `/new?media=${encodeURIComponent(MEDIA_URL)}&title=${encodeURIComponent('Friday film')}`);++ assert.strictEqual(signIn.status, 303);++ assert.match(String(signIn.headers.location), /^\/auth\/login\?next=%2Fnew%3Fmedia%3D/);++++ // A link never starts a party by itself: GET /new asks, and only the page's own POST starts it.++ const asks = await raw('GET', `/new?media=${encodeURIComponent(MEDIA_URL)}&title=${encodeURIComponent('Movie night')}`, { cookie: cookieOf(ann) });++ assert.strictEqual(asks.status, 200, asks.text.slice(0, 200));++ assert.match(asks.text, /Start a watch party\?/);++ assert.match(asks.text, /<form method="post" action="\/new">/);++ assert.match(asks.text, /name="media" value="https:\/\/openvibe\.media\/vod\/friday\.webm"/);++ const crossSite = await raw('POST', '/new', { cookie: cookieOf(ann), form: { media: MEDIA_URL, title: 'Evil' }, headers: { origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(crossSite.status, 403, 'a cross-site POST starts nothing');++ const made = await raw('POST', '/new', { cookie: cookieOf(ann), form: { media: MEDIA_URL, title: 'Movie night' }, headers: { origin: 'https://openvibe.party', 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));++ const loc = String(made.headers.location);++ assert.match(loc, /^\/p\/party-[0-9a-f]{8}\?k=/);++ const slug = loc.slice('/p/'.length).split('?')[0];++ const madeKey = decodeURIComponent(loc.split('k=')[1]);++++ // The chat site still answers on its own host.++ const chatSite = await raw('GET', '/', { host: 'openvibe.chat' });++ assert.strictEqual(chatSite.status, 200);++ assert.match(chatSite.text, /Global chat/);++++ // A stranger on the party host: the same 404 as a missing room.++ const stranger = await raw('GET', `/p/${slug}`);++ assert.strictEqual(stranger.status, 404);++ const bobOutsider = await raw('GET', `/p/${slug}`, { cookie: cookieOf(bob) });++ assert.strictEqual(bobOutsider.status, 404, 'bob joined the other party, not this one');++++ // The host's shell: the data hooks, the party JSON, and never the key in the HTML.++ const shell = await raw('GET', `/p/${slug}?k=${encodeURIComponent(madeKey)}`, { cookie: cookieOf(ann) });++ assert.strictEqual(shell.status, 200, shell.text.slice(0, 300));++ for (const hook of ['data-party-stage', 'data-party-people', 'data-party-chat', 'data-party-remote', 'data-party-invite']) {++ assert.ok(shell.text.includes(hook), `${hook} is in the shell`);++ }++ assert.match(shell.text, new RegExp(`<main id="main"[^>]*data-party="${slug}"`));++ const data = dataOf(shell.text);++ assert.strictEqual(data.party.id, slug);++ assert.strictEqual(data.party.title, 'Movie night');++ assert.deepStrictEqual(data.party.clock.media, MEDIA_URL);++ assert.strictEqual(data.party.clock.paused, true);++ assert.strictEqual(data.party.lease.holder.subject, ann.subject_id);++ assert.ok(!shell.text.includes(madeKey), 'the link\'s secret is not echoed into the page');++ // The page loads Opus's page and Shared's player and clock.++ for (const file of ['/web/party.js', '/web/party.css', '/shared/ov-sync.js', '/shared/player.js', '/shared/player.css']) {++ assert.ok(shell.text.includes(file), `${file} is on the page`);++ }++++ // A member who joined through the API without k sees the shell and still gets no key.++ const joined = await api('POST', `/rooms/${slug}/party/join`, cat, { k: madeKey });++ assert.strictEqual(joined.status, 200, joined.text);++ const member = await raw('GET', `/p/${slug}`, { cookie: cookieOf(cat) });++ assert.strictEqual(member.status, 200);++ assert.ok(!member.text.includes(madeKey), 'no key leak for a member who joined without k');++ assert.deepStrictEqual(dataOf(member.text).party.members.map((m) => m.subject).sort(), [ann.subject_id, cat.subject_id].sort());++++ // A stranger opens the link: the page joins them (the link is the invitation), and the shell carries no key.++ const invited = await raw('GET', `/p/${slug}?k=${encodeURIComponent(madeKey)}`, { cookie: cookieOf(bob) });++ assert.strictEqual(invited.status, 200);++ assert.ok(!invited.text.includes(madeKey));++ const bobNow = await api('GET', `/parties/${slug}`, bob);++ assert.strictEqual(bobNow.body.party.member, true, 'opening the link makes the person a member');++});++++t('close', async () => {++ await h.close();++});++++t.run();
@@ -37,6 +37,13 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -37,6 +37,13 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_OAUTH_CLIENT_ID=chat
OV_OAUTH_CLIENT_ID=chat
OV_OAUTH_CLIENT_SECRET=written-by-service-principal-js
OV_OAUTH_CLIENT_SECRET=written-by-service-principal-js
+# openvibe.party, Watch Together's host, served by this process by the Host header
+# (server/web/party-pages.js): its own host-only session, on a second redirect URI of the same client
+# (<CHAT_PARTY_URL>/auth/callback, seeded in Network). Production: openvibe.party.
+#CHAT_PARTY_HOST=openvibe.party
+#CHAT_PARTY_URL=https://openvibe.party
+#CHAT_PARTY_OAUTH_REDIRECT_URI=https://openvibe.party/auth/callback
+
# OpenVibe.Live — every Live-owned read and side effect (server/live-context.js)
# OpenVibe.Live — every Live-owned read and side effect (server/live-context.js)
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
#LIVE_TIMEOUT_MS=4000
#LIVE_TIMEOUT_MS=4000
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.party — Nginx Configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.party.conf — keep them in sync and reload nginx
+## after changing it.
+##
+## OpenVibe.Chat (port 4400, systemd unit openvibe-chat) serves this host too, by the Host header
+## (server/web/party-pages.js): the party page shell and its start form, openvibe.party's own sign-in
+## (server/web/session.js, redirect <this host>/auth/callback on Chat's OAuth client), and the same
+## Chat API and /ws/chat the party's chat and clock use. nginx terminates TLS, sets the client address
+## from the connection only (Cloudflare → nginx → Node, as Chat's own vhost does), and caches nothing:
+## Chat's Cache-Control decides.
+##
+## The zone names carry the party prefix: nginx refuses a duplicate limit_req_zone across the whole
+## configuration, and openvibe.chat.conf already defines ovchat_*.
+## Install: sudo ov-vhost-install deploy/nginx/openvibe.party.conf openvibe.party.conf
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovparty_api:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=ovparty_auth:10m rate=10r/m;
+map $request_method $ovparty_write_key { default ""; POST $binary_remote_addr; }
+limit_req_zone $ovparty_write_key zone=ovparty_write:10m rate=30r/m;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.party www.openvibe.party;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.party$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.party;
+ ssl_certificate /etc/letsencrypt/live/openvibe.party/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.party/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ return 301 https://openvibe.party$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.party;
+ include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo)
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.party/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.party/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.party.access.log;
+ error_log /var/log/nginx/openvibe.party.error.log;
+
+ client_max_body_size 1m;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml;
+
+ 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;
+ proxy_no_cache 1;
+ proxy_cache_bypass 1;
+
+ # Host-only surfaces (the app refuses them from the proxy too).
+ location ~* ^/metrics(/|$) { return 404; }
+ location ~* ^/internal(/|$) { return 404; }
+ location ~* ^/api/v1/resources(/|$) { return 404; }
+
+ # The party's socket: its chat, presence, the shared clock and the control lease all ride it.
+ # A location's proxy_set_header replaces every server-level one, so the client address is set here
+ # again, from the connection only (never a forwarded header from the client).
+ location ^~ /ws/chat {
+ proxy_pass http://127.0.0.1:4400;
+ 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;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ }
+
+ # The session probe the shared navbar asks on every page view: on its own budget, as on openvibe.chat.
+ location = /auth/me {
+ limit_req zone=ovparty_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_set_header Connection "";
+ 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;
+ proxy_pass http://127.0.0.1:4400;
+ }
+
+ location /auth/ {
+ limit_req zone=ovparty_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_set_header Connection "";
+ 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;
+ proxy_pass http://127.0.0.1:4400;
+ }
+
+ location /api/ {
+ limit_req zone=ovparty_api burst=40 nodelay;
+ client_max_body_size 10m;
+ proxy_set_header Connection "";
+ 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;
+ proxy_pass http://127.0.0.1:4400;
+ }
+
+ location / {
+ limit_req zone=ovparty_write burst=20 nodelay;
+ proxy_set_header Connection "";
+ 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;
+ proxy_pass http://127.0.0.1:4400;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+-- phase: expand
+-- Party watch v1 (plan R1, 2026-11-07): a party may watch OpenVibe media instead of an agent stream.
+--
+-- Until now a party watched a public agent stream (or its VOD) and the party row carried the stream and the host's
+-- position. A media party watches a Media ref or an https URL on an OpenVibe host instead: the room's shared clock
+-- (server/rooms/sync-clock.js, migrations/0042_room_shared_state.sql) holds the timeline and the control lease holds
+-- the remote, so the party row keeps only the media, the page it was started from and whose party it is.
+--
+-- agent_stream_id nullable now; an agent-stream party still sets it and keeps working as it did
+-- party_kind 'agent_stream' (the default, so existing rows need no rewrite) or 'media'
+-- media a Media ref { media_id, role?, variant? } or an https URL on an OpenVibe host, as the clock
+-- validates it; agent-stream parties leave it null
+-- source_url the OpenVibe page the party was started from (same host rule), or null
+--
+-- Expand only: one nullable column, two new columns with defaults and new constraints; an older release ignores them
+-- and its INSERT (which names neither column) still fits, because party_kind defaults to 'agent_stream'.
+ALTER TABLE room_parties ALTER COLUMN agent_stream_id DROP NOT NULL;
+
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS party_kind text COLLATE "C" NOT NULL DEFAULT 'agent_stream';
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS media jsonb;
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS source_url text COLLATE "C";
+
+DO $$ BEGIN
+ ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_allowed CHECK (party_kind IN ('agent_stream', 'media'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+-- A media party names its media and no stream; an agent-stream party names its stream and no media.
+DO $$ BEGIN
+ 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 = 'media' AND agent_stream_id IS NULL AND media IS NOT NULL)
+ );
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.333.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.333.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.143.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1601,9 +1601,9 @@
@@ -1601,9 +1601,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.142.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
- "integrity": "sha512-C+h3qpRCN6Sgwd9qLsAICyzttiPlipk8p8hgaI0e2/gj3nt4/vLFmMxJsIoltYh413yJ4JwynrXAwfM0akSbmA==",
+ "version": "4.143.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.143.0",
+ "integrity": "sha512-B47vk4OuZDMb6EQRjIJDvhspUJLuwTLDS3ngXVNa+Vh75WSt7Z4B9uZLuGknbLZSjcicdRRtKPRexzCTImqqQg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.333.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.333.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.143.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+/* openvibe.party (Party watch v1, Opus's design). Desktop: the stage at 16:9 on the left, a rail on the right with the
+ people over the room's chat. Phone: the stage full width, one row of faces, the chat filling the rest. No tabs.
+ Colours follow the network theme (theme-loader.js) through Chat's tokens, with dark defaults. */
+main[data-party] { max-width: none; padding: 0; }
+main[data-party] .oc-party {
+ --pt-bg: var(--oc-bg, var(--bg-primary, #0a0f1c));
+ --pt-panel: var(--oc-panel, var(--bg-secondary, #111a2c));
+ --pt-panel-2: var(--oc-panel-2, var(--bg-tertiary, #16213a));
+ --pt-text: var(--oc-text, var(--text-primary, #e6edf7));
+ --pt-muted: var(--oc-muted, var(--text-secondary, #9fb0c8));
+ --pt-line: var(--oc-line, var(--border-color, rgba(255, 255, 255, .12)));
+ --pt-accent: var(--oc-accent, var(--accent, #60a5fa));
+ --pt-on-accent: var(--oc-on-accent, var(--on-accent, #0a0f1c));
+ --pt-nav: var(--ovnav-h, 60px);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
+ grid-template-rows: auto minmax(0, 1fr);
+ grid-template-areas: "stage people" "stage chat";
+ gap: 16px;
+ box-sizing: border-box;
+ height: calc(100dvh - var(--pt-nav));
+ max-width: 1640px;
+ margin: 0 auto;
+ padding: 16px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px));
+ color: var(--pt-text);
+}
+main[data-party] * { box-sizing: border-box; }
+main[data-party] [hidden] { display: none !important; }
+
+/* ── The stage ── */
+.oc-party .oc-party-head h1 { margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.25; overflow-wrap: anywhere; flex: 1 1 auto; min-width: 0; }
+.oc-party .oc-party-head p { margin: 0; flex-basis: 100%; order: 3; font-size: 13px; }
+.oc-party .oc-party-head p .pt-from { font-size: inherit; }
+[data-party-stage] { grid-area: stage; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
+.pt-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-width: 0; }
+.pt-title { flex: 1 1 auto; min-width: 0; margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.25; overflow-wrap: anywhere; text-wrap: balance; }
+.pt-from { flex: none; color: var(--pt-muted); font-size: 13.5px; text-decoration: none; }
+.pt-from:hover, .pt-from:focus-visible { color: var(--pt-text); text-decoration: underline; }
+[data-party-invite] { flex: none; }
+.pt-invite { min-height: 40px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--pt-accent); color: var(--pt-on-accent); font: inherit; font-size: 14px; font-weight: 750; cursor: pointer; }
+.pt-invite:hover { filter: brightness(1.08); }
+
+.pt-frame {
+ position: relative; overflow: hidden; border-radius: 16px; background: #000;
+ aspect-ratio: 16 / 9; width: min(100%, calc((100dvh - var(--pt-nav) - 200px) * 16 / 9)); max-width: 100%; align-self: center;
+ box-shadow: 0 18px 50px rgba(0, 0, 0, .35);
+}
+.pt-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
+/* Honest states over the picture: waiting for the host, joining, reconnecting. */
+.pt-veil {
+ position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center;
+ background: radial-gradient(ellipse at center, rgba(0, 0, 0, .35), rgba(0, 0, 0, .72));
+ color: #fff; font-size: clamp(15px, 2.2vw, 20px); font-weight: 700; text-wrap: balance; pointer-events: none;
+}
+.pt-drift {
+ position: absolute; top: 12px; left: 12px; padding: 4px 10px; border-radius: 999px;
+ background: rgba(0, 0, 0, .66); color: #fff; font-size: 12.5px; font-weight: 700; pointer-events: none;
+}
+.pt-floats { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
+.pt-float { position: absolute; bottom: 12px; font-size: 30px; animation: pt-rise 2.4s cubic-bezier(.2, .7, .3, 1) forwards; }
+@keyframes pt-rise { 0% { transform: translateY(0) scale(.7); opacity: 0; } 12% { opacity: 1; transform: translateY(-14px) scale(1.05); } 100% { transform: translateY(-190px) scale(1); opacity: 0; } }
+@media (prefers-reduced-motion: reduce) { .pt-float { animation: pt-fade 1.6s ease forwards; } @keyframes pt-fade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } }
+
+/* The bar under the picture: the remote pill and the reactions. */
+.pt-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
+[data-party-remote] { min-width: 0; }
+.pt-remote {
+ display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 44px; padding: 5px 6px 5px 12px;
+ border-radius: 999px; background: var(--pt-panel); border: 1px solid var(--pt-line);
+}
+.pt-remote.is-menu { border-radius: 18px; }
+.pt-remote-ic { display: grid; place-items: center; color: var(--pt-accent); }
+.pt-remote-text { font-size: 14px; font-weight: 650; overflow-wrap: anywhere; }
+.pt-remote-btn {
+ min-height: 34px; padding: 0 14px; border: 0; border-radius: 999px; background: var(--pt-accent); color: var(--pt-on-accent);
+ font: inherit; font-size: 13.5px; font-weight: 750; cursor: pointer;
+}
+.pt-remote-btn.is-quiet { background: transparent; color: var(--pt-text); box-shadow: inset 0 0 0 1px var(--pt-line); }
+.pt-remote-btn:hover { filter: brightness(1.08); }
+.pt-remote-btn.is-quiet:hover { background: var(--pt-panel-2); filter: none; }
+.pt-react { display: flex; gap: 4px; }
+.pt-react-btn {
+ width: 44px; height: 44px; border: 0; border-radius: 50%; background: var(--pt-panel); font-size: 20px; line-height: 1;
+ cursor: pointer; transition: transform .12s ease, background .12s ease;
+}
+.pt-react-btn:hover { background: var(--pt-panel-2); transform: translateY(-2px); }
+.pt-react-btn:active { transform: scale(.92); }
+.pt-flash { margin: 0; padding: 8px 12px; border-radius: 10px; background: var(--pt-panel-2); color: var(--pt-text); font-size: 14px; }
+
+/* ── The people ── */
+.pt-h { margin: 0 0 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--pt-muted); }
+[data-party-people] { grid-area: people; min-width: 0; padding: 14px; border-radius: 16px; background: var(--pt-panel); border: 1px solid var(--pt-line); }
+.pt-faces { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 10px 14px; }
+.pt-face { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.pt-avatar {
+ flex: none; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
+ background: hsl(var(--h, 210) 55% 42%); color: #fff; font-size: 13px; font-weight: 800;
+ box-shadow: 0 0 0 2px var(--pt-panel);
+}
+.pt-face.has-remote .pt-avatar { box-shadow: 0 0 0 2px var(--pt-panel), 0 0 0 4px var(--pt-accent); }
+.pt-face.is-away { opacity: .5; }
+.pt-face-name { font-size: 14px; font-weight: 650; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pt-face-tag { padding: 1px 8px; border-radius: 999px; background: color-mix(in srgb, var(--pt-accent) 18%, transparent); color: var(--pt-accent); font-size: 11.5px; font-weight: 750; }
+.pt-face-tag.is-quiet { background: var(--pt-panel-2); color: var(--pt-muted); }
+
+/* ── The chat ── */
+[data-party-chat] {
+ grid-area: chat; min-width: 0; min-height: 0; display: flex; flex-direction: column;
+ padding: 14px; border-radius: 16px; background: var(--pt-panel); border: 1px solid var(--pt-line);
+}
+.pt-thread { list-style: none; margin: 0; padding: 0 2px 8px 0; flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; overscroll-behavior: contain; }
+.pt-empty { margin: auto 0; color: var(--pt-muted); font-size: 14px; text-align: center; }
+.pt-msg { font-size: 14.5px; line-height: 1.45; overflow-wrap: anywhere; }
+.pt-msg-who { margin-right: 6px; font-weight: 750; }
+.pt-msg.is-react .pt-msg-text { font-size: 20px; }
+.pt-say { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid var(--pt-line); }
+.pt-say-input {
+ flex: 1 1 auto; min-width: 0; height: 44px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--pt-line);
+ background: var(--pt-bg); color: var(--pt-text); font: inherit; font-size: 15px;
+}
+.pt-say-btn { flex: none; height: 44px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--pt-accent); color: var(--pt-on-accent); font: inherit; font-weight: 750; cursor: pointer; }
+
+/* ── Phones and narrow windows: one column, no tabs ── */
+@media (max-width: 900px) {
+ main[data-party] .oc-party {
+ display: flex; flex-direction: column; gap: 10px; height: auto; min-height: calc(100dvh - var(--pt-nav));
+ padding: 0 0 max(12px, env(safe-area-inset-bottom, 0px));
+ }
+ [data-party-stage] { gap: 8px; }
+ .pt-head { order: 3; padding: 0 max(14px, env(safe-area-inset-left, 0px)); }
+ .pt-frame { order: 1; width: 100%; border-radius: 0; box-shadow: none; position: sticky; top: var(--pt-nav); z-index: 2; }
+ .pt-bar { order: 2; padding: 0 max(12px, env(safe-area-inset-left, 0px)); }
+ .pt-flash { order: 4; margin: 0 14px; }
+ .pt-title { font-size: 1.05rem; }
+ [data-party-people] { margin: 0 12px; padding: 10px 12px; }
+ [data-party-people] .pt-h { margin-bottom: 6px; }
+ /* One row of compact faces: the avatar over the first name, so five or six fit; the ring marks the remote. */
+ .pt-faces { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding: 5px 4px 2px; margin: -5px -4px 0; gap: 6px; }
+ .pt-faces::-webkit-scrollbar { display: none; }
+ .pt-face { flex: none; flex-direction: column; gap: 4px; width: 60px; }
+ .pt-face-name { max-width: 60px; font-size: 12px; text-align: center; }
+ .pt-face-tag { display: none; }
+ .pt-avatar { width: 40px; height: 40px; font-size: 14px; }
+ [data-party-chat] { flex: 1 1 auto; margin: 0 12px; min-height: 320px; }
+ .pt-thread { max-height: 52dvh; }
+}
+@media (max-width: 420px) {
+ .pt-react-btn { width: 40px; height: 44px; font-size: 19px; }
+ .pt-remote { flex: 1 1 100%; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,361 @@
@@ -0,0 +1,361 @@
+/* openvibe.party: watch together (plan R1, Party watch v1; Opus's design). The page shell (server/web) gives
+ <main data-party> with five empty regions and the party's view as JSON in #party-data. This script builds:
+
+ - the stage: Shared's player (<video data-ov-player>) kept on the room's timeline by Shared's ov-sync over Chat's
+ room socket (common.sync-clock@1), with honest states over it: waiting for the host, catching up (only past a
+ second of drift), reconnecting;
+ - the remote pill under it (common.control-lease@1): who has it and the one move that makes sense for you (Ask for
+ it, Pass it, Take it, and Take it back for the room's owner, which always wins);
+ - the people: faces, the one with the remote marked, the away ones dimmed while their seat waits;
+ - the room's chat, with reactions that also float over the stage;
+ - Invite: Shared's share sheet (the link and a QR code).
+
+ Everything a person wrote is set with textContent; links are built from nodes. No tabs: on a phone the stage, a row
+ of faces and the chat stack in one column. */
+(function () {
+ 'use strict';
+ var root = document.querySelector('main[data-party]');
+ var dataEl = document.getElementById('party-data');
+ if (!root || !dataEl) return;
+ var data;
+ try { data = JSON.parse(dataEl.textContent || '{}'); } catch (e) { return; }
+ // The shell (server/web/party-pages.js) sends { party: view }: the slug is its id, the host its subject.
+ if (data && data.party) {
+ var v = data.party;
+ data = {
+ slug: v.id, title: v.title, media: v.media, source_url: v.source_url, member: v.member,
+ host: { subject: v.host, name: v.host_name || null },
+ members: (v.members || []).map(function (m) { return { subject: m.subject, name: m.name }; }),
+ // The shell's join_url never carries the key (the secret is not echoed into a page): only a URL that has one
+ // is an invitation; otherwise the key kept from the link the person came by makes it (inviteUrl).
+ clock: v.clock || null, lease: v.lease || null, invite_url: v.join_url && /[?&]k=/.test(v.join_url) ? v.join_url : null,
+ };
+ }
+
+ var slug = root.getAttribute('data-party') || data.slug;
+ var region = function (name) { return root.querySelector('[data-party-' + name + ']'); };
+ var stage = region('stage'), people = region('people'), chat = region('chat'), remote = region('remote'), invite = region('invite');
+ var REACTIONS = ['😂', '😮', '🔥', '❤️', '👏'];
+ var DRIFT_SHOWN_MS = 1000;
+ var self = data.self || null; // { subject, name } for a signed-in member
+ var owner = data.host ? data.host.subject : null; // the room's owner: Take it back always works for them
+ var names = {};
+ (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 lease = data.lease || null, clock = data.clock || null, asked = null, everPlayed = !!(clock && (!clock.paused || clock.position_ms > 0));
+ var away = {}; // subject -> when they left (their seat waits a minute)
+ var ws = null, sync = null, delay = 1000, latest = 0, joined = false;
+
+ // ── small helpers ────────────────────────────────────────────────────────────
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function nameOf(subject) { return subject === (self && self.subject) ? 'You' : (names[subject] || 'Someone'); }
+ function initials(name) { var p = String(name || '?').trim().split(/\s+/); return ((p[0] || '?')[0] + (p[1] ? p[1][0] : '')).toUpperCase(); }
+ // FNV-1a over the whole subject, so two subjects that differ in one letter still get far-apart colours.
+ function hue(s) { var h = 2166136261; s = String(s || ''); for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return (h % 12) * 30; }
+ function realName(subject) { return names[subject] || 'Someone'; }
+ function send(frame) { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify(frame)); return true; } catch (e) { /* closed */ } } return false; }
+ function token() {
+ if (data.token) return data.token;
+ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
+ return m ? decodeURIComponent(m[1]) : undefined;
+ }
+ // The invite link carries the party's key. The server shows the key once (when the party is made); a member who
+ // came by the link keeps it here, so they can invite others too.
+ function inviteUrl() {
+ var KEY = 'ov-party-key:' + slug, k = null;
+ try { k = new URLSearchParams(location.search).get('k'); } catch (e) { /* old browser */ }
+ try { if (k) localStorage.setItem(KEY, k); else k = localStorage.getItem(KEY); } catch (e) { /* storage blocked */ }
+ return data.invite_url || ('https://openvibe.party/p/' + encodeURIComponent(slug) + (k ? '?k=' + encodeURIComponent(k) : ''));
+ }
+
+ // ── the stage ────────────────────────────────────────────────────────────────
+ var video = el('video');
+ video.setAttribute('data-ov-player', '');
+ video.setAttribute('playsinline', '');
+ video.preload = 'auto';
+ video.controls = true;
+ var src = typeof (clock && clock.media) === 'string' ? clock.media : (typeof data.media === 'string' ? data.media : null);
+ if (src) video.src = src;
+ var frame = el('div', 'pt-frame');
+ frame.appendChild(video);
+ var veil = el('div', 'pt-veil'); // waiting / reconnecting, over the picture
+ veil.setAttribute('role', 'status');
+ var drift = el('span', 'pt-drift', 'Catching up…');
+ drift.hidden = true;
+ 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);
+ // The shell's own header (the title and who started it) leads; without one, the page writes its own.
+ var shellHead = root.querySelector('.oc-party-head');
+ var head = shellHead || el('div', 'pt-head');
+ if (shellHead) head.classList.add('pt-head');
+ else head.appendChild(el('h1', 'pt-title', data.title || 'Watch party'));
+ if (data.source_url) {
+ var from = el('a', 'pt-from', 'Open on its page ↗');
+ from.href = data.source_url; from.target = '_blank'; from.rel = 'noopener';
+ // Beside "Started by …" when the shell has that line; otherwise in the header.
+ var by = shellHead && shellHead.querySelector('p');
+ if (by) { by.appendChild(document.createTextNode(' · ')); by.appendChild(from); } else head.appendChild(from);
+ }
+ var bar = el('div', 'pt-bar'); // the remote pill and the reactions, under the picture
+ // Invite sits right after the title, on its line.
+ 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);
+ stage.appendChild(bar);
+ if (remote) bar.appendChild(remote);
+ var react = el('div', 'pt-react');
+ react.setAttribute('aria-label', 'React');
+ REACTIONS.forEach(function (r) {
+ var b = el('button', 'pt-react-btn', r);
+ b.type = 'button';
+ b.setAttribute('aria-label', 'React ' + r);
+ // It rises when the room's echo arrives, for everyone at once, the sender too.
+ b.addEventListener('click', function () { send({ type: 'room_message', message: r }); });
+ react.appendChild(b);
+ });
+ bar.appendChild(react);
+
+ function rise(emoji) {
+ var f = el('span', 'pt-float', emoji);
+ f.style.left = (12 + Math.random() * 70) + '%';
+ floats.appendChild(f);
+ setTimeout(function () { f.remove(); }, 2600);
+ }
+ 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() {
+ var text = '';
+ if (!ws || ws.readyState !== 1) text = joined ? 'Reconnecting…' : 'Joining the party…';
+ else if (!src) text = 'This party has nothing to play yet.';
+ else if (!everPlayed && clock && clock.paused) text = iHold() ? 'Press play when everyone is here.' : 'Waiting for ' + (holder().kind === 'person' ? nameOf(holder().subject) : 'the host') + ' to press play';
+ veil.textContent = text;
+ veil.hidden = !text;
+ frame.classList.toggle('is-waiting', !!text);
+ }
+
+ // ── the remote ───────────────────────────────────────────────────────────────
+ function pill(text, moves) {
+ remote.textContent = '';
+ var p = el('div', 'pt-remote');
+ var icon = el('span', 'pt-remote-ic');
+ icon.setAttribute('aria-hidden', 'true');
+ icon.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="7" y="2.5" width="10" height="19" rx="3"/><circle cx="12" cy="8" r="1.6"/><path d="M10 13h4M10 16.5h4"/></svg>';
+ p.appendChild(icon);
+ p.appendChild(el('span', 'pt-remote-text', text));
+ (moves || []).forEach(function (m) {
+ var b = el('button', 'pt-remote-btn' + (m.quiet ? ' is-quiet' : ''), m.label);
+ b.type = 'button';
+ b.addEventListener('click', m.run);
+ p.appendChild(b);
+ });
+ remote.appendChild(p);
+ }
+ function passMenu() {
+ var here = (lease && lease.watchers || []).filter(function (s) { return !self || s !== self.subject; });
+ remote.textContent = '';
+ var p = el('div', 'pt-remote is-menu');
+ p.appendChild(el('span', 'pt-remote-text', here.length ? 'Pass the remote to' : 'Nobody else is here yet'));
+ here.forEach(function (s) {
+ var b = el('button', 'pt-remote-btn is-quiet', nameOf(s));
+ b.type = 'button';
+ b.addEventListener('click', function () { send({ type: 'lease.grant', to: { kind: 'person', subject: s } }); });
+ p.appendChild(b);
+ });
+ var back = el('button', 'pt-remote-btn is-quiet', 'Cancel');
+ back.type = 'button';
+ back.addEventListener('click', paintRemote);
+ p.appendChild(back);
+ remote.appendChild(p);
+ }
+ function paintRemote() {
+ if (!remote) return;
+ var h = holder(), mine = iHold(), isOwner = !!(self && self.subject === owner);
+ var takeBack = { label: 'Take it back', run: function () { send({ type: 'lease.take' }); } };
+ if (!self) return pill(h.kind === 'person' ? nameOf(h.subject) + ' has the remote' : 'Sign in to share the remote', []);
+ if (mine) {
+ var moves = [{ label: 'Pass it', run: passMenu }, { label: 'Let go', quiet: true, run: function () { send({ type: 'lease.release' }); } }];
+ if (asked && asked !== self.subject) {
+ moves.unshift({ label: 'Pass to ' + nameOf(asked), run: function () { send({ type: 'lease.grant', to: { kind: 'person', subject: asked } }); asked = null; } });
+ return pill(nameOf(asked) + ' asks for the remote', moves);
+ }
+ return pill('You have the remote', moves);
+ }
+ if (h.kind === 'nobody') return pill('Nobody has the remote', [{ label: 'Take it', run: function () { send({ type: 'lease.request' }); } }]);
+ if (h.kind === 'ai') return pill((names[h.subject] || 'Vibe') + ' has the remote', isOwner ? [takeBack] : []);
+ var label = nameOf(h.subject) + ' has the remote' + (away[h.subject] ? ' (away a moment)' : '');
+ var ask = { label: asked === self.subject ? 'Asked' : 'Ask for it', quiet: asked === self.subject, run: function () { if (send({ type: 'lease.request' })) { asked = self.subject; paintRemote(); } } };
+ return pill(label, isOwner ? [ask, takeBack] : [ask]);
+ }
+
+ // ── the people ───────────────────────────────────────────────────────────────
+ function paintPeople() {
+ if (!people) return;
+ people.textContent = '';
+ var watching = (lease && lease.watchers) || [];
+ var all = Object.keys(names);
+ watching.forEach(function (s) { if (all.indexOf(s) < 0) all.push(s); });
+ // Here first, then the ones whose seat waits, then the members who are not here.
+ var rank = function (s) { return watching.indexOf(s) >= 0 ? 0 : away[s] ? 1 : 2; };
+ all.sort(function (a, b) { return rank(a) - rank(b) || nameOf(a).localeCompare(nameOf(b)); });
+ var h = people.appendChild(el('h2', 'pt-h', watching.length === 1 ? '1 watching' : watching.length + ' watching'));
+ h.id = 'pt-people-h';
+ var list = el('ul', 'pt-faces');
+ list.setAttribute('aria-labelledby', 'pt-people-h');
+ all.forEach(function (s) {
+ var here = watching.indexOf(s) >= 0, li = el('li', 'pt-face' + (here ? '' : ' is-away'));
+ var dot = el('span', 'pt-avatar', initials(realName(s)));
+ dot.style.setProperty('--h', String(hue(s)));
+ li.appendChild(dot);
+ li.appendChild(el('span', 'pt-face-name', nameOf(s)));
+ var hd = holder();
+ if (hd.kind === 'person' && hd.subject === s) { li.classList.add('has-remote'); li.appendChild(el('span', 'pt-face-tag', 'Remote')); }
+ else if (!here && away[s]) li.appendChild(el('span', 'pt-face-tag is-quiet', 'Back soon'));
+ if (s === owner) li.title = nameOf(s) + ' made this party';
+ list.appendChild(li);
+ });
+ people.appendChild(list);
+ }
+
+ // ── the chat ─────────────────────────────────────────────────────────────────
+ var thread = el('ol', 'pt-thread');
+ thread.setAttribute('aria-live', 'polite');
+ var empty = el('li', 'pt-empty', 'Say hello. Everyone here sees it.');
+ thread.appendChild(empty);
+ var form = el('form', 'pt-say');
+ var input = el('input', 'pt-say-input');
+ input.type = 'text'; input.maxLength = 500; input.placeholder = 'Message the party'; input.setAttribute('aria-label', 'Message the party'); input.autocomplete = 'off';
+ var go = el('button', 'pt-say-btn', 'Send');
+ go.type = 'submit';
+ form.appendChild(input); form.appendChild(go);
+ if (chat) {
+ chat.appendChild(el('h2', 'pt-h', 'Chat'));
+ chat.appendChild(thread);
+ chat.appendChild(form);
+ }
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ var text = input.value.trim();
+ if (!text) return;
+ if (send({ type: 'room_message', message: text })) input.value = '';
+ });
+ function textNodes(parent, text) {
+ var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;
+ while ((m = re.exec(text))) {
+ if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));
+ var url = m[0].replace(/[.,!?)]+$/, ''), a = el('a', null, url);
+ a.href = url; a.target = '_blank'; a.rel = 'noopener nofollow ugc';
+ parent.appendChild(a); last = m.index + url.length; re.lastIndex = last;
+ }
+ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
+ }
+ var ONLY_EMOJI = /^(?:\p{Extended_Pictographic}|️|){1,8}$/u;
+ function render(m, live) {
+ if (!m || m.id == null || thread.querySelector('[data-id="' + m.id + '"]')) return;
+ if (empty.parentNode) empty.remove();
+ var stick = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120;
+ var text = String(m.message || '');
+ var li = el('li', 'pt-msg' + (ONLY_EMOJI.test(text) ? ' is-react' : ''));
+ li.dataset.id = String(m.id);
+ li.appendChild(el('b', 'pt-msg-who', m.display_name || m.username || 'Someone'));
+ var t = el('span', 'pt-msg-text');
+ textNodes(t, text);
+ li.appendChild(t);
+ thread.appendChild(li);
+ latest = Math.max(latest, Number(m.id) || 0);
+ if (stick) thread.scrollTop = thread.scrollHeight;
+ if (live && ONLY_EMOJI.test(text)) rise(text);
+ }
+ function catchUp() {
+ var headers = {};
+ var t = token();
+ if (t) headers.Authorization = 'Bearer ' + t;
+ fetch('/api/chat/rooms/' + encodeURIComponent(slug) + '/messages' + (latest ? '?after=' + latest : ''), { headers: headers, credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { if (d && d.messages) d.messages.forEach(function (m) { render(m, false); }); })
+ .catch(function () { /* offline: the socket brings what comes next */ });
+ }
+
+ // ── the socket ───────────────────────────────────────────────────────────────
+ function connect() {
+ if (!window.WebSocket) { paintVeil(); return; }
+ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
+ ws.onopen = function () { delay = 1000; send({ type: 'join_room', room: slug, token: token() }); paintVeil(); };
+ ws.onmessage = function (ev) {
+ var m;
+ try { m = JSON.parse(ev.data); } catch (e) { return; }
+ if (m.room && m.room !== slug) return;
+ 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 });
+ catchUp();
+ } else if (m.type === 'sync' && m.state) {
+ clock = m.state;
+ if (!clock.paused || clock.position_ms > 0) everPlayed = true;
+ if (!src && typeof clock.media === 'string') { src = clock.media; video.src = src; }
+ } else if (m.type === 'lease' && m.state) {
+ var before = lease && lease.watchers ? lease.watchers : [];
+ lease = m.state;
+ before.forEach(function (s) { if ((lease.watchers || []).indexOf(s) < 0) away[s] = Date.now(); });
+ (lease.watchers || []).forEach(function (s) { delete away[s]; });
+ if (iHold() || holder().kind !== 'person' || (asked && holder().subject === asked)) asked = null;
+ } else if (m.type === 'lease.request' && m.request && m.request.requester) {
+ if (iHold()) asked = m.request.requester.subject;
+ } else if (m.type === 'room_message' && m.message) {
+ render(m.message, true);
+ } else if (m.type === 'lease_error' || m.type === 'sync_error' || m.type === 'room_error') {
+ flash(m.message || 'That did not work.');
+ }
+ paintVeil(); paintRemote(); paintPeople();
+ };
+ ws.onclose = function () {
+ if (sync && sync.destroy) { try { sync.destroy(); } catch (e) { /* gone */ } }
+ sync = null;
+ paintVeil();
+ setTimeout(connect, delay);
+ delay = Math.min(delay * 2, 30000);
+ };
+ }
+ var flashEl = el('p', 'pt-flash');
+ flashEl.setAttribute('role', 'alert');
+ flashEl.hidden = true;
+ stage.appendChild(flashEl);
+ var flashTimer = null;
+ function flash(text) { flashEl.textContent = text; flashEl.hidden = false; clearTimeout(flashTimer); flashTimer = setTimeout(function () { flashEl.hidden = true; }, 4000); }
+
+ // Catching up shows only past a second of drift, and only while the room plays.
+ setInterval(function () {
+ var d = sync && sync.drift ? Math.abs(Number(sync.drift()) || 0) : 0;
+ drift.hidden = !(clock && !clock.paused && d > DRIFT_SHOWN_MS);
+ // A seat waits a minute; after it, the person is simply not here.
+ var now = Date.now(), changed = false;
+ Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });
+ if (changed) { paintPeople(); paintRemote(); }
+ }, 500);
+ video.addEventListener('play', function () { everPlayed = true; paintVeil(); });
+
+ // ── invite ───────────────────────────────────────────────────────────────────
+ if (invite) {
+ var ib = el('button', 'pt-invite', 'Invite');
+ ib.type = 'button';
+ ib.addEventListener('click', function () {
+ var url = inviteUrl();
+ var open = function () { window.OVShare.open({ url: url, title: data.title || 'Watch with me', kind: 'party', id: slug }); };
+ if (window.OVShare) return open();
+ var s = document.createElement('script');
+ s.src = '/shared/ov-share.js';
+ s.onload = function () { if (window.OVShare) open(); };
+ s.onerror = function () {
+ if (navigator.clipboard) navigator.clipboard.writeText(url).then(function () { flash('Invite link copied'); }, function () { flash(url); });
+ else flash(url);
+ };
+ document.head.appendChild(s);
+ });
+ invite.appendChild(ib);
+ }
+
+ paintVeil(); paintRemote(); paintPeople();
+ connect();
+})();
@@ -53,6 +53,8 @@ function getAllowedOrigins() {
@@ -53,6 +53,8 @@ function getAllowedOrigins() {
}
}
allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');
allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');
allowed.add(normalizeOrigin(config.web.baseUrl) || 'https://openvibe.chat');
allowed.add(normalizeOrigin(config.web.baseUrl) || 'https://openvibe.chat');
+ // openvibe.party is Chat's other host (Watch Together): its page's socket is an upgrade from this origin.
+ allowed.add(normalizeOrigin(config.party && config.party.baseUrl) || 'https://openvibe.party');
allowed.add('https://openvibe.games');
allowed.add('https://openvibe.games');
allowed.add('https://openvibe.tools');
allowed.add('https://openvibe.tools');
if (!config.isProduction) {
if (!config.isProduction) {
@@ -339,6 +341,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -339,6 +341,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// The chat plays queue of a room (Agent Streams S6): the votes and the moderators' hand.
// The chat plays queue of a room (Agent Streams S6): the votes and the moderators' hand.
app.use('/api/chat/rooms', require('./chat/play-queue-routes').member);
app.use('/api/chat/rooms', require('./chat/play-queue-routes').member);
app.use('/api/chat/rooms', require('./rooms/routes'));
app.use('/api/chat/rooms', require('./rooms/routes'));
+ // Watch parties' own API (server/rooms/party-routes.js): start a media party, read one a viewer may see.
+ app.use('/api/chat/parties', require('./rooms/party-routes'));
app.use('/api/chat/vibe', require('./rooms/vibe-routes'));
app.use('/api/chat/vibe', require('./rooms/vibe-routes'));
// Support inboxes (server/inbox/): the widget's visitor API, and the inbox's people.
// Support inboxes (server/inbox/): the widget's visitor API, and the inbox's people.
app.use('/api/chat/widget/inbox/:id', require('./inbox/routes').widget);
app.use('/api/chat/widget/inbox/:id', require('./inbox/routes').widget);
@@ -375,6 +379,11 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -375,6 +379,11 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
limits.identify(req, res, () => callReads(req, res, () => callRoutes(req, res, next)));
limits.identify(req, res, () => callReads(req, res, () => callRoutes(req, res, next)));
});
});
+ // openvibe.party, Watch Together's host: its pages, sign-in and assets (server/web/party-pages.js). Its own host
+ // only; every other request falls through to openvibe.chat's site below.
+ const party = require('./web/party-pages').createPartyRoutes({ config });
+ app.use((req, res, next) => (party.isPartyHost(req) ? party.router(req, res, next) : next()));
+
// openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).
// openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).
const webRoutes = require('./web/pages').createWebRoutes({ config });
const webRoutes = require('./web/pages').createWebRoutes({ config });
app.use(webRoutes);
app.use(webRoutes);
@@ -90,6 +90,14 @@ module.exports = {
@@ -90,6 +90,14 @@ module.exports = {
// Where a room's Community attachments link (a space is <communityUrl>/forum/<slug>).
// Where a room's Community attachments link (a space is <communityUrl>/forum/<slug>).
communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
},
},
+ // openvibe.party, Watch Together's host (Party watch v1, plan R1): Chat serves it too (server/web/party-pages.js),
+ // with its own host-only session. Its sign-in is a second redirect URI on Chat's OAuth client (a Network seed).
+ party: {
+ host: String(process.env.CHAT_PARTY_HOST || 'openvibe.party').toLowerCase(),
+ baseUrl: strip(process.env.CHAT_PARTY_URL || (isProduction ? 'https://openvibe.party' : `http://localhost:${port}`)),
+ redirectUri: process.env.CHAT_PARTY_OAUTH_REDIRECT_URI || `${strip(process.env.CHAT_PARTY_URL || (isProduction ? 'https://openvibe.party' : `http://localhost:${port}`))}/auth/callback`,
+ scope: 'profile theme',
+ },
// Pictures in rooms and channels (server/rooms/pictures.js): uploaded to OpenVibe.Pics as the person (their own
// Pictures in rooms and channels (server/rooms/pictures.js): uploaded to OpenVibe.Pics as the person (their own
// Network token) through MediaHub on this host, and shown inline. Off without CHAT_PICS_INTERNAL_URL.
// Network token) through MediaHub on this host, and shown inline. Off without CHAT_PICS_INTERNAL_URL.
pics: {
pics: {
@@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
'use strict';
'use strict';
/**
/**
- * Watch parties (Agent Streams S7, design 2026-11-06 §4): a Chat room of kind 'party' where friends watch a public
- * agent stream or its VOD in sync, with their own chat beside the stream's.
+ * Watch parties (Agent Streams S7, design 2026-11-06 §4; media parties, plan R1 2026-11-07): a Chat room of kind
+ * 'party' where friends watch something in sync, with their own chat beside it.
*
*
- * - Sync: on a VOD the position follows the host's; on a live stream it follows the live edge (no position).
+ * - An agent-stream party watches a public agent stream or its VOD: on a VOD the position follows the host's; on a
+ * live stream it follows the live edge (no position).
+ * - A media party watches a Media ref or an https URL on an OpenVibe host, exactly as a room's shared clock accepts
+ * media (server/rooms/sync-clock.js). Its timeline is the room's clock (paused at 0 when it starts) and its remote
+ * is the room's control lease, granted to the person who started it.
* - Joining is by the party's link. The link carries a secret, the row stores its sha256 only, and a stranger
* - Joining is by the party's link. The link carries a secret, the row stores its sha256 only, and a stranger
* without it sees the same 404 as a missing room.
* without it sees the same 404 as a missing room.
* - Private by default: rooms.visibility is 'private', the party is never in the directory, and `listed` only lets
* - Private by default: rooms.visibility is 'private', the party is never in the directory, and `listed` only lets
@@ -12,13 +16,17 @@
@@ -12,13 +16,17 @@
* The room itself is rooms.js's (name, members, messages, moderation); this module owns only the party facts.
* The room itself is rooms.js's (name, members, messages, moderation); this module owns only the party facts.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
+const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
const rooms = require('./rooms');
const rooms = require('./rooms');
+const syncClock = require('./sync-clock');
+const controlLease = require('./control-lease');
const AGENT_STREAM_RE = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
const AGENT_STREAM_RE = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
const VOD_RE = /^\d{1,16}$/;
const VOD_RE = /^\d{1,16}$/;
const MAX_TITLE = 200;
const MAX_TITLE = 200;
const BASE_URL = () => String(process.env.OV_CHAT_PUBLIC_URL || 'https://openvibe.chat').replace(/\/+$/, '');
const BASE_URL = () => String(process.env.OV_CHAT_PUBLIC_URL || 'https://openvibe.chat').replace(/\/+$/, '');
+const PARTY_BASE_URL = () => String(config.party.baseUrl || 'https://openvibe.party').replace(/\/+$/, '');
class PartyError extends Error {
class PartyError extends Error {
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
@@ -42,6 +50,35 @@ function vodOf(value) {
@@ -42,6 +50,35 @@ function vodOf(value) {
function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }
function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }
+/**
+ * A media party's media: a Media ref or an https URL on an OpenVibe host, validated by the shared clock's own rules
+ * (server/rooms/sync-clock.js). A refusal is the party API's 422, not the clock's.
+ */
+function mediaOf(value) {
+ try {
+ return syncClock._mediaOf(value);
+ } catch (err) {
+ if (err instanceof syncClock.SyncError) fail(422, 'party.media', err.message);
+ throw err;
+ }
+}
+
+/** The OpenVibe page a party was started from: an https page on an OpenVibe host, or null. */
+function sourceUrlOf(value) {
+ if (value == null || value === '') return null;
+ if (typeof value !== 'string' || !syncClock.onOpenVibe(value)) fail(422, 'party.source_url', 'source_url is an https page on an OpenVibe site');
+ return value;
+}
+
+/** The party row's media, as stored (jsonb comes back as an object; older drivers may give text). */
+function mediaFromRow(value) {
+ if (value == null) return null;
+ if (typeof value !== 'string') return value;
+ try { return JSON.parse(value); } catch { return null; }
+}
+
+const kindOfParty = (party) => (party && party.party_kind === 'media' ? 'media' : 'agent_stream');
+
/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */
/** 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()) {
function syncOf(party, now = Date.now()) {
if (!party || party.mode === 'live') return { mode: 'live', playing: true };
if (!party || party.mode === 'live') return { mode: 'live', playing: true };
@@ -55,32 +92,70 @@ function syncOf(party, now = Date.now()) {
@@ -55,32 +92,70 @@ function syncOf(party, now = Date.now()) {
};
};
}
}
-function joinUrl(row, rawSecret = null) {
- const base = `${BASE_URL()}/rooms/${encodeURIComponent(row.slug)}`;
+function joinUrl(row, kind, rawSecret = null) {
+ const slug = encodeURIComponent(row.slug);
+ const base = kind === 'media' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;
return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
}
}
function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
+ const kind = kindOfParty(party);
return {
return {
id: row.slug,
id: row.slug,
- agent_stream_id: party.agent_stream_id,
+ kind,
+ agent_stream_id: party.agent_stream_id || null,
vod_id: party.vod_id == null ? null : String(party.vod_id),
vod_id: party.vod_id == null ? null : String(party.vod_id),
+ ...(kind === 'media' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),
host: party.host_subject,
host: party.host_subject,
title: row.name,
title: row.name,
- sync: syncOf(party, now),
+ ...(kind === 'agent_stream' ? { sync: syncOf(party, now) } : {}),
visibility: 'private',
visibility: 'private',
listed: !!party.listed,
listed: !!party.listed,
- join_url: joinUrl(row, rawSecret),
+ join_url: joinUrl(row, kind, rawSecret),
created_at: new Date(party.created_at instanceof Date ? party.created_at.getTime() : Date.parse(String(party.created_at).includes('T') ? party.created_at : `${String(party.created_at).replace(' ', 'T')}Z`)).toISOString(),
created_at: new Date(party.created_at instanceof Date ? party.created_at.getTime() : Date.parse(String(party.created_at).includes('T') ? party.created_at : `${String(party.created_at).replace(' ', 'T')}Z`)).toISOString(),
...(party.ended_at ? { ended_at: new Date(party.ended_at).toISOString() } : {}),
...(party.ended_at ? { ended_at: new Date(party.ended_at).toISOString() } : {}),
};
};
}
}
+/**
+ * What a viewer sees of a party: the view, the host's name, and — for a media party — the room's clock. A member also
+ * gets the members (their subjects and names) and the remote's state; someone holding only the link's secret gets
+ * neither, because the lease's watchers are live presence and the members are the room's people.
+ * → null when the caller may not view it at all (the caller answers the missing-room 404).
+ */
+async function detail(row, party, { user = null, key = null, now = Date.now() } = {}) {
+ const access = await rooms.access(row, user || null);
+ const member = !!access.read;
+ if (!member && !keyMatches(party, key)) return null;
+ const host = await db.get('SELECT username, display_name FROM ctx_users WHERE subject_id = ? LIMIT 1', [party.host_subject]);
+ const out = {
+ ...view(row, party, { now }),
+ host_name: host ? (host.display_name || host.username) : null,
+ member,
+ };
+ if (kindOfParty(party) === 'media') {
+ const room = { id: row.id, slug: row.slug };
+ out.clock = await syncClock.get(room);
+ if (member) {
+ out.lease = await controlLease.state(room);
+ out.members = (await rooms.members(row, { subjects: true }))
+ .filter((m) => m.role !== 'blocked')
+ .map((m) => ({ subject: m.subject_id || null, name: m.display_name || m.username, role: m.role }));
+ }
+ }
+ return out;
+}
+
/**
/**
* Start a party on a public agent stream or its VOD. The room is private; only the link (with its secret) opens it.
* Start a party on a public agent stream or its VOD. The room is private; only the link (with its secret) opens it.
* Returns the party and the one time its raw join secret exists.
* Returns the party and the one time its raw join secret exists.
*/
*/
-async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = null, title, listed = false } = {}) {
+async function create(user, input = {}) {
+ if (input && input.media !== undefined && input.media !== null && input.media !== '') return await createMedia(user, input);
+ return await createAgentStream(user, input);
+}
+
+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 (!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');
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');
const streamId = String(agentStreamId || '');
const streamId = String(agentStreamId || '');
@@ -103,8 +178,37 @@ async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = nu
@@ -103,8 +178,37 @@ async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = nu
return { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };
return { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };
}
}
+/**
+ * Start a party on OpenVibe media: the room, the party row, the room's clock seeded paused at 0 with that media, and
+ * the remote granted to the person who started it. The join link is openvibe.party/p/<slug>?k=…, and the secret is
+ * in this answer only.
+ */
+async function createMedia(user, { media, source_url: sourceUrl = 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');
+ const chosen = mediaOf(media);
+ const source = sourceUrlOf(sourceUrl);
+ const name = titleOf(title);
+ const raw = secret();
+ const roomName = (name.length < 3 ? `Party: ${name}` : name).slice(0, 40);
+ const slug = `party-${crypto.randomBytes(4).toString('hex')}`;
+ const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });
+ const id = room.id;
+ 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, media, source_url)
+ VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, 'media', ?, ?)`,
+ [id, user.id, user.subject_id || '', !!listed, sha256(raw), JSON.stringify(chosen), source]
+ );
+ const shared = { id, slug: room.slug };
+ await syncClock.set(shared, user.subject_id, { media: chosen, position_ms: 0, rate: 1, paused: true });
+ await controlLease.take(shared, user.subject_id, { watchers: [] });
+ const row = await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
+ const party = await db.get('SELECT * FROM room_parties WHERE room_id = ?', [id]);
+ return { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };
+}
+
async function bySlug(slug) {
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 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 FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
return row || null;
return row || null;
}
}
@@ -114,6 +218,7 @@ function partyOf(row) {
@@ -114,6 +218,7 @@ function partyOf(row) {
host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,
host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,
position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,
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,
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,
} : null;
} : null;
}
}
@@ -145,6 +250,7 @@ async function joinWithLink(row, party, user, key) {
@@ -145,6 +250,7 @@ async function joinWithLink(row, party, user, key) {
/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */
/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */
async function setSync(row, party, user, { position_s: position, playing } = {}) {
async function setSync(row, party, user, { position_s: position, playing } = {}) {
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');
+ if (kindOfParty(party) === 'media') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');
if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');
if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');
let pos = party.position_s;
let pos = party.position_s;
if (position !== undefined) {
if (position !== undefined) {
@@ -174,5 +280,5 @@ async function end(row, party, user) {
@@ -174,5 +280,5 @@ async function end(row, party, user) {
module.exports = {
module.exports = {
AGENT_STREAM_RE, MAX_TITLE, PartyError,
AGENT_STREAM_RE, MAX_TITLE, PartyError,
- create, bySlug, partyOf, view, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,
+ create, createMedia, createAgentStream, bySlug, partyOf, view, detail, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+/**
+ * /api/chat/parties — Watch Together's parties, the way openvibe.party and the sites that start one reach them.
+ * The room routes (server/rooms/routes.js, /api/chat/rooms/parties…) keep serving the agent-stream parties; both
+ * go through server/rooms/parties.js, so the secret, the 404 and the clock are decided in one place.
+ *
+ * POST / { media, source_url?, title } — a signed-in person starts a media party. 201 { party, join_secret }:
+ * the join link (openvibe.party/p/<slug>?k=…) carries the secret, and this answer is the only place
+ * it exists. The room's clock starts paused at 0 on the media and the remote is the creator's.
+ * GET /:slug ?k=<secret> — the party as a viewer sees it: title, media, source_url, the host (subject and name),
+ * the clock, and (for a member) the people and the remote. A stranger without the key, or with a
+ * wrong one, gets the same 404 as a missing room.
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { requireAuth, optionalAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
+const parties = require('../rooms/parties');
+
+const router = express.Router();
+
+// Starting a party: the same budget as making a room (owners keep at most MAX_ROOMS_PER_OWNER).
+const createLimit = limits('chat.party.create', { minute: 10, hour: 60 });
+
+function send(res, err) {
+ if (err instanceof parties.PartyError) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.error('[Parties]', err && err.message);
+ return res.status(500).json({ error: 'Something went wrong' });
+}
+
+router.post('/', requireAuth, createLimit, async (req, res) => {
+ try {
+ const out = await parties.create(req.user, req.body || {});
+ res.status(201).json({ party: out.party, join_secret: out.join_secret });
+ } catch (err) { send(res, err); }
+});
+
+router.get('/:slug', optionalAuth, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug);
+ const party = parties.partyOf(row);
+ const out = row && party ? await parties.detail(row, party, { user: req.user || null, key: req.query.k }) : null;
+ if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out });
+ } catch (err) { send(res, err); }
+});
+
+module.exports = router;
@@ -141,8 +141,9 @@ router.get('/:slug/party', optionalAuth, async (req, res) => {
@@ -141,8 +141,9 @@ router.get('/:slug/party', optionalAuth, async (req, res) => {
const row = await parties.bySlug(req.params.slug);
const row = await parties.bySlug(req.params.slug);
const party = parties.partyOf(row);
const party = parties.partyOf(row);
if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
- if (!(await parties.mayView(row, party, req.user || null, req.query.k))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: parties.view(row, party) });
+ const out = await parties.detail(row, party, { user: req.user || null, key: req.query.k });
+ if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
@@ -204,6 +204,6 @@ function close() { _cache.clear(); _queues.clear(); }
@@ -204,6 +204,6 @@ function close() { _cache.clear(); _queues.clear(); }
module.exports = {
module.exports = {
MAX_POSITION_MS, MAX_RATE, SyncError, set, get, evict, close,
MAX_POSITION_MS, MAX_RATE, SyncError, set, get, evict, close,
- // Exposed for the WS layer and its tests; the validation stays here.
- _mediaOf: mediaOf, _positionOf: positionOf, _rateOf: rateOf, _pausedOf: pausedOf,
+ // Exposed for the WS layer, the party row and their tests; the validation stays here.
+ onOpenVibe, _mediaOf: mediaOf, _positionOf: positionOf, _rateOf: rateOf, _pausedOf: pausedOf,
};
};
@@ -57,12 +57,12 @@ function links(o) {
@@ -57,12 +57,12 @@ function links(o) {
return LINKS.map((l) => ({ key: l.key, href: l.href, icon: l.icon, more: l.more, label: counts[l.key] > 0 ? `${l.label} (${counts[l.key] > 99 ? '99+' : counts[l.key]})` : l.label }));
return LINKS.map((l) => ({ key: l.key, href: l.href, icon: l.icon, more: l.more, label: counts[l.key] > 0 ? `${l.label} (${counts[l.key] > 99 ? '99+' : counts[l.key]})` : l.label }));
}
}
-function navConfig(o, config) {
+function navConfig(o, config, site = {}, siteLinks = null) {
return {
return {
- service: 'chat',
+ service: site.service || 'chat',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: links(o).map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: o.active === l.key })),
- history: { type: 'page', title: o.title || SITE_NAME },
+ links: (siteLinks || site.links || links(o)).map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: o.active === l.key })),
+ history: { type: 'page', title: o.title || site.name || SITE_NAME },
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
@@ -78,7 +78,7 @@ function navConfig(o, config) {
@@ -78,7 +78,7 @@ function navConfig(o, config) {
* pictures, avatars), and connections go to this site, Network (sign-in, the Frame) and Events (release and
* pictures, avatars), and connections go to this site, Network (sign-in, the Frame) and Events (release and
* notification streams). Checked on production pages with the policy attached before shipping (2026-10-17).
* notification streams). Checked on production pages with the policy attached before shipping (2026-10-17).
*/
*/
-function contentSecurityPolicy(nonce) {
+function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
return [
return [
"default-src 'self'",
"default-src 'self'",
// 'inline-speculation-rules': the shared navbar's prefetch rules (Shared 4.70) run under the nonce policy too.
// 'inline-speculation-rules': the shared navbar's prefetch rules (Shared 4.70) run under the nonce policy too.
@@ -87,7 +87,7 @@ function contentSecurityPolicy(nonce) {
@@ -87,7 +87,7 @@ function contentSecurityPolicy(nonce) {
"img-src 'self' data: blob: https:",
"img-src 'self' data: blob: https:",
"media-src 'self' blob: https:",
"media-src 'self' blob: https:",
"font-src 'self' data:",
"font-src 'self' data:",
- "connect-src 'self' wss://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com",
+ `connect-src 'self' ${wsOrigin} 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).
// A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url).
"frame-src 'self' https://openvibe.bot",
"frame-src 'self' https://openvibe.bot",
"frame-ancestors 'self'",
"frame-ancestors 'self'",
@@ -110,18 +110,25 @@ function contentSecurityPolicy(nonce) {
@@ -110,18 +110,25 @@ function contentSecurityPolicy(nonce) {
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
* inlineStyles (CSS the page needs and the site's stylesheet cannot hold: the rule that shows the picked block of a
* inlineStyles (CSS the page needs and the site's stylesheet cannot hold: the rule that shows the picked block of a
- * pick block is written for the page, because there is one per block)
+ * pick block is written for the page, because there is one per block), site ({ name, service, links }: a page served
+ * on another of Chat's hosts, openvibe.party, in the same Frame under its own name), mainAttrs (attributes for the
+ * <main>, e.g. openvibe.party's data-party="<slug>"), party (a watch party's page: Shared's player.css/player.js and
+ * ov-sync.js, then public/web/party.css and party.js, Opus's design).
*/
*/
function renderPage(o) {
function renderPage(o) {
const { config } = o;
const { config } = o;
if (!o.robots) throw new TypeError('renderPage needs explicit robots');
if (!o.robots) throw new TypeError('renderPage needs explicit robots');
+ const site = o.site || {};
+ const siteName = site.name || SITE_NAME;
+ const siteService = site.service || 'chat';
+ const siteLinks = site.links || links(o);
// fullTitle: a page that names the site itself (the front page) takes no suffix.
// fullTitle: a page that names the site itself (the front page) takes no suffix.
- const title = o.fullTitle || (o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME);
+ const title = o.fullTitle || (o.title ? `${o.title} · ${siteName}` : siteName);
let head = sharedSeo.headTags({
let head = sharedSeo.headTags({
title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,
title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,
- robots: o.robots, siteName: SITE_NAME, type: 'website', jsonLd: o.jsonLd,
+ robots: o.robots, siteName, type: 'website', jsonLd: o.jsonLd,
// Indexable pages share the site's card (openvibe-shared/social); private ones advertise nothing.
// Indexable pages share the site's card (openvibe-shared/social); private ones advertise nothing.
- image: /^index/.test(o.robots) ? social.url('chat') : undefined, imageAlt: /^index/.test(o.robots) ? SITE_NAME : undefined,
+ image: /^index/.test(o.robots) ? social.url('chat') : undefined, imageAlt: /^index/.test(o.robots) ? siteName : undefined,
});
});
// A public page names its Markdown twin and the site's llms.txt (openvibe-shared/agent-layer); gated and
// A public page names its Markdown twin and the site's llms.txt (openvibe-shared/agent-layer); gated and
// private pages have no twin and advertise none.
// private pages have no twin and advertise none.
@@ -149,6 +156,7 @@ ${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
@@ -149,6 +156,7 @@ ${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
+${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
@@ -162,14 +170,14 @@ ${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${
@@ -162,14 +170,14 @@ ${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
<a class="oc-skip" href="#main">Skip to content</a>
<a class="oc-skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
-${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}
-<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.wide ? ' oc-main--wide' : ''}`}">
+${frame.noscriptNav({ name: siteName, home: '/', links: siteLinks.map(({ label, href }) => ({ label, href })) })}
+<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.wide ? ' oc-main--wide' : ''}`}"${o.mainAttrs ? ` ${o.mainAttrs}` : ''}>
<noscript><p class="oc-account">${who}</p></noscript>
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
${o.body || ''}
</main>
</main>
-${o.app ? '' : frame.footer({ service: 'chat', variant: footerVariant, updates: '/updates' })}
+${o.app ? '' : frame.footer({ service: siteService, variant: footerVariant, updates: '/updates' })}
<script>
<script>
-window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'chat', variant: footerVariant, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
+window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config, site, siteLinks), footer: { service: siteService, variant: footerVariant, mount: '#ov-footer', brandName: siteName, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function () {
try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }
try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }
try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
@@ -1200,4 +1200,4 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
@@ -1200,4 +1200,4 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
return router;
return router;
}
}
-module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml };
+module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml, viewerOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+/**
+ * openvibe.party, Watch Together's host (Party watch v1, plan R1): Chat serves it beside openvibe.chat, so a party is
+ * a Chat room and its chat, socket, clock and remote already exist (server/rooms/parties.js, sync-clock.js,
+ * control-lease.js).
+ *
+ * GET / "Start a party": one field, paste any OpenVibe link (signed in; sign-in otherwise)
+ * GET /new?media=&title= signed in: starts the party and 303s to /p/<slug>?k=<secret>; signed out: sign-in and
+ * back here. `source_url` (the page it was started from) is optional, as the API's is.
+ * GET /p/:slug?k= the party page shell: the Shared Frame, a <main data-party="<slug>"> and the empty
+ * regions the page's own script fills (server/rooms/parties.js keeps the data), with the
+ * party view as JSON in #party-data. The link's secret joins the person and is never
+ * echoed into the page.
+ * /auth/* sign-in with OpenVibe.Network: its own host-only session and its own redirect URI on
+ * Chat's OAuth client (<party base>/auth/callback, in Network's seed)
+ * /shared/*, /web/* the Frame's scripts and the site's stylesheet, the same files openvibe.chat serves
+ *
+ * A stranger without the link (or with a wrong secret) gets the missing-room 404, exactly as on openvibe.chat.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const path = require('path');
+const cache = require('openvibe-shared/cache-policy');
+const ovServe = require('openvibe-shared/serve');
+const { renderPage, contentSecurityPolicy, esc } = require('./layout');
+const { createSessionRoutes } = require('./session');
+const { viewerOf } = require('./pages');
+const parties = require('../rooms/parties');
+
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
+const SITE = Object.freeze({ name: 'OpenVibe.Party', service: 'party', links: [{ key: 'start', label: 'Start a party', href: '/' }] });
+const DESCRIPTION = 'Watch together: start a party on any OpenVibe video and everyone watches in step, with the chat beside it.';
+
+const escJson = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
+
+/** A title out of the link someone pasted: the last path segment, or the host's name. */
+function titleFromMedia(media) {
+ try {
+ const u = new URL(media);
+ const last = decodeURIComponent((u.pathname.split('/').filter(Boolean).pop() || ''));
+ const words = last.replace(/[-_]+/g, ' ').replace(/\.(mp4|webm|m3u8|mov|mkv|m4v)$/i, '').replace(/\s+/g, ' ').trim();
+ return (words || `${u.hostname} party`).slice(0, 80);
+ } catch { return 'Watch party'; }
+}
+
+function createPartyRoutes({ config }) {
+ const router = express.Router();
+ const base = String(config.party.baseUrl).replace(/\/+$/, '');
+ const wsOrigin = base.replace(/^http/, 'ws');
+ const partyConfig = {
+ ...config,
+ baseUrl: base,
+ web: { ...config.web, baseUrl: base },
+ oauth: { ...config.oauth, redirectUri: config.party.redirectUri, scope: config.party.scope },
+ };
+ const viewers = { resolve: async (req) => await viewerOf(req) };
+
+ const hostOf = (req) => String((req.headers && req.headers.host) || '').toLowerCase().replace(/:\d+$/, '');
+ const isPartyHost = (req) => hostOf(req) === config.party.host || hostOf(req) === `www.${config.party.host}`;
+
+ const page = async (req, res, status, o) => {
+ const actor = o.actor || await viewerOf(req);
+ const nonce = crypto.randomBytes(16).toString('base64');
+ res.status(status)
+ .set('Cache-Control', cache.htmlHeaders({ private: true }))
+ .set('Content-Security-Policy', contentSecurityPolicy(nonce, wsOrigin))
+ .type('html')
+ .send(renderPage({ config: partyConfig, actor, nonce, site: SITE, ...o }));
+ };
+
+ router.use('/auth', createSessionRoutes({ config: partyConfig, viewers }));
+ router.use('/shared', ovServe.handler());
+ router.use('/web', express.static(PUBLIC_DIR, { index: false, setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=300'); } }));
+
+ const notice = (kind, text) => `<p class="oc-notice oc-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${esc(text)}</p>`;
+
+ router.get('/', async (req, res) => {
+ const actor = await viewerOf(req);
+ const signedIn = actor.kind === 'user';
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ const form = signedIn
+ ? `<form class="oc-party-start" method="get" action="/new">
+<label for="party-media">Paste an OpenVibe link</label>
+<input id="party-media" name="media" type="url" inputmode="url" autocomplete="off" required placeholder="https://openvibe.live/v/…">
+<button type="submit" class="oc-button">Start a party</button>
+</form>`
+ : `<p class="oc-party-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to start a party.</p>`;
+ await page(req, res, 200, {
+ actor, title: 'Watch together', path: '/', robots: 'index, follow', footer: 'full',
+ description: DESCRIPTION,
+ body: `<header class="oc-party-hero">
+<h1>Watch together</h1>
+<p class="oc-lede">Start a party on any OpenVibe video: everyone watches the same moment, and the conversation is right there.</p>
+</header>
+${error}
+${form}`,
+ });
+ });
+
+ // GET /new asks before it starts anything: a link (Watch together on a video page, or one pasted anywhere) must never
+ // start a party in a signed-in person's name on its own. The page names what will play; Start is a same-origin POST.
+ 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)}`);
+ const media = String(req.query.media || '').trim();
+ if (!media) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
+ const title = String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200) || titleFromMedia(media);
+ const source = req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '';
+ let host = '';
+ try { host = new URL(media).hostname; } catch { host = ''; }
+ await page(req, res, 200, {
+ actor, title: 'Start a party', path: '/new', robots: 'noindex, nofollow', footer: 'compact',
+ body: `<section class="oc-party-confirm">
+<h1>Start a watch party?</h1>
+<p class="oc-lede"><strong>${esc(title)}</strong>${host ? ` <span class="oc-muted">from ${esc(host)}</span>` : ''}</p>
+<p class="oc-muted">Everyone you invite watches the same moment, with a chat beside it. You hold the remote, and you can pass it on.</p>
+<form method="post" action="/new">
+<input type="hidden" name="media" value="${esc(media)}">
+<input type="hidden" name="title" value="${esc(title)}">
+${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''}
+<button type="submit" class="oc-button">Start the party</button> <a href="/" class="oc-muted">Not now</a>
+</form>
+</section>`,
+ });
+ });
+
+ router.post('/new', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ const actor = await viewerOf(req);
+ if (actor.kind !== 'user') return res.redirect(303, '/auth/login?next=%2F');
+ // Started from this site only: a cookie-carrying POST from anywhere else is refused.
+ 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.');
+ const media = String((req.body && req.body.media) || '').trim();
+ if (!media) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
+ const title = String((req.body && req.body.title) || '').replace(/\s+/g, ' ').trim().slice(0, 200) || titleFromMedia(media);
+ try {
+ const out = await parties.create(actor.user, { media, source_url: (req.body && req.body.source_url) || null, title });
+ return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);
+ } catch (err) {
+ const message = err instanceof parties.PartyError ? err.message : 'That party could not be started';
+ return res.redirect(303, `/?error=${encodeURIComponent(message)}`);
+ }
+ });
+
+ const notFound = async (req, res) => page(req, res, 404, {
+ title: 'Page not found', path: req.path, robots: 'noindex, nofollow', footer: 'compact',
+ body: `<h1>This party is not here</h1><p class="oc-muted">The link may be wrong, or the party may have ended.</p><p><a class="oc-button" href="/">Start a party</a></p>`,
+ });
+
+ router.get('/p/:slug', async (req, res, next) => {
+ try {
+ const actor = await viewerOf(req);
+ const user = actor.kind === 'user' ? actor.user : null;
+ const row = await parties.bySlug(req.params.slug);
+ const party = parties.partyOf(row);
+ const key = req.query.k;
+ // The link's secret is the invitation: joining it here is the same path the API's join uses. A person who
+ // is already a member is simply let in. An ended party (or a banned account) still shows its page.
+ if (row && party && user && parties.keyMatches(party, key)) {
+ try { await parties.joinWithLink(row, party, user, key); } catch (err) {
+ if (!(err instanceof parties.PartyError)) throw err;
+ if (err.status === 404) return await notFound(req, res);
+ }
+ }
+ const out = row && party ? await parties.detail(row, party, { user, key }) : null;
+ if (!out) return await notFound(req, res);
+ await page(req, res, 200, {
+ actor, title: out.title, path: `/p/${out.id}`, robots: 'noindex, nofollow', footer: 'compact',
+ description: 'Watch together on OpenVibe.',
+ mainAttrs: `data-party="${esc(out.id)}"`, party: true, wide: true,
+ body: `<article class="oc-party">
+<header class="oc-party-head"><h1>${esc(out.title)}</h1><p class="oc-muted">Started by ${esc(out.host_name || 'someone')}</p></header>
+<div class="oc-party-stage" data-party-stage aria-label="The video"></div>
+<div class="oc-party-people" data-party-people aria-label="People here"></div>
+<div class="oc-party-chat" data-party-chat aria-label="Chat"></div>
+<div class="oc-party-remote" data-party-remote aria-label="The remote"></div>
+<div class="oc-party-invite" data-party-invite aria-label="Invite"></div>
+</article>
+<script type="application/json" id="party-data">${escJson({ party: out })}</script>`,
+ });
+ } catch (err) { next(err); }
+ });
+
+ // The party host's own 404 for a browser (the same words as Chat's), and the JSON 404 for anything else.
+ router.use((req, res, next) => {
+ if ((req.method !== 'GET' && req.method !== 'HEAD') || /^\/(api|internal|ws|auth|shared|web)(\/|$)/.test(req.path)) return next();
+ return page(req, res, 404, {
+ title: 'Page not found', path: req.path, robots: 'noindex, nofollow', footer: 'compact',
+ body: `<h1>Page not found</h1><p><a href="/">Start a party</a></p>`,
+ }).catch(next);
+ });
+
+ return { router, isPartyHost };
+}
+
+module.exports = { createPartyRoutes, titleFromMedia, SITE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+'use strict';
+/**
+ * Watch Together v1, the media party and its host (plan R1, 2026-11-07):
+ *
+ * - a media party is a private Chat room whose row carries the media (a Media ref or an OpenVibe https URL) and the
+ * page it was started from; the room's clock starts paused at 0 on that media and the creator holds the remote;
+ * - the join link is openvibe.party/p/<slug>?k=…, and the secret is in the create answer only;
+ * - a stranger without the key (or with a wrong one) gets the missing-room 404; a member gets the members, and a
+ * holder of just the link does not;
+ * - agent-stream parties keep their stream, VOD position and /rooms/ link;
+ * - openvibe.party is served by Host: the start form, /new (sign-in when signed out, the party when signed in) and
+ * the page shell with its data hooks and no key in the HTML.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('party media');
+let h, ann, bob, cat;
+const MEDIA = { media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', role: 'video' };
+const MEDIA_URL = 'https://openvibe.media/vod/friday.webm';
+const api = (method, path, u, body) => h.http(method, `/api/chat${path}`, { token: u && u.token, body });
+
+/** A request with its own Host header (the party host is chosen by it, not by the port we reached). */
+function raw(method, path, { host = 'openvibe.party', cookie = null, form = null, headers = {} } = {}) {
+ return new Promise((resolve, reject) => {
+ const body = form ? new URLSearchParams(form).toString() : null;
+ const req = http.request({
+ host: '127.0.0.1', port: h.port, path, method,
+ headers: { host, ...(cookie ? { cookie } : {}), ...(body ? { 'content-type': 'application/x-www-form-urlencoded', 'content-length': Buffer.byteLength(body) } : {}), ...headers },
+ }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text }));
+ });
+ req.on('error', reject);
+ req.end(body || undefined);
+ });
+}
+const cookieOf = (u) => `ov_token=${u.token}`;
+const dataOf = (html) => {
+ const m = /<script type="application\/json" id="party-data">([\s\S]*?)<\/script>/.exec(html);
+ assert.ok(m, 'the page carries its party data');
+ return JSON.parse(m[1]);
+};
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_PARTY_URL: 'https://openvibe.party', CHAT_PARTY_OAUTH_REDIRECT_URI: 'https://openvibe.party/auth/callback' } });
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ cat = h.addUser('cat', { subject: ids.newId('user') });
+ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+let party, key;
+
+t('a media party starts paused at 0, on the media, with the remote to the person who started it', async () => {
+ const r = await api('POST', '/parties', ann, { media: MEDIA, source_url: 'https://openvibe.live/v/abc123', title: 'Friday film' });
+ assert.strictEqual(r.status, 201, r.text);
+ party = r.body.party;
+ key = r.body.join_secret;
+ assert.strictEqual(party.kind, 'media');
+ assert.strictEqual(party.title, 'Friday film');
+ assert.deepStrictEqual(party.media, MEDIA);
+ assert.strictEqual(party.source_url, 'https://openvibe.live/v/abc123');
+ assert.match(party.id, /^party-[0-9a-f]{8}$/);
+ assert.strictEqual(party.join_url, `https://openvibe.party/p/${party.id}?k=${encodeURIComponent(key)}`);
+ assert.ok(key && key.length >= 20, 'the secret appears in the create answer');
+ assert.ok(!('sync' in party), 'a media party has no VOD position of its own');
+ assert.strictEqual(party.visibility, 'private');
+
+ const seen = await api('GET', `/parties/${party.id}`, ann);
+ assert.strictEqual(seen.status, 200, seen.text);
+ const view = seen.body.party;
+ assert.strictEqual(view.member, true);
+ assert.strictEqual(view.host, ann.subject_id);
+ assert.strictEqual(view.host_name, 'Ann');
+ assert.deepStrictEqual(view.clock && view.clock.media, MEDIA);
+ assert.strictEqual(view.clock.position_ms, 0);
+ assert.strictEqual(view.clock.paused, true, 'the clock is seeded paused');
+ assert.deepStrictEqual(view.lease.holder, { kind: 'person', subject: ann.subject_id }, 'the creator has the remote');
+ assert.deepStrictEqual(view.members.map((m) => [m.subject, m.name]), [[ann.subject_id, 'Ann']]);
+});
+
+t('the media must be OpenVibe media, and the source page on an OpenVibe host', async () => {
+ const outside = await api('POST', '/parties', ann, { media: 'https://tracker.example.com/film.webm', title: 'Elsewhere' });
+ assert.strictEqual(outside.status, 422, outside.text);
+ assert.strictEqual(outside.body.code, 'party.media');
+ const notHttps = await api('POST', '/parties', ann, { media: { media_id: 'not-a-ref' }, title: 'Bad ref' });
+ assert.strictEqual(notHttps.status, 422, notHttps.text);
+ assert.strictEqual(notHttps.body.code, 'party.media');
+ const source = await api('POST', '/parties', ann, { media: MEDIA, source_url: 'https://example.com/page', title: 'Wrong page' });
+ assert.strictEqual(source.status, 422, source.text);
+ assert.strictEqual(source.body.code, 'party.source_url');
+ assert.strictEqual((await api('POST', '/parties', null, { media: MEDIA, title: 'Signed out' })).status, 401);
+});
+
+t('a stranger without the key, or with a wrong one, sees the missing-room 404', async () => {
+ for (const path of [`/parties/${party.id}`, `/parties/${party.id}?k=not-the-link`, `/rooms/${party.id}/party`]) {
+ const r = await api('GET', path, bob);
+ assert.strictEqual(r.status, 404, `${path}: ${r.text}`);
+ assert.strictEqual(r.body.code, 'rooms.not_found');
+ }
+ // One character off (never the same character: a key that already ends in x would be the right key).
+ const wrongKey = key.slice(0, -1) + (key.endsWith('x') ? 'y' : 'x');
+ const wrongRoom = await api('GET', `/parties/${party.id}?k=${encodeURIComponent(wrongKey)}`, bob);
+ assert.strictEqual(wrongRoom.status, 404);
+});
+
+t('the link invites (the members see the people), and joining keeps the seat', async () => {
+ const invited = await api('GET', `/parties/${party.id}?k=${encodeURIComponent(key)}`, cat);
+ assert.strictEqual(invited.status, 200, invited.text);
+ assert.strictEqual(invited.body.party.member, false, 'holding the link is not membership');
+ assert.ok(!('members' in invited.body.party), 'a key holder is not shown the room\'s people');
+ assert.ok(!('lease' in invited.body.party), 'nor the remote\'s watchers');
+ assert.deepStrictEqual(invited.body.party.clock.media, MEDIA, 'but the clock is there');
+
+ const joined = await api('POST', `/rooms/${party.id}/party/join`, bob, { k: key });
+ assert.strictEqual(joined.status, 200, joined.text);
+ assert.strictEqual(joined.body.role, 'member');
+ const seen = await api('GET', `/parties/${party.id}`, bob);
+ assert.strictEqual(seen.status, 200, seen.text);
+ assert.strictEqual(seen.body.party.member, true);
+ assert.deepStrictEqual(seen.body.party.members.map((m) => m.subject).sort(), [ann.subject_id, bob.subject_id].sort());
+ assert.ok(seen.body.party.lease, 'a member sees the remote');
+ const again = await api('POST', `/rooms/${party.id}/party/join`, bob, { k: key });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.body.role, 'member', 'a member keeps their seat');
+});
+
+t('agent-stream parties are unchanged', async () => {
+ const AGENT = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';
+ const r = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Stream party', listed: true });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.party.kind, 'agent_stream');
+ assert.strictEqual(r.body.party.agent_stream_id, AGENT);
+ assert.strictEqual(r.body.party.vod_id, '4242');
+ assert.match(r.body.party.join_url, /\/rooms\/party-[0-9a-f]{8}\?k=/);
+ assert.deepStrictEqual(r.body.party.sync.mode, 'vod');
+ assert.strictEqual(r.body.party.sync.playing, false);
+ assert.ok(!('media' in r.body.party));
+ assert.strictEqual((await api('PUT', `/rooms/${party.id}/party`, ann, { position_s: 10 })).body.code, 'party.clock');
+});
+
+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));
+ assert.match(signedOut.text, /Start a party/);
+ assert.match(signedOut.text, /\/auth\/login\?next=%2F/);
+ assert.ok(!/name="media"/.test(signedOut.text), 'a signed-out visitor gets sign-in, not the form');
+
+ const signedIn = await raw('GET', '/', { cookie: cookieOf(ann) });
+ assert.strictEqual(signedIn.status, 200);
+ assert.match(signedIn.text, /action="\/new"/);
+ assert.match(signedIn.text, /name="media"/);
+
+ const signIn = await raw('GET', `/new?media=${encodeURIComponent(MEDIA_URL)}&title=${encodeURIComponent('Friday film')}`);
+ assert.strictEqual(signIn.status, 303);
+ assert.match(String(signIn.headers.location), /^\/auth\/login\?next=%2Fnew%3Fmedia%3D/);
+
+ // A link never starts a party by itself: GET /new asks, and only the page's own POST starts it.
+ const asks = await raw('GET', `/new?media=${encodeURIComponent(MEDIA_URL)}&title=${encodeURIComponent('Movie night')}`, { cookie: cookieOf(ann) });
+ assert.strictEqual(asks.status, 200, asks.text.slice(0, 200));
+ assert.match(asks.text, /Start a watch party\?/);
+ assert.match(asks.text, /<form method="post" action="\/new">/);
+ assert.match(asks.text, /name="media" value="https:\/\/openvibe\.media\/vod\/friday\.webm"/);
+ const crossSite = await raw('POST', '/new', { cookie: cookieOf(ann), form: { media: MEDIA_URL, title: 'Evil' }, headers: { origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(crossSite.status, 403, 'a cross-site POST starts nothing');
+ const made = await raw('POST', '/new', { cookie: cookieOf(ann), form: { media: MEDIA_URL, title: 'Movie night' }, headers: { origin: 'https://openvibe.party', 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
+ const loc = String(made.headers.location);
+ assert.match(loc, /^\/p\/party-[0-9a-f]{8}\?k=/);
+ const slug = loc.slice('/p/'.length).split('?')[0];
+ const madeKey = decodeURIComponent(loc.split('k=')[1]);
+
+ // The chat site still answers on its own host.
+ const chatSite = await raw('GET', '/', { host: 'openvibe.chat' });
+ assert.strictEqual(chatSite.status, 200);
+ assert.match(chatSite.text, /Global chat/);
+
+ // A stranger on the party host: the same 404 as a missing room.
+ const stranger = await raw('GET', `/p/${slug}`);
+ assert.strictEqual(stranger.status, 404);
+ const bobOutsider = await raw('GET', `/p/${slug}`, { cookie: cookieOf(bob) });
+ assert.strictEqual(bobOutsider.status, 404, 'bob joined the other party, not this one');
+
+ // The host's shell: the data hooks, the party JSON, and never the key in the HTML.
+ const shell = await raw('GET', `/p/${slug}?k=${encodeURIComponent(madeKey)}`, { cookie: cookieOf(ann) });
+ assert.strictEqual(shell.status, 200, shell.text.slice(0, 300));
+ for (const hook of ['data-party-stage', 'data-party-people', 'data-party-chat', 'data-party-remote', 'data-party-invite']) {
+ assert.ok(shell.text.includes(hook), `${hook} is in the shell`);
+ }
+ assert.match(shell.text, new RegExp(`<main id="main"[^>]*data-party="${slug}"`));
+ const data = dataOf(shell.text);
+ assert.strictEqual(data.party.id, slug);
+ assert.strictEqual(data.party.title, 'Movie night');
+ assert.deepStrictEqual(data.party.clock.media, MEDIA_URL);
+ assert.strictEqual(data.party.clock.paused, true);
+ assert.strictEqual(data.party.lease.holder.subject, ann.subject_id);
+ assert.ok(!shell.text.includes(madeKey), 'the link\'s secret is not echoed into the page');
+ // The page loads Opus's page and Shared's player and clock.
+ for (const file of ['/web/party.js', '/web/party.css', '/shared/ov-sync.js', '/shared/player.js', '/shared/player.css']) {
+ assert.ok(shell.text.includes(file), `${file} is on the page`);
+ }
+
+ // A member who joined through the API without k sees the shell and still gets no key.
+ const joined = await api('POST', `/rooms/${slug}/party/join`, cat, { k: madeKey });
+ assert.strictEqual(joined.status, 200, joined.text);
+ const member = await raw('GET', `/p/${slug}`, { cookie: cookieOf(cat) });
+ assert.strictEqual(member.status, 200);
+ assert.ok(!member.text.includes(madeKey), 'no key leak for a member who joined without k');
+ assert.deepStrictEqual(dataOf(member.text).party.members.map((m) => m.subject).sort(), [ann.subject_id, cat.subject_id].sort());
+
+ // A stranger opens the link: the page joins them (the link is the invitation), and the shell carries no key.
+ const invited = await raw('GET', `/p/${slug}?k=${encodeURIComponent(madeKey)}`, { cookie: cookieOf(bob) });
+ assert.strictEqual(invited.status, 200);
+ assert.ok(!invited.text.includes(madeKey));
+ const bobNow = await api('GET', `/parties/${slug}`, bob);
+ assert.strictEqual(bobNow.body.party.member, true, 'opening the link makes the person a member');
+});
+
+t('close', async () => {
+ await h.close();
+});
+
+t.run();