openvibers/openvibe.surf Commits
Watch together: a party follows its host's channel and moment over SSE; join by link, leave any time; host commands same-origin only; parties live in this process (a restart ends them)
Changes
@@@ -33,6 +33,9 @@ SURF_GAMES_PUBLIC_URL=https://openvibe.games# SURF_GAMES_TIMEOUT_MS=4000# SURF_ACTOR_TIMEOUT_MS=15000# SURF_RATE_LIMIT_PER_MIN=120++# Watch together: open parties and members per party (these may only be lowered; hard caps 200/50).++# SURF_PARTY_MAX=200++# SURF_PARTY_MEMBER_MAX=50# optional: defaults to true in production# COOKIE_SECURE=true@@@ -2,6 +2,19 @@What changed in OpenVibe.Surf, newest first. Each site also publishes its patch notes at /updates.++## Unreleased — watch together++++- **Watch together** (`server/watch/party.js`, `server/http/party.js`, `server/views/index.js`,++ `public/js/surf.js`): a signed-in person opens a party (`POST /watch/party`) and shares an unguessable++ `/watch/p/<id>` link. The page reuses the TV markup with a `sf-party-` bar (members, host, Copy link, Leave); the++ host commands `{ channel_key, position_s, playing, at }` from this site only, members follow over SSE++ (`GET /watch/p/<id>/events`) and correct drift over 2 s, and anyone can leave. State is one in-process Map,++ capped at 200 parties and 50 members each; an empty party ends 30 minutes after its last member leaves, and a++ restart ends every party. The optional chat room is a validated `https://openvibe.chat/...` link (no chat in++ Surf). nginx gets an unbuffered SSE location with a 1-hour read timeout.++- **Tests:** `test/party.test.js` covers create/join/leave, host-only and cross-site commands, SSE delivery, the++ 30-minute TTL and the lowered caps.++## 0.1.0 — 2026-10-28The service skeleton: three surfs, one room.@@@ -11,7 +11,7 @@ OpenVibe.Surf is the Surf room. One room, three surfs:- **Ride** — CS-style surf in the browser, served by OpenVibe.Games. The map catalogue is proxied from Games andevery map plays in an embed (`https://openvibe.games/surf/embed?map=<id>&embed=1`).- **Watch** — lean-back channel surfing over OpenVibe.Live: live streams, clips people cut and the AI moments of-- the week, merged into one feed with one card shape.++ the week, merged into one feed with one card shape. Open a party and everyone follows the host's channel.- **Web** — *Surf with Vibe*: tell an agent what you are looking for and it browses the web for you on a privatedesk through OpenVibe.Actor, keeps its sources and finishes with a cited report.@@@ -21,8 +21,9 @@ OpenVibe.Surf is the Surf room. One room, three surfs:database and no tokens: the person's Network session lives in httpOnly cookies.- **Sign-in:** an OAuth 2 client of OpenVibe.Network (`surf`), authorization code with PKCE S256, a state checkand a server-side code exchange.--- **Stateless:** no database, no Valkey, no Events. The only state is a 30 s Watch feed cache, a 60 s Ride cache-- and the SDK's JWKS cache, all in memory.++- **In memory, no database:** no database, no Valkey, no Events. The only state is a 30 s Watch feed cache, a++ 60 s Ride cache, the SDK's JWKS cache and the watch-together parties, all in this one process. **A restart ends++ every watch party.**## Watch@@@ -65,6 +66,25 @@ Every card is one shape:- **Cursor:** opaque; it carries each Live feed's own `next` cursor. Page two does not repeat live streams.- **Cache-Control:** `public, max-age=30`.++### Watch together++++A signed-in person opens a party (`POST /watch/party`, from this site) and gets an unguessable id at++`/watch/p/<id>`; that link is the invitation. Opening it sends a guest to sign in first and joins everyone else.++The page reuses the TV markup with a `sf-party-` bar: members, host, **Copy link**, **Leave**, and, if the host++pasted one, a link to an OpenVibe.Chat room (`https://openvibe.chat/...`, validated). There is no chat inside Surf.++++- **Host:** the person who opened the party. Only the host commands, with++ `POST /watch/p/<id>/command` (also `POST /watch/p/<id>` and `/state`) and++ `{ channel_key, position_s, playing, at }`, from this site's own Origin. A member's command is 403, and so is a++ command from another site.++- **Sync:** `GET /watch/p/<id>/events` is Server-Sent Events (`text/event-stream`): `state` once, then `command`++ when the host changes channel, play/pause or seek, `members` on a join/leave, and `chat` when the room link++ changes. The browser applies commands, follows play/pause and seeks, and corrects a drift over 2 s.++- **Leaving and lifetime:** `POST /watch/p/<id>/leave` (the Leave button) removes that member; if the host leaves++ while others remain, the earliest remaining member becomes host. The last member out starts a 30-minute clock.++ The Map holds at most 200 open parties and 50 members each; an empty party is dropped on the next read 30++ minutes after its last member leaves. Everything is memory only: a restart ends every party.++## Ride`GET /api/v1/ride/maps` and `GET /api/v1/ride/maps/:id`@@@ -108,7 +128,8 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star| `/` | the Surf room: the run motif (ramps, speed lines, a ticking timer), the three tiles under the hero, scenes, questions || `/ride` | the maps with tier, stages, runs and the record, and the embed of the first map || `/ride/:map` | one map: name, tier, stages, records if Games answers, the embed |--| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; a live stream that records to MP4 plays over Media's DVR playlist, a little behind live, and any other opens on its channel |++| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; a live stream that records to MP4 plays over Media's DVR playlist, a little behind live, and any other opens on its channel; **Watch together** opens a party |++| `/watch/p/:id` | a watch party: the same TV, a `sf-party-` bar with the members, host, Copy link and Leave, and the optional OpenVibe.Chat room link; signed-in members follow the host's channel and position over SSE || `/web` | the Surf with Vibe form, examples, and what happens next || `/developers` | the API, for people and agents || anything else | the 404 page |@@@ -128,6 +149,11 @@ the home timer; everything works without it.| `GET /api/v1/ride/maps/:id` | none | `public, max-age=60` | one map and its fastest runs || `POST /api/v1/web/tasks` | cookie + Origin | `private, no-store` | create one Actor task as the person || `POST /web/go` | cookie, this site only | `private, no-store` | the no-JavaScript task form, 303 to Actor |++| `POST /watch/party` | cookie + Origin | `private, no-store` | open a party, 303 to `/watch/p/<id>` (JSON: 201) |++| `GET /watch/p/:id/events` | cookie, member | `no-store` | the party's SSE stream (`state`, `command`, `members`, `chat`) |++| `POST /watch/p/:id/command` | cookie, host + Origin | `private, no-store` | `{ channel_key, position_s, playing, at }`, broadcast over SSE |++| `POST /watch/p/:id/leave` | cookie + Origin | `private, no-store` | leave the party, 303 to `/watch` (JSON: 200) |++| `POST /watch/p/:id/chat` | cookie, host + Origin | `private, no-store` | set the `https://openvibe.chat/...` room link |Errors are RFC 9457 `application/problem+json`. A blank map id is 400; a bad Watch topic or cursor is 400. Theonly outbound person token is the `Authorization: Bearer` on the request to Actor.@@@ -177,6 +203,8 @@ OAuth client on the Network).| `SURF_GAMES_TIMEOUT_MS` | `4000` | one Games read's budget || `SURF_ACTOR_TIMEOUT_MS` | `15000` | one Actor task creation's budget || `SURF_RATE_LIMIT_PER_MIN` | `120` | per-IP `/api` and `/auth` budget |++| `SURF_PARTY_MAX` | `200` | open watch parties (may only be lowered, hard cap 200) |++| `SURF_PARTY_MEMBER_MAX` | `50` | members per party (may only be lowered, hard cap 50) || `COOKIE_SECURE` | true in production | Secure cookies |## How to run@@@ -187,7 +215,8 @@ npm run dev # http://localhost:5110OV_ALLOW_SUITE=1 npm test```--`npm test` runs every `test/*.test.js` in its own process with in-process stand-ins++`npm test` runs every `test/*.test.js` (including `test/party.test.js`: create/join/leave, host-only and++same-site commands, SSE delivery, the 30-minute TTL and the caps) in its own process with in-process stand-ins(`test/helpers/network.js`, `test/helpers/upstreams.js`): a real RS256 key pair and the OAuth token endpoint forNetwork, and recorded Live, Games and Actor routes. Nothing calls the real internet. `OV_ALLOW_SUITE=1` is thesuite opt-in the shared test runner expects.@@@ -198,7 +227,8 @@ suite opt-in the shared test runner expects.`openvibe-surf.service` on 127.0.0.1:5110, env `/etc/openvibe/surf.env`).- **nginx:** [deploy/nginx/openvibe.surf.conf](deploy/nginx/openvibe.surf.conf), installed with`ov-vhost-install`. `/api` is reads only; `/api/v1/web/` and `/web/go` are the forms zone; `/auth` is GET only;-- `/auth/me` gets the API budget; `/internal` and `/metrics` are 404.++ `/auth/me` gets the API budget; `/internal` and `/metrics` are 404. The SSE route++ `GET /watch/p/<id>/events` has `proxy_buffering off`, no gzip and a 1-hour read timeout.- **Readiness:** `/api/ready` reports `degraded` (HTTP 200) when Network, Live or Games does not answer; theprocess still serves the pages and the crawl artifacts.@@@ -214,6 +244,11 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).browsers drop or rewrite them.- Watch's card fields are allow-listed and escaped; only `http(s)` URLs become `href`/`src`. Actor's errordetail is trimmed and no token or request body is logged.++- A watch party's id is 144 random bits. Only its host may command; a browser command must come from this++ site's own Origin (`Origin`/`Sec-Fetch-Site`, while a request with neither is a non-browser client and still++ needs the session cookie). Only a member may open the SSE stream, and the party page is `private, no-store`.++ The Map is capped and an empty party is dropped after 30 minutes. Member names and the optional Chat link are++ escaped; the Chat link is an absolute `https://openvibe.chat/` URL with no credentials, and Surf stores no chat.- Secrets live only in the environment file; only variable names appear in code and docs, and no secret or tokenis logged.@@@ -5,7 +5,7 @@"code": true,"wave": "W20","plan": "OpenVibe.Surf: one room with three surfs. Ride is CS-style surf in the browser served by OpenVibe.Games; Watch is lean-back channel surfing over OpenVibe.Live's live streams, clips and AI moments; Web is Surf with Vibe, where an agent browses for you on a private desk through OpenVibe.Actor.",-- "runtime": "node 22, express 4, openvibe-shared/shell; stateless (no database, no Valkey, no Events)",++ "runtime": "node 22, express 4, openvibe-shared/shell; no database, no Valkey, no Events; watch parties are in memory and end on restart","port": "5110","domain": "openvibe.surf","contracts": "openvibe-contracts v0.232.0",@@@ -16,14 +16,15 @@"openvibe-sdk v0.70.0","openvibe-shared v4.33.0"],-- "summary": "openvibe.surf, the Surf room: Network sign-in as OAuth client `surf`; Ride (OpenVibe.Games' surf maps, cached 60 s, embedded); Watch (a lean-back TV over OpenVibe.Live's live streams, clips and AI moments, clips and moments playing in the page over HLS); Web (Surf with Vibe: one OpenVibe.Actor task made with the person's own Network token). Stateless.",++ "summary": "openvibe.surf, the Surf room: Network sign-in as OAuth client `surf`; Ride (OpenVibe.Games' surf maps, cached 60 s, embedded); Watch (a lean-back TV over OpenVibe.Live's live streams, clips and AI moments, clips and moments playing in the page over HLS) plus watch together (in-memory SSE parties); Web (Surf with Vibe: one OpenVibe.Actor task made with the person's own Network token). No database.","works": ["Sign-in with OpenVibe.Network: OAuth 2 authorization code, PKCE S256, state check, server-side exchange, httpOnly surf_at/surf_rt cookies, sanitizeNext (own origins only, no control characters or backslashes)","Watch GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=: merges OpenVibe.Live's /api/streams, /api/content/feed?type=clips and /api/content/moments?type=clips into one item shape, ranks live by viewers then moments/clips by freshness with no channel twice in a row, caches 30 s, 2 s per source, a failing source left out and named in sources, public max-age=30","Ride GET /api/v1/ride/maps and /api/v1/ride/maps/:id: proxies OpenVibe.Games' /api/surf/maps routes, adds each map's embed_url (https://openvibe.games/surf/embed?map=<id>&embed=1), caches 60 s, Games 404 → empty list/map shell with games: 'not_ready'","Web POST /api/v1/web/tasks: signed in, same-origin, query 3–500 characters, sends the person's surf_at token to OpenVibe.Actor as a Bearer with an Idempotency-Key and the prefixed browsing brief, answers { task_id, page }, and passes Actor's 4xx refusal through in words","POST /web/go: the same task as a plain form for no-JavaScript (this site only, the question never in a URL): 303 to the Actor task page, sign-in redirect for a guest, Actor's refusal in plain words",-- "Designed pages: / (the run motif, three tiles, scenes, questions), /ride and /ride/:map, /watch (the TV and its guide, HLS in the page), /web, /developers and a 404; public max-age=60 with Vary: Cookie, signed-in private, no-store",++ "Designed pages: / (the run motif, three tiles, scenes, questions), /ride and /ride/:map, /watch (the TV and its guide, HLS in the page), /watch/p/:id (watch together), /web, /developers and a 404; public max-age=60 with Vary: Cookie, signed-in private, no-store",++ "Watch together: POST /watch/party makes an unguessable /watch/p/<id>, signed-in members join by opening it, the host commands { channel_key, position_s, playing, at } from this site, GET /watch/p/:id/events carries SSE to members (drift > 2 s corrected), and anyone can leave; the Map is capped at 200 parties/50 members, an empty party is dropped 30 min after its last member leaves, and a restart ends every party","Crawl artifacts (robots.txt, sitemap.xml with the public pages and Ride maps, llms.txt), /api/health, /api/ready (degraded when Network/Live/Games/Actor do not answer), /release.json, /release-metrics, /shared/*","Helmet with the network's CSP (Games and Live frames, openvibe.media HLS), per-IP 120/min on /api and /auth, content-hashed /css and /js, graceful stop","Deploy files: openvibe-surf.service, nginx vhost for openvibe.surf on 127.0.0.1:5110 with the forms zone, /auth/me on the API zone and /internal + /metrics 404",@@@ -31,7 +32,7 @@],"notYet": ["OpenVibe.Games' surf mode and GET /api/surf/maps: until they ship, Ride answers games: 'not_ready' and the page says the maps are being built",-- "A browser (WHIP) live stream in the TV: it has no DVR playlist yet, so it opens on its channel; watch together"++ "A browser (WHIP) live stream in the TV: it has no DVR playlist yet, so it opens on its channel"],"capabilitiesRegistered": false,"serviceManifestRegistered": true,@@@ -40,6 +41,7 @@"features": ["Network sign-in (PKCE)","Watch merged Live feed",++ "Watch together (SSE parties)","Ride Games maps proxy","Surf with Vibe via OpenVibe.Actor","server-rendered pages",@@@ -7,7 +7,8 @@## and compresses text.#### No proxy_cache: the app decides cacheability per response (public 30 s for the Watch feed, 60 s for Ride and the--## pages; private, no-store for a page or answer rendered for a signed-in person). Never log request bodies here.++## pages; private, no-store for a page or answer rendered for a signed-in person). Watch-together SSE is the one++## unbuffered location. Never log request bodies here.## ═══════════════════════════════════════════════════════════════limit_req_zone $binary_remote_addr zone=surf_auth:10m rate=10r/m;@@@ -87,6 +88,21 @@ server {proxy_pass http://127.0.0.1:5110;}++ # ── Watch together: SSE (GET /watch/p/<id>/events) ──────++ # Server-Sent Events must reach the browser as they are written: no proxy buffering, no gzip, and a read++ # timeout longer than the browser's idle window (the app sends a keepalive comment every 15 s). A restart of++ # the app ends every party; nginx only carries the stream.++ location ~ ^/watch/p/[A-Za-z0-9_-]+/events$ {++ limit_except GET { deny all; }++ proxy_buffering off;++ proxy_cache off;++ proxy_read_timeout 1h;++ proxy_send_timeout 1h;++ gzip off;++ proxy_set_header Connection "";++ proxy_pass http://127.0.0.1:5110;++ }++# ── The Watch and Ride API: reads only ──────────────────location /api/ {limit_except GET { deny all; }@@@ -111,6 +111,8 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima.sf-board .is-wr td { font-weight: 800; color: var(--room-strong); }/* Watch: the TV and the guide. */++.sf-watch-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }++.sf-party-start { margin: 0; }.sf-watch-h { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; }.sf-topics { display: flex; gap: 4px; padding: 3px; border-radius: 999px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); overflow-x: auto; scrollbar-width: none; max-width: 100%; }.sf-topic { flex: none; padding: 6px 14px; border-radius: 999px; font-size: .86rem; font-weight: 700; text-decoration: none; color: var(--text-secondary, #96a7c2); }@@@ -148,6 +150,27 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima.sf-ch-body small { font-size: .76rem; color: var(--text-muted, #7386a3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.sf-guide .sf-more { margin-top: 10px; }++/* Watch together: the party bar above the same TV. */++.sf-party { display: grid; gap: 8px; margin: 0 0 16px; padding: 12px 14px; border-radius: 18px; border: 1px solid color-mix(in srgb, var(--room-accent) 35%, var(--border, #1f2d47)); background: color-mix(in srgb, var(--room-accent) 7%, var(--bg-card, #0f1729)); }++.sf-party-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }++.sf-party-who { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; font-size: .92rem; color: var(--text-secondary, #96a7c2); }++.sf-party-who b { color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }++.sf-party-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--live-red, #e11d48); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e11d48) 20%, transparent); }++html.ov-motion .sf-party-dot { animation: sf-party-pulse 2.4s ease-in-out infinite; }++@keyframes sf-party-pulse { 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--live-red, #e11d48) 5%, transparent); } }++.sf-party-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.sf-party-actions form { margin: 0; }++.sf-party-share { margin: 0; overflow-wrap: anywhere; }++.sf-party-share a { color: var(--room-strong); font-weight: 700; }++.sf-party-chat { margin: 0; }++.sf-party-chat[hidden] { display: none; }++.sf-party-chat a { display: inline-flex; align-items: center; gap: 6px; color: var(--room-strong); font-weight: 800; text-decoration: none; }++.sf-party-chat a .ovk-i { width: 1.05em; height: 1.05em; }++.sf-party-chat-form { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.sf-party-chat-form input { flex: 1 1 260px; min-width: 0; padding: 7px 10px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, #0d1322); color: var(--text-primary, #e6edf7); font: inherit; font-size: .9rem; }++.sf-party-chat-form input:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; }++.sf-party-page .sf-tv { margin-top: 8px; }++/* Web: Surf with Vibe. */.sf-web-form { display: grid; gap: 10px; }.sf-web-form textarea { width: 100%; min-height: 96px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, #0d1322); color: var(--text-primary, #e6edf7); font: inherit; font-size: 1.02rem; line-height: 1.5; resize: vertical; }@@@ -170,5 +193,26 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima.sf-eps dt code { font: 600 .88rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--room-strong); overflow-wrap: anywhere; }.sf-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }--@media (prefers-reduced-motion: reduce) { .sf-run-speed i, .sf-demo-steps li.is-now::before, .sf-tv-screen.is-changing { animation: none !important; } }++@media (prefers-reduced-motion: reduce) { .sf-run-speed i, .sf-demo-steps li.is-now::before, .sf-tv-screen.is-changing, .sf-party-dot { animation: none !important; } }html[data-ov-motion=reduced] .sf-run-speed i { animation: none !important; }++++/* Watch together: the party bar above the TV. Everyone sees the host's channel and moment; the dot pulses while++ the party is in sync. */++.sf-party-start { margin: 0; }++.sf-party-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; margin: 0 0 14px; padding: 12px 16px; border-radius: 16px;++ background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); border: 1px solid color-mix(in srgb, var(--room-accent) 40%, var(--border, #1f2d47)); }++.sf-party-who, .sf-party-status { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font-size: .9rem; }++.sf-party-who b, .sf-party-host-name { font-weight: 800; }++.sf-party-members, .sf-party-at, .sf-party-position, .sf-party-channel { color: var(--text-secondary, #96a7c2); font-size: .84rem; font-variant-numeric: tabular-nums; }++.sf-party-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success, #22c55e) 60%, transparent); }++html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-out infinite; }++@keyframes sf-pulse { to { box-shadow: 0 0 0 10px transparent; } }++.sf-party-is-host { padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--room-on); background: var(--room-fill); }++.sf-party-actions, .sf-party-share { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }++.sf-party-actions form { margin: 0; }++.sf-party-url, .sf-party-link { max-width: 260px; min-width: 0; padding: 7px 10px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-secondary, #96a7c2); font: 500 .8rem ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.sf-party-chat { margin: 10px 0 0; } .sf-party-chat a { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }++.sf-party-chat-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }++.sf-party-chat-url { flex: 1 1 240px; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; }++.sf-party-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--text-muted, #7386a3); }++@media (prefers-reduced-motion: reduce) { .sf-party-dot { animation: none !important; } }@@@ -42,11 +42,191 @@var title = root.querySelector('[data-sf-title]'), why = root.querySelector('[data-sf-why]'), meta = root.querySelector('[data-sf-meta]');var kindPill = root.querySelector('.sf-tv-kind .sf-pill');var LABEL = { live: 'Live', clip: 'Clip', moment: 'AI moment' };-- var cur = 0, hls = null;++ var cur = Math.max(0, Math.min(chs.length - 1, parseInt(root.dataset.sfStart || '0', 10) || 0)), hls = null;++ var party = document.querySelector('[data-sf-party]');++ var partyId = party && party.getAttribute('data-sf-party-id');++ var partyPath = (party && party.getAttribute('data-sf-party-url')) || (partyId ? '/watch/p/' + encodeURIComponent(partyId) : '');++ var isHost = Boolean(party) && party.getAttribute('data-sf-party-is-host') === '1';++ var partyStatus = party && party.querySelector('[data-sf-party-status]');++ var partyState = party ? {++ channel_key: party.getAttribute('data-sf-party-channel') || '',++ position_s: Number(party.getAttribute('data-sf-party-position')) || 0,++ playing: party.getAttribute('data-sf-party-playing') !== '0',++ at: Number(party.getAttribute('data-sf-party-at')) || Date.now(),++ } : null;++ var applying = false, partEvent = null, programmatic = 0;++function el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }-- function show(i, dir) {++ function say(msg, bad) {++ if (!partyStatus) return;++ partyStatus.textContent = msg || '';++ partyStatus.classList.toggle('is-bad', Boolean(bad));++ }++ function currentVideo() { return screen.querySelector('video'); }++ function expectedOf(state) {++ if (!state) return 0;++ var at = Number(state.at), base = Number(state.position_s) || 0;++ if (!Number.isFinite(at)) at = Date.now();++ var elapsed = state.playing ? Math.max(0, Date.now() - at) / 1000 : 0;++ return Math.max(0, base + elapsed);++ }++ function applyVideo(v, state) {++ if (!v || !state) return;++ var target = expectedOf(state);++ var seek = function () {++ try { if (Math.abs(v.currentTime - target) > 2) v.currentTime = target; } catch (e) { /* the playlist may not seek that far */ }++ };++ if (v.readyState >= 1) seek(); else v.addEventListener('loadedmetadata', seek, { once: true });++ if (state.playing) v.play().catch(function () {});++ else v.pause();++ }++ function startVideo(v, d, node) {++ if (v.canPlayType('application/vnd.apple.mpegurl')) {++ v.src = d.sfSrc;++ if (partyState) { applying = true; applyVideo(v, partyState); applying = false; }++ else v.play().catch(function () {});++ } else {++ loadHls().then(function (Hls) {++ if (!Hls || !Hls.isSupported()) {++ v.src = d.sfSrc;++ if (partyState) { applying = true; applyVideo(v, partyState); applying = false; }++ return;++ }++ if (chs[cur] !== node) return;++ hls = new Hls({ maxBufferLength: 20 }); hls.loadSource(d.sfSrc); hls.attachMedia(v);++ hls.on(Hls.Events.MANIFEST_PARSED, function () {++ if (partyState) { applying = true; applyVideo(v, partyState); applying = false; }++ else v.play().catch(function () {});++ });++ });++ }++ }++++ // Host: tell the party what changed (channel, play/pause, seek). Members never write.++ function postCommand(extra) {++ if (!party || !partyId || !isHost || !chs[cur]) return;++ var v = currentVideo();++ var cmd = {++ channel_key: chs[cur].dataset.sfKey || '',++ position_s: v && Number.isFinite(v.currentTime) ? v.currentTime : 0,++ playing: v ? !v.paused : true,++ at: Date.now(),++ };++ if (extra) for (var k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) cmd[k] = extra[k];++ partyState = cmd;++ fetch(partyPath + '/command', {++ method: 'POST', credentials: 'same-origin',++ headers: { 'content-type': 'application/json', accept: 'application/json' },++ body: JSON.stringify(cmd),++ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (b) { return { r: r, b: b }; }); })++ .then(function (x) {++ if (x.r.ok) return;++ say((x.b && x.b.detail) || 'That command did not reach the party.', true);++ })++ .catch(function () { say('The party did not answer. It will reconnect.', true); });++ }++++ function syncMembers(data) {++ if (!party || !data) return;++ var count = party.querySelector('[data-sf-party-members]');++ var host = party.querySelector('[data-sf-party-host-name]');++ if (count && data.members != null) count.textContent = data.members;++ if (host && data.host) host.textContent = data.host;++ }++ function syncChat(url) {++ if (!party || !url) return;++ var line = party.querySelector('[data-sf-party-chat]');++ if (!line) { line = el('p', 'sf-party-chat'); line.setAttribute('data-sf-party-chat', ''); party.appendChild(line); }++ line.hidden = false;++ line.replaceChildren();++ var a = el('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = 'Chat in OpenVibe.Chat';++ line.appendChild(a);++ var input = party.querySelector('[data-sf-party-chat-form] input');++ if (input) input.value = url;++ }++++ function applyCommand(cmd) {++ if (!party || !cmd || !cmd.channel_key) return;++ partyState = {++ channel_key: String(cmd.channel_key),++ position_s: Number(cmd.position_s) || 0,++ playing: cmd.playing !== false,++ at: Number(cmd.at) || Date.now(),++ };++ var at = -1;++ chs.forEach(function (c, n) { if (c.dataset.sfKey === partyState.channel_key) at = n; });++ if (at >= 0 && at !== cur) {++ show(at, at > cur ? 1 : -1, { fromSync: true });++ } else {++ var v = currentVideo();++ applying = true;++ applyVideo(v, partyState);++ applying = false;++ }++ }++++ function connectParty() {++ if (!partyId || typeof EventSource === 'undefined') return;++ partEvent = new EventSource(partyPath + '/events');++ partEvent.addEventListener('open', function () {++ say(isHost ? 'You are the host: everyone follows your channel, play and seek.' : 'Following the host.');++ });++ partEvent.addEventListener('state', function (e) {++ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }++ syncMembers(d);++ if (d.chat_url) syncChat(d.chat_url);++ if (!isHost) applyCommand(d);++ });++ partEvent.addEventListener('command', function (e) {++ if (isHost) return; // the host is the source of truth for its own screen++ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }++ applyCommand(d);++ });++ partEvent.addEventListener('members', function (e) {++ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }++ syncMembers(d);++ if (typeof d.is_host === 'boolean' && d.is_host !== isHost) {++ isHost = d.is_host;++ party.setAttribute('data-sf-party-is-host', isHost ? '1' : '0');++ if (prev) { prev.disabled = !isHost; prev.title = isHost ? '' : 'The host picks the channel'; }++ if (next) { next.disabled = !isHost; next.title = isHost ? '' : 'The host picks the channel'; }++ say(isHost ? 'You are now the host: everyone follows your channel, play and seek.' : 'Following the host.');++ }++ });++ partEvent.addEventListener('chat', function (e) {++ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }++ if (d.chat_url) syncChat(d.chat_url);++ });++ partEvent.addEventListener('error', function () {++ say('Reconnecting to the party…', true);++ });++ }++++ // Members correct drift above 2 s, and follow the host's play/pause.++ function drift() {++ if (!document.body.contains(root)) { clearInterval(drift.timer); if (partEvent) partEvent.close(); return; }++ if (!party || isHost || applying || !partyState || !partyState.channel_key) return;++ if (!chs[cur] || chs[cur].dataset.sfKey !== partyState.channel_key) return;++ var v = currentVideo();++ if (!v || v.readyState < 1 || !Number.isFinite(v.currentTime)) return;++ var target = expectedOf(partyState);++ if (Math.abs(v.currentTime - target) > 2) {++ try { v.currentTime = target; } catch (e) { /* not seekable */ }++ }++ if (partyState.playing && v.paused) { programmatic = Date.now(); v.play().catch(function () {}); }++ else if (!partyState.playing && !v.paused) { programmatic = Date.now(); v.pause(); }++ }++ drift.timer = setInterval(drift, 1000);++++ function show(i, dir, opts) {if (!chs.length) return;++ opts = opts || {};++ if (party && !isHost && !opts.fromSync) {++ say('The host picks the channel and the position. You can leave any time.');++ return;++ }cur = (i + chs.length) % chs.length;++ programmatic = Date.now();var c = chs[cur], d = c.dataset;chs.forEach(function (x, n) { x.classList.toggle('is-on', n === cur); });if (hls) { try { hls.destroy(); } catch (e) { /* */ } hls = null; }@@@ -58,14 +238,12 @@if (d.sfThumb) v.poster = d.sfThumb;v.setAttribute('data-ov-player', '');v.addEventListener('volumechange', function () { if (!v.muted) screen.dataset.sound = '1'; });++ // Host: every play/pause/seek is a command; applying one is not. Members only receive.++ v.addEventListener('play', function () { if (isHost && !applying && Date.now() - programmatic > 500) postCommand(); });++ v.addEventListener('pause', function () { if (isHost && !applying && Date.now() - programmatic > 500) postCommand(); });++ v.addEventListener('seeked', function () { if (isHost && !applying && Date.now() - programmatic > 500) postCommand(); });screen.appendChild(v);-- if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = d.sfSrc; v.play().catch(function () {}); }-- else loadHls().then(function (Hls) {-- if (!Hls || !Hls.isSupported()) { v.src = d.sfSrc; return; }-- if (chs[cur] !== c) return;-- hls = new Hls({ maxBufferLength: 20 }); hls.loadSource(d.sfSrc); hls.attachMedia(v);-- hls.on(Hls.Events.MANIFEST_PARSED, function () { v.play().catch(function () {}); });-- });++ startVideo(v, d, c);} else {if (d.sfThumb) { var img = el('img', 'sf-tv-poster'); img.src = d.sfThumb; img.alt = ''; screen.appendChild(img); }var a = el('a', 'sf-tv-out'); a.href = d.sfPage || '#';@@@ -80,26 +258,74 @@if (d.sfChannel) { var l = el('a'); l.href = 'https://openvibe.live/@' + encodeURIComponent(d.sfChannel); l.textContent = '@' + d.sfChannel; meta.appendChild(l); }}if (c.scrollIntoView && window.innerWidth >= 980) c.scrollIntoView({ block: 'nearest', behavior: reduced() ? 'auto' : 'smooth' });++ if (party && isHost && !opts.fromSync) postCommand({ position_s: 0, playing: true, at: Date.now() });++ }++++ var prev = root.querySelector('[data-sf-prev]'), next = root.querySelector('[data-sf-next]');++ if (party && !isHost) {++ if (prev) { prev.disabled = true; prev.title = 'The host picks the channel'; }++ if (next) { next.disabled = true; next.title = 'The host picks the channel'; }}-- root.querySelector('[data-sf-prev]').addEventListener('click', function () { show(cur - 1, -1); });-- root.querySelector('[data-sf-next]').addEventListener('click', function () { show(cur + 1, 1); });++ if (prev) prev.addEventListener('click', function () { show(cur - 1, -1); });++ if (next) next.addEventListener('click', function () { show(cur + 1, 1); });chs.forEach(function (c, n) {-- c.querySelector('.sf-ch-link').addEventListener('click', function (e) { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); show(n, n > cur ? 1 : -1); screen.scrollIntoView && window.innerWidth < 980 && screen.scrollIntoView({ block: 'start', behavior: reduced() ? 'auto' : 'smooth' }); });++ c.querySelector('.sf-ch-link').addEventListener('click', function (e) {++ if (e.metaKey || e.ctrlKey || e.shiftKey) return;++ e.preventDefault();++ if (party && !isHost) { say('The host picks the channel and the position.'); return; }++ show(n, n > cur ? 1 : -1);++ screen.scrollIntoView && window.innerWidth < 980 && screen.scrollIntoView({ block: 'start', behavior: reduced() ? 'auto' : 'smooth' });++ });});document.addEventListener('keydown', function (e) {if (!document.body.contains(root)) return;var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;++ if (party && !isHost) return;if (e.key === 'ArrowDown') { e.preventDefault(); show(cur + 1, 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); show(cur - 1, -1); }});// A vertical swipe on the screen changes channel (the video's own controls keep their taps).var y0 = null, x0 = null;screen.addEventListener('touchstart', function (e) { var t = e.touches[0]; y0 = t.clientY; x0 = t.clientX; }, { passive: true });screen.addEventListener('touchend', function (e) {-- if (y0 == null) return; var t = e.changedTouches[0], dy = t.clientY - y0, dx = t.clientX - x0; y0 = null;-- if (Math.abs(dy) > 60 && Math.abs(dy) > Math.abs(dx) * 1.5) show(cur + (dy < 0 ? 1 : -1), dy < 0 ? 1 : -1);++ if (y0 == null) return;++ var t = e.changedTouches[0], dy = t.clientY - y0, dx = t.clientX - x0; y0 = null;++ if (Math.abs(dy) > 60 && Math.abs(dy) > Math.abs(dx) * 1.5) {++ if (party && !isHost) { say('The host picks the channel and the position.'); return; }++ show(cur + (dy < 0 ? 1 : -1), dy < 0 ? 1 : -1);++ }}, { passive: true });-- var first = chs[0];-- if (first && first.dataset.sfPlayType === 'hls') show(0, 1);++++ // Party bar: Copy link, the optional Chat room link, and the live member count.++ if (party) {++ var copy = party.querySelector('[data-sf-party-copy]');++ if (copy) copy.addEventListener('click', function () {++ var path = copy.getAttribute('data-sf-party-url') || partyPath;++ var url = location.origin + path;++ if (navigator.clipboard && navigator.clipboard.writeText) {++ navigator.clipboard.writeText(url).then(function () { say('Party link copied.'); }, function () { window.prompt('Copy this party link', url); });++ } else window.prompt('Copy this party link', url);++ });++ var chatForm = party.querySelector('[data-sf-party-chat-form]');++ if (chatForm) chatForm.addEventListener('submit', function (e) {++ e.preventDefault();++ var input = chatForm.querySelector('input');++ if (!input) return;++ fetch(partyPath + '/chat', {++ method: 'POST', credentials: 'same-origin',++ headers: { 'content-type': 'application/json', accept: 'application/json' },++ body: JSON.stringify({ chat_url: input.value }),++ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (b) { return { r: r, b: b }; }); })++ .then(function (x) {++ if (!x.r.ok) throw new Error((x.b && x.b.detail) || 'That is not a https://openvibe.chat/... link.');++ syncChat(x.b.chat_url); say('Chat room linked.');++ })++ .catch(function (err) { say(err.message, true); });++ });++ connectParty();++ }++++ var first = chs[cur];++ if (first && (first.dataset.sfPlayType === 'hls' || party)) show(cur, 1, { fromSync: true });}// ── Web: start in the page ────────────────────────────────@@@ -4,6 +4,7 @@* OpenVibe.Surf — Express app factory; server/index.js listens, tests build their own instance with mock upstreams.** /, /ride, /ride/:map, /watch, /web, /developers the pages (http/pages.js)++ * /watch/party, /watch/p/:id, …/events watch together (http/party.js)* /web/go the no-JavaScript task form* /api/v1/watch/feed, /api/v1/ride/maps… Watch and Ride (http/api.js)* /api/v1/web/tasks Surf with Vibe (http/api.js)@@@ -11,8 +12,9 @@* /api/health, /api/ready, /release.json liveness, truthful readiness, release metadata* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)*-- * Stateless: no database, no Valkey, no Events. The dependencies are OpenVibe.Network (sign-in), OpenVibe.Live-- * (Watch), OpenVibe.Games (Ride) and OpenVibe.Actor (Web); the pages degrade honestly without any of them.++ * No database, no Valkey, no Events. The only live state is in memory: the Watch feed cache and the watch-together++ * parties (a restart ends every party). The dependencies are OpenVibe.Network (sign-in), OpenVibe.Live (Watch),++ * OpenVibe.Games (Ride) and OpenVibe.Actor (Web); the pages degrade honestly without any of them.*/const path = require('path');const express = require('express');@@@ -29,10 +31,12 @@ const configLib = require('./config');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createWatchFeed } = require('./watch/feed');++const { createParties } = require('./watch/party');const { createRide } = require('./ride/games');const { createWeb } = require('./web/tasks');const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');++const { createPartyRoutes } = require('./http/party');const { createDiscoveryRoutes } = require('./http/discovery');const { createServiceReadiness } = require('./observability');const { PUBLIC_DIR, assetVersion } = require('./assets');@@@ -53,9 +57,14 @@ function createApp(opts = {}) {const keys = createKeyStore({ config, fetchImpl, log });const sso = createSso({ config, keys, fetchImpl, log, now });const feed = createWatchFeed({ config, fetchImpl, log, now });++ const parties = createParties({ now, maxParties: config.partyMax, maxMembers: config.partyMemberMax });const ride = createRide({ config, fetchImpl, log, now });const web = createWeb({ config, fetchImpl, log });-- const ctx = { config, keys, sso, feed, ride, web, log };++ const ctx = { config, keys, sso, feed, parties, ride, web, log };++ // Empty parties are dropped lazily on every read; this also frees them when nobody is asking. It never keeps++ // the process alive (unref) and is only memory, like the Map itself.++ const partySweep = setInterval(() => parties.sweep(), 60_000);++ if (partySweep.unref) partySweep.unref();const release = opts.release || releaseLib.createRelease({ service: 'surf', root: path.join(__dirname, '..') });views.setRelease(release.release);@@@ -135,6 +144,9 @@ function createApp(opts = {}) {// hls.js 1.7.3 light (the same file openvibe.video serves), loaded by js/surf.js only where a browser cannot play HLS.app.use('/vendor', express.static(path.join(PUBLIC_DIR, 'vendor'), { index: false, redirect: false, setHeaders: setAssetHeaders }));++ // ── Watch together: the party page, SSE and host commands ─++ app.use(createPartyRoutes(ctx));++// ── API: Watch, Ride and Web ────────────────────────────app.use('/api', express.json({ limit: '32kb' }));app.use('/api', createApi(ctx));@@@ -57,6 +57,10 @@ function load(env = process.env) {gamesTimeoutMs: atLeast(env.SURF_GAMES_TIMEOUT_MS, 200, 4000),// Web: Actor creates a task quickly, but a cold allowance check may take a moment.actorTimeoutMs: atLeast(env.SURF_ACTOR_TIMEOUT_MS, 200, 15_000),++ // Watch together: in-memory parties, hard caps 200 open parties and 50 members each. The environment may++ // lower the caps (tests do), never raise them; the 30-minute empty TTL is fixed in watch/party.js.++ partyMax: Math.min(200, Math.max(1, int(env.SURF_PARTY_MAX, 200))),++ partyMemberMax: Math.min(50, Math.max(1, int(env.SURF_PARTY_MEMBER_MAX, 50))),oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'surf',nnew file mode 100644@@@ -0,0 +1,238 @@++'use strict';++++/**++ * Watch together: the party page, its SSE stream and the host's commands.++ *++ * POST /watch/party signed in, this site only → 303 /watch/p/<unguessable id> (JSON: 201)++ * GET /watch/p/:id signed in; joins once and renders the TV with the party bar++ * GET /watch/p/:id/events SSE: `state` once, then `command` / `members` / `chat`; 15 s keepalive++ * POST /watch/p/:id/command host only, this site only: { channel_key, position_s, playing, at }++ * POST /watch/p/:id/leave anyone in the party; the last one out starts the 30-minute TTL++ * POST /watch/p/:id/chat host only: a validated https://openvibe.chat/... room URL (no chat here)++ *++ * State is in memory (watch/party.js): a restart ends every party; no database, no Events.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { ownOrigin } = require('./api');++const { send } = require('../render/layout');++const { PartyError } = require('../watch/party');++const views = require('../views');++++const jsonBody = express.json({ limit: '16kb' });++const formBody = express.urlencoded({ extended: false, limit: '8kb' });++const noStore = (res) => res.set('Cache-Control', 'private, no-store');++++function prefersJson(req) {++ const accept = String(req.get('accept') || '');++ const type = String(req.get('content-type') || '');++ return accept.includes('application/json') || type.includes('application/json') || String(req.query.format || '') === 'json';++}++++/**++ * A write made with the session cookie is this site's own: Sec-Fetch-Site same-origin, else an Origin equal to ours.++ * A request carrying neither is refused too (an old browser, or a client riding someone's cookie), as everywhere else.++ */++function crossSite(req, origin) {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site !== 'same-origin';++ const sent = String(req.get('origin') || '');++ return !sent || !origin || sent !== origin;++}++++function createPartyRoutes(ctx) {++ const { parties, feed, config, log } = ctx;++ const r = asyncRouter();++ const origin = ownOrigin(config);++++ const problem = (req, res, status, code, detail) => {++ noStore(res);++ return http.sendProblem(res, status, code, { detail, ctx: req.ov });++ };++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user';++ const isJson = (req) => prefersJson(req);++++ function fail(req, res, err) {++ if (err instanceof PartyError) return problem(req, res, err.status, err.code, err.message);++ log.warn('[OpenVibe.Surf] watch party failed:', (err && err.message) || err);++ return problem(req, res, 500, 'party.error', 'The party hit a problem. Try again in a moment.');++ }++++ // ── Open a party ────────────────────────────────────────++ r.post('/watch/party', jsonBody, formBody, async (req, res) => {++ noStore(res);++ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'A party starts from openvibe.surf itself.');++ if (!signedIn(req)) {++ if (isJson(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to watch together.');++ return res.redirect(303, '/auth/login?next=%2Fwatch');++ }++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ // Start on what is playing now, unless the person asked for a channel (the Watch page can pass one).++ let channelKey = body.channel_key == null ? null : body.channel_key;++ if (!channelKey) {++ try {++ const out = await feed.feed('all', null);++ channelKey = out && out.items && out.items[0] ? out.items[0].key : null;++ } catch (err) {++ log.warn('[OpenVibe.Surf] watch party start channel failed:', (err && err.message) || err);++ }++ }++ try {++ const opts = { host: req.viewer, channelKey, positionS: body.position_s, playing: body.playing, chatUrl: body.chat_url };++ if (body.at != null) opts.at = body.at;++ const party = parties.create(opts);++ const url = `/watch/p/${party.id}`;++ res.set('Location', url);++ if (isJson(req)) return res.status(201).json({ id: party.id, url, events_url: `${url}/events`, party: parties.snapshot(party) });++ return res.redirect(303, url);++ } catch (err) {++ return fail(req, res, err);++ }++ });++++ // ── The party page (joining happens here; the SSE stream only serves members) ──++ r.get('/watch/p/:id', async (req, res) => {++ noStore(res);++ const party = parties.get(req.params.id);++ if (!party) return send(res, 404, views.notFound({ party: true }), { viewer: req.viewer });++ if (!signedIn(req)) {++ return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/watch/p/${party.id}`)}`);++ }++ const had = party.members.size;++ try {++ parties.join(party, req.viewer);++ } catch (err) {++ if (err instanceof PartyError) return res.status(err.status).type('text/plain').send(err.message);++ throw err;++ }++ if (party.members.size > had) broadcastMembers(party);++ let out = null;++ try {++ out = await feed.feed('all', null);++ } catch (err) {++ log.warn('[OpenVibe.Surf] watch party page feed failed:', (err && err.message) || err);++ }++ const state = parties.snapshot(party);++ return send(res, 200, views.party({++ feed: out,++ party: state,++ selectedKey: state.channel_key,++ isHost: parties.isHost(party, req.viewer),++ partyPath: `/watch/p/${party.id}`,++ }), { viewer: req.viewer });++ });++++ // ── SSE: one initial state, then host commands and membership changes ──++ r.get('/watch/p/:id/events', (req, res) => {++ noStore(res);++ const party = parties.get(req.params.id);++ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');++ if (!signedIn(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to join this party.');++ const member = parties.memberOf(party, req.viewer);++ if (!member) return problem(req, res, 403, 'party.join', 'Open the party page before joining its stream.');++++ res.status(200).set({++ 'Content-Type': 'text/event-stream; charset=utf-8',++ 'Cache-Control': 'no-cache, no-store, no-transform',++ 'Connection': 'keep-alive',++ 'X-Accel-Buffering': 'no',++ });++ res.flushHeaders();++ res.setTimeout && res.setTimeout(0);++ parties.attach(party, res, member.key);++++ const state = parties.snapshot(party);++ const write = (event, data) => {++ try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); return true; } catch { return false; }++ };++ res.write('retry: 3000\n\n');++ write('state', { ...state, is_host: parties.isHost(party, req.viewer), you: member.name });++++ const beat = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* the close handler removes it */ } }, 15_000);++ if (beat.unref) beat.unref();++ let done = false;++ const close = () => {++ if (done) return;++ done = true;++ clearInterval(beat);++ parties.detach(party, res);++ };++ req.on('close', close);++ res.on('close', close);++ });++++ /** Send one SSE event to every stream of a party; a dead stream drops out. */++ function broadcast(party, event, data) {++ const line = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;++ for (const stream of party.connections.keys()) {++ try { stream.write(line); } catch { party.connections.delete(stream); }++ }++ }++++ /** Membership changes are personal only in `is_host`, so a promoted member learns it without a reload. */++ function broadcastMembers(party) {++ for (const [stream, key] of party.connections) {++ const data = { members: party.members.size, host: party.hostName, is_host: party.hostId === key };++ try { stream.write(`event: members\ndata: ${JSON.stringify(data)}\n\n`); } catch { party.connections.delete(stream); }++ }++ }++++ // ── Host commands ───────────────────────────────────────++ // `/command` is the documented route; the bare party URL and `/state` are accepted aliases for older clients.++ const command = (req, res) => {++ noStore(res);++ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'Host controls must come from openvibe.surf itself.');++ if (!signedIn(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to control this party.');++ const party = parties.get(req.params.id);++ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');++ try {++ const next = parties.command(party, req.viewer, req.body || {});++ broadcast(party, 'command', next);++ return res.json({ ok: true, command: next });++ } catch (err) {++ return fail(req, res, err);++ }++ };++ r.post('/watch/p/:id/command', jsonBody, formBody, command);++ r.post('/watch/p/:id/state', jsonBody, formBody, command);++ r.post('/watch/p/:id', jsonBody, formBody, command);++++ // ── Leave (the last member out starts the 30-minute clock) ──++ r.post('/watch/p/:id/leave', jsonBody, formBody, (req, res) => {++ noStore(res);++ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'Leaving a party is a same-site action.');++ if (!signedIn(req)) {++ if (isJson(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to leave this party.');++ return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/watch/p/${req.params.id}`)}`);++ }++ const party = parties.get(req.params.id);++ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');++ const out = parties.leave(party, req.viewer);++ if (out.left) broadcastMembers(party);++ if (isJson(req)) return res.json({ ok: true, ...out });++ return res.redirect(303, '/watch');++ });++++ // ── The OpenVibe.Chat room link (a link only: there is no chat inside Surf) ──++ r.post('/watch/p/:id/chat', jsonBody, formBody, (req, res) => {++ noStore(res);++ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'The chat link is set from openvibe.surf itself.');++ if (!signedIn(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to set the chat link.');++ const party = parties.get(req.params.id);++ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ try {++ const chatUrl = parties.setChat(party, req.viewer, body.chat_url == null ? body.url : body.chat_url);++ broadcast(party, 'chat', { chat_url: chatUrl });++ if (isJson(req)) return res.json({ ok: true, chat_url: chatUrl });++ return res.redirect(303, `/watch/p/${party.id}`);++ } catch (err) {++ return fail(req, res, err);++ }++ });++++ return r;++}++++module.exports = { createPartyRoutes, crossSite };@@@ -141,45 +141,85 @@ ${rows ? `<table class="sf-board" id="sf-top"><thead><tr><th scope="col">#</th><return layout({ title: `${name}: Ride`, description: `${name}: a surf map you ride in the browser, with stages, a millisecond timer and records.`, path: `/ride/${encodeURIComponent(id || one.id || '')}`, body, active: '/ride' });}--function watch({ feed = null, topic = 'all' } = {}) {++const TV_LABEL = { live: 'Live', clip: 'Clip', moment: 'AI moment' };++const kindOf = (it) => (it.kind === 'moment' ? 'moment' : it.kind === 'clip' ? 'clip' : 'live');++++/** The TV itself, shared by /watch and the party page: selected channel first, then the whole guide. */++function tvBlock({ feed = null, selectedKey = null, empty = null } = {}) {const items = feed && Array.isArray(feed.items) ? feed.items : [];-- const tabs = [['all', 'All'], ['live', 'Live'], ['clips', 'Clips'], ['moments', 'AI moments']].map(([id, label]) =>-- `<a class="sf-topic" href="/watch?topic=${id}"${id === topic ? ' aria-current="page"' : ''}>${label}</a>`).join('');-- const first = items[0] || null;-- const kindOf = (it) => (it.kind === 'moment' ? 'moment' : it.kind === 'clip' ? 'clip' : 'live');-- const label = { live: 'Live', clip: 'Clip', moment: 'AI moment' };-- const stage = first ? (() => {-- const k = kindOf(first);-- const p = first.play || {};-- const media = p.type === 'hls' && p.src-- ? `<video class="sf-tv-video" data-sf-video playsinline controls preload="none" poster="${esc(first.thumbnail || '')}" src="${esc(p.src)}"></video>`-- : `${first.thumbnail ? `<img class="sf-tv-poster" src="${esc(first.thumbnail)}" alt="" loading="eager">` : '<span class="sf-tv-poster"></span>'}<a class="sf-tv-out" href="${esc(p.page || '#')}">${kit.icon('play')}${k === 'live' ? 'Watch live on OpenVibe.Live' : 'Play on OpenVibe.Live'}</a>`;-- return `<div class="sf-tv-main"><div class="sf-tv-screen" data-sf-screen>${media}</div>--<div class="sf-tv-info"><p class="sf-tv-kind"><span class="sf-pill sf-pill--${esc(k)}">${esc(label[k])}</span><span data-sf-why>${esc(first.why || '')}</span></p>++ const first = (selectedKey && items.find((it) => it.key === selectedKey)) || items[0] || null;++ const start = first ? items.indexOf(first) : 0;++ if (!first) {++ return `<div class="sf-empty" id="sf-watch-empty">${kit.icon('play')}<p>${esc(empty || (feed ? 'Nothing on this channel right now. Try another, or come back soon.' : 'OpenVibe.Live did not answer. Try again in a moment.'))}</p></div>`;++ }++ const k = kindOf(first);++ const p = first.play || {};++ const media = p.type === 'hls' && p.src++ ? `<video class="sf-tv-video" data-sf-video playsinline controls preload="none" poster="${esc(first.thumbnail || '')}" src="${esc(p.src)}"></video>`++ : `${first.thumbnail ? `<img class="sf-tv-poster" src="${esc(first.thumbnail)}" alt="" loading="eager">` : '<span class="sf-tv-poster"></span>'}<a class="sf-tv-out" href="${esc(p.page || '#')}">${kit.icon('play')}${k === 'live' ? 'Watch live on OpenVibe.Live' : 'Play on OpenVibe.Live'}</a>`;++ const stage = `<div class="sf-tv-main"><div class="sf-tv-screen" data-sf-screen>${media}</div>++<div class="sf-tv-info"><p class="sf-tv-kind"><span class="sf-pill sf-pill--${esc(k)}">${esc(TV_LABEL[k])}</span><span data-sf-why>${esc(first.why || '')}</span></p><h2 data-sf-title>${esc(first.title || '')}</h2><p class="sf-tv-meta" data-sf-meta>${first.channel && first.channel.username ? `<a href="https://openvibe.live/@${encodeURIComponent(first.channel.username)}">@${esc(first.channel.username)}</a>` : ''}</p><p class="sf-tv-keys"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-prev aria-label="Previous channel">${kit.icon('up')}</button><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-next aria-label="Next channel">${kit.icon('down')}</button><span class="sf-fine">Swipe, or press <kbd>↑</kbd> <kbd>↓</kbd></span></p></div></div>`;-- })() : '';const rows = items.map((it, i) => {-- const k = kindOf(it);-- const p = it.play || {};-- return `<li class="sf-ch${i === 0 ? ' is-on' : ''}" id="sf-item-${esc(idSafe(it.key))}" data-sf-i="${i}" data-sf-kind="${esc(k)}" data-sf-play-type="${esc(p.type || '')}"${p.src ? ` data-sf-src="${esc(p.src)}"` : ''} data-sf-page="${esc(p.page || '')}" data-sf-thumb="${esc(it.thumbnail || '')}" data-sf-why="${esc(it.why || '')}" data-sf-channel="${esc((it.channel && it.channel.username) || '')}">--<a class="sf-ch-link" href="${esc(p.page || '#')}">--<span class="sf-ch-thumb">${it.thumbnail ? `<img src="${esc(it.thumbnail)}" alt="" loading="lazy" decoding="async">` : ''}${it.duration_seconds != null ? `<span class="sf-ch-len">${esc(clipLen(it.duration_seconds))}</span>` : ''}${k === 'live' ? '<span class="sf-ch-live">LIVE</span>' : ''}</span>--<span class="sf-ch-body"><b>${esc(it.title || '')}</b><small>${it.channel && it.channel.username ? `@${esc(it.channel.username)} · ` : ''}${esc(it.why || label[k])}</small></span>++ const rowKind = kindOf(it);++ const rowPlay = it.play || {};++ const on = it === first;++ return `<li class="sf-ch${on ? ' is-on' : ''}" id="sf-item-${esc(idSafe(it.key))}" data-sf-i="${i}" data-sf-kind="${esc(rowKind)}" data-sf-play-type="${esc(rowPlay.type || '')}"${rowPlay.src ? ` data-sf-src="${esc(rowPlay.src)}"` : ''} data-sf-page="${esc(rowPlay.page || '')}" data-sf-thumb="${esc(it.thumbnail || '')}" data-sf-why="${esc(it.why || '')}" data-sf-channel="${esc((it.channel && it.channel.username) || '')}" data-sf-key="${esc(it.key || '')}">++<a class="sf-ch-link" href="${esc(rowPlay.page || '#')}">++<span class="sf-ch-thumb">${it.thumbnail ? `<img src="${esc(it.thumbnail)}" alt="" loading="lazy" decoding="async">` : ''}${it.duration_seconds != null ? `<span class="sf-ch-len">${esc(clipLen(it.duration_seconds))}</span>` : ''}${rowKind === 'live' ? '<span class="sf-ch-live">LIVE</span>' : ''}</span>++<span class="sf-ch-body"><b>${esc(it.title || '')}</b><small>${it.channel && it.channel.username ? `@${esc(it.channel.username)} · ` : ''}${esc(it.why || TV_LABEL[rowKind])}</small></span></a></li>`;}).join('');++ return `<div class="sf-tv" id="sf-tv" data-sf-tv data-sf-start="${start}">${stage}<aside class="sf-guide" aria-labelledby="sf-guide-h"><h2 id="sf-guide-h">Up next</h2><ol class="sf-chs" id="sf-watch-feed">${rows}</ol>++${feed && feed.next ? `<a class="sf-more" id="sf-watch-next" href="/watch?topic=${esc(feed.topic || 'all')}&cursor=${esc(feed.next)}">More${kit.icon('chevron')}</a>` : ''}</aside></div>`;++}++++function watch({ feed = null, topic = 'all' } = {}) {++ const items = feed && Array.isArray(feed.items) ? feed.items : [];++ const tabs = [['all', 'All'], ['live', 'Live'], ['clips', 'Clips'], ['moments', 'AI moments']].map(([id, label]) =>++ `<a class="sf-topic" href="/watch?topic=${id}"${id === topic ? ' aria-current="page"' : ''}>${label}</a>`).join('');const missing = feed && feed.sources ? Object.entries(feed.sources).filter(([, v]) => v !== 'ok').map(([k]) => k) : [];const body = `<div class="sf-wrap sf-page-pad">--<header class="sf-page-h sf-watch-h"><div><p class="sf-kicker">Watch</p><h1>Surf what's on</h1></div><nav class="sf-topics" id="sf-topics" aria-label="What to watch">${tabs}</nav></header>++<header class="sf-page-h sf-watch-h"><div><p class="sf-kicker">Watch</p><h1>Surf what's on</h1></div><div class="sf-watch-tools"><nav class="sf-topics" id="sf-topics" aria-label="What to watch">${tabs}</nav>++<form class="sf-party-start" method="post" action="/watch/party"><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">${kit.icon('play')}Watch together</button></form></div></header>${missing.length ? `<p class="sf-note" id="sf-sources">Some of OpenVibe.Live did not answer (${esc(missing.join(', '))}); the rest is here.</p>` : ''}--${items.length ? `<div class="sf-tv" id="sf-tv" data-sf-tv>${stage}<aside class="sf-guide" aria-labelledby="sf-guide-h"><h2 id="sf-guide-h">Up next</h2><ol class="sf-chs" id="sf-watch-feed">${rows}</ol>--${feed && feed.next ? `<a class="sf-more" id="sf-watch-next" href="/watch?topic=${esc(topic)}&cursor=${esc(feed.next)}">More${kit.icon('chevron')}</a>` : ''}</aside></div>`-- : `<div class="sf-empty" id="sf-watch-empty">${kit.icon('play')}<p>${feed ? 'Nothing on this channel right now. Try another, or come back soon.' : 'OpenVibe.Live did not answer. Try again in a moment.'}</p></div>`}++${tvBlock({ feed })}</div>`;return layout({ title: 'Watch', description: 'Lean-back channel surfing over OpenVibe.Live: who is live, clips and the week\'s AI moments in one feed.', path: '/watch', body, active: '/watch' });}++/** The party bar: members, host, Copy link, Leave, and the optional OpenVibe.Chat room link (a link only). */++function partyBar({ party = {}, isHost = false, partyPath = '' } = {}) {++ const chat = party.chat_url ? String(party.chat_url) : null;++ const chatLink = chat++ ? `<p class="sf-party-chat" data-sf-party-chat><a href="${esc(chat)}" target="_blank" rel="noopener noreferrer">${kit.icon('globe')}Chat in OpenVibe.Chat</a></p>`++ : '<p class="sf-party-chat" data-sf-party-chat hidden></p>';++ const chatForm = isHost++ ? `<form class="sf-party-chat-form" data-sf-party-chat-form action="${esc(partyPath)}/chat" method="post"><label for="sf-party-chat-url" class="sf-fine">OpenVibe.Chat room link (optional)</label><input id="sf-party-chat-url" name="chat_url" type="url" inputmode="url" maxlength="500" placeholder="https://openvibe.chat/..." value="${esc(chat || '')}"><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Set</button></form>`++ : '';++ return `<section class="sf-party" id="sf-party" data-sf-party data-sf-party-id="${esc(party.id || '')}" data-sf-party-is-host="${isHost ? '1' : '0'}" data-sf-party-channel="${esc(party.channel_key || '')}" data-sf-party-position="${esc(party.position_s == null ? 0 : party.position_s)}" data-sf-party-playing="${party.playing === false ? '0' : '1'}" data-sf-party-at="${esc(party.at || 0)}" data-sf-party-url="${esc(partyPath)}">++<div class="sf-party-bar">++<p class="sf-party-who"><span class="sf-party-dot" aria-hidden="true"></span>Watch together · <b data-sf-party-members>${esc(party.members == null ? 1 : party.members)}</b> here · host <b data-sf-party-host-name>${esc(party.host || 'the host')}</b></p>++<div class="sf-party-actions"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-party-copy data-sf-party-url="${esc(partyPath)}">Copy link</button>++<form method="post" action="${esc(partyPath)}/leave"><button type="submit" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-party-leave>Leave</button></form></div>++</div>++<p class="sf-fine sf-party-share">Share this link: <a href="${esc(partyPath)}" data-sf-party-link>${esc(partyPath)}</a>${isHost ? ' · you are the host' : ' · the host picks the channel and the position'}</p>++<p class="sf-status" data-sf-party-status role="status"></p>++${chatLink}${chatForm}++</section>`;++}++++function party({ feed = null, party: p = {}, selectedKey = null, isHost = false, partyPath = '' } = {}) {++ const body = `<div class="sf-wrap sf-page-pad sf-party-page">++<header class="sf-page-h sf-watch-h"><div><p class="sf-kicker">Watch · Together</p><h1>Watch together</h1></div><a class="sf-more" href="/watch">Back to Watch${kit.icon('chevron')}</a></header>++${partyBar({ party: p, isHost, partyPath })}++${tvBlock({ feed, selectedKey, empty: "This party's channel is not in the guide right now, or OpenVibe.Live did not answer. Try again in a moment." })}++</div>`;++ return layout({ title: 'Watch together', description: 'Watch the same OpenVibe.Live channel at the same moment with friends: the host picks, everyone follows.', path: partyPath, robots: 'noindex, nofollow', body, active: '/watch' });++}++const EXAMPLES = ['A cheap flight to Lisbon in March and three things to do', 'What do runners say about the Pegasus 42?', 'Compare three hosting plans under $10 a month'];function web({ me = {}, query = '' } = {}) {@@@ -214,6 +254,7 @@ function developers() {['GET /api/v1/ride/maps', 'The surf maps from OpenVibe.Games: id, name, tier, stages, author, runs, the record. Cached 60 s.'],['GET /api/v1/ride/maps/:id', 'One map, its fastest runs and its <code>embed_url</code>.'],['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>.'],++ ['POST /watch/party · GET /watch/p/:id/events', 'Watch together: signed in and same-site, opens an in-memory party at <code>/watch/p/<id></code> (303, or 201 JSON). The SSE stream carries the host\'s <code>{ channel_key, position_s, playing, at }</code> commands to every member; a restart ends the party.'],];const body = `<div class="sf-wrap sf-page-pad"><header class="sf-page-h"><p class="sf-kicker">For developers</p><h1>The Surf API</h1>@@@ -223,13 +264,16 @@ function developers() {return layout({ title: 'The Surf API', path: '/developers', body, active: '/developers' });}--function notFound() {-- const body = `<div class="sf-wrap sf-page-pad sf-narrow"><div class="sf-empty" id="sf-not-found">${kit.icon('game')}<h1>Wiped out</h1><p>There is nothing at this address. Try <a href="/ride">a map</a>, <a href="/watch">what's on</a> or <a href="/web">asking Vibe</a>.</p></div></div>`;++function notFound({ party = false } = {}) {++ const text = party++ ? 'This watch party has ended, or its link is not one we made. Start a new one from <a href="/watch">Watch</a>.'++ : 'There is nothing at this address. Try <a href="/ride">a map</a>, <a href="/watch">what\'s on</a> or <a href="/web">asking Vibe</a>.';++ const body = `<div class="sf-wrap sf-page-pad sf-narrow"><div class="sf-empty" id="sf-not-found">${kit.icon('game')}<h1>${party ? 'Party over' : 'Wiped out'}</h1><p>${text}</p></div></div>`;return layout({ title: 'Not found', robots: 'noindex, follow', body, active: '/' });}module.exports = {-- home, ride, map, watch, web, developers, notFound, runTime,++ home, ride, map, watch, party, web, developers, notFound, runTime,layout,setRelease: layoutLib.setRelease,configure: layoutLib.configure,nnew file mode 100644@@@ -0,0 +1,237 @@++'use strict';++++/**++ * Watch together: parties held in this one process's memory (there is no database or Events bus).++ *++ * A signed-in person opens a party; the host's commands (channel, position, playing, at) are broadcast to every++ * member over SSE. The Map is bounded (200 parties, 50 members each) and an empty party is dropped 30 minutes++ * after its last member leaves; a process restart ends every party.++ *++ * This module holds state only. HTTP, SSE and the page live in http/party.js; the TV markup is views/index.js.++ */++const crypto = require('crypto');++++const DEFAULT_TTL_MS = 30 * 60 * 1000;++const MAX_PARTIES = 200;++const MAX_MEMBERS = 50;++const ID_RE = /^[A-Za-z0-9_-]{20,64}$/;++const MAX_CHANNEL_KEY = 200;++++class PartyError extends Error {++ constructor(status, code, message) {++ super(message);++ this.status = status;++ this.code = code;++ }++}++++const clean = (v, max = 200) => {++ const s = String(v == null ? '' : v).replace(/\s+/g, ' ').trim();++ return s.length > max ? s.slice(0, max) : s;++};++const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (Number.isFinite(Number(v)) ? Number(v) : null));++++/** The signed-in person behind a request: their Network subject when there is one, else their username. */++function personOf(viewer) {++ if (!viewer || viewer.kind !== 'user') throw new PartyError(401, 'party.signin', 'Sign in at openvibe.surf to watch together.');++ const key = viewer.subject || viewer.username;++ if (!key) throw new PartyError(401, 'party.signin', 'Sign in at openvibe.surf to watch together.');++ return {++ key: String(key),++ name: clean(viewer.displayName || viewer.username || 'A surfer', 80) || 'A surfer',++ };++}++++/** A channel key as the Watch feed made it (`live:bo`, `clip:201`, `moment:7`). */++function channelKeyOf(v, { required = false } = {}) {++ if (v == null || v === '') {++ if (required) throw new PartyError(400, 'party.invalid_channel', 'channel_key is required.');++ return null;++ }++ const s = String(v).trim();++ if (!s || s.length > MAX_CHANNEL_KEY || /[\u0000-\u001f\u007f]/.test(s)) {++ throw new PartyError(400, 'party.invalid_channel', 'channel_key must be a Watch channel key.');++ }++ return s;++}++++/** `{ channel_key, position_s, playing, at }` from a host POST, validated. */++function commandOf(body, fallbackAt) {++ const b = body && typeof body === 'object' ? body : {};++ const channel_key = channelKeyOf(b.channel_key, { required: true });++ const rawPosition = b.position_s == null ? 0 : finite(b.position_s);++ if (rawPosition == null || rawPosition < 0 || rawPosition > 7 * 24 * 60 * 60) {++ throw new PartyError(400, 'party.invalid_position', 'position_s must be a number of seconds from 0 to 604800.');++ }++ let playing = true;++ if (typeof b.playing === 'boolean') playing = b.playing;++ else if (b.playing === 'false' || b.playing === '0') playing = false;++ else if (b.playing != null && b.playing !== 'true' && b.playing !== '1') {++ throw new PartyError(400, 'party.invalid_playing', 'playing must be true or false.');++ }++ const rawAt = b.at == null ? fallbackAt : finite(b.at);++ if (rawAt == null || rawAt < 0) throw new PartyError(400, 'party.invalid_at', 'at must be a millisecond timestamp.');++ // Accept the host's clock when it is plausible; a wildly wrong clock would put every member years away.++ const at = Math.abs(rawAt - fallbackAt) > 5 * 60 * 1000 ? fallbackAt : rawAt;++ return { channel_key, position_s: rawPosition, playing, at };++}++++/** Only an absolute https link on openvibe.chat; no credentials, no other host. */++function validChatUrl(value) {++ if (typeof value !== 'string' || !value.trim() || value.length > 500) return null;++ try {++ const u = new URL(value.trim());++ if (u.protocol !== 'https:' || u.hostname !== 'openvibe.chat') return null;++ if (u.username || u.password || (u.port && u.port !== '443')) return null;++ return u.toString();++ } catch { return null; }++}++++function createParties({ now = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxParties = MAX_PARTIES, maxMembers = MAX_MEMBERS } = {}) {++ const parties = new Map();++ const limitParties = Math.min(MAX_PARTIES, Math.max(1, maxParties));++ const limitMembers = Math.min(MAX_MEMBERS, Math.max(1, maxMembers));++++ /** Drop empty parties whose 30 minutes are up. Every public read calls this first. */++ function prune() {++ const t = now();++ for (const [id, p] of parties) {++ if (p.members.size !== 0 || p.emptySince == null || t - p.emptySince < ttlMs) continue;++ // A member who left without closing their browser tab may still hold a stream: end it with the party.++ for (const stream of p.connections.keys()) { try { stream.end(); } catch { /* already gone */ } }++ p.connections.clear();++ parties.delete(id);++ }++ }++++ function get(id) {++ prune();++ const key = String(id == null ? '' : id);++ return ID_RE.test(key) ? parties.get(key) || null : null;++ }++++ function create({ host, channelKey = null, positionS = 0, playing = true, at = now(), chatUrl = null } = {}) {++ prune();++ if (parties.size >= limitParties) throw new PartyError(429, 'party.limit', 'Too many watch parties are open right now. Try again later.');++ const who = personOf(host);++ const t = now();++ let id;++ do { id = crypto.randomBytes(18).toString('base64url'); } while (parties.has(id));++ const member = { key: who.key, name: who.name, joinedAt: t };++ const startPos = finite(positionS);++ const startAt = finite(at);++ const party = {++ id,++ hostId: who.key,++ hostName: who.name,++ members: new Map([[who.key, member]]),++ state: {++ channel_key: channelKeyOf(channelKey),++ position_s: startPos == null || startPos < 0 ? 0 : startPos,++ playing: playing !== false,++ at: startAt == null ? t : startAt,++ },++ chatUrl: validChatUrl(chatUrl),++ createdAt: t,++ emptySince: null,++ // SSE responses → the member key that opened them (so a new host can be told it is host).++ connections: new Map(),++ };++ parties.set(id, party);++ return party;++ }++++ function memberOf(party, viewer) {++ const who = personOf(viewer);++ return party.members.get(who.key) || null;++ }++++ function join(party, viewer) {++ const who = personOf(viewer);++ const existing = party.members.get(who.key);++ if (existing) {++ existing.name = who.name;++ party.emptySince = null;++ return existing;++ }++ if (party.members.size >= limitMembers) throw new PartyError(429, 'party.full', `This party is full (${limitMembers} members).`);++ const member = { key: who.key, name: who.name, joinedAt: now() };++ party.members.set(who.key, member);++ // A party whose host left with nobody left has no host; the first person back is it.++ if (!party.hostId) { party.hostId = who.key; party.hostName = who.name; }++ party.emptySince = null;++ return member;++ }++++ function leave(party, viewer) {++ const who = personOf(viewer);++ const left = party.members.delete(who.key);++ let hostChanged = false;++ if (left && party.hostId === who.key) {++ const next = [...party.members.values()].sort((a, b) => (a.joinedAt - b.joinedAt) || a.key.localeCompare(b.key))[0] || null;++ party.hostId = next ? next.key : null;++ party.hostName = next ? next.name : null;++ hostChanged = true;++ }++ if (party.members.size === 0 && party.emptySince == null) party.emptySince = now();++ return { left: Boolean(left), hostChanged, members: party.members.size };++ }++++ function requireHost(party, viewer) {++ const who = personOf(viewer);++ if (party.hostId !== who.key) throw new PartyError(403, 'party.not_host', 'Only the host changes the channel and the position.');++ return who;++ }++++ function command(party, viewer, body) {++ requireHost(party, viewer);++ const next = commandOf(body, now());++ party.state = next;++ return next;++ }++++ function setChat(party, viewer, raw) {++ requireHost(party, viewer);++ const url = validChatUrl(raw);++ if (!url) throw new PartyError(400, 'party.invalid_chat', 'Paste a https://openvibe.chat/... link.');++ party.chatUrl = url;++ return url;++ }++++ function isHost(party, viewer) {++ try { return party.hostId === personOf(viewer).key; } catch { return false; }++ }++++ /** What the page and the SSE clients may see: no network subject ids, no tokens. */++ function snapshot(party) {++ return {++ id: party.id,++ host: party.hostName,++ members: party.members.size,++ chat_url: party.chatUrl,++ channel_key: party.state.channel_key,++ position_s: party.state.position_s,++ playing: party.state.playing,++ at: party.state.at,++ created_at: party.createdAt,++ };++ }++++ function attach(party, res, memberKey) { party.connections.set(res, memberKey); }++ function detach(party, res) { party.connections.delete(res); }++++ function sweep() { prune(); return parties.size; }++ function clear() { parties.clear(); }++++ return {++ create, get, join, leave, command, setChat, isHost, memberOf, snapshot,++ attach, detach, sweep, clear,++ get size() { return parties.size; },++ get parties() { return parties; },++ };++}++++module.exports = {++ createParties, PartyError, validChatUrl, channelKeyOf, commandOf,++ DEFAULT_TTL_MS, MAX_PARTIES, MAX_MEMBERS, ID_RE,++};nnew file mode 100644@@@ -0,0 +1,250 @@++'use strict';++/**++ * Watch together: open/join/leave, the host-only and same-site command rules, SSE delivering a command, the++ * empty-party TTL and the hard caps (200 parties, 50 members; boot lowers them for this file).++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const ORIGIN = 'https://openvibe.surf';++const own = { origin: ORIGIN, 'sec-fetch-site': 'same-origin' };++++/** A tiny SSE client: events are parsed as they arrive, waitFor() blocks until one named event shows up. */++function openEvents(t, path, cookie) {++ const events = [];++ let buffer = '';++ let req = null;++ const ready = new Promise((resolve, reject) => {++ req = http.get(`${t.base}${path}`, { headers: { cookie, accept: 'text/event-stream' } }, (res) => {++ res.setEncoding('utf8');++ if (res.statusCode !== 200) {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => reject(new Error(`SSE ${res.statusCode}: ${text}`)));++ return;++ }++ res.on('data', (chunk) => {++ buffer += chunk;++ let at;++ while ((at = buffer.indexOf('\n\n')) >= 0) {++ const block = buffer.slice(0, at);++ buffer = buffer.slice(at + 2);++ let event = 'message';++ let data = '';++ for (const line of block.split('\n')) {++ if (line.startsWith('event:')) event = line.slice(6).trim();++ else if (line.startsWith('data:')) data += line.slice(5).trim();++ }++ if (!data) continue;++ let parsed = data;++ try { parsed = JSON.parse(data); } catch { /* keep the text */ }++ events.push({ event, data: parsed });++ }++ });++ resolve();++ });++ req.on('error', reject);++ });++ return {++ ready,++ events,++ async waitFor(event, timeoutMs = 3000) {++ const started = Date.now();++ for (;;) {++ const hit = events.find((e) => e.event === event);++ if (hit) return hit;++ if (Date.now() - started > timeoutMs) {++ throw new Error(`no ${event} event; got ${events.map((e) => e.event).join(', ') || 'nothing'}`);++ }++ await new Promise((r) => setTimeout(r, 20));++ }++ },++ close() { if (req) req.destroy(); },++ };++}++++(async () => {++ const t = await boot();++ const host = t.network.addUser('kim');++ const member = t.network.addUser('ada');++ const other = t.network.addUser('noa');++ const hostCookie = t.signIn(host);++ const memberCookie = t.signIn(member);++ let partyPath = null;++++ await check('POST /watch/party opens an unguessable party and redirects to it', async () => {++ const r = await t.get('/watch/party', { cookie: hostCookie, method: 'POST', headers: own });++ assert.strictEqual(r.status, 303, r.text);++ const location = r.headers.get('location') || '';++ assert.match(location, /^\/watch\/p\/[A-Za-z0-9_-]{20,64}$/, 'an unguessable id in the path');++ partyPath = location;++ const page = await t.get(partyPath, { cookie: hostCookie });++ assert.strictEqual(page.status, 200, page.text);++ assert.match(page.text, /id="sf-party"/);++ assert.match(page.text, /data-sf-party-is-host="1"/);++ assert.match(page.text, /data-sf-party-members>1</);++ assert.match(page.text, /host <b data-sf-party-host-name>kim<\/b>/);++ assert.match(page.text, /id="sf-tv"/, 'the same TV markup');++ assert.match(page.text, /data-sf-party-copy/, 'Copy link');++ assert.match(page.text, /data-sf-party-leave/, 'Leave');++ assert.match(page.text, /data-sf-party-chat-form/, 'the host can paste a chat link');++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');++ assert.match(page.headers.get('vary'), /Cookie/i);++ });++++ await check('a signed-out visitor is sent to sign in before joining', async () => {++ const r = await t.get(partyPath);++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(partyPath)}`);++ });++++ await check('a signed-in person joins once; the party bar counts them and names the host', async () => {++ const first = await t.get(partyPath, { cookie: memberCookie });++ assert.strictEqual(first.status, 200, first.text);++ assert.match(first.text, /data-sf-party-is-host="0"/);++ assert.match(first.text, /data-sf-party-members>2</);++ assert.match(first.text, /host <b data-sf-party-host-name>kim<\/b>/);++ assert.ok(!first.text.includes('data-sf-party-chat-form'), 'only the host sets the chat link');++ const again = await t.get(partyPath, { cookie: memberCookie });++ assert.match(again.text, /data-sf-party-members>2</, 'a second visit is not a second member');++ });++++ await check('SSE delivers the host command; a member and another site cannot command', async () => {++ const stream = openEvents(t, `${partyPath}/events`, memberCookie);++ try {++ await stream.ready;++ const initial = await stream.waitFor('state');++ assert.strictEqual(initial.data.members, 2);++ assert.strictEqual(initial.data.host, 'kim');++ assert.strictEqual(initial.data.id, partyPath.split('/').pop());++ assert.strictEqual(initial.data.is_host, false);++++ const command = { channel_key: 'clip:101', position_s: 12.5, playing: false, at: Date.now() };++ const byMember = await t.get(`${partyPath}/command`, { cookie: memberCookie, method: 'POST', json: command, headers: own });++ assert.strictEqual(byMember.status, 403, byMember.text);++ assert.strictEqual(byMember.json().code, 'party.not_host');++++ const byOtherSite = await t.get(`${partyPath}/command`, {++ cookie: hostCookie, method: 'POST', json: command, headers: { origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },++ });++ assert.strictEqual(byOtherSite.status, 403, 'a cross-site command is refused');++ assert.strictEqual(byOtherSite.json().code, 'party.cross_site');++++ const sent = await t.get(`${partyPath}/command`, { cookie: hostCookie, method: 'POST', json: command, headers: own });++ assert.strictEqual(sent.status, 200, sent.text);++ assert.deepStrictEqual(sent.json().command, command);++ const event = await stream.waitFor('command');++ assert.strictEqual(event.data.channel_key, 'clip:101');++ assert.strictEqual(event.data.position_s, 12.5);++ assert.strictEqual(event.data.playing, false);++ assert.strictEqual(event.data.at, command.at, 'the host timestamp is relayed');++ } finally {++ stream.close();++ }++ });++++ await check('the host can link a validated OpenVibe.Chat room, and nothing else', async () => {++ const bad = await t.get(`${partyPath}/chat`, {++ cookie: hostCookie, method: 'POST', json: { chat_url: 'https://evil.example/room' }, headers: own,++ });++ assert.strictEqual(bad.status, 400);++ assert.strictEqual(bad.json().code, 'party.invalid_chat');++ const good = await t.get(`${partyPath}/chat`, {++ cookie: hostCookie, method: 'POST', json: { chat_url: 'https://openvibe.chat/room/surf-1' }, headers: own,++ });++ assert.strictEqual(good.status, 200, good.text);++ assert.strictEqual(good.json().chat_url, 'https://openvibe.chat/room/surf-1');++ const page = await t.get(partyPath, { cookie: memberCookie });++ assert.match(page.text, /href="https:\/\/openvibe\.chat\/room\/surf-1"/);++ assert.match(page.text, /Chat in OpenVibe\.Chat/);++ assert.match(page.text, /class="sf-ch is-on"[^>]*data-sf-key="clip:101"/, 'the page renders the host\'s last channel');++ });++++ await check('anyone can leave, the member count follows, and the last one out starts the TTL', async () => {++ const id = partyPath.split('/').pop();++ const left = await t.get(`${partyPath}/leave`, { cookie: memberCookie, method: 'POST', headers: own });++ assert.strictEqual(left.status, 303);++ assert.strictEqual(left.headers.get('location'), '/watch');++ assert.strictEqual(t.ctx.parties.get(id).members.size, 1);++ const hostPage = await t.get(partyPath, { cookie: hostCookie });++ assert.match(hostPage.text, /data-sf-party-members>1</);++ const last = await t.get(`${partyPath}/leave`, { cookie: hostCookie, method: 'POST', headers: own });++ assert.strictEqual(last.status, 303);++ const empty = t.ctx.parties.get(id);++ assert.ok(empty);++ assert.strictEqual(empty.members.size, 0, 'the party stays for its 30-minute window');++ t.clock.advance(30 * 60 * 1000 - 1);++ assert.ok(t.ctx.parties.get(id), 'still there a moment before 30 minutes');++ t.clock.advance(2);++ assert.strictEqual(t.ctx.parties.get(id), null, 'an empty party ends 30 minutes after the last member leaves');++ const gone = await t.get(partyPath, { cookie: memberCookie });++ assert.strictEqual(gone.status, 404);++ assert.match(gone.text, /Party over/);++ });++++ await check('if the host leaves, the earliest remaining member becomes host', async () => {++ const made = await t.get('/watch/party', {++ cookie: hostCookie, method: 'POST', json: {},++ headers: { ...own, accept: 'application/json' },++ });++ const path = made.json().url;++ assert.strictEqual((await t.get(path, { cookie: memberCookie })).status, 200);++ const leaving = await t.get(`${path}/leave`, { cookie: hostCookie, method: 'POST', json: {}, headers: own });++ assert.strictEqual(leaving.status, 200, leaving.text);++ assert.strictEqual(leaving.json().hostChanged, true);++ const promoted = await t.get(`${path}/command`, {++ cookie: memberCookie, method: 'POST', json: { channel_key: 'live:ada', position_s: 4, playing: true, at: Date.now() }, headers: own,++ });++ assert.strictEqual(promoted.status, 200, promoted.text);++ assert.strictEqual(promoted.json().command.channel_key, 'live:ada');++ await t.get(`${path}/leave`, { cookie: memberCookie, method: 'POST', json: {}, headers: own });++ });++++ await check('the JSON form creates a party without a redirect and names its stream', async () => {++ const r = await t.get('/watch/party', {++ cookie: other ? t.signIn(other) : hostCookie, method: 'POST', json: {},++ headers: { ...own, accept: 'application/json' },++ });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.match(b.id, /^[A-Za-z0-9_-]{20,64}$/);++ assert.strictEqual(b.url, `/watch/p/${b.id}`);++ assert.strictEqual(b.events_url, `/watch/p/${b.id}/events`);++ assert.strictEqual(b.party.members, 1);++ assert.ok(b.party.channel_key, 'the party starts on the first channel in the guide');++ });++++ await t.close();++++ // A second process with low caps: the limits are hard caps (200/50), testable by lowering them.++ const small = await boot({ env: { SURF_PARTY_MAX: '2', SURF_PARTY_MEMBER_MAX: '2' } });++ const a = small.network.addUser('a');++ const b = small.network.addUser('b');++ const c = small.network.addUser('c');++ const create = async (user) => small.get('/watch/party', {++ cookie: small.signIn(user), method: 'POST', json: {},++ headers: { ...own, accept: 'application/json' },++ });++++ await check('at most 200 parties and 50 members each: the lowered caps refuse the next one', async () => {++ const one = await create(a);++ const two = await create(b);++ assert.strictEqual(one.status, 201, one.text);++ assert.strictEqual(two.status, 201, two.text);++ const tooMany = await create(c);++ assert.strictEqual(tooMany.status, 429, tooMany.text);++ assert.strictEqual(tooMany.json().code, 'party.limit');++++ const p1 = one.json().url;++ const joined = await small.get(p1, { cookie: small.signIn(b) });++ assert.strictEqual(joined.status, 200, joined.text);++ assert.match(joined.text, /data-sf-party-members>2</);++ const full = await small.get(p1, { cookie: small.signIn(c) });++ assert.strictEqual(full.status, 429, 'the third member is refused');++ assert.match(full.text, /party is full/);++ });++++ await small.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -33,6 +33,9 @@ SURF_GAMES_PUBLIC_URL=https://openvibe.games
@@ -33,6 +33,9 @@ SURF_GAMES_PUBLIC_URL=https://openvibe.games
# SURF_GAMES_TIMEOUT_MS=4000
# SURF_GAMES_TIMEOUT_MS=4000
# SURF_ACTOR_TIMEOUT_MS=15000
# SURF_ACTOR_TIMEOUT_MS=15000
# SURF_RATE_LIMIT_PER_MIN=120
# SURF_RATE_LIMIT_PER_MIN=120
+# Watch together: open parties and members per party (these may only be lowered; hard caps 200/50).
+# SURF_PARTY_MAX=200
+# SURF_PARTY_MEMBER_MAX=50
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
@@ -2,6 +2,19 @@
@@ -2,6 +2,19 @@
What changed in OpenVibe.Surf, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Surf, newest first. Each site also publishes its patch notes at /updates.
+## Unreleased — watch together
+
+- **Watch together** (`server/watch/party.js`, `server/http/party.js`, `server/views/index.js`,
+ `public/js/surf.js`): a signed-in person opens a party (`POST /watch/party`) and shares an unguessable
+ `/watch/p/<id>` link. The page reuses the TV markup with a `sf-party-` bar (members, host, Copy link, Leave); the
+ host commands `{ channel_key, position_s, playing, at }` from this site only, members follow over SSE
+ (`GET /watch/p/<id>/events`) and correct drift over 2 s, and anyone can leave. State is one in-process Map,
+ capped at 200 parties and 50 members each; an empty party ends 30 minutes after its last member leaves, and a
+ restart ends every party. The optional chat room is a validated `https://openvibe.chat/...` link (no chat in
+ Surf). nginx gets an unbuffered SSE location with a 1-hour read timeout.
+- **Tests:** `test/party.test.js` covers create/join/leave, host-only and cross-site commands, SSE delivery, the
+ 30-minute TTL and the lowered caps.
+
## 0.1.0 — 2026-10-28
## 0.1.0 — 2026-10-28
The service skeleton: three surfs, one room.
The service skeleton: three surfs, one room.
@@ -11,7 +11,7 @@ OpenVibe.Surf is the Surf room. One room, three surfs:
@@ -11,7 +11,7 @@ OpenVibe.Surf is the Surf room. One room, three surfs:
- **Ride** — CS-style surf in the browser, served by OpenVibe.Games. The map catalogue is proxied from Games and
- **Ride** — CS-style surf in the browser, served by OpenVibe.Games. The map catalogue is proxied from Games and
every map plays in an embed (`https://openvibe.games/surf/embed?map=<id>&embed=1`).
every map plays in an embed (`https://openvibe.games/surf/embed?map=<id>&embed=1`).
- **Watch** — lean-back channel surfing over OpenVibe.Live: live streams, clips people cut and the AI moments of
- **Watch** — lean-back channel surfing over OpenVibe.Live: live streams, clips people cut and the AI moments of
- the week, merged into one feed with one card shape.
+ the week, merged into one feed with one card shape. Open a party and everyone follows the host's channel.
- **Web** — *Surf with Vibe*: tell an agent what you are looking for and it browses the web for you on a private
- **Web** — *Surf with Vibe*: tell an agent what you are looking for and it browses the web for you on a private
desk through OpenVibe.Actor, keeps its sources and finishes with a cited report.
desk through OpenVibe.Actor, keeps its sources and finishes with a cited report.
@@ -21,8 +21,9 @@ OpenVibe.Surf is the Surf room. One room, three surfs:
@@ -21,8 +21,9 @@ OpenVibe.Surf is the Surf room. One room, three surfs:
database and no tokens: the person's Network session lives in httpOnly cookies.
database and no tokens: the person's Network session lives in httpOnly cookies.
- **Sign-in:** an OAuth 2 client of OpenVibe.Network (`surf`), authorization code with PKCE S256, a state check
- **Sign-in:** an OAuth 2 client of OpenVibe.Network (`surf`), authorization code with PKCE S256, a state check
and a server-side code exchange.
and a server-side code exchange.
-- **Stateless:** no database, no Valkey, no Events. The only state is a 30 s Watch feed cache, a 60 s Ride cache
- and the SDK's JWKS cache, all in memory.
+- **In memory, no database:** no database, no Valkey, no Events. The only state is a 30 s Watch feed cache, a
+ 60 s Ride cache, the SDK's JWKS cache and the watch-together parties, all in this one process. **A restart ends
+ every watch party.**
## Watch
## Watch
@@ -65,6 +66,25 @@ Every card is one shape:
@@ -65,6 +66,25 @@ Every card is one shape:
- **Cursor:** opaque; it carries each Live feed's own `next` cursor. Page two does not repeat live streams.
- **Cursor:** opaque; it carries each Live feed's own `next` cursor. Page two does not repeat live streams.
- **Cache-Control:** `public, max-age=30`.
- **Cache-Control:** `public, max-age=30`.
+### Watch together
+
+A signed-in person opens a party (`POST /watch/party`, from this site) and gets an unguessable id at
+`/watch/p/<id>`; that link is the invitation. Opening it sends a guest to sign in first and joins everyone else.
+The page reuses the TV markup with a `sf-party-` bar: members, host, **Copy link**, **Leave**, and, if the host
+pasted one, a link to an OpenVibe.Chat room (`https://openvibe.chat/...`, validated). There is no chat inside Surf.
+
+- **Host:** the person who opened the party. Only the host commands, with
+ `POST /watch/p/<id>/command` (also `POST /watch/p/<id>` and `/state`) and
+ `{ channel_key, position_s, playing, at }`, from this site's own Origin. A member's command is 403, and so is a
+ command from another site.
+- **Sync:** `GET /watch/p/<id>/events` is Server-Sent Events (`text/event-stream`): `state` once, then `command`
+ when the host changes channel, play/pause or seek, `members` on a join/leave, and `chat` when the room link
+ changes. The browser applies commands, follows play/pause and seeks, and corrects a drift over 2 s.
+- **Leaving and lifetime:** `POST /watch/p/<id>/leave` (the Leave button) removes that member; if the host leaves
+ while others remain, the earliest remaining member becomes host. The last member out starts a 30-minute clock.
+ The Map holds at most 200 open parties and 50 members each; an empty party is dropped on the next read 30
+ minutes after its last member leaves. Everything is memory only: a restart ends every party.
+
## Ride
## Ride
`GET /api/v1/ride/maps` and `GET /api/v1/ride/maps/:id`
`GET /api/v1/ride/maps` and `GET /api/v1/ride/maps/:id`
@@ -108,7 +128,8 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star
@@ -108,7 +128,8 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star
| `/` | the Surf room: the run motif (ramps, speed lines, a ticking timer), the three tiles under the hero, scenes, questions |
| `/` | the Surf room: the run motif (ramps, speed lines, a ticking timer), the three tiles under the hero, scenes, questions |
| `/ride` | the maps with tier, stages, runs and the record, and the embed of the first map |
| `/ride` | the maps with tier, stages, runs and the record, and the embed of the first map |
| `/ride/:map` | one map: name, tier, stages, records if Games answers, the embed |
| `/ride/:map` | one map: name, tier, stages, records if Games answers, the embed |
-| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; a live stream that records to MP4 plays over Media's DVR playlist, a little behind live, and any other opens on its channel |
+| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; a live stream that records to MP4 plays over Media's DVR playlist, a little behind live, and any other opens on its channel; **Watch together** opens a party |
+| `/watch/p/:id` | a watch party: the same TV, a `sf-party-` bar with the members, host, Copy link and Leave, and the optional OpenVibe.Chat room link; signed-in members follow the host's channel and position over SSE |
| `/web` | the Surf with Vibe form, examples, and what happens next |
| `/web` | the Surf with Vibe form, examples, and what happens next |
| `/developers` | the API, for people and agents |
| `/developers` | the API, for people and agents |
| anything else | the 404 page |
| anything else | the 404 page |
@@ -128,6 +149,11 @@ the home timer; everything works without it.
@@ -128,6 +149,11 @@ the home timer; everything works without it.
| `GET /api/v1/ride/maps/:id` | none | `public, max-age=60` | one map and its fastest runs |
| `GET /api/v1/ride/maps/:id` | none | `public, max-age=60` | one map and its fastest runs |
| `POST /api/v1/web/tasks` | cookie + Origin | `private, no-store` | create one Actor task as the person |
| `POST /api/v1/web/tasks` | cookie + Origin | `private, no-store` | create one Actor task as the person |
| `POST /web/go` | cookie, this site only | `private, no-store` | the no-JavaScript task form, 303 to Actor |
| `POST /web/go` | cookie, this site only | `private, no-store` | the no-JavaScript task form, 303 to Actor |
+| `POST /watch/party` | cookie + Origin | `private, no-store` | open a party, 303 to `/watch/p/<id>` (JSON: 201) |
+| `GET /watch/p/:id/events` | cookie, member | `no-store` | the party's SSE stream (`state`, `command`, `members`, `chat`) |
+| `POST /watch/p/:id/command` | cookie, host + Origin | `private, no-store` | `{ channel_key, position_s, playing, at }`, broadcast over SSE |
+| `POST /watch/p/:id/leave` | cookie + Origin | `private, no-store` | leave the party, 303 to `/watch` (JSON: 200) |
+| `POST /watch/p/:id/chat` | cookie, host + Origin | `private, no-store` | set the `https://openvibe.chat/...` room link |
Errors are RFC 9457 `application/problem+json`. A blank map id is 400; a bad Watch topic or cursor is 400. The
Errors are RFC 9457 `application/problem+json`. A blank map id is 400; a bad Watch topic or cursor is 400. The
only outbound person token is the `Authorization: Bearer` on the request to Actor.
only outbound person token is the `Authorization: Bearer` on the request to Actor.
@@ -177,6 +203,8 @@ OAuth client on the Network).
@@ -177,6 +203,8 @@ OAuth client on the Network).
| `SURF_GAMES_TIMEOUT_MS` | `4000` | one Games read's budget |
| `SURF_GAMES_TIMEOUT_MS` | `4000` | one Games read's budget |
| `SURF_ACTOR_TIMEOUT_MS` | `15000` | one Actor task creation's budget |
| `SURF_ACTOR_TIMEOUT_MS` | `15000` | one Actor task creation's budget |
| `SURF_RATE_LIMIT_PER_MIN` | `120` | per-IP `/api` and `/auth` budget |
| `SURF_RATE_LIMIT_PER_MIN` | `120` | per-IP `/api` and `/auth` budget |
+| `SURF_PARTY_MAX` | `200` | open watch parties (may only be lowered, hard cap 200) |
+| `SURF_PARTY_MEMBER_MAX` | `50` | members per party (may only be lowered, hard cap 50) |
| `COOKIE_SECURE` | true in production | Secure cookies |
| `COOKIE_SECURE` | true in production | Secure cookies |
## How to run
## How to run
@@ -187,7 +215,8 @@ npm run dev # http://localhost:5110
@@ -187,7 +215,8 @@ npm run dev # http://localhost:5110
OV_ALLOW_SUITE=1 npm test
OV_ALLOW_SUITE=1 npm test
```
```
-`npm test` runs every `test/*.test.js` in its own process with in-process stand-ins
+`npm test` runs every `test/*.test.js` (including `test/party.test.js`: create/join/leave, host-only and
+same-site commands, SSE delivery, the 30-minute TTL and the caps) in its own process with in-process stand-ins
(`test/helpers/network.js`, `test/helpers/upstreams.js`): a real RS256 key pair and the OAuth token endpoint for
(`test/helpers/network.js`, `test/helpers/upstreams.js`): a real RS256 key pair and the OAuth token endpoint for
Network, and recorded Live, Games and Actor routes. Nothing calls the real internet. `OV_ALLOW_SUITE=1` is the
Network, and recorded Live, Games and Actor routes. Nothing calls the real internet. `OV_ALLOW_SUITE=1` is the
suite opt-in the shared test runner expects.
suite opt-in the shared test runner expects.
@@ -198,7 +227,8 @@ suite opt-in the shared test runner expects.
@@ -198,7 +227,8 @@ suite opt-in the shared test runner expects.
`openvibe-surf.service` on 127.0.0.1:5110, env `/etc/openvibe/surf.env`).
`openvibe-surf.service` on 127.0.0.1:5110, env `/etc/openvibe/surf.env`).
- **nginx:** [deploy/nginx/openvibe.surf.conf](deploy/nginx/openvibe.surf.conf), installed with
- **nginx:** [deploy/nginx/openvibe.surf.conf](deploy/nginx/openvibe.surf.conf), installed with
`ov-vhost-install`. `/api` is reads only; `/api/v1/web/` and `/web/go` are the forms zone; `/auth` is GET only;
`ov-vhost-install`. `/api` is reads only; `/api/v1/web/` and `/web/go` are the forms zone; `/auth` is GET only;
- `/auth/me` gets the API budget; `/internal` and `/metrics` are 404.
+ `/auth/me` gets the API budget; `/internal` and `/metrics` are 404. The SSE route
+ `GET /watch/p/<id>/events` has `proxy_buffering off`, no gzip and a 1-hour read timeout.
- **Readiness:** `/api/ready` reports `degraded` (HTTP 200) when Network, Live or Games does not answer; the
- **Readiness:** `/api/ready` reports `degraded` (HTTP 200) when Network, Live or Games does not answer; the
process still serves the pages and the crawl artifacts.
process still serves the pages and the crawl artifacts.
@@ -214,6 +244,11 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -214,6 +244,11 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
browsers drop or rewrite them.
browsers drop or rewrite them.
- Watch's card fields are allow-listed and escaped; only `http(s)` URLs become `href`/`src`. Actor's error
- Watch's card fields are allow-listed and escaped; only `http(s)` URLs become `href`/`src`. Actor's error
detail is trimmed and no token or request body is logged.
detail is trimmed and no token or request body is logged.
+- A watch party's id is 144 random bits. Only its host may command; a browser command must come from this
+ site's own Origin (`Origin`/`Sec-Fetch-Site`, while a request with neither is a non-browser client and still
+ needs the session cookie). Only a member may open the SSE stream, and the party page is `private, no-store`.
+ The Map is capped and an empty party is dropped after 30 minutes. Member names and the optional Chat link are
+ escaped; the Chat link is an absolute `https://openvibe.chat/` URL with no credentials, and Surf stores no chat.
- Secrets live only in the environment file; only variable names appear in code and docs, and no secret or token
- Secrets live only in the environment file; only variable names appear in code and docs, and no secret or token
is logged.
is logged.
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
"plan": "OpenVibe.Surf: one room with three surfs. Ride is CS-style surf in the browser served by OpenVibe.Games; Watch is lean-back channel surfing over OpenVibe.Live's live streams, clips and AI moments; Web is Surf with Vibe, where an agent browses for you on a private desk through OpenVibe.Actor.",
"plan": "OpenVibe.Surf: one room with three surfs. Ride is CS-style surf in the browser served by OpenVibe.Games; Watch is lean-back channel surfing over OpenVibe.Live's live streams, clips and AI moments; Web is Surf with Vibe, where an agent browses for you on a private desk through OpenVibe.Actor.",
- "runtime": "node 22, express 4, openvibe-shared/shell; stateless (no database, no Valkey, no Events)",
+ "runtime": "node 22, express 4, openvibe-shared/shell; no database, no Valkey, no Events; watch parties are in memory and end on restart",
"port": "5110",
"port": "5110",
"domain": "openvibe.surf",
"domain": "openvibe.surf",
"contracts": "openvibe-contracts v0.232.0",
"contracts": "openvibe-contracts v0.232.0",
@@ -16,14 +16,15 @@
@@ -16,14 +16,15 @@
"openvibe-sdk v0.70.0",
"openvibe-sdk v0.70.0",
"openvibe-shared v4.33.0"
"openvibe-shared v4.33.0"
],
],
- "summary": "openvibe.surf, the Surf room: Network sign-in as OAuth client `surf`; Ride (OpenVibe.Games' surf maps, cached 60 s, embedded); Watch (a lean-back TV over OpenVibe.Live's live streams, clips and AI moments, clips and moments playing in the page over HLS); Web (Surf with Vibe: one OpenVibe.Actor task made with the person's own Network token). Stateless.",
+ "summary": "openvibe.surf, the Surf room: Network sign-in as OAuth client `surf`; Ride (OpenVibe.Games' surf maps, cached 60 s, embedded); Watch (a lean-back TV over OpenVibe.Live's live streams, clips and AI moments, clips and moments playing in the page over HLS) plus watch together (in-memory SSE parties); Web (Surf with Vibe: one OpenVibe.Actor task made with the person's own Network token). No database.",
"works": [
"works": [
"Sign-in with OpenVibe.Network: OAuth 2 authorization code, PKCE S256, state check, server-side exchange, httpOnly surf_at/surf_rt cookies, sanitizeNext (own origins only, no control characters or backslashes)",
"Sign-in with OpenVibe.Network: OAuth 2 authorization code, PKCE S256, state check, server-side exchange, httpOnly surf_at/surf_rt cookies, sanitizeNext (own origins only, no control characters or backslashes)",
"Watch GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=: merges OpenVibe.Live's /api/streams, /api/content/feed?type=clips and /api/content/moments?type=clips into one item shape, ranks live by viewers then moments/clips by freshness with no channel twice in a row, caches 30 s, 2 s per source, a failing source left out and named in sources, public max-age=30",
"Watch GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=: merges OpenVibe.Live's /api/streams, /api/content/feed?type=clips and /api/content/moments?type=clips into one item shape, ranks live by viewers then moments/clips by freshness with no channel twice in a row, caches 30 s, 2 s per source, a failing source left out and named in sources, public max-age=30",
"Ride GET /api/v1/ride/maps and /api/v1/ride/maps/:id: proxies OpenVibe.Games' /api/surf/maps routes, adds each map's embed_url (https://openvibe.games/surf/embed?map=<id>&embed=1), caches 60 s, Games 404 → empty list/map shell with games: 'not_ready'",
"Ride GET /api/v1/ride/maps and /api/v1/ride/maps/:id: proxies OpenVibe.Games' /api/surf/maps routes, adds each map's embed_url (https://openvibe.games/surf/embed?map=<id>&embed=1), caches 60 s, Games 404 → empty list/map shell with games: 'not_ready'",
"Web POST /api/v1/web/tasks: signed in, same-origin, query 3–500 characters, sends the person's surf_at token to OpenVibe.Actor as a Bearer with an Idempotency-Key and the prefixed browsing brief, answers { task_id, page }, and passes Actor's 4xx refusal through in words",
"Web POST /api/v1/web/tasks: signed in, same-origin, query 3–500 characters, sends the person's surf_at token to OpenVibe.Actor as a Bearer with an Idempotency-Key and the prefixed browsing brief, answers { task_id, page }, and passes Actor's 4xx refusal through in words",
"POST /web/go: the same task as a plain form for no-JavaScript (this site only, the question never in a URL): 303 to the Actor task page, sign-in redirect for a guest, Actor's refusal in plain words",
"POST /web/go: the same task as a plain form for no-JavaScript (this site only, the question never in a URL): 303 to the Actor task page, sign-in redirect for a guest, Actor's refusal in plain words",
- "Designed pages: / (the run motif, three tiles, scenes, questions), /ride and /ride/:map, /watch (the TV and its guide, HLS in the page), /web, /developers and a 404; public max-age=60 with Vary: Cookie, signed-in private, no-store",
+ "Designed pages: / (the run motif, three tiles, scenes, questions), /ride and /ride/:map, /watch (the TV and its guide, HLS in the page), /watch/p/:id (watch together), /web, /developers and a 404; public max-age=60 with Vary: Cookie, signed-in private, no-store",
+ "Watch together: POST /watch/party makes an unguessable /watch/p/<id>, signed-in members join by opening it, the host commands { channel_key, position_s, playing, at } from this site, GET /watch/p/:id/events carries SSE to members (drift > 2 s corrected), and anyone can leave; the Map is capped at 200 parties/50 members, an empty party is dropped 30 min after its last member leaves, and a restart ends every party",
"Crawl artifacts (robots.txt, sitemap.xml with the public pages and Ride maps, llms.txt), /api/health, /api/ready (degraded when Network/Live/Games/Actor do not answer), /release.json, /release-metrics, /shared/*",
"Crawl artifacts (robots.txt, sitemap.xml with the public pages and Ride maps, llms.txt), /api/health, /api/ready (degraded when Network/Live/Games/Actor do not answer), /release.json, /release-metrics, /shared/*",
"Helmet with the network's CSP (Games and Live frames, openvibe.media HLS), per-IP 120/min on /api and /auth, content-hashed /css and /js, graceful stop",
"Helmet with the network's CSP (Games and Live frames, openvibe.media HLS), per-IP 120/min on /api and /auth, content-hashed /css and /js, graceful stop",
"Deploy files: openvibe-surf.service, nginx vhost for openvibe.surf on 127.0.0.1:5110 with the forms zone, /auth/me on the API zone and /internal + /metrics 404",
"Deploy files: openvibe-surf.service, nginx vhost for openvibe.surf on 127.0.0.1:5110 with the forms zone, /auth/me on the API zone and /internal + /metrics 404",
@@ -31,7 +32,7 @@
@@ -31,7 +32,7 @@
],
],
"notYet": [
"notYet": [
"OpenVibe.Games' surf mode and GET /api/surf/maps: until they ship, Ride answers games: 'not_ready' and the page says the maps are being built",
"OpenVibe.Games' surf mode and GET /api/surf/maps: until they ship, Ride answers games: 'not_ready' and the page says the maps are being built",
- "A browser (WHIP) live stream in the TV: it has no DVR playlist yet, so it opens on its channel; watch together"
+ "A browser (WHIP) live stream in the TV: it has no DVR playlist yet, so it opens on its channel"
],
],
"capabilitiesRegistered": false,
"capabilitiesRegistered": false,
"serviceManifestRegistered": true,
"serviceManifestRegistered": true,
@@ -40,6 +41,7 @@
@@ -40,6 +41,7 @@
"features": [
"features": [
"Network sign-in (PKCE)",
"Network sign-in (PKCE)",
"Watch merged Live feed",
"Watch merged Live feed",
+ "Watch together (SSE parties)",
"Ride Games maps proxy",
"Ride Games maps proxy",
"Surf with Vibe via OpenVibe.Actor",
"Surf with Vibe via OpenVibe.Actor",
"server-rendered pages",
"server-rendered pages",
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
## and compresses text.
## and compresses text.
##
##
## No proxy_cache: the app decides cacheability per response (public 30 s for the Watch feed, 60 s for Ride and the
## No proxy_cache: the app decides cacheability per response (public 30 s for the Watch feed, 60 s for Ride and the
-## pages; private, no-store for a page or answer rendered for a signed-in person). Never log request bodies here.
+## pages; private, no-store for a page or answer rendered for a signed-in person). Watch-together SSE is the one
+## unbuffered location. Never log request bodies here.
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=surf_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=surf_auth:10m rate=10r/m;
@@ -87,6 +88,21 @@ server {
@@ -87,6 +88,21 @@ server {
proxy_pass http://127.0.0.1:5110;
proxy_pass http://127.0.0.1:5110;
}
}
+ # ── Watch together: SSE (GET /watch/p/<id>/events) ──────
+ # Server-Sent Events must reach the browser as they are written: no proxy buffering, no gzip, and a read
+ # timeout longer than the browser's idle window (the app sends a keepalive comment every 15 s). A restart of
+ # the app ends every party; nginx only carries the stream.
+ location ~ ^/watch/p/[A-Za-z0-9_-]+/events$ {
+ limit_except GET { deny all; }
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 1h;
+ proxy_send_timeout 1h;
+ gzip off;
+ proxy_set_header Connection "";
+ proxy_pass http://127.0.0.1:5110;
+ }
+
# ── The Watch and Ride API: reads only ──────────────────
# ── The Watch and Ride API: reads only ──────────────────
location /api/ {
location /api/ {
limit_except GET { deny all; }
limit_except GET { deny all; }
@@ -111,6 +111,8 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
@@ -111,6 +111,8 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
.sf-board .is-wr td { font-weight: 800; color: var(--room-strong); }
.sf-board .is-wr td { font-weight: 800; color: var(--room-strong); }
/* Watch: the TV and the guide. */
/* Watch: the TV and the guide. */
+.sf-watch-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.sf-party-start { margin: 0; }
.sf-watch-h { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; }
.sf-watch-h { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; }
.sf-topics { display: flex; gap: 4px; padding: 3px; border-radius: 999px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); overflow-x: auto; scrollbar-width: none; max-width: 100%; }
.sf-topics { display: flex; gap: 4px; padding: 3px; border-radius: 999px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); overflow-x: auto; scrollbar-width: none; max-width: 100%; }
.sf-topic { flex: none; padding: 6px 14px; border-radius: 999px; font-size: .86rem; font-weight: 700; text-decoration: none; color: var(--text-secondary, #96a7c2); }
.sf-topic { flex: none; padding: 6px 14px; border-radius: 999px; font-size: .86rem; font-weight: 700; text-decoration: none; color: var(--text-secondary, #96a7c2); }
@@ -148,6 +150,27 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
@@ -148,6 +150,27 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
.sf-ch-body small { font-size: .76rem; color: var(--text-muted, #7386a3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-ch-body small { font-size: .76rem; color: var(--text-muted, #7386a3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-guide .sf-more { margin-top: 10px; }
.sf-guide .sf-more { margin-top: 10px; }
+/* Watch together: the party bar above the same TV. */
+.sf-party { display: grid; gap: 8px; margin: 0 0 16px; padding: 12px 14px; border-radius: 18px; border: 1px solid color-mix(in srgb, var(--room-accent) 35%, var(--border, #1f2d47)); background: color-mix(in srgb, var(--room-accent) 7%, var(--bg-card, #0f1729)); }
+.sf-party-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
+.sf-party-who { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; font-size: .92rem; color: var(--text-secondary, #96a7c2); }
+.sf-party-who b { color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
+.sf-party-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--live-red, #e11d48); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e11d48) 20%, transparent); }
+html.ov-motion .sf-party-dot { animation: sf-party-pulse 2.4s ease-in-out infinite; }
+@keyframes sf-party-pulse { 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--live-red, #e11d48) 5%, transparent); } }
+.sf-party-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.sf-party-actions form { margin: 0; }
+.sf-party-share { margin: 0; overflow-wrap: anywhere; }
+.sf-party-share a { color: var(--room-strong); font-weight: 700; }
+.sf-party-chat { margin: 0; }
+.sf-party-chat[hidden] { display: none; }
+.sf-party-chat a { display: inline-flex; align-items: center; gap: 6px; color: var(--room-strong); font-weight: 800; text-decoration: none; }
+.sf-party-chat a .ovk-i { width: 1.05em; height: 1.05em; }
+.sf-party-chat-form { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.sf-party-chat-form input { flex: 1 1 260px; min-width: 0; padding: 7px 10px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, #0d1322); color: var(--text-primary, #e6edf7); font: inherit; font-size: .9rem; }
+.sf-party-chat-form input:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; }
+.sf-party-page .sf-tv { margin-top: 8px; }
+
/* Web: Surf with Vibe. */
/* Web: Surf with Vibe. */
.sf-web-form { display: grid; gap: 10px; }
.sf-web-form { display: grid; gap: 10px; }
.sf-web-form textarea { width: 100%; min-height: 96px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, #0d1322); color: var(--text-primary, #e6edf7); font: inherit; font-size: 1.02rem; line-height: 1.5; resize: vertical; }
.sf-web-form textarea { width: 100%; min-height: 96px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, #0d1322); color: var(--text-primary, #e6edf7); font: inherit; font-size: 1.02rem; line-height: 1.5; resize: vertical; }
@@ -170,5 +193,26 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
@@ -170,5 +193,26 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
.sf-eps dt code { font: 600 .88rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
.sf-eps dt code { font: 600 .88rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
.sf-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
.sf-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
-@media (prefers-reduced-motion: reduce) { .sf-run-speed i, .sf-demo-steps li.is-now::before, .sf-tv-screen.is-changing { animation: none !important; } }
+@media (prefers-reduced-motion: reduce) { .sf-run-speed i, .sf-demo-steps li.is-now::before, .sf-tv-screen.is-changing, .sf-party-dot { animation: none !important; } }
html[data-ov-motion=reduced] .sf-run-speed i { animation: none !important; }
html[data-ov-motion=reduced] .sf-run-speed i { animation: none !important; }
+
+/* Watch together: the party bar above the TV. Everyone sees the host's channel and moment; the dot pulses while
+ the party is in sync. */
+.sf-party-start { margin: 0; }
+.sf-party-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; margin: 0 0 14px; padding: 12px 16px; border-radius: 16px;
+ background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); border: 1px solid color-mix(in srgb, var(--room-accent) 40%, var(--border, #1f2d47)); }
+.sf-party-who, .sf-party-status { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font-size: .9rem; }
+.sf-party-who b, .sf-party-host-name { font-weight: 800; }
+.sf-party-members, .sf-party-at, .sf-party-position, .sf-party-channel { color: var(--text-secondary, #96a7c2); font-size: .84rem; font-variant-numeric: tabular-nums; }
+.sf-party-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success, #22c55e) 60%, transparent); }
+html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-out infinite; }
+@keyframes sf-pulse { to { box-shadow: 0 0 0 10px transparent; } }
+.sf-party-is-host { padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--room-on); background: var(--room-fill); }
+.sf-party-actions, .sf-party-share { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.sf-party-actions form { margin: 0; }
+.sf-party-url, .sf-party-link { max-width: 260px; min-width: 0; padding: 7px 10px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-secondary, #96a7c2); font: 500 .8rem ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sf-party-chat { margin: 10px 0 0; } .sf-party-chat a { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
+.sf-party-chat-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
+.sf-party-chat-url { flex: 1 1 240px; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; }
+.sf-party-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--text-muted, #7386a3); }
+@media (prefers-reduced-motion: reduce) { .sf-party-dot { animation: none !important; } }
@@ -42,11 +42,191 @@
@@ -42,11 +42,191 @@
var title = root.querySelector('[data-sf-title]'), why = root.querySelector('[data-sf-why]'), meta = root.querySelector('[data-sf-meta]');
var title = root.querySelector('[data-sf-title]'), why = root.querySelector('[data-sf-why]'), meta = root.querySelector('[data-sf-meta]');
var kindPill = root.querySelector('.sf-tv-kind .sf-pill');
var kindPill = root.querySelector('.sf-tv-kind .sf-pill');
var LABEL = { live: 'Live', clip: 'Clip', moment: 'AI moment' };
var LABEL = { live: 'Live', clip: 'Clip', moment: 'AI moment' };
- var cur = 0, hls = null;
+ var cur = Math.max(0, Math.min(chs.length - 1, parseInt(root.dataset.sfStart || '0', 10) || 0)), hls = null;
+ var party = document.querySelector('[data-sf-party]');
+ var partyId = party && party.getAttribute('data-sf-party-id');
+ var partyPath = (party && party.getAttribute('data-sf-party-url')) || (partyId ? '/watch/p/' + encodeURIComponent(partyId) : '');
+ var isHost = Boolean(party) && party.getAttribute('data-sf-party-is-host') === '1';
+ var partyStatus = party && party.querySelector('[data-sf-party-status]');
+ var partyState = party ? {
+ channel_key: party.getAttribute('data-sf-party-channel') || '',
+ position_s: Number(party.getAttribute('data-sf-party-position')) || 0,
+ playing: party.getAttribute('data-sf-party-playing') !== '0',
+ at: Number(party.getAttribute('data-sf-party-at')) || Date.now(),
+ } : null;
+ var applying = false, partEvent = null, programmatic = 0;
+
function el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
function el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
- function show(i, dir) {
+ function say(msg, bad) {
+ if (!partyStatus) return;
+ partyStatus.textContent = msg || '';
+ partyStatus.classList.toggle('is-bad', Boolean(bad));
+ }
+ function currentVideo() { return screen.querySelector('video'); }
+ function expectedOf(state) {
+ if (!state) return 0;
+ var at = Number(state.at), base = Number(state.position_s) || 0;
+ if (!Number.isFinite(at)) at = Date.now();
+ var elapsed = state.playing ? Math.max(0, Date.now() - at) / 1000 : 0;
+ return Math.max(0, base + elapsed);
+ }
+ function applyVideo(v, state) {
+ if (!v || !state) return;
+ var target = expectedOf(state);
+ var seek = function () {
+ try { if (Math.abs(v.currentTime - target) > 2) v.currentTime = target; } catch (e) { /* the playlist may not seek that far */ }
+ };
+ if (v.readyState >= 1) seek(); else v.addEventListener('loadedmetadata', seek, { once: true });
+ if (state.playing) v.play().catch(function () {});
+ else v.pause();
+ }
+ function startVideo(v, d, node) {
+ if (v.canPlayType('application/vnd.apple.mpegurl')) {
+ v.src = d.sfSrc;
+ if (partyState) { applying = true; applyVideo(v, partyState); applying = false; }
+ else v.play().catch(function () {});
+ } else {
+ loadHls().then(function (Hls) {
+ if (!Hls || !Hls.isSupported()) {
+ v.src = d.sfSrc;
+ if (partyState) { applying = true; applyVideo(v, partyState); applying = false; }
+ return;
+ }
+ if (chs[cur] !== node) return;
+ hls = new Hls({ maxBufferLength: 20 }); hls.loadSource(d.sfSrc); hls.attachMedia(v);
+ hls.on(Hls.Events.MANIFEST_PARSED, function () {
+ if (partyState) { applying = true; applyVideo(v, partyState); applying = false; }
+ else v.play().catch(function () {});
+ });
+ });
+ }
+ }
+
+ // Host: tell the party what changed (channel, play/pause, seek). Members never write.
+ function postCommand(extra) {
+ if (!party || !partyId || !isHost || !chs[cur]) return;
+ var v = currentVideo();
+ var cmd = {
+ channel_key: chs[cur].dataset.sfKey || '',
+ position_s: v && Number.isFinite(v.currentTime) ? v.currentTime : 0,
+ playing: v ? !v.paused : true,
+ at: Date.now(),
+ };
+ if (extra) for (var k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) cmd[k] = extra[k];
+ partyState = cmd;
+ fetch(partyPath + '/command', {
+ method: 'POST', credentials: 'same-origin',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify(cmd),
+ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (b) { return { r: r, b: b }; }); })
+ .then(function (x) {
+ if (x.r.ok) return;
+ say((x.b && x.b.detail) || 'That command did not reach the party.', true);
+ })
+ .catch(function () { say('The party did not answer. It will reconnect.', true); });
+ }
+
+ function syncMembers(data) {
+ if (!party || !data) return;
+ var count = party.querySelector('[data-sf-party-members]');
+ var host = party.querySelector('[data-sf-party-host-name]');
+ if (count && data.members != null) count.textContent = data.members;
+ if (host && data.host) host.textContent = data.host;
+ }
+ function syncChat(url) {
+ if (!party || !url) return;
+ var line = party.querySelector('[data-sf-party-chat]');
+ if (!line) { line = el('p', 'sf-party-chat'); line.setAttribute('data-sf-party-chat', ''); party.appendChild(line); }
+ line.hidden = false;
+ line.replaceChildren();
+ var a = el('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = 'Chat in OpenVibe.Chat';
+ line.appendChild(a);
+ var input = party.querySelector('[data-sf-party-chat-form] input');
+ if (input) input.value = url;
+ }
+
+ function applyCommand(cmd) {
+ if (!party || !cmd || !cmd.channel_key) return;
+ partyState = {
+ channel_key: String(cmd.channel_key),
+ position_s: Number(cmd.position_s) || 0,
+ playing: cmd.playing !== false,
+ at: Number(cmd.at) || Date.now(),
+ };
+ var at = -1;
+ chs.forEach(function (c, n) { if (c.dataset.sfKey === partyState.channel_key) at = n; });
+ if (at >= 0 && at !== cur) {
+ show(at, at > cur ? 1 : -1, { fromSync: true });
+ } else {
+ var v = currentVideo();
+ applying = true;
+ applyVideo(v, partyState);
+ applying = false;
+ }
+ }
+
+ function connectParty() {
+ if (!partyId || typeof EventSource === 'undefined') return;
+ partEvent = new EventSource(partyPath + '/events');
+ partEvent.addEventListener('open', function () {
+ say(isHost ? 'You are the host: everyone follows your channel, play and seek.' : 'Following the host.');
+ });
+ partEvent.addEventListener('state', function (e) {
+ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }
+ syncMembers(d);
+ if (d.chat_url) syncChat(d.chat_url);
+ if (!isHost) applyCommand(d);
+ });
+ partEvent.addEventListener('command', function (e) {
+ if (isHost) return; // the host is the source of truth for its own screen
+ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }
+ applyCommand(d);
+ });
+ partEvent.addEventListener('members', function (e) {
+ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }
+ syncMembers(d);
+ if (typeof d.is_host === 'boolean' && d.is_host !== isHost) {
+ isHost = d.is_host;
+ party.setAttribute('data-sf-party-is-host', isHost ? '1' : '0');
+ if (prev) { prev.disabled = !isHost; prev.title = isHost ? '' : 'The host picks the channel'; }
+ if (next) { next.disabled = !isHost; next.title = isHost ? '' : 'The host picks the channel'; }
+ say(isHost ? 'You are now the host: everyone follows your channel, play and seek.' : 'Following the host.');
+ }
+ });
+ partEvent.addEventListener('chat', function (e) {
+ var d = null; try { d = JSON.parse(e.data); } catch (err) { return; }
+ if (d.chat_url) syncChat(d.chat_url);
+ });
+ partEvent.addEventListener('error', function () {
+ say('Reconnecting to the party…', true);
+ });
+ }
+
+ // Members correct drift above 2 s, and follow the host's play/pause.
+ function drift() {
+ if (!document.body.contains(root)) { clearInterval(drift.timer); if (partEvent) partEvent.close(); return; }
+ if (!party || isHost || applying || !partyState || !partyState.channel_key) return;
+ if (!chs[cur] || chs[cur].dataset.sfKey !== partyState.channel_key) return;
+ var v = currentVideo();
+ if (!v || v.readyState < 1 || !Number.isFinite(v.currentTime)) return;
+ var target = expectedOf(partyState);
+ if (Math.abs(v.currentTime - target) > 2) {
+ try { v.currentTime = target; } catch (e) { /* not seekable */ }
+ }
+ if (partyState.playing && v.paused) { programmatic = Date.now(); v.play().catch(function () {}); }
+ else if (!partyState.playing && !v.paused) { programmatic = Date.now(); v.pause(); }
+ }
+ drift.timer = setInterval(drift, 1000);
+
+ function show(i, dir, opts) {
if (!chs.length) return;
if (!chs.length) return;
+ opts = opts || {};
+ if (party && !isHost && !opts.fromSync) {
+ say('The host picks the channel and the position. You can leave any time.');
+ return;
+ }
cur = (i + chs.length) % chs.length;
cur = (i + chs.length) % chs.length;
+ programmatic = Date.now();
var c = chs[cur], d = c.dataset;
var c = chs[cur], d = c.dataset;
chs.forEach(function (x, n) { x.classList.toggle('is-on', n === cur); });
chs.forEach(function (x, n) { x.classList.toggle('is-on', n === cur); });
if (hls) { try { hls.destroy(); } catch (e) { /* */ } hls = null; }
if (hls) { try { hls.destroy(); } catch (e) { /* */ } hls = null; }
@@ -58,14 +238,12 @@
@@ -58,14 +238,12 @@
if (d.sfThumb) v.poster = d.sfThumb;
if (d.sfThumb) v.poster = d.sfThumb;
v.setAttribute('data-ov-player', '');
v.setAttribute('data-ov-player', '');
v.addEventListener('volumechange', function () { if (!v.muted) screen.dataset.sound = '1'; });
v.addEventListener('volumechange', function () { if (!v.muted) screen.dataset.sound = '1'; });
+ // Host: every play/pause/seek is a command; applying one is not. Members only receive.
+ v.addEventListener('play', function () { if (isHost && !applying && Date.now() - programmatic > 500) postCommand(); });
+ v.addEventListener('pause', function () { if (isHost && !applying && Date.now() - programmatic > 500) postCommand(); });
+ v.addEventListener('seeked', function () { if (isHost && !applying && Date.now() - programmatic > 500) postCommand(); });
screen.appendChild(v);
screen.appendChild(v);
- if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = d.sfSrc; v.play().catch(function () {}); }
- else loadHls().then(function (Hls) {
- if (!Hls || !Hls.isSupported()) { v.src = d.sfSrc; return; }
- if (chs[cur] !== c) return;
- hls = new Hls({ maxBufferLength: 20 }); hls.loadSource(d.sfSrc); hls.attachMedia(v);
- hls.on(Hls.Events.MANIFEST_PARSED, function () { v.play().catch(function () {}); });
- });
+ startVideo(v, d, c);
} else {
} else {
if (d.sfThumb) { var img = el('img', 'sf-tv-poster'); img.src = d.sfThumb; img.alt = ''; screen.appendChild(img); }
if (d.sfThumb) { var img = el('img', 'sf-tv-poster'); img.src = d.sfThumb; img.alt = ''; screen.appendChild(img); }
var a = el('a', 'sf-tv-out'); a.href = d.sfPage || '#';
var a = el('a', 'sf-tv-out'); a.href = d.sfPage || '#';
@@ -80,26 +258,74 @@
@@ -80,26 +258,74 @@
if (d.sfChannel) { var l = el('a'); l.href = 'https://openvibe.live/@' + encodeURIComponent(d.sfChannel); l.textContent = '@' + d.sfChannel; meta.appendChild(l); }
if (d.sfChannel) { var l = el('a'); l.href = 'https://openvibe.live/@' + encodeURIComponent(d.sfChannel); l.textContent = '@' + d.sfChannel; meta.appendChild(l); }
}
}
if (c.scrollIntoView && window.innerWidth >= 980) c.scrollIntoView({ block: 'nearest', behavior: reduced() ? 'auto' : 'smooth' });
if (c.scrollIntoView && window.innerWidth >= 980) c.scrollIntoView({ block: 'nearest', behavior: reduced() ? 'auto' : 'smooth' });
+ if (party && isHost && !opts.fromSync) postCommand({ position_s: 0, playing: true, at: Date.now() });
+ }
+
+ var prev = root.querySelector('[data-sf-prev]'), next = root.querySelector('[data-sf-next]');
+ if (party && !isHost) {
+ if (prev) { prev.disabled = true; prev.title = 'The host picks the channel'; }
+ if (next) { next.disabled = true; next.title = 'The host picks the channel'; }
}
}
- root.querySelector('[data-sf-prev]').addEventListener('click', function () { show(cur - 1, -1); });
- root.querySelector('[data-sf-next]').addEventListener('click', function () { show(cur + 1, 1); });
+ if (prev) prev.addEventListener('click', function () { show(cur - 1, -1); });
+ if (next) next.addEventListener('click', function () { show(cur + 1, 1); });
chs.forEach(function (c, n) {
chs.forEach(function (c, n) {
- c.querySelector('.sf-ch-link').addEventListener('click', function (e) { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); show(n, n > cur ? 1 : -1); screen.scrollIntoView && window.innerWidth < 980 && screen.scrollIntoView({ block: 'start', behavior: reduced() ? 'auto' : 'smooth' }); });
+ c.querySelector('.sf-ch-link').addEventListener('click', function (e) {
+ if (e.metaKey || e.ctrlKey || e.shiftKey) return;
+ e.preventDefault();
+ if (party && !isHost) { say('The host picks the channel and the position.'); return; }
+ show(n, n > cur ? 1 : -1);
+ screen.scrollIntoView && window.innerWidth < 980 && screen.scrollIntoView({ block: 'start', behavior: reduced() ? 'auto' : 'smooth' });
+ });
});
});
document.addEventListener('keydown', function (e) {
document.addEventListener('keydown', function (e) {
if (!document.body.contains(root)) return;
if (!document.body.contains(root)) return;
var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
+ if (party && !isHost) return;
if (e.key === 'ArrowDown') { e.preventDefault(); show(cur + 1, 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); show(cur - 1, -1); }
if (e.key === 'ArrowDown') { e.preventDefault(); show(cur + 1, 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); show(cur - 1, -1); }
});
});
// A vertical swipe on the screen changes channel (the video's own controls keep their taps).
// A vertical swipe on the screen changes channel (the video's own controls keep their taps).
var y0 = null, x0 = null;
var y0 = null, x0 = null;
screen.addEventListener('touchstart', function (e) { var t = e.touches[0]; y0 = t.clientY; x0 = t.clientX; }, { passive: true });
screen.addEventListener('touchstart', function (e) { var t = e.touches[0]; y0 = t.clientY; x0 = t.clientX; }, { passive: true });
screen.addEventListener('touchend', function (e) {
screen.addEventListener('touchend', function (e) {
- if (y0 == null) return; var t = e.changedTouches[0], dy = t.clientY - y0, dx = t.clientX - x0; y0 = null;
- if (Math.abs(dy) > 60 && Math.abs(dy) > Math.abs(dx) * 1.5) show(cur + (dy < 0 ? 1 : -1), dy < 0 ? 1 : -1);
+ if (y0 == null) return;
+ var t = e.changedTouches[0], dy = t.clientY - y0, dx = t.clientX - x0; y0 = null;
+ if (Math.abs(dy) > 60 && Math.abs(dy) > Math.abs(dx) * 1.5) {
+ if (party && !isHost) { say('The host picks the channel and the position.'); return; }
+ show(cur + (dy < 0 ? 1 : -1), dy < 0 ? 1 : -1);
+ }
}, { passive: true });
}, { passive: true });
- var first = chs[0];
- if (first && first.dataset.sfPlayType === 'hls') show(0, 1);
+
+ // Party bar: Copy link, the optional Chat room link, and the live member count.
+ if (party) {
+ var copy = party.querySelector('[data-sf-party-copy]');
+ if (copy) copy.addEventListener('click', function () {
+ var path = copy.getAttribute('data-sf-party-url') || partyPath;
+ var url = location.origin + path;
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(url).then(function () { say('Party link copied.'); }, function () { window.prompt('Copy this party link', url); });
+ } else window.prompt('Copy this party link', url);
+ });
+ var chatForm = party.querySelector('[data-sf-party-chat-form]');
+ if (chatForm) chatForm.addEventListener('submit', function (e) {
+ e.preventDefault();
+ var input = chatForm.querySelector('input');
+ if (!input) return;
+ fetch(partyPath + '/chat', {
+ method: 'POST', credentials: 'same-origin',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({ chat_url: input.value }),
+ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (b) { return { r: r, b: b }; }); })
+ .then(function (x) {
+ if (!x.r.ok) throw new Error((x.b && x.b.detail) || 'That is not a https://openvibe.chat/... link.');
+ syncChat(x.b.chat_url); say('Chat room linked.');
+ })
+ .catch(function (err) { say(err.message, true); });
+ });
+ connectParty();
+ }
+
+ var first = chs[cur];
+ if (first && (first.dataset.sfPlayType === 'hls' || party)) show(cur, 1, { fromSync: true });
}
}
// ── Web: start in the page ────────────────────────────────
// ── Web: start in the page ────────────────────────────────
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
* OpenVibe.Surf — Express app factory; server/index.js listens, tests build their own instance with mock upstreams.
* OpenVibe.Surf — Express app factory; server/index.js listens, tests build their own instance with mock upstreams.
*
*
* /, /ride, /ride/:map, /watch, /web, /developers the pages (http/pages.js)
* /, /ride, /ride/:map, /watch, /web, /developers the pages (http/pages.js)
+ * /watch/party, /watch/p/:id, …/events watch together (http/party.js)
* /web/go the no-JavaScript task form
* /web/go the no-JavaScript task form
* /api/v1/watch/feed, /api/v1/ride/maps… Watch and Ride (http/api.js)
* /api/v1/watch/feed, /api/v1/ride/maps… Watch and Ride (http/api.js)
* /api/v1/web/tasks Surf with Vibe (http/api.js)
* /api/v1/web/tasks Surf with Vibe (http/api.js)
@@ -11,8 +12,9 @@
@@ -11,8 +12,9 @@
* /api/health, /api/ready, /release.json liveness, truthful readiness, release metadata
* /api/health, /api/ready, /release.json liveness, truthful readiness, release metadata
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
*
*
- * Stateless: no database, no Valkey, no Events. The dependencies are OpenVibe.Network (sign-in), OpenVibe.Live
- * (Watch), OpenVibe.Games (Ride) and OpenVibe.Actor (Web); the pages degrade honestly without any of them.
+ * No database, no Valkey, no Events. The only live state is in memory: the Watch feed cache and the watch-together
+ * parties (a restart ends every party). The dependencies are OpenVibe.Network (sign-in), OpenVibe.Live (Watch),
+ * OpenVibe.Games (Ride) and OpenVibe.Actor (Web); the pages degrade honestly without any of them.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -29,10 +31,12 @@ const configLib = require('./config');
@@ -29,10 +31,12 @@ const configLib = require('./config');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createWatchFeed } = require('./watch/feed');
const { createWatchFeed } = require('./watch/feed');
+const { createParties } = require('./watch/party');
const { createRide } = require('./ride/games');
const { createRide } = require('./ride/games');
const { createWeb } = require('./web/tasks');
const { createWeb } = require('./web/tasks');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createPartyRoutes } = require('./http/party');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { PUBLIC_DIR, assetVersion } = require('./assets');
const { PUBLIC_DIR, assetVersion } = require('./assets');
@@ -53,9 +57,14 @@ function createApp(opts = {}) {
@@ -53,9 +57,14 @@ function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, log, now });
const sso = createSso({ config, keys, fetchImpl, log, now });
const feed = createWatchFeed({ config, fetchImpl, log, now });
const feed = createWatchFeed({ config, fetchImpl, log, now });
+ const parties = createParties({ now, maxParties: config.partyMax, maxMembers: config.partyMemberMax });
const ride = createRide({ config, fetchImpl, log, now });
const ride = createRide({ config, fetchImpl, log, now });
const web = createWeb({ config, fetchImpl, log });
const web = createWeb({ config, fetchImpl, log });
- const ctx = { config, keys, sso, feed, ride, web, log };
+ const ctx = { config, keys, sso, feed, parties, ride, web, log };
+ // Empty parties are dropped lazily on every read; this also frees them when nobody is asking. It never keeps
+ // the process alive (unref) and is only memory, like the Map itself.
+ const partySweep = setInterval(() => parties.sweep(), 60_000);
+ if (partySweep.unref) partySweep.unref();
const release = opts.release || releaseLib.createRelease({ service: 'surf', root: path.join(__dirname, '..') });
const release = opts.release || releaseLib.createRelease({ service: 'surf', root: path.join(__dirname, '..') });
views.setRelease(release.release);
views.setRelease(release.release);
@@ -135,6 +144,9 @@ function createApp(opts = {}) {
@@ -135,6 +144,9 @@ function createApp(opts = {}) {
// hls.js 1.7.3 light (the same file openvibe.video serves), loaded by js/surf.js only where a browser cannot play HLS.
// hls.js 1.7.3 light (the same file openvibe.video serves), loaded by js/surf.js only where a browser cannot play HLS.
app.use('/vendor', express.static(path.join(PUBLIC_DIR, 'vendor'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/vendor', express.static(path.join(PUBLIC_DIR, 'vendor'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
+ // ── Watch together: the party page, SSE and host commands ─
+ app.use(createPartyRoutes(ctx));
+
// ── API: Watch, Ride and Web ────────────────────────────
// ── API: Watch, Ride and Web ────────────────────────────
app.use('/api', express.json({ limit: '32kb' }));
app.use('/api', express.json({ limit: '32kb' }));
app.use('/api', createApi(ctx));
app.use('/api', createApi(ctx));
@@ -57,6 +57,10 @@ function load(env = process.env) {
@@ -57,6 +57,10 @@ function load(env = process.env) {
gamesTimeoutMs: atLeast(env.SURF_GAMES_TIMEOUT_MS, 200, 4000),
gamesTimeoutMs: atLeast(env.SURF_GAMES_TIMEOUT_MS, 200, 4000),
// Web: Actor creates a task quickly, but a cold allowance check may take a moment.
// Web: Actor creates a task quickly, but a cold allowance check may take a moment.
actorTimeoutMs: atLeast(env.SURF_ACTOR_TIMEOUT_MS, 200, 15_000),
actorTimeoutMs: atLeast(env.SURF_ACTOR_TIMEOUT_MS, 200, 15_000),
+ // Watch together: in-memory parties, hard caps 200 open parties and 50 members each. The environment may
+ // lower the caps (tests do), never raise them; the 30-minute empty TTL is fixed in watch/party.js.
+ partyMax: Math.min(200, Math.max(1, int(env.SURF_PARTY_MAX, 200))),
+ partyMemberMax: Math.min(50, Math.max(1, int(env.SURF_PARTY_MEMBER_MAX, 50))),
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'surf',
clientId: env.OV_OAUTH_CLIENT_ID || 'surf',
new file mode 100644
new file mode 100644
@@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
+'use strict';
+
+/**
+ * Watch together: the party page, its SSE stream and the host's commands.
+ *
+ * POST /watch/party signed in, this site only → 303 /watch/p/<unguessable id> (JSON: 201)
+ * GET /watch/p/:id signed in; joins once and renders the TV with the party bar
+ * GET /watch/p/:id/events SSE: `state` once, then `command` / `members` / `chat`; 15 s keepalive
+ * POST /watch/p/:id/command host only, this site only: { channel_key, position_s, playing, at }
+ * POST /watch/p/:id/leave anyone in the party; the last one out starts the 30-minute TTL
+ * POST /watch/p/:id/chat host only: a validated https://openvibe.chat/... room URL (no chat here)
+ *
+ * State is in memory (watch/party.js): a restart ends every party; no database, no Events.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { ownOrigin } = require('./api');
+const { send } = require('../render/layout');
+const { PartyError } = require('../watch/party');
+const views = require('../views');
+
+const jsonBody = express.json({ limit: '16kb' });
+const formBody = express.urlencoded({ extended: false, limit: '8kb' });
+const noStore = (res) => res.set('Cache-Control', 'private, no-store');
+
+function prefersJson(req) {
+ const accept = String(req.get('accept') || '');
+ const type = String(req.get('content-type') || '');
+ return accept.includes('application/json') || type.includes('application/json') || String(req.query.format || '') === 'json';
+}
+
+/**
+ * A write made with the session cookie is this site's own: Sec-Fetch-Site same-origin, else an Origin equal to ours.
+ * A request carrying neither is refused too (an old browser, or a client riding someone's cookie), as everywhere else.
+ */
+function crossSite(req, origin) {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site !== 'same-origin';
+ const sent = String(req.get('origin') || '');
+ return !sent || !origin || sent !== origin;
+}
+
+function createPartyRoutes(ctx) {
+ const { parties, feed, config, log } = ctx;
+ const r = asyncRouter();
+ const origin = ownOrigin(config);
+
+ const problem = (req, res, status, code, detail) => {
+ noStore(res);
+ return http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ };
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user';
+ const isJson = (req) => prefersJson(req);
+
+ function fail(req, res, err) {
+ if (err instanceof PartyError) return problem(req, res, err.status, err.code, err.message);
+ log.warn('[OpenVibe.Surf] watch party failed:', (err && err.message) || err);
+ return problem(req, res, 500, 'party.error', 'The party hit a problem. Try again in a moment.');
+ }
+
+ // ── Open a party ────────────────────────────────────────
+ r.post('/watch/party', jsonBody, formBody, async (req, res) => {
+ noStore(res);
+ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'A party starts from openvibe.surf itself.');
+ if (!signedIn(req)) {
+ if (isJson(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to watch together.');
+ return res.redirect(303, '/auth/login?next=%2Fwatch');
+ }
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ // Start on what is playing now, unless the person asked for a channel (the Watch page can pass one).
+ let channelKey = body.channel_key == null ? null : body.channel_key;
+ if (!channelKey) {
+ try {
+ const out = await feed.feed('all', null);
+ channelKey = out && out.items && out.items[0] ? out.items[0].key : null;
+ } catch (err) {
+ log.warn('[OpenVibe.Surf] watch party start channel failed:', (err && err.message) || err);
+ }
+ }
+ try {
+ const opts = { host: req.viewer, channelKey, positionS: body.position_s, playing: body.playing, chatUrl: body.chat_url };
+ if (body.at != null) opts.at = body.at;
+ const party = parties.create(opts);
+ const url = `/watch/p/${party.id}`;
+ res.set('Location', url);
+ if (isJson(req)) return res.status(201).json({ id: party.id, url, events_url: `${url}/events`, party: parties.snapshot(party) });
+ return res.redirect(303, url);
+ } catch (err) {
+ return fail(req, res, err);
+ }
+ });
+
+ // ── The party page (joining happens here; the SSE stream only serves members) ──
+ r.get('/watch/p/:id', async (req, res) => {
+ noStore(res);
+ const party = parties.get(req.params.id);
+ if (!party) return send(res, 404, views.notFound({ party: true }), { viewer: req.viewer });
+ if (!signedIn(req)) {
+ return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/watch/p/${party.id}`)}`);
+ }
+ const had = party.members.size;
+ try {
+ parties.join(party, req.viewer);
+ } catch (err) {
+ if (err instanceof PartyError) return res.status(err.status).type('text/plain').send(err.message);
+ throw err;
+ }
+ if (party.members.size > had) broadcastMembers(party);
+ let out = null;
+ try {
+ out = await feed.feed('all', null);
+ } catch (err) {
+ log.warn('[OpenVibe.Surf] watch party page feed failed:', (err && err.message) || err);
+ }
+ const state = parties.snapshot(party);
+ return send(res, 200, views.party({
+ feed: out,
+ party: state,
+ selectedKey: state.channel_key,
+ isHost: parties.isHost(party, req.viewer),
+ partyPath: `/watch/p/${party.id}`,
+ }), { viewer: req.viewer });
+ });
+
+ // ── SSE: one initial state, then host commands and membership changes ──
+ r.get('/watch/p/:id/events', (req, res) => {
+ noStore(res);
+ const party = parties.get(req.params.id);
+ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');
+ if (!signedIn(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to join this party.');
+ const member = parties.memberOf(party, req.viewer);
+ if (!member) return problem(req, res, 403, 'party.join', 'Open the party page before joining its stream.');
+
+ res.status(200).set({
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache, no-store, no-transform',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no',
+ });
+ res.flushHeaders();
+ res.setTimeout && res.setTimeout(0);
+ parties.attach(party, res, member.key);
+
+ const state = parties.snapshot(party);
+ const write = (event, data) => {
+ try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); return true; } catch { return false; }
+ };
+ res.write('retry: 3000\n\n');
+ write('state', { ...state, is_host: parties.isHost(party, req.viewer), you: member.name });
+
+ const beat = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* the close handler removes it */ } }, 15_000);
+ if (beat.unref) beat.unref();
+ let done = false;
+ const close = () => {
+ if (done) return;
+ done = true;
+ clearInterval(beat);
+ parties.detach(party, res);
+ };
+ req.on('close', close);
+ res.on('close', close);
+ });
+
+ /** Send one SSE event to every stream of a party; a dead stream drops out. */
+ function broadcast(party, event, data) {
+ const line = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ for (const stream of party.connections.keys()) {
+ try { stream.write(line); } catch { party.connections.delete(stream); }
+ }
+ }
+
+ /** Membership changes are personal only in `is_host`, so a promoted member learns it without a reload. */
+ function broadcastMembers(party) {
+ for (const [stream, key] of party.connections) {
+ const data = { members: party.members.size, host: party.hostName, is_host: party.hostId === key };
+ try { stream.write(`event: members\ndata: ${JSON.stringify(data)}\n\n`); } catch { party.connections.delete(stream); }
+ }
+ }
+
+ // ── Host commands ───────────────────────────────────────
+ // `/command` is the documented route; the bare party URL and `/state` are accepted aliases for older clients.
+ const command = (req, res) => {
+ noStore(res);
+ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'Host controls must come from openvibe.surf itself.');
+ if (!signedIn(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to control this party.');
+ const party = parties.get(req.params.id);
+ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');
+ try {
+ const next = parties.command(party, req.viewer, req.body || {});
+ broadcast(party, 'command', next);
+ return res.json({ ok: true, command: next });
+ } catch (err) {
+ return fail(req, res, err);
+ }
+ };
+ r.post('/watch/p/:id/command', jsonBody, formBody, command);
+ r.post('/watch/p/:id/state', jsonBody, formBody, command);
+ r.post('/watch/p/:id', jsonBody, formBody, command);
+
+ // ── Leave (the last member out starts the 30-minute clock) ──
+ r.post('/watch/p/:id/leave', jsonBody, formBody, (req, res) => {
+ noStore(res);
+ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'Leaving a party is a same-site action.');
+ if (!signedIn(req)) {
+ if (isJson(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to leave this party.');
+ return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/watch/p/${req.params.id}`)}`);
+ }
+ const party = parties.get(req.params.id);
+ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');
+ const out = parties.leave(party, req.viewer);
+ if (out.left) broadcastMembers(party);
+ if (isJson(req)) return res.json({ ok: true, ...out });
+ return res.redirect(303, '/watch');
+ });
+
+ // ── The OpenVibe.Chat room link (a link only: there is no chat inside Surf) ──
+ r.post('/watch/p/:id/chat', jsonBody, formBody, (req, res) => {
+ noStore(res);
+ if (crossSite(req, origin)) return problem(req, res, 403, 'party.cross_site', 'The chat link is set from openvibe.surf itself.');
+ if (!signedIn(req)) return problem(req, res, 401, 'party.signin', 'Sign in at openvibe.surf to set the chat link.');
+ const party = parties.get(req.params.id);
+ if (!party) return problem(req, res, 404, 'party.not_found', 'That watch party has ended.');
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ try {
+ const chatUrl = parties.setChat(party, req.viewer, body.chat_url == null ? body.url : body.chat_url);
+ broadcast(party, 'chat', { chat_url: chatUrl });
+ if (isJson(req)) return res.json({ ok: true, chat_url: chatUrl });
+ return res.redirect(303, `/watch/p/${party.id}`);
+ } catch (err) {
+ return fail(req, res, err);
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createPartyRoutes, crossSite };
@@ -141,45 +141,85 @@ ${rows ? `<table class="sf-board" id="sf-top"><thead><tr><th scope="col">#</th><
@@ -141,45 +141,85 @@ ${rows ? `<table class="sf-board" id="sf-top"><thead><tr><th scope="col">#</th><
return layout({ title: `${name}: Ride`, description: `${name}: a surf map you ride in the browser, with stages, a millisecond timer and records.`, path: `/ride/${encodeURIComponent(id || one.id || '')}`, body, active: '/ride' });
return layout({ title: `${name}: Ride`, description: `${name}: a surf map you ride in the browser, with stages, a millisecond timer and records.`, path: `/ride/${encodeURIComponent(id || one.id || '')}`, body, active: '/ride' });
}
}
-function watch({ feed = null, topic = 'all' } = {}) {
+const TV_LABEL = { live: 'Live', clip: 'Clip', moment: 'AI moment' };
+const kindOf = (it) => (it.kind === 'moment' ? 'moment' : it.kind === 'clip' ? 'clip' : 'live');
+
+/** The TV itself, shared by /watch and the party page: selected channel first, then the whole guide. */
+function tvBlock({ feed = null, selectedKey = null, empty = null } = {}) {
const items = feed && Array.isArray(feed.items) ? feed.items : [];
const items = feed && Array.isArray(feed.items) ? feed.items : [];
- const tabs = [['all', 'All'], ['live', 'Live'], ['clips', 'Clips'], ['moments', 'AI moments']].map(([id, label]) =>
- `<a class="sf-topic" href="/watch?topic=${id}"${id === topic ? ' aria-current="page"' : ''}>${label}</a>`).join('');
- const first = items[0] || null;
- const kindOf = (it) => (it.kind === 'moment' ? 'moment' : it.kind === 'clip' ? 'clip' : 'live');
- const label = { live: 'Live', clip: 'Clip', moment: 'AI moment' };
- const stage = first ? (() => {
- const k = kindOf(first);
- const p = first.play || {};
- const media = p.type === 'hls' && p.src
- ? `<video class="sf-tv-video" data-sf-video playsinline controls preload="none" poster="${esc(first.thumbnail || '')}" src="${esc(p.src)}"></video>`
- : `${first.thumbnail ? `<img class="sf-tv-poster" src="${esc(first.thumbnail)}" alt="" loading="eager">` : '<span class="sf-tv-poster"></span>'}<a class="sf-tv-out" href="${esc(p.page || '#')}">${kit.icon('play')}${k === 'live' ? 'Watch live on OpenVibe.Live' : 'Play on OpenVibe.Live'}</a>`;
- return `<div class="sf-tv-main"><div class="sf-tv-screen" data-sf-screen>${media}</div>
-<div class="sf-tv-info"><p class="sf-tv-kind"><span class="sf-pill sf-pill--${esc(k)}">${esc(label[k])}</span><span data-sf-why>${esc(first.why || '')}</span></p>
+ const first = (selectedKey && items.find((it) => it.key === selectedKey)) || items[0] || null;
+ const start = first ? items.indexOf(first) : 0;
+ if (!first) {
+ return `<div class="sf-empty" id="sf-watch-empty">${kit.icon('play')}<p>${esc(empty || (feed ? 'Nothing on this channel right now. Try another, or come back soon.' : 'OpenVibe.Live did not answer. Try again in a moment.'))}</p></div>`;
+ }
+ const k = kindOf(first);
+ const p = first.play || {};
+ const media = p.type === 'hls' && p.src
+ ? `<video class="sf-tv-video" data-sf-video playsinline controls preload="none" poster="${esc(first.thumbnail || '')}" src="${esc(p.src)}"></video>`
+ : `${first.thumbnail ? `<img class="sf-tv-poster" src="${esc(first.thumbnail)}" alt="" loading="eager">` : '<span class="sf-tv-poster"></span>'}<a class="sf-tv-out" href="${esc(p.page || '#')}">${kit.icon('play')}${k === 'live' ? 'Watch live on OpenVibe.Live' : 'Play on OpenVibe.Live'}</a>`;
+ const stage = `<div class="sf-tv-main"><div class="sf-tv-screen" data-sf-screen>${media}</div>
+<div class="sf-tv-info"><p class="sf-tv-kind"><span class="sf-pill sf-pill--${esc(k)}">${esc(TV_LABEL[k])}</span><span data-sf-why>${esc(first.why || '')}</span></p>
<h2 data-sf-title>${esc(first.title || '')}</h2>
<h2 data-sf-title>${esc(first.title || '')}</h2>
<p class="sf-tv-meta" data-sf-meta>${first.channel && first.channel.username ? `<a href="https://openvibe.live/@${encodeURIComponent(first.channel.username)}">@${esc(first.channel.username)}</a>` : ''}</p>
<p class="sf-tv-meta" data-sf-meta>${first.channel && first.channel.username ? `<a href="https://openvibe.live/@${encodeURIComponent(first.channel.username)}">@${esc(first.channel.username)}</a>` : ''}</p>
<p class="sf-tv-keys"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-prev aria-label="Previous channel">${kit.icon('up')}</button><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-next aria-label="Next channel">${kit.icon('down')}</button><span class="sf-fine">Swipe, or press <kbd>↑</kbd> <kbd>↓</kbd></span></p></div></div>`;
<p class="sf-tv-keys"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-prev aria-label="Previous channel">${kit.icon('up')}</button><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-next aria-label="Next channel">${kit.icon('down')}</button><span class="sf-fine">Swipe, or press <kbd>↑</kbd> <kbd>↓</kbd></span></p></div></div>`;
- })() : '';
const rows = items.map((it, i) => {
const rows = items.map((it, i) => {
- const k = kindOf(it);
- const p = it.play || {};
- return `<li class="sf-ch${i === 0 ? ' is-on' : ''}" id="sf-item-${esc(idSafe(it.key))}" data-sf-i="${i}" data-sf-kind="${esc(k)}" data-sf-play-type="${esc(p.type || '')}"${p.src ? ` data-sf-src="${esc(p.src)}"` : ''} data-sf-page="${esc(p.page || '')}" data-sf-thumb="${esc(it.thumbnail || '')}" data-sf-why="${esc(it.why || '')}" data-sf-channel="${esc((it.channel && it.channel.username) || '')}">
-<a class="sf-ch-link" href="${esc(p.page || '#')}">
-<span class="sf-ch-thumb">${it.thumbnail ? `<img src="${esc(it.thumbnail)}" alt="" loading="lazy" decoding="async">` : ''}${it.duration_seconds != null ? `<span class="sf-ch-len">${esc(clipLen(it.duration_seconds))}</span>` : ''}${k === 'live' ? '<span class="sf-ch-live">LIVE</span>' : ''}</span>
-<span class="sf-ch-body"><b>${esc(it.title || '')}</b><small>${it.channel && it.channel.username ? `@${esc(it.channel.username)} · ` : ''}${esc(it.why || label[k])}</small></span>
+ const rowKind = kindOf(it);
+ const rowPlay = it.play || {};
+ const on = it === first;
+ return `<li class="sf-ch${on ? ' is-on' : ''}" id="sf-item-${esc(idSafe(it.key))}" data-sf-i="${i}" data-sf-kind="${esc(rowKind)}" data-sf-play-type="${esc(rowPlay.type || '')}"${rowPlay.src ? ` data-sf-src="${esc(rowPlay.src)}"` : ''} data-sf-page="${esc(rowPlay.page || '')}" data-sf-thumb="${esc(it.thumbnail || '')}" data-sf-why="${esc(it.why || '')}" data-sf-channel="${esc((it.channel && it.channel.username) || '')}" data-sf-key="${esc(it.key || '')}">
+<a class="sf-ch-link" href="${esc(rowPlay.page || '#')}">
+<span class="sf-ch-thumb">${it.thumbnail ? `<img src="${esc(it.thumbnail)}" alt="" loading="lazy" decoding="async">` : ''}${it.duration_seconds != null ? `<span class="sf-ch-len">${esc(clipLen(it.duration_seconds))}</span>` : ''}${rowKind === 'live' ? '<span class="sf-ch-live">LIVE</span>' : ''}</span>
+<span class="sf-ch-body"><b>${esc(it.title || '')}</b><small>${it.channel && it.channel.username ? `@${esc(it.channel.username)} · ` : ''}${esc(it.why || TV_LABEL[rowKind])}</small></span>
</a></li>`;
</a></li>`;
}).join('');
}).join('');
+ return `<div class="sf-tv" id="sf-tv" data-sf-tv data-sf-start="${start}">${stage}<aside class="sf-guide" aria-labelledby="sf-guide-h"><h2 id="sf-guide-h">Up next</h2><ol class="sf-chs" id="sf-watch-feed">${rows}</ol>
+${feed && feed.next ? `<a class="sf-more" id="sf-watch-next" href="/watch?topic=${esc(feed.topic || 'all')}&cursor=${esc(feed.next)}">More${kit.icon('chevron')}</a>` : ''}</aside></div>`;
+}
+
+function watch({ feed = null, topic = 'all' } = {}) {
+ const items = feed && Array.isArray(feed.items) ? feed.items : [];
+ const tabs = [['all', 'All'], ['live', 'Live'], ['clips', 'Clips'], ['moments', 'AI moments']].map(([id, label]) =>
+ `<a class="sf-topic" href="/watch?topic=${id}"${id === topic ? ' aria-current="page"' : ''}>${label}</a>`).join('');
const missing = feed && feed.sources ? Object.entries(feed.sources).filter(([, v]) => v !== 'ok').map(([k]) => k) : [];
const missing = feed && feed.sources ? Object.entries(feed.sources).filter(([, v]) => v !== 'ok').map(([k]) => k) : [];
const body = `<div class="sf-wrap sf-page-pad">
const body = `<div class="sf-wrap sf-page-pad">
-<header class="sf-page-h sf-watch-h"><div><p class="sf-kicker">Watch</p><h1>Surf what's on</h1></div><nav class="sf-topics" id="sf-topics" aria-label="What to watch">${tabs}</nav></header>
+<header class="sf-page-h sf-watch-h"><div><p class="sf-kicker">Watch</p><h1>Surf what's on</h1></div><div class="sf-watch-tools"><nav class="sf-topics" id="sf-topics" aria-label="What to watch">${tabs}</nav>
+<form class="sf-party-start" method="post" action="/watch/party"><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">${kit.icon('play')}Watch together</button></form></div></header>
${missing.length ? `<p class="sf-note" id="sf-sources">Some of OpenVibe.Live did not answer (${esc(missing.join(', '))}); the rest is here.</p>` : ''}
${missing.length ? `<p class="sf-note" id="sf-sources">Some of OpenVibe.Live did not answer (${esc(missing.join(', '))}); the rest is here.</p>` : ''}
-${items.length ? `<div class="sf-tv" id="sf-tv" data-sf-tv>${stage}<aside class="sf-guide" aria-labelledby="sf-guide-h"><h2 id="sf-guide-h">Up next</h2><ol class="sf-chs" id="sf-watch-feed">${rows}</ol>
-${feed && feed.next ? `<a class="sf-more" id="sf-watch-next" href="/watch?topic=${esc(topic)}&cursor=${esc(feed.next)}">More${kit.icon('chevron')}</a>` : ''}</aside></div>`
- : `<div class="sf-empty" id="sf-watch-empty">${kit.icon('play')}<p>${feed ? 'Nothing on this channel right now. Try another, or come back soon.' : 'OpenVibe.Live did not answer. Try again in a moment.'}</p></div>`}
+${tvBlock({ feed })}
</div>`;
</div>`;
return layout({ title: 'Watch', description: 'Lean-back channel surfing over OpenVibe.Live: who is live, clips and the week\'s AI moments in one feed.', path: '/watch', body, active: '/watch' });
return layout({ title: 'Watch', description: 'Lean-back channel surfing over OpenVibe.Live: who is live, clips and the week\'s AI moments in one feed.', path: '/watch', body, active: '/watch' });
}
}
+/** The party bar: members, host, Copy link, Leave, and the optional OpenVibe.Chat room link (a link only). */
+function partyBar({ party = {}, isHost = false, partyPath = '' } = {}) {
+ const chat = party.chat_url ? String(party.chat_url) : null;
+ const chatLink = chat
+ ? `<p class="sf-party-chat" data-sf-party-chat><a href="${esc(chat)}" target="_blank" rel="noopener noreferrer">${kit.icon('globe')}Chat in OpenVibe.Chat</a></p>`
+ : '<p class="sf-party-chat" data-sf-party-chat hidden></p>';
+ const chatForm = isHost
+ ? `<form class="sf-party-chat-form" data-sf-party-chat-form action="${esc(partyPath)}/chat" method="post"><label for="sf-party-chat-url" class="sf-fine">OpenVibe.Chat room link (optional)</label><input id="sf-party-chat-url" name="chat_url" type="url" inputmode="url" maxlength="500" placeholder="https://openvibe.chat/..." value="${esc(chat || '')}"><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Set</button></form>`
+ : '';
+ return `<section class="sf-party" id="sf-party" data-sf-party data-sf-party-id="${esc(party.id || '')}" data-sf-party-is-host="${isHost ? '1' : '0'}" data-sf-party-channel="${esc(party.channel_key || '')}" data-sf-party-position="${esc(party.position_s == null ? 0 : party.position_s)}" data-sf-party-playing="${party.playing === false ? '0' : '1'}" data-sf-party-at="${esc(party.at || 0)}" data-sf-party-url="${esc(partyPath)}">
+<div class="sf-party-bar">
+<p class="sf-party-who"><span class="sf-party-dot" aria-hidden="true"></span>Watch together · <b data-sf-party-members>${esc(party.members == null ? 1 : party.members)}</b> here · host <b data-sf-party-host-name>${esc(party.host || 'the host')}</b></p>
+<div class="sf-party-actions"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-party-copy data-sf-party-url="${esc(partyPath)}">Copy link</button>
+<form method="post" action="${esc(partyPath)}/leave"><button type="submit" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-party-leave>Leave</button></form></div>
+</div>
+<p class="sf-fine sf-party-share">Share this link: <a href="${esc(partyPath)}" data-sf-party-link>${esc(partyPath)}</a>${isHost ? ' · you are the host' : ' · the host picks the channel and the position'}</p>
+<p class="sf-status" data-sf-party-status role="status"></p>
+${chatLink}${chatForm}
+</section>`;
+}
+
+function party({ feed = null, party: p = {}, selectedKey = null, isHost = false, partyPath = '' } = {}) {
+ const body = `<div class="sf-wrap sf-page-pad sf-party-page">
+<header class="sf-page-h sf-watch-h"><div><p class="sf-kicker">Watch · Together</p><h1>Watch together</h1></div><a class="sf-more" href="/watch">Back to Watch${kit.icon('chevron')}</a></header>
+${partyBar({ party: p, isHost, partyPath })}
+${tvBlock({ feed, selectedKey, empty: "This party's channel is not in the guide right now, or OpenVibe.Live did not answer. Try again in a moment." })}
+</div>`;
+ return layout({ title: 'Watch together', description: 'Watch the same OpenVibe.Live channel at the same moment with friends: the host picks, everyone follows.', path: partyPath, robots: 'noindex, nofollow', body, active: '/watch' });
+}
+
const EXAMPLES = ['A cheap flight to Lisbon in March and three things to do', 'What do runners say about the Pegasus 42?', 'Compare three hosting plans under $10 a month'];
const EXAMPLES = ['A cheap flight to Lisbon in March and three things to do', 'What do runners say about the Pegasus 42?', 'Compare three hosting plans under $10 a month'];
function web({ me = {}, query = '' } = {}) {
function web({ me = {}, query = '' } = {}) {
@@ -214,6 +254,7 @@ function developers() {
@@ -214,6 +254,7 @@ function developers() {
['GET /api/v1/ride/maps', 'The surf maps from OpenVibe.Games: id, name, tier, stages, author, runs, the record. Cached 60 s.'],
['GET /api/v1/ride/maps', 'The surf maps from OpenVibe.Games: id, name, tier, stages, author, runs, the record. Cached 60 s.'],
['GET /api/v1/ride/maps/:id', 'One map, its fastest runs and its <code>embed_url</code>.'],
['GET /api/v1/ride/maps/:id', 'One map, its fastest runs and its <code>embed_url</code>.'],
['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>.'],
['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>.'],
+ ['POST /watch/party · GET /watch/p/:id/events', 'Watch together: signed in and same-site, opens an in-memory party at <code>/watch/p/<id></code> (303, or 201 JSON). The SSE stream carries the host\'s <code>{ channel_key, position_s, playing, at }</code> commands to every member; a restart ends the party.'],
];
];
const body = `<div class="sf-wrap sf-page-pad">
const body = `<div class="sf-wrap sf-page-pad">
<header class="sf-page-h"><p class="sf-kicker">For developers</p><h1>The Surf API</h1>
<header class="sf-page-h"><p class="sf-kicker">For developers</p><h1>The Surf API</h1>
@@ -223,13 +264,16 @@ function developers() {
@@ -223,13 +264,16 @@ function developers() {
return layout({ title: 'The Surf API', path: '/developers', body, active: '/developers' });
return layout({ title: 'The Surf API', path: '/developers', body, active: '/developers' });
}
}
-function notFound() {
- const body = `<div class="sf-wrap sf-page-pad sf-narrow"><div class="sf-empty" id="sf-not-found">${kit.icon('game')}<h1>Wiped out</h1><p>There is nothing at this address. Try <a href="/ride">a map</a>, <a href="/watch">what's on</a> or <a href="/web">asking Vibe</a>.</p></div></div>`;
+function notFound({ party = false } = {}) {
+ const text = party
+ ? 'This watch party has ended, or its link is not one we made. Start a new one from <a href="/watch">Watch</a>.'
+ : 'There is nothing at this address. Try <a href="/ride">a map</a>, <a href="/watch">what\'s on</a> or <a href="/web">asking Vibe</a>.';
+ const body = `<div class="sf-wrap sf-page-pad sf-narrow"><div class="sf-empty" id="sf-not-found">${kit.icon('game')}<h1>${party ? 'Party over' : 'Wiped out'}</h1><p>${text}</p></div></div>`;
return layout({ title: 'Not found', robots: 'noindex, follow', body, active: '/' });
return layout({ title: 'Not found', robots: 'noindex, follow', body, active: '/' });
}
}
module.exports = {
module.exports = {
- home, ride, map, watch, web, developers, notFound, runTime,
+ home, ride, map, watch, party, web, developers, notFound, runTime,
layout,
layout,
setRelease: layoutLib.setRelease,
setRelease: layoutLib.setRelease,
configure: layoutLib.configure,
configure: layoutLib.configure,
new file mode 100644
new file mode 100644
@@ -0,0 +1,237 @@
@@ -0,0 +1,237 @@
+'use strict';
+
+/**
+ * Watch together: parties held in this one process's memory (there is no database or Events bus).
+ *
+ * A signed-in person opens a party; the host's commands (channel, position, playing, at) are broadcast to every
+ * member over SSE. The Map is bounded (200 parties, 50 members each) and an empty party is dropped 30 minutes
+ * after its last member leaves; a process restart ends every party.
+ *
+ * This module holds state only. HTTP, SSE and the page live in http/party.js; the TV markup is views/index.js.
+ */
+const crypto = require('crypto');
+
+const DEFAULT_TTL_MS = 30 * 60 * 1000;
+const MAX_PARTIES = 200;
+const MAX_MEMBERS = 50;
+const ID_RE = /^[A-Za-z0-9_-]{20,64}$/;
+const MAX_CHANNEL_KEY = 200;
+
+class PartyError extends Error {
+ constructor(status, code, message) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+}
+
+const clean = (v, max = 200) => {
+ const s = String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
+ return s.length > max ? s.slice(0, max) : s;
+};
+const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (Number.isFinite(Number(v)) ? Number(v) : null));
+
+/** The signed-in person behind a request: their Network subject when there is one, else their username. */
+function personOf(viewer) {
+ if (!viewer || viewer.kind !== 'user') throw new PartyError(401, 'party.signin', 'Sign in at openvibe.surf to watch together.');
+ const key = viewer.subject || viewer.username;
+ if (!key) throw new PartyError(401, 'party.signin', 'Sign in at openvibe.surf to watch together.');
+ return {
+ key: String(key),
+ name: clean(viewer.displayName || viewer.username || 'A surfer', 80) || 'A surfer',
+ };
+}
+
+/** A channel key as the Watch feed made it (`live:bo`, `clip:201`, `moment:7`). */
+function channelKeyOf(v, { required = false } = {}) {
+ if (v == null || v === '') {
+ if (required) throw new PartyError(400, 'party.invalid_channel', 'channel_key is required.');
+ return null;
+ }
+ const s = String(v).trim();
+ if (!s || s.length > MAX_CHANNEL_KEY || /[\u0000-\u001f\u007f]/.test(s)) {
+ throw new PartyError(400, 'party.invalid_channel', 'channel_key must be a Watch channel key.');
+ }
+ return s;
+}
+
+/** `{ channel_key, position_s, playing, at }` from a host POST, validated. */
+function commandOf(body, fallbackAt) {
+ const b = body && typeof body === 'object' ? body : {};
+ const channel_key = channelKeyOf(b.channel_key, { required: true });
+ const rawPosition = b.position_s == null ? 0 : finite(b.position_s);
+ if (rawPosition == null || rawPosition < 0 || rawPosition > 7 * 24 * 60 * 60) {
+ throw new PartyError(400, 'party.invalid_position', 'position_s must be a number of seconds from 0 to 604800.');
+ }
+ let playing = true;
+ if (typeof b.playing === 'boolean') playing = b.playing;
+ else if (b.playing === 'false' || b.playing === '0') playing = false;
+ else if (b.playing != null && b.playing !== 'true' && b.playing !== '1') {
+ throw new PartyError(400, 'party.invalid_playing', 'playing must be true or false.');
+ }
+ const rawAt = b.at == null ? fallbackAt : finite(b.at);
+ if (rawAt == null || rawAt < 0) throw new PartyError(400, 'party.invalid_at', 'at must be a millisecond timestamp.');
+ // Accept the host's clock when it is plausible; a wildly wrong clock would put every member years away.
+ const at = Math.abs(rawAt - fallbackAt) > 5 * 60 * 1000 ? fallbackAt : rawAt;
+ return { channel_key, position_s: rawPosition, playing, at };
+}
+
+/** Only an absolute https link on openvibe.chat; no credentials, no other host. */
+function validChatUrl(value) {
+ if (typeof value !== 'string' || !value.trim() || value.length > 500) return null;
+ try {
+ const u = new URL(value.trim());
+ if (u.protocol !== 'https:' || u.hostname !== 'openvibe.chat') return null;
+ if (u.username || u.password || (u.port && u.port !== '443')) return null;
+ return u.toString();
+ } catch { return null; }
+}
+
+function createParties({ now = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxParties = MAX_PARTIES, maxMembers = MAX_MEMBERS } = {}) {
+ const parties = new Map();
+ const limitParties = Math.min(MAX_PARTIES, Math.max(1, maxParties));
+ const limitMembers = Math.min(MAX_MEMBERS, Math.max(1, maxMembers));
+
+ /** Drop empty parties whose 30 minutes are up. Every public read calls this first. */
+ function prune() {
+ const t = now();
+ for (const [id, p] of parties) {
+ if (p.members.size !== 0 || p.emptySince == null || t - p.emptySince < ttlMs) continue;
+ // A member who left without closing their browser tab may still hold a stream: end it with the party.
+ for (const stream of p.connections.keys()) { try { stream.end(); } catch { /* already gone */ } }
+ p.connections.clear();
+ parties.delete(id);
+ }
+ }
+
+ function get(id) {
+ prune();
+ const key = String(id == null ? '' : id);
+ return ID_RE.test(key) ? parties.get(key) || null : null;
+ }
+
+ function create({ host, channelKey = null, positionS = 0, playing = true, at = now(), chatUrl = null } = {}) {
+ prune();
+ if (parties.size >= limitParties) throw new PartyError(429, 'party.limit', 'Too many watch parties are open right now. Try again later.');
+ const who = personOf(host);
+ const t = now();
+ let id;
+ do { id = crypto.randomBytes(18).toString('base64url'); } while (parties.has(id));
+ const member = { key: who.key, name: who.name, joinedAt: t };
+ const startPos = finite(positionS);
+ const startAt = finite(at);
+ const party = {
+ id,
+ hostId: who.key,
+ hostName: who.name,
+ members: new Map([[who.key, member]]),
+ state: {
+ channel_key: channelKeyOf(channelKey),
+ position_s: startPos == null || startPos < 0 ? 0 : startPos,
+ playing: playing !== false,
+ at: startAt == null ? t : startAt,
+ },
+ chatUrl: validChatUrl(chatUrl),
+ createdAt: t,
+ emptySince: null,
+ // SSE responses → the member key that opened them (so a new host can be told it is host).
+ connections: new Map(),
+ };
+ parties.set(id, party);
+ return party;
+ }
+
+ function memberOf(party, viewer) {
+ const who = personOf(viewer);
+ return party.members.get(who.key) || null;
+ }
+
+ function join(party, viewer) {
+ const who = personOf(viewer);
+ const existing = party.members.get(who.key);
+ if (existing) {
+ existing.name = who.name;
+ party.emptySince = null;
+ return existing;
+ }
+ if (party.members.size >= limitMembers) throw new PartyError(429, 'party.full', `This party is full (${limitMembers} members).`);
+ const member = { key: who.key, name: who.name, joinedAt: now() };
+ party.members.set(who.key, member);
+ // A party whose host left with nobody left has no host; the first person back is it.
+ if (!party.hostId) { party.hostId = who.key; party.hostName = who.name; }
+ party.emptySince = null;
+ return member;
+ }
+
+ function leave(party, viewer) {
+ const who = personOf(viewer);
+ const left = party.members.delete(who.key);
+ let hostChanged = false;
+ if (left && party.hostId === who.key) {
+ const next = [...party.members.values()].sort((a, b) => (a.joinedAt - b.joinedAt) || a.key.localeCompare(b.key))[0] || null;
+ party.hostId = next ? next.key : null;
+ party.hostName = next ? next.name : null;
+ hostChanged = true;
+ }
+ if (party.members.size === 0 && party.emptySince == null) party.emptySince = now();
+ return { left: Boolean(left), hostChanged, members: party.members.size };
+ }
+
+ function requireHost(party, viewer) {
+ const who = personOf(viewer);
+ if (party.hostId !== who.key) throw new PartyError(403, 'party.not_host', 'Only the host changes the channel and the position.');
+ return who;
+ }
+
+ function command(party, viewer, body) {
+ requireHost(party, viewer);
+ const next = commandOf(body, now());
+ party.state = next;
+ return next;
+ }
+
+ function setChat(party, viewer, raw) {
+ requireHost(party, viewer);
+ const url = validChatUrl(raw);
+ if (!url) throw new PartyError(400, 'party.invalid_chat', 'Paste a https://openvibe.chat/... link.');
+ party.chatUrl = url;
+ return url;
+ }
+
+ function isHost(party, viewer) {
+ try { return party.hostId === personOf(viewer).key; } catch { return false; }
+ }
+
+ /** What the page and the SSE clients may see: no network subject ids, no tokens. */
+ function snapshot(party) {
+ return {
+ id: party.id,
+ host: party.hostName,
+ members: party.members.size,
+ chat_url: party.chatUrl,
+ channel_key: party.state.channel_key,
+ position_s: party.state.position_s,
+ playing: party.state.playing,
+ at: party.state.at,
+ created_at: party.createdAt,
+ };
+ }
+
+ function attach(party, res, memberKey) { party.connections.set(res, memberKey); }
+ function detach(party, res) { party.connections.delete(res); }
+
+ function sweep() { prune(); return parties.size; }
+ function clear() { parties.clear(); }
+
+ return {
+ create, get, join, leave, command, setChat, isHost, memberOf, snapshot,
+ attach, detach, sweep, clear,
+ get size() { return parties.size; },
+ get parties() { return parties; },
+ };
+}
+
+module.exports = {
+ createParties, PartyError, validChatUrl, channelKeyOf, commandOf,
+ DEFAULT_TTL_MS, MAX_PARTIES, MAX_MEMBERS, ID_RE,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,250 @@
@@ -0,0 +1,250 @@
+'use strict';
+/**
+ * Watch together: open/join/leave, the host-only and same-site command rules, SSE delivering a command, the
+ * empty-party TTL and the hard caps (200 parties, 50 members; boot lowers them for this file).
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const ORIGIN = 'https://openvibe.surf';
+const own = { origin: ORIGIN, 'sec-fetch-site': 'same-origin' };
+
+/** A tiny SSE client: events are parsed as they arrive, waitFor() blocks until one named event shows up. */
+function openEvents(t, path, cookie) {
+ const events = [];
+ let buffer = '';
+ let req = null;
+ const ready = new Promise((resolve, reject) => {
+ req = http.get(`${t.base}${path}`, { headers: { cookie, accept: 'text/event-stream' } }, (res) => {
+ res.setEncoding('utf8');
+ if (res.statusCode !== 200) {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => reject(new Error(`SSE ${res.statusCode}: ${text}`)));
+ return;
+ }
+ res.on('data', (chunk) => {
+ buffer += chunk;
+ let at;
+ while ((at = buffer.indexOf('\n\n')) >= 0) {
+ const block = buffer.slice(0, at);
+ buffer = buffer.slice(at + 2);
+ let event = 'message';
+ let data = '';
+ for (const line of block.split('\n')) {
+ if (line.startsWith('event:')) event = line.slice(6).trim();
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
+ }
+ if (!data) continue;
+ let parsed = data;
+ try { parsed = JSON.parse(data); } catch { /* keep the text */ }
+ events.push({ event, data: parsed });
+ }
+ });
+ resolve();
+ });
+ req.on('error', reject);
+ });
+ return {
+ ready,
+ events,
+ async waitFor(event, timeoutMs = 3000) {
+ const started = Date.now();
+ for (;;) {
+ const hit = events.find((e) => e.event === event);
+ if (hit) return hit;
+ if (Date.now() - started > timeoutMs) {
+ throw new Error(`no ${event} event; got ${events.map((e) => e.event).join(', ') || 'nothing'}`);
+ }
+ await new Promise((r) => setTimeout(r, 20));
+ }
+ },
+ close() { if (req) req.destroy(); },
+ };
+}
+
+(async () => {
+ const t = await boot();
+ const host = t.network.addUser('kim');
+ const member = t.network.addUser('ada');
+ const other = t.network.addUser('noa');
+ const hostCookie = t.signIn(host);
+ const memberCookie = t.signIn(member);
+ let partyPath = null;
+
+ await check('POST /watch/party opens an unguessable party and redirects to it', async () => {
+ const r = await t.get('/watch/party', { cookie: hostCookie, method: 'POST', headers: own });
+ assert.strictEqual(r.status, 303, r.text);
+ const location = r.headers.get('location') || '';
+ assert.match(location, /^\/watch\/p\/[A-Za-z0-9_-]{20,64}$/, 'an unguessable id in the path');
+ partyPath = location;
+ const page = await t.get(partyPath, { cookie: hostCookie });
+ assert.strictEqual(page.status, 200, page.text);
+ assert.match(page.text, /id="sf-party"/);
+ assert.match(page.text, /data-sf-party-is-host="1"/);
+ assert.match(page.text, /data-sf-party-members>1</);
+ assert.match(page.text, /host <b data-sf-party-host-name>kim<\/b>/);
+ assert.match(page.text, /id="sf-tv"/, 'the same TV markup');
+ assert.match(page.text, /data-sf-party-copy/, 'Copy link');
+ assert.match(page.text, /data-sf-party-leave/, 'Leave');
+ assert.match(page.text, /data-sf-party-chat-form/, 'the host can paste a chat link');
+ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
+ assert.match(page.headers.get('vary'), /Cookie/i);
+ });
+
+ await check('a signed-out visitor is sent to sign in before joining', async () => {
+ const r = await t.get(partyPath);
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(partyPath)}`);
+ });
+
+ await check('a signed-in person joins once; the party bar counts them and names the host', async () => {
+ const first = await t.get(partyPath, { cookie: memberCookie });
+ assert.strictEqual(first.status, 200, first.text);
+ assert.match(first.text, /data-sf-party-is-host="0"/);
+ assert.match(first.text, /data-sf-party-members>2</);
+ assert.match(first.text, /host <b data-sf-party-host-name>kim<\/b>/);
+ assert.ok(!first.text.includes('data-sf-party-chat-form'), 'only the host sets the chat link');
+ const again = await t.get(partyPath, { cookie: memberCookie });
+ assert.match(again.text, /data-sf-party-members>2</, 'a second visit is not a second member');
+ });
+
+ await check('SSE delivers the host command; a member and another site cannot command', async () => {
+ const stream = openEvents(t, `${partyPath}/events`, memberCookie);
+ try {
+ await stream.ready;
+ const initial = await stream.waitFor('state');
+ assert.strictEqual(initial.data.members, 2);
+ assert.strictEqual(initial.data.host, 'kim');
+ assert.strictEqual(initial.data.id, partyPath.split('/').pop());
+ assert.strictEqual(initial.data.is_host, false);
+
+ const command = { channel_key: 'clip:101', position_s: 12.5, playing: false, at: Date.now() };
+ const byMember = await t.get(`${partyPath}/command`, { cookie: memberCookie, method: 'POST', json: command, headers: own });
+ assert.strictEqual(byMember.status, 403, byMember.text);
+ assert.strictEqual(byMember.json().code, 'party.not_host');
+
+ const byOtherSite = await t.get(`${partyPath}/command`, {
+ cookie: hostCookie, method: 'POST', json: command, headers: { origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
+ });
+ assert.strictEqual(byOtherSite.status, 403, 'a cross-site command is refused');
+ assert.strictEqual(byOtherSite.json().code, 'party.cross_site');
+
+ const sent = await t.get(`${partyPath}/command`, { cookie: hostCookie, method: 'POST', json: command, headers: own });
+ assert.strictEqual(sent.status, 200, sent.text);
+ assert.deepStrictEqual(sent.json().command, command);
+ const event = await stream.waitFor('command');
+ assert.strictEqual(event.data.channel_key, 'clip:101');
+ assert.strictEqual(event.data.position_s, 12.5);
+ assert.strictEqual(event.data.playing, false);
+ assert.strictEqual(event.data.at, command.at, 'the host timestamp is relayed');
+ } finally {
+ stream.close();
+ }
+ });
+
+ await check('the host can link a validated OpenVibe.Chat room, and nothing else', async () => {
+ const bad = await t.get(`${partyPath}/chat`, {
+ cookie: hostCookie, method: 'POST', json: { chat_url: 'https://evil.example/room' }, headers: own,
+ });
+ assert.strictEqual(bad.status, 400);
+ assert.strictEqual(bad.json().code, 'party.invalid_chat');
+ const good = await t.get(`${partyPath}/chat`, {
+ cookie: hostCookie, method: 'POST', json: { chat_url: 'https://openvibe.chat/room/surf-1' }, headers: own,
+ });
+ assert.strictEqual(good.status, 200, good.text);
+ assert.strictEqual(good.json().chat_url, 'https://openvibe.chat/room/surf-1');
+ const page = await t.get(partyPath, { cookie: memberCookie });
+ assert.match(page.text, /href="https:\/\/openvibe\.chat\/room\/surf-1"/);
+ assert.match(page.text, /Chat in OpenVibe\.Chat/);
+ assert.match(page.text, /class="sf-ch is-on"[^>]*data-sf-key="clip:101"/, 'the page renders the host\'s last channel');
+ });
+
+ await check('anyone can leave, the member count follows, and the last one out starts the TTL', async () => {
+ const id = partyPath.split('/').pop();
+ const left = await t.get(`${partyPath}/leave`, { cookie: memberCookie, method: 'POST', headers: own });
+ assert.strictEqual(left.status, 303);
+ assert.strictEqual(left.headers.get('location'), '/watch');
+ assert.strictEqual(t.ctx.parties.get(id).members.size, 1);
+ const hostPage = await t.get(partyPath, { cookie: hostCookie });
+ assert.match(hostPage.text, /data-sf-party-members>1</);
+ const last = await t.get(`${partyPath}/leave`, { cookie: hostCookie, method: 'POST', headers: own });
+ assert.strictEqual(last.status, 303);
+ const empty = t.ctx.parties.get(id);
+ assert.ok(empty);
+ assert.strictEqual(empty.members.size, 0, 'the party stays for its 30-minute window');
+ t.clock.advance(30 * 60 * 1000 - 1);
+ assert.ok(t.ctx.parties.get(id), 'still there a moment before 30 minutes');
+ t.clock.advance(2);
+ assert.strictEqual(t.ctx.parties.get(id), null, 'an empty party ends 30 minutes after the last member leaves');
+ const gone = await t.get(partyPath, { cookie: memberCookie });
+ assert.strictEqual(gone.status, 404);
+ assert.match(gone.text, /Party over/);
+ });
+
+ await check('if the host leaves, the earliest remaining member becomes host', async () => {
+ const made = await t.get('/watch/party', {
+ cookie: hostCookie, method: 'POST', json: {},
+ headers: { ...own, accept: 'application/json' },
+ });
+ const path = made.json().url;
+ assert.strictEqual((await t.get(path, { cookie: memberCookie })).status, 200);
+ const leaving = await t.get(`${path}/leave`, { cookie: hostCookie, method: 'POST', json: {}, headers: own });
+ assert.strictEqual(leaving.status, 200, leaving.text);
+ assert.strictEqual(leaving.json().hostChanged, true);
+ const promoted = await t.get(`${path}/command`, {
+ cookie: memberCookie, method: 'POST', json: { channel_key: 'live:ada', position_s: 4, playing: true, at: Date.now() }, headers: own,
+ });
+ assert.strictEqual(promoted.status, 200, promoted.text);
+ assert.strictEqual(promoted.json().command.channel_key, 'live:ada');
+ await t.get(`${path}/leave`, { cookie: memberCookie, method: 'POST', json: {}, headers: own });
+ });
+
+ await check('the JSON form creates a party without a redirect and names its stream', async () => {
+ const r = await t.get('/watch/party', {
+ cookie: other ? t.signIn(other) : hostCookie, method: 'POST', json: {},
+ headers: { ...own, accept: 'application/json' },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.match(b.id, /^[A-Za-z0-9_-]{20,64}$/);
+ assert.strictEqual(b.url, `/watch/p/${b.id}`);
+ assert.strictEqual(b.events_url, `/watch/p/${b.id}/events`);
+ assert.strictEqual(b.party.members, 1);
+ assert.ok(b.party.channel_key, 'the party starts on the first channel in the guide');
+ });
+
+ await t.close();
+
+ // A second process with low caps: the limits are hard caps (200/50), testable by lowering them.
+ const small = await boot({ env: { SURF_PARTY_MAX: '2', SURF_PARTY_MEMBER_MAX: '2' } });
+ const a = small.network.addUser('a');
+ const b = small.network.addUser('b');
+ const c = small.network.addUser('c');
+ const create = async (user) => small.get('/watch/party', {
+ cookie: small.signIn(user), method: 'POST', json: {},
+ headers: { ...own, accept: 'application/json' },
+ });
+
+ await check('at most 200 parties and 50 members each: the lowered caps refuse the next one', async () => {
+ const one = await create(a);
+ const two = await create(b);
+ assert.strictEqual(one.status, 201, one.text);
+ assert.strictEqual(two.status, 201, two.text);
+ const tooMany = await create(c);
+ assert.strictEqual(tooMany.status, 429, tooMany.text);
+ assert.strictEqual(tooMany.json().code, 'party.limit');
+
+ const p1 = one.json().url;
+ const joined = await small.get(p1, { cookie: small.signIn(b) });
+ assert.strictEqual(joined.status, 200, joined.text);
+ assert.match(joined.text, /data-sf-party-members>2</);
+ const full = await small.get(p1, { cookie: small.signIn(c) });
+ assert.strictEqual(full.status, 429, 'the third member is refused');
+ assert.match(full.text, /party is full/);
+ });
+
+ await small.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });