openvibers/openvibe.surf README.md
README.md
| 1 | # OpenVibe.Surf |
| 2 | |
| 3 | > Ride maps, surf the web, surf what's on. |
| 4 | |
| 5 | **Live site:** https://openvibe.surf · **API docs:** https://openvibe.services · **For AI:** https://openvibe.surf/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** v1, the three-surf front (v0.1.0, see [STATUS.json](STATUS.json)). |
| 9 | **Domain:** `openvibe.surf` · **Port:** 5110 · **Service id:** `surf` · **Env prefix:** `SURF` |
| 10 | **License:** AGPL-3.0-only (the same as every OpenVibe service). |
| 11 | |
| 12 | OpenVibe.Surf is the Surf room. One room, three surfs: |
| 13 | |
| 14 | - **Ride** — CS-style surf in the browser, served by OpenVibe.Games. The map catalogue is proxied from Games and |
| 15 | every map plays in an embed (`https://openvibe.games/surf/embed?map=<id>&embed=1`). |
| 16 | - **Watch** — lean-back channel surfing over OpenVibe.Live: live streams, clips people cut and the AI moments of |
| 17 | the week, merged into one feed with one card shape. Open a party and everyone follows the host's channel. |
| 18 | - **Web** — *Surf with Vibe*: tell an agent what you are looking for and it browses the web for you on a private |
| 19 | desk through OpenVibe.Actor, keeps its sources and finishes with a cited report. |
| 20 | |
| 21 | ## What it is |
| 22 | |
| 23 | - **Identity:** service id `surf`, name OpenVibe.Surf, port 5110, host 127.0.0.1. It stores no accounts, no |
| 24 | database and no tokens: the person's Network session lives in httpOnly cookies. |
| 25 | - **Sign-in:** an OAuth 2 client of OpenVibe.Network (`surf`), authorization code with PKCE S256, a state check |
| 26 | and a server-side code exchange. |
| 27 | - **In memory, no database:** no database, no Valkey, no Events. The only state is a 30 s Watch feed cache, a |
| 28 | 60 s Ride cache, the SDK's JWKS cache and the watch-together parties, all in this one process. **A restart ends |
| 29 | every watch party.** |
| 30 | |
| 31 | ## Watch |
| 32 | |
| 33 | `GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=` |
| 34 | |
| 35 | Merges three public OpenVibe.Live reads (`OV_LIVE_INTERNAL_URL`, default `http://127.0.0.1:3000`). The home's |
| 36 | Watch scene is the head of this same feed, fetched server-side, so it never shows a written-in channel: |
| 37 | |
| 38 | | Source | Live route | What it gives | |
| 39 | |---|---|---| |
| 40 | | live | `GET /api/streams` | `{ streams: [...] }` with `username`, `display_name`, `avatar_url`, `title`, `viewer_count`, `thumbnail_url` | |
| 41 | | clips | `GET /api/content/feed?type=clips&sort=new&window=week` | `items` with `href`, `thumbnail_url`, `preview_url`, `title`, `channel`, `duration_seconds` | |
| 42 | | moments | `GET /api/content/moments?type=clips&sort=top&window=week` | the same shape, AI-cut clips | |
| 43 | |
| 44 | Every card is one shape: |
| 45 | |
| 46 | ```json |
| 47 | { |
| 48 | "key": "live:bo", |
| 49 | "kind": "live", |
| 50 | "title": "Surf practice, all day", |
| 51 | "channel": { "username": "bo", "display_name": "Bo", "avatar_url": null }, |
| 52 | "thumbnail": "https://openvibe.media/live/bo.jpg", |
| 53 | "play": { "type": "live", "src": null, "page": "https://openvibe.live/@bo" }, |
| 54 | "duration_seconds": null, |
| 55 | "viewers": 38, |
| 56 | "why": "Live now · 38 watching" |
| 57 | } |
| 58 | ``` |
| 59 | |
| 60 | - A clip or moment has `play.type: "hls"`, `play.src` = its `preview_url` (an HLS master playlist on |
| 61 | `openvibe.media`: Media 302s `/c/<id>` to `master.m3u8`) and `play.page` = its Live page. A live stream has |
| 62 | `play.type: "live"`, `src: null` and `page` = `https://openvibe.live/@<username>` (Live has no embeddable |
| 63 | player yet). |
| 64 | - **Ranking:** live streams first, by viewers; then moments and clips interleaved by freshness. Finally, no two |
| 65 | cards from the same channel sit next to each other when another channel is available. |
| 66 | - **Cache:** a complete merged page for a topic/cursor is cached 30 s (`SURF_WATCH_CACHE_MS`); a partial page is |
| 67 | not kept, so the next request retries the missing source. Each Live source gets 2 s |
| 68 | (`SURF_WATCH_SOURCE_TIMEOUT_MS`); a source that fails or times out is left out, named in `sources` |
| 69 | (`{ live: "ok", clips: "ok", moments: "error" }`) and `partial` is true. |
| 70 | - **Cursor:** opaque; it carries each Live feed's own `next` cursor. Page two does not repeat live streams. |
| 71 | - **Cache-Control:** `public, max-age=30`. |
| 72 | |
| 73 | ### Watch together |
| 74 | |
| 75 | A signed-in person opens a party (`POST /watch/party`, from this site) and gets an unguessable id at |
| 76 | `/watch/p/<id>`; that link is the invitation. Opening it sends a guest to sign in first and joins everyone else. |
| 77 | The page reuses the TV markup with a `sf-party-` bar: members, host, **Share** (the network's one Share sheet), **Leave**, and, if the host |
| 78 | pasted one, a link to an OpenVibe.Chat room (`https://openvibe.chat/...`, validated). There is no chat inside Surf. |
| 79 | |
| 80 | - **Host:** the person who opened the party. Only the host commands, with |
| 81 | `POST /watch/p/<id>/command` (also `POST /watch/p/<id>` and `/state`) and |
| 82 | `{ channel_key, position_s, playing, at }`, from this site's own Origin. A member's command is 403, and so is a |
| 83 | command from another site. |
| 84 | - **Sync:** `GET /watch/p/<id>/events` is Server-Sent Events (`text/event-stream`): `state` once, then `command` |
| 85 | when the host changes channel, play/pause or seek, `members` on a join/leave, and `chat` when the room link |
| 86 | changes. The browser applies commands, follows play/pause and seeks, and corrects a drift over 2 s. |
| 87 | - **Leaving and lifetime:** `POST /watch/p/<id>/leave` (the Leave button) removes that member; if the host leaves |
| 88 | while others remain, the earliest remaining member becomes host. The last member out starts a 30-minute clock. |
| 89 | The Map holds at most 200 open parties and 50 members each; an empty party is dropped on the next read 30 |
| 90 | minutes after its last member leaves. Everything is memory only: a restart ends every party. |
| 91 | |
| 92 | ## Ride |
| 93 | |
| 94 | `GET /api/v1/ride/maps` and `GET /api/v1/ride/maps/:id` |
| 95 | |
| 96 | Proxy to OpenVibe.Games (`OV_GAMES_INTERNAL_URL`, default `http://127.0.0.1:8000`), cached 60 s |
| 97 | (`SURF_RIDE_CACHE_MS`). The routes `/api/surf/maps` and `/api/surf/maps/:id` are being added to Games now. Until |
| 98 | they answer, Games gives 404: the list is `{ "maps": [], "games": "not_ready" }` and a map detail is a shell that |
| 99 | still carries the embed (`{ "map": { "id": … }, "top": [], "games": "not_ready" }`). A Games timeout or 5xx is a |
| 100 | `502 games.unavailable` problem and is never cached. |
| 101 | |
| 102 | Each map carries `embed_url`: |
| 103 | `https://openvibe.games/surf/embed?map=<id>&embed=1` (`SURF_GAMES_PUBLIC_URL`). |
| 104 | |
| 105 | ## Web — Surf with Vibe |
| 106 | |
| 107 | `POST /api/v1/web/tasks` (signed in; this site's own `Origin`) with `{ "query": "…" }` (3–500 characters). |
| 108 | |
| 109 | The person's own Network access token from the httpOnly `surf_at` cookie is sent to OpenVibe.Actor |
| 110 | (`OV_ACTOR_INTERNAL_URL`, default `http://127.0.0.1:4950`) as a Bearer on `POST /api/v1/tasks`. Actor accepts a |
| 111 | person's session token there; the task is theirs — their allowance and their task list — not OpenVibe.Surf's. |
| 112 | |
| 113 | The task text is a short standing brief plus the query unchanged: |
| 114 | |
| 115 | > Surf the web for me with your tools. Use search_web to find pages, read_page to read them, and a desk when a |
| 116 | > page needs one. Keep every source you use and finish with a short report that cites each source. |
| 117 | |
| 118 | The request carries an `Idempotency-Key` header (and Actor's body `idempotency_key`, the same value), so a retried |
| 119 | form does not start a second task. The answer is `{ "task_id": "tsk_…", "page": "https://openvibe.actor/tasks/tsk_…" }` |
| 120 | (201, `Location` set). Actor's 4xx refusal (401, 403, 429 allowance/budget, 422) keeps its status and its words; |
| 121 | an Actor that does not answer is a `502 actor.unavailable`. |
| 122 | |
| 123 | `POST /web/go` (form field `q`) is the same task as a plain form for no-JavaScript: it creates the task and |
| 124 | **303**s to the Actor task page, so the question is never in a URL. It is refused from another site (Origin or |
| 125 | Sec-Fetch-Site), a guest is sent to sign in, a bad query is a 400 with the form again, and an Actor refusal keeps |
| 126 | its status and prints its words. It is `private, no-store`; a `GET /web/go` starts nothing and goes back to `/web`. |
| 127 | |
| 128 | ## Pages |
| 129 | |
| 130 | | Route | What it is | |
| 131 | |---|---| |
| 132 | | `/` | the Surf room: the run motif (ramps, speed lines, a ticking timer), the three tiles under the hero, scenes (the Watch scene is the real Live feed's first cards), questions | |
| 133 | | `/ride` | the maps with tier, stages, runs and the record, and the embed of the first map | |
| 134 | | `/ride/:map` | one map: name, tier, stages, records if Games answers, the embed | |
| 135 | | `/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 | |
| 136 | | `/watch/p/:id` | a watch party: the same TV, a `sf-party-` bar with the members, host, Share and Leave, and the optional OpenVibe.Chat room link; signed-in members follow the host's channel and position over SSE | |
| 137 | | `/web` | the Surf with Vibe form, examples, and what happens next | |
| 138 | | `/developers` | the API, for people and agents | |
| 139 | | anything else | the 404 page | |
| 140 | |
| 141 | All of them are server-rendered through the OpenVibe Frame (`openvibe-shared/shell`) and readable without |
| 142 | JavaScript, with stable `sf-` ids and classes. A page rendered for a signed-in person is `private, no-store`; a |
| 143 | public page is `public, max-age=60` with `Vary: Cookie`. `public/js/surf.js` adds what the pages are complete |
| 144 | without: the TV (native HLS, else `/vendor/hls.light.min.js` 1.7.3, loaded only then), the Web form in the page and |
| 145 | the home timer; everything works without it. |
| 146 | |
| 147 | ## API |
| 148 | |
| 149 | | Route | Auth | Cache | What it does | |
| 150 | |---|---|---|---| |
| 151 | | `GET /api/v1/watch/feed` | none | `public, max-age=30` | the merged Live feed | |
| 152 | | `GET /api/v1/ride/maps` | none | `public, max-age=60` | the Games surf map catalogue | |
| 153 | | `GET /api/v1/ride/maps/:id` | none | `public, max-age=60` | one map and its fastest runs | |
| 154 | | `POST /api/v1/web/tasks` | cookie + Origin | `private, no-store` | create one Actor task as the person | |
| 155 | | `POST /web/go` | cookie, this site only | `private, no-store` | the no-JavaScript task form, 303 to Actor | |
| 156 | | `POST /watch/party` | cookie + Origin | `private, no-store` | open a party, 303 to `/watch/p/<id>` (JSON: 201) | |
| 157 | | `GET /watch/p/:id/events` | cookie, member | `no-store` | the party's SSE stream (`state`, `command`, `members`, `chat`) | |
| 158 | | `POST /watch/p/:id/command` | cookie, host + Origin | `private, no-store` | `{ channel_key, position_s, playing, at }`, broadcast over SSE | |
| 159 | | `POST /watch/p/:id/leave` | cookie + Origin | `private, no-store` | leave the party, 303 to `/watch` (JSON: 200) | |
| 160 | | `POST /watch/p/:id/chat` | cookie, host + Origin | `private, no-store` | set the `https://openvibe.chat/...` room link | |
| 161 | |
| 162 | Errors are RFC 9457 `application/problem+json`. A blank map id is 400; a bad Watch topic or cursor is 400. The |
| 163 | only outbound person token is the `Authorization: Bearer` on the request to Actor. |
| 164 | |
| 165 | Plumbing: `GET /api/health`, `GET /api/ready` (200 `ready` or `degraded`; Network, JWKS, the OAuth client, Live, |
| 166 | Games and Actor are optional checks), `GET /release.json`, `POST /release-metrics`, `/shared/*` (the pinned Frame), |
| 167 | `/css/*` and `/js/*` (content-hashed `?v=` → immutable), `GET /robots.txt`, `GET /sitemap.xml` (the public pages |
| 168 | and the Ride maps), `GET /llms.txt` and `GET /metrics` (loopback only). |
| 169 | |
| 170 | ## Sign-in |
| 171 | |
| 172 | `server/auth/sso.js` is OpenVibe.Design's module with this site's names: |
| 173 | |
| 174 | - `GET /auth/login?next=/web[&silent=1]` mints a state and a PKCE S256 verifier/challenge, stores them (and the |
| 175 | sanitized `next`) in the httpOnly `surf_oauth` flow cookie scoped to `/auth`, and redirects to |
| 176 | `${OV_NETWORK_URL}/oauth/authorize` with `client_id=surf` and `redirect_uri=${BASE_URL}/auth/callback`. |
| 177 | - `GET /auth/callback` checks the state in constant time, exchanges the code at the Network's internal |
| 178 | `/oauth/token` with the client secret and the code verifier, and sets `surf_at` (24 h) and `surf_rt` (30 d): |
| 179 | httpOnly, SameSite=Lax, Secure in production, Path=/. |
| 180 | - `GET /auth/logout` clears them and revokes the refresh token (best effort); `GET /auth/me` answers `{ user }` or |
| 181 | `{ user: null }` for the shared navbar. A FedCM assertion or an app/service token never becomes a session. |
| 182 | - `sanitizeNext` accepts same-site relative paths only. An absolute URL on this site's own origin counts as its |
| 183 | path; another origin, `//evil`, a control character or a backslash goes to `/`. |
| 184 | |
| 185 | The Network signs the access token (RS256); `server/auth/keys.js` fetches and refreshes its JWKS from |
| 186 | `${OV_NETWORK_INTERNAL_URL}/api/.well-known/jwks` through `openvibe-sdk/auth`. |
| 187 | |
| 188 | ## Configuration |
| 189 | |
| 190 | See [.env.example](.env.example). Required in production: `BASE_URL` and `OV_OAUTH_CLIENT_SECRET` (the `surf` |
| 191 | OAuth client on the Network). |
| 192 | |
| 193 | | Variable | Default | What | |
| 194 | |---|---|---| |
| 195 | | `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | sign-in token exchange and JWKS | |
| 196 | | `OV_NETWORK_URL` | `https://openvibe.network` | where a browser is sent to authorize | |
| 197 | | `OV_NETWORK_ISSUER` | `OV_NETWORK_URL` | the token issuer | |
| 198 | | `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Watch's three Live reads | |
| 199 | | `OV_GAMES_INTERNAL_URL` | `http://127.0.0.1:8000` | Ride's map reads | |
| 200 | | `OV_ACTOR_INTERNAL_URL` | `http://127.0.0.1:4950` | Web's task creation | |
| 201 | | `SURF_GAMES_PUBLIC_URL` | `https://openvibe.games` | the embed origin and `embed_url` | |
| 202 | | `SURF_LIVE_PUBLIC_URL` | `https://openvibe.live` | card page links | |
| 203 | | `SURF_ACTOR_PUBLIC_URL` | `https://openvibe.actor` | the task page a person is sent to | |
| 204 | | `SURF_WATCH_CACHE_MS` | `30000` | the merged Watch feed cache | |
| 205 | | `SURF_WATCH_SOURCE_TIMEOUT_MS` | `2000` | one Live source's budget | |
| 206 | | `SURF_RIDE_CACHE_MS` | `60000` | the Ride cache | |
| 207 | | `SURF_GAMES_TIMEOUT_MS` | `4000` | one Games read's budget | |
| 208 | | `SURF_ACTOR_TIMEOUT_MS` | `15000` | one Actor task creation's budget | |
| 209 | | `SURF_RATE_LIMIT_PER_MIN` | `120` | per-IP `/api` and `/auth` budget | |
| 210 | | `SURF_PARTY_MAX` | `200` | open watch parties (may only be lowered, hard cap 200) | |
| 211 | | `SURF_PARTY_MEMBER_MAX` | `50` | members per party (may only be lowered, hard cap 50) | |
| 212 | | `COOKIE_SECURE` | true in production | Secure cookies | |
| 213 | |
| 214 | ## How to run |
| 215 | |
| 216 | ```bash |
| 217 | npm install |
| 218 | npm run dev # http://localhost:5110 |
| 219 | OV_ALLOW_SUITE=1 npm test |
| 220 | ``` |
| 221 | |
| 222 | `npm test` runs every `test/*.test.js` (including `test/party.test.js`: create/join/leave, host-only and |
| 223 | same-site commands, SSE delivery, the 30-minute TTL and the caps) in its own process with in-process stand-ins |
| 224 | (`test/helpers/network.js`, `test/helpers/upstreams.js`): a real RS256 key pair and the OAuth token endpoint for |
| 225 | Network, and recorded Live, Games and Actor routes. Nothing calls the real internet. `OV_ALLOW_SUITE=1` is the |
| 226 | suite opt-in the shared test runner expects. |
| 227 | |
| 228 | ## Deploy (for the lead) |
| 229 | |
| 230 | - **Deploy:** `sudo ovhost deploy surf` on the host (checkout at `/opt/openvibe.surf`, unit |
| 231 | `openvibe-surf.service` on 127.0.0.1:5110, env `/etc/openvibe/surf.env`). |
| 232 | - **nginx:** [deploy/nginx/openvibe.surf.conf](deploy/nginx/openvibe.surf.conf), installed with |
| 233 | `ov-vhost-install`. `/api` is reads only; `/api/v1/web/` and `/web/go` are the forms zone; `/auth` is GET only; |
| 234 | `/auth/me` gets the API budget; `/internal` and `/metrics` are 404. The SSE route |
| 235 | `GET /watch/p/<id>/events` has `proxy_buffering off`, no gzip and a 1-hour read timeout. |
| 236 | - **Readiness:** `/api/ready` reports `degraded` (HTTP 200) when Network, Live or Games does not answer; the |
| 237 | process still serves the pages and the crawl artifacts. |
| 238 | |
| 239 | ## Security (threat notes) |
| 240 | |
| 241 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). |
| 242 | |
| 243 | - Session tokens are httpOnly cookies; no script can read them and nothing renders or logs them. The only place |
| 244 | they go is the `Authorization` header of a request to the configured Actor origin. |
| 245 | - A Web task must come from this site's own origin (`Origin`), so another site cannot make a signed-in visitor |
| 246 | spend their Actor allowance. `/web/go` refuses a cross-site navigation when the browser says it is one. |
| 247 | - `next` after sign-in is this site's own path only; control characters and backslashes are refused because |
| 248 | browsers drop or rewrite them. |
| 249 | - Watch's card fields are allow-listed and escaped; only `http(s)` URLs become `href`/`src`. Actor's error |
| 250 | detail is trimmed and no token or request body is logged. |
| 251 | - A watch party's id is 144 random bits. Only its host may command; a browser command must come from this |
| 252 | site's own Origin (`Origin`/`Sec-Fetch-Site`, while a request with neither is a non-browser client and still |
| 253 | needs the session cookie). Only a member may open the SSE stream, and the party page is `private, no-store`. |
| 254 | The Map is capped and an empty party is dropped after 30 minutes. Member names and the optional Chat link are |
| 255 | escaped; the Chat link is an absolute `https://openvibe.chat/` URL with no credentials, and Surf stores no chat. |
| 256 | - Secrets live only in the environment file; only variable names appear in code and docs, and no secret or token |
| 257 | is logged. |
| 258 | |
| 259 | --- |
| 260 | |
| 261 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by |
| 262 | [OpenVibers](https://github.com/OpenVibers). |
| 263 | |
| 264 | <!-- versions:start --> |
| 265 | - openvibe-contracts: v0.419.0 |
| 266 | - openvibe-sdk: v0.161.0 |
| 267 | - openvibe-shared: v4.241.0 |
| 268 | <!-- versions:end --> |
| 269 |