openvibers/openvibe.chat Commits

Watch together v1: media parties, their clock, and the openvibe.party host (#392)

OP OpenVibers committed 5 days ago · 440d9183f5 · 17 files, +1339 −35

* Party watch v1: the page (Opus's design)

public/web/party.js + party.css, for the page shell the backend serves at openvibe.party/p/:slug (main[data-party]
with stage, people, chat, remote and invite regions and #party-data):
- the stage: Shared's player kept on the room's timeline by ov-sync over Chat's room socket, with honest states over
  it (joining, waiting for the host to press play, reconnecting; Catching up only past a second of drift);
- the remote pill: who has it and the one move that fits (Ask for it, Pass it to someone here, Let go, Take it, and
  Take it back for the room's owner);
- the people: faces in distinct colours, the remote marked by a ring, the away ones dimmed while their seat waits;
- the room's chat, links built from nodes, reactions that rise over the picture for everyone at once;
- Invite: Shared's share sheet (link and QR), the party key kept for members who came by the link.
Desktop: the stage at 16:9 with the rail (people over chat). Phone: the stage full width and sticky, the remote and
reactions, the title, one row of compact faces, the chat. No tabs; checked at 390 and 1280 px.

* Watch together v1: media parties, their clock and the openvibe.party host

A party may now watch OpenVibe media instead of an agent stream (plan R1, Party watch v1).
migrations/0043_party_media.sql makes room_parties.agent_stream_id nullable and adds
party_kind ('agent_stream' | 'media'), media (jsonb: a Media ref or an https URL on an
OpenVibe host, validated by the shared clock's own rules) and source_url (the page it was
started from, the same host rule). Agent-stream parties keep their stream, VOD position
and /rooms/ link unchanged.

POST /api/chat/parties starts one: the room, the party row, the room's clock seeded
paused at 0 on the media, and the remote granted to the creator. The answer carries the
join link openvibe.party/p/<slug>?k= and the secret exists only there. GET
/api/chat/parties/:slug?k= is the view: title, media, source_url, the host's subject and
name, the clock, and for a member the people and the lease; a stranger without the key
(or with a wrong one) gets the missing-room 404. The existing rooms party view now
returns the same detail.

Chat serves openvibe.party by Host too (server/web/party-pages.js): the start form, /new
(sign-in and back when signed out), and the /p/:slug shell with the Shared Frame, a
<main data-party> and the empty stage/people/chat/remote/invite regions plus the party
view JSON. It has its own host-only session on a second redirect URI of Chat's OAuth
client, and the link's secret is never echoed into the page. deploy/nginx/openvibe.party.conf
is the vhost (own certificate, /ws/chat upgrade, Cloudflare real IP as Chat's own).

test/party-media.test.js covers the seeded clock and lease, the non-OpenVibe refusal, the
404s, members-only data, unchanged agent-stream parties, and the host routes by Host header.
Checked: party-media, rooms, room-sync-clock, pg-schema, agent-parties, live-down, the
security suite, nginx-auth-limit, open-redirect, room-kinds, room-embed, chat-modules.

* Merge remote-tracking branch 'origin/party/page' into party/media-parties

* Party: the page runs on the shell, and /new asks before it starts

- The layout's party option loads Shared's player and clock (4.143.0, https only) and the page's own files.
- GET /new names what will play and asks; only a same-origin POST starts the party (a link or a
  cross-site form never starts one in a signed-in person's name).
- party.js reads the shell's { party } shape and reuses its header: Invite beside the title, the
  source link on the Started-by line; an invitation is offered only from a URL that carries the key.
- party.css: the grid runs on the shell's article, the main is full width.

* party-media test: a wrong key is always wrong (a key ending in x was the right one, 1 run in 64)

Changes

ddiff --git a/.env.example b/.env.example
iindex 63d1070..0df6b7b 100644
---- a/.env.example
++++ b/.env.example
@@@ -37,6 +37,13 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_OAUTH_CLIENT_ID=chat
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)
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
#LIVE_TIMEOUT_MS=4000
ddiff --git a/deploy/nginx/openvibe.party.conf b/deploy/nginx/openvibe.party.conf
nnew file mode 100644
iindex 0000000..07a6461
---- /dev/null
++++ b/deploy/nginx/openvibe.party.conf
@@@ -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;
++ }
++}
ddiff --git a/migrations/0043_party_media.sql b/migrations/0043_party_media.sql
nnew file mode 100644
iindex 0000000..544e973
---- /dev/null
++++ b/migrations/0043_party_media.sql
@@@ -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 $$;
ddiff --git a/package-lock.json b/package-lock.json
iindex 229280d..70010ae 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 21bf4ad..305a00a 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/public/web/party.css b/public/web/party.css
nnew file mode 100644
iindex 0000000..ed5fefb
---- /dev/null
++++ b/public/web/party.css
@@@ -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%; }
++}
ddiff --git a/public/web/party.js b/public/web/party.js
nnew file mode 100644
iindex 0000000..9b87b7f
---- /dev/null
++++ b/public/web/party.js
@@@ -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();
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 750b1e6..8e233a4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, settin
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/).
const webRoutes = require('./web/pages').createWebRoutes({ config });
app.use(webRoutes);
ddiff --git a/server/config.js b/server/config.js
iindex fa9d209..c2a91fb 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/rooms/parties.js b/server/rooms/parties.js
iindex cbc4014..8b3c01c 100644
---- a/server/rooms/parties.js
++++ b/server/rooms/parties.js
@@@ -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 = nu
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) {
-- 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,
};
ddiff --git a/server/rooms/party-routes.js b/server/rooms/party-routes.js
nnew file mode 100644
iindex 0000000..7d5b312
---- /dev/null
++++ b/server/rooms/party-routes.js
@@@ -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;
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex b2758e7..aeba8bd 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -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); }
});
ddiff --git a/server/rooms/sync-clock.js b/server/rooms/sync-clock.js
iindex 917a563..3ae7599 100644
---- a/server/rooms/sync-clock.js
++++ b/server/rooms/sync-clock.js
@@@ -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,
};
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 60b3cd2..cc06001 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -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) { /* */ }
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 157af8c..c791bf7 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -1200,4 +1200,4 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
return router;
}
--module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml };
++module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml, viewerOf };
ddiff --git a/server/web/party-pages.js b/server/web/party-pages.js
nnew file mode 100644
iindex 0000000..990ee6c
---- /dev/null
++++ b/server/web/party-pages.js
@@@ -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 };
ddiff --git a/test/party-media.test.js b/test/party-media.test.js
nnew file mode 100644
iindex 0000000..f32133a
---- /dev/null
++++ b/test/party-media.test.js
@@@ -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();