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)

OP OpenVibers committed 16 days ago · af4be0dd5e · 13 files, +1183 −59


Changes

ddiff --git a/.env.example b/.env.example
iindex e53a357..53e12b5 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0dafb9d..8b81957 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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-28
The service skeleton: three surfs, one room.
ddiff --git a/README.md b/README.md
iindex 3041a89..fd5e8bb 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
-- 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
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:
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
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
@@@ -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. The
only 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:5110
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
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.
@@@ -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; the
process 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 error
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
is logged.
ddiff --git a/STATUS.json b/STATUS.json
iindex b4503dd..33fbd69 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/deploy/nginx/openvibe.surf.conf b/deploy/nginx/openvibe.surf.conf
iindex 0ce3597..6ea84a0 100644
---- a/deploy/nginx/openvibe.surf.conf
++++ b/deploy/nginx/openvibe.surf.conf
@@@ -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; }
ddiff --git a/public/css/surf.css b/public/css/surf.css
iindex 2c933f1..c35a37c 100644
---- a/public/css/surf.css
++++ b/public/css/surf.css
@@@ -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; } }
ddiff --git a/public/js/surf.js b/public/js/surf.js
iindex 34dec35..083cfad 100644
---- a/public/js/surf.js
++++ b/public/js/surf.js
@@@ -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 ────────────────────────────────
ddiff --git a/server/app.js b/server/app.js
iindex dce03a6..623dfb4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/config.js b/server/config.js
iindex cb323f7..4382ba9 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
ddiff --git a/server/http/party.js b/server/http/party.js
nnew file mode 100644
iindex 0000000..987c7b2
---- /dev/null
++++ b/server/http/party.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 3f9f6f5..9d30f53 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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')}&amp;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)}&amp;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/&lt;id&gt;</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,
ddiff --git a/server/watch/party.js b/server/watch/party.js
nnew file mode 100644
iindex 0000000..2685fb2
---- /dev/null
++++ b/server/watch/party.js
@@@ -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,
++};
ddiff --git a/test/party.test.js b/test/party.test.js
nnew file mode 100644
iindex 0000000..dd932c3
---- /dev/null
++++ b/test/party.test.js
@@@ -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); });