From dfd264adb433c9ac4f9f5357721a54b501fcded7 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:17:24 +0800 Subject: [PATCH] feat: add persistent reusable rooms (#10) * feat: add persistent reusable rooms * fix: preserve unicode documentation paths --- .env.example | 9 +- .gitignore | 4 + README.md | 2 +- deploy/systemd/screener.service.example | 2 + docs/README.md | 1 + docs/adr/0001-p2p-first-media-topology.md | 2 +- docs/adr/0002-persistent-protected-rooms.md | 117 ++++++++++++++ docs/deployment.md | 70 ++++++-- docs/project-memory.md | 11 +- docs/research/webrtc-p2p-screen-sharing.md | 6 +- docs/status.md | 32 ++-- docs/方案设计.md | 39 +++-- docs/需求理解.md | 16 +- src/client/lib/session.ts | 58 ++++++- src/client/pages/HostPage.tsx | 170 +++++++++++--------- src/client/pages/ViewerPage.tsx | 64 ++++++-- src/server/app.ts | 20 ++- src/server/config.ts | 16 +- src/server/room-database.ts | 113 +++++++++++++ src/server/room-store.ts | 74 +++++++-- src/server/signaling.ts | 68 ++++++-- src/shared/protocol.ts | 13 +- tests/client-state.test.ts | 80 ++++++++- tests/protocol.test.ts | 42 ++++- tests/room-store.test.ts | 90 +++++++++-- tests/server-config.test.ts | 52 ++++-- tests/server-http.test.ts | 49 ++++++ tests/server-signal.test.ts | 120 +++++++++++++- 28 files changed, 1115 insertions(+), 225 deletions(-) create mode 100644 docs/adr/0002-persistent-protected-rooms.md create mode 100644 src/server/room-database.ts diff --git a/.env.example b/.env.example index 3527320..b3baaa5 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,13 @@ PORT=8787 PUBLIC_BASE_URL=http://localhost:8787 # Include both the host browser origin and PUBLIC_BASE_URL when they differ. ALLOWED_ORIGINS=http://localhost:8787 -# Optional. Use 12-128 visible ASCII characters in production. When set, every -# browser must sign in before sharing or watching. +# Optional whole-site gate. A non-empty value must contain 1-128 visible ASCII +# characters (0x21-0x7e). When set, every browser signs in before sharing or watching. ACCESS_PASSWORD= +# Optional SQLite file for persistent protected rooms. Requires ACCESS_PASSWORD. +# The systemd example creates /var/lib/screener for this production path. +ROOM_DATABASE_PATH= +# Applies only to temporary rooms when ROOM_DATABASE_PATH is empty. ROOM_TTL_SECONDS=14400 MAX_ROOMS=1000 MAX_VIEWERS_PER_ROOM=8 @@ -21,6 +25,7 @@ TURN_CREDENTIAL_TTL_SECONDS=3600 # Minimal production example (use independent untracked secrets): # ACCESS_PASSWORD=replace-with-a-strong-passphrase +# ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite # STUN_URLS=stun:turn.example.com:3478 # TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp # TURN_SHARED_SECRET=replace-with-at-least-32-random-bytes diff --git a/.gitignore b/.gitignore index 499130a..7535eae 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ build/ coverage/ dist/ .cache/ +*.sqlite +*.sqlite-journal +*.sqlite-shm +*.sqlite-wal # Editor and operating-system noise .DS_Store diff --git a/README.md b/README.md index cb2a323..9624cf1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its media path is WebRTC P2P-first, with authenticated TURN fallback for network pairs that cannot connect directly. Viewers should be able to join from a desktop or mobile browser without installing the sender application. -The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and creates an expiring room with a 12-digit numeric code. Friends can open `/r/{code}` directly or enter the code at `/join`; viewer links contain no separate token. Each viewer receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The default room capacity is eight viewers and deployments may configure 1 through 16, but a real 1:8 media session has not yet been validated and this setting is not a performance promise. The Node.js service carries only room, access, ICE credential, and signaling traffic. +The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code; friends can open `/r/{code}` directly or enter the code at `/join`, with no separate viewer token. Rooms are random and temporary by default. A password-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Each viewer currently receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise. This is not yet a production release. Public TURN/UDP and TURN/TCP relay paths are verified, but real cross-network Screener media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. diff --git a/deploy/systemd/screener.service.example b/deploy/systemd/screener.service.example index 73acb0c..f1d66f6 100644 --- a/deploy/systemd/screener.service.example +++ b/deploy/systemd/screener.service.example @@ -14,6 +14,8 @@ Restart=on-failure RestartSec=3s TimeoutStopSec=20s UMask=0077 +StateDirectory=screener +StateDirectoryMode=0700 NoNewPrivileges=true PrivateDevices=true diff --git a/docs/README.md b/docs/README.md index 5b05264..de3c431 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,5 +9,6 @@ - [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment. - [Agent context governance research](./research/agent-context-governance.md): official Codex, Claude Code, Hermes Agent, and GitHub practices adopted by this repository. - [ADR-0001](./adr/0001-p2p-first-media-topology.md): accepted P2P-first media topology and its consequences. +- [ADR-0002](./adr/0002-persistent-protected-rooms.md): optional SQLite persistence, protected sequential room IDs, and room lifecycle. Documentation and project memory are part of the product source of truth. Update them in the same change that alters the corresponding requirement or architecture. diff --git a/docs/adr/0001-p2p-first-media-topology.md b/docs/adr/0001-p2p-first-media-topology.md index 166ff0d..d210257 100644 --- a/docs/adr/0001-p2p-first-media-topology.md +++ b/docs/adr/0001-p2p-first-media-topology.md @@ -12,7 +12,7 @@ The product targets one game broadcaster and a small group of trusted friends. L Use separate control and media planes: - A small HTTPS/WSS service owns identity, rooms, invitations, presence, and WebRTC signaling. -- A viewer can join through a 12-digit room code on desktop or mobile without installing the sharing client. The viewer link has no separate token or fragment; the host publication token remains internal. A deployment may place one site-wide password gate in front of both hosting and viewing; access policy remains control-plane state and does not change the media topology. +- A viewer can join through a numeric room code on desktop or mobile without installing the sharing client. The viewer link has no separate token or fragment; the host publication token remains internal. A deployment may place one site-wide password gate in front of both hosting and viewing; access policy remains control-plane state and does not change the media topology. ADR-0002 owns the current room-ID and lifetime policy. - ICE attempts a direct UDP path for every broadcaster-viewer pair, using STUN to discover candidates. - Only pairs that cannot connect directly use authenticated TURN. Production requires STUN plus TURN over UDP and TCP; TURN/TLS is an optional restrictive-network enhancement, using its standard TCP port 5349 by default. Port 443 is optional and requires a dedicated public IP or a validated layer-4/SNI route when HTTPS already owns that address and port. - Candidate selection is independent per pair. A room may simultaneously contain direct and relayed viewers without moving working peers onto the server. diff --git a/docs/adr/0002-persistent-protected-rooms.md b/docs/adr/0002-persistent-protected-rooms.md new file mode 100644 index 0000000..96120b7 --- /dev/null +++ b/docs/adr/0002-persistent-protected-rooms.md @@ -0,0 +1,117 @@ +# ADR-0002: Persistent Protected Rooms + +- Status: Accepted +- Date: 2026-08-18 + +## Context + +The trusted-friends workflow benefits from short room numbers, reusable links, +and a room that remains available while its broadcaster is offline. The previous +ephemeral policy used random room codes and invalidated a room after its TTL. +Making short sequential IDs public would create a trivial enumeration surface, +while adding accounts, per-room passwords, invitations, or a general database +would exceed the current product need. + +The media topology is independent of this decision. Rooms still use the +P2P-first WebRTC design in ADR-0001, with TURN only for viewer pairs that cannot +connect directly. + +## Decision + +Keep persistence an explicit, protected deployment option: + +- `ACCESS_PASSWORD` remains the single whole-site gate. An unset or empty value + means public mode; a non-empty value must contain 1 through 128 visible ASCII + characters (`0x21` through `0x7e`). +- `ROOM_DATABASE_PATH` is optional and requires `ACCESS_PASSWORD`. Configuring a + database path without the password is a startup error. +- When both values are configured, the service creates persistent protected + rooms. SQLite allocates positive decimal room IDs with + `INTEGER PRIMARY KEY AUTOINCREMENT`, beginning at `1`. These rooms and their + `/r/{roomId}` links do not expire. +- Without `ROOM_DATABASE_PATH`, rooms remain temporary and use random numeric IDs + plus `ROOM_TTL_SECONDS`. Public mode is therefore always random and temporary; + a password-only deployment is protected but still temporary. +- An explicit `stop-sharing` or capture track ending ends only the current + publication. The room remains joinable and viewers enter a waiting state. A + brief host signaling disconnect does not stop healthy P2P media. A temporary + room is removed only when its TTL expires; a persistent room is not + automatically deleted. +- A canceled request may abandon a newly allocated room only before the host + client claims it. SQLite `AUTOINCREMENT` ensures that an abandoned numeric ID + is never reassigned. Normal stop and reload paths never abandon the room. +- The built-in `node:sqlite` database stores only the room ID (the table rowid) + and SHA-256 host-token digest. It never stores the host token in plaintext, the + site password, access cookies, participants, SDP, ICE candidates, IP addresses, + TURN credentials, or media. +- The host browser retains the plaintext room response in same-origin + `localStorage` so it can reclaim publication rights after a reload. Clearing + site data or moving to another device loses that local ownership token; the + server cannot recover it from the digest. +- Persistence remains a single-process, single-file facility. Do not add an ORM, + Redis, an account service, or multi-node coordination without a measured need. +- The systemd example uses `StateDirectory=screener`; its protected-room database + path is `/var/lib/screener/rooms.sqlite`. + +This decision supersedes only the fixed room-code and room-lifetime details in +ADR-0001. It does not change that ADR's media topology. + +## Consequences + +Positive: + +- A protected instance gets memorable room numbers and links that survive a + stopped share or application restart. +- The persistent state is deliberately small and uses the Node.js runtime's + built-in SQLite implementation, so deployment gains no additional service. +- Public deployments retain random temporary identifiers rather than exposing a + predictable room sequence. + +Negative: + +- Anyone who knows the whole-site password can enumerate persistent room IDs. + This is accepted for one trusted friend group; the password is not per-room + authorization. +- The SQLite file becomes operational state that must have restricted + permissions and be included in backups when room continuity matters. +- Deleting or losing the database loses room ownership mappings. A SHA-256 + digest cannot recover a lost plaintext host token. +- `MAX_ROOMS` bounds new room creation against the in-process room count. + Persistent rooms have no automatic retention policy, so an operator must + deliberately handle future cleanup if the small-instance assumption stops + holding. +- If SQLite commits a new room but the HTTP response never reaches the host, + its plaintext host token is unrecoverable and the row remains until an + operator cleans it up. `MAX_ROOMS` bounds this failure mode; adding an + idempotency or administration subsystem is deferred until it is observed. +- The first rollout cannot migrate rooms that existed only in process memory; + those temporary links end with the deployment restart. +- This design does not provide horizontal scaling or failover across multiple + application processes. +- In Node.js 24.19.0, `node:sqlite` has Stability 1.2 (release candidate), not + stable status. It is accepted here to avoid a native addon and hand-written + JSON transaction/recovery logic; follow its status and rerun persistence tests + with each Node LTS update. See the + [official Node.js SQLite documentation](https://nodejs.org/download/release/v24.19.0/docs/api/sqlite.html). + +## Rejected Alternatives + +- Sequential IDs in public mode: easy to enumerate and unnecessary. +- Persisting every deployment automatically: changes the simple public/local + default and creates filesystem state where none is required. +- Accounts, per-room passwords, invite tokens, or a general authorization + database: additional concepts without a current trusted-friends requirement. +- Storing plaintext host tokens: unnecessary credential exposure. +- An external database, ORM, Redis, or distributed room allocator: operational + complexity without a present multi-node consumer. +- Deleting a room whenever sharing stops: breaks reusable links and turns a + temporary media action into an unrelated room-lifecycle action. + +## Revisit Triggers + +- Multiple application processes or regions must allocate and serve the same + room namespace. +- Different friend groups need isolation beyond one deployment-wide password. +- Persistent room count makes deliberate archival or deletion necessary. +- Product requirements need revocable invitations, accounts, or auditable room + ownership. diff --git a/docs/deployment.md b/docs/deployment.md index 3e71767..f5444d3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,8 +30,9 @@ The minimum runtime is Node.js 24 LTS and coturn 4.17.2 or a newer patched release. Provision a valid TLS certificate for the Web name and, only when enabling TURN/TLS, for the TURN name. Enable operating system time synchronization and keep the Node application port reachable only from its -reverse proxy. A single Node process is intentional: rooms are held in memory, -so restarting it ends signaling and invalidates room state. +reverse proxy. A single Node process is intentional. Active participants and +signaling remain in memory; an optional protected SQLite file can restore room +identity after a restart, but it does not restore live WebRTC connections. Build and validate the exact revision before starting it: @@ -70,7 +71,8 @@ LISTEN_HOST=127.0.0.1 PORT=8787 PUBLIC_BASE_URL=https://share.example.com ALLOWED_ORIGINS=https://share.example.com -ACCESS_PASSWORD= +ACCESS_PASSWORD= +ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite ROOM_TTL_SECONDS=14400 MAX_ROOMS=1000 MAX_VIEWERS_PER_ROOM=8 @@ -82,11 +84,16 @@ TURN_CREDENTIAL_TTL_SECONDS=3600 ``` `ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. -`ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. For -an Internet deployment intended to stay private, set a strong independent value; -production accepts 12 through 128 visible ASCII characters so the password can -be carried safely in the login header. `TURN_SHARED_SECRET` must +`ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. A +non-empty value must contain 1 through 128 visible ASCII characters (`0x21` +through `0x7e`). For an Internet deployment intended to stay private, use an +independent value that is not reused elsewhere. `TURN_SHARED_SECRET` must contain at least 32 bytes and must not reuse the access password. +`ROOM_DATABASE_PATH` is optional but requires `ACCESS_PASSWORD`; startup fails +if a database path is supplied without the whole-site gate. The baseline above +enables persistent protected rooms. Omit `ROOM_DATABASE_PATH` to keep random +temporary rooms, and omit both values for public mode. `ROOM_TTL_SECONDS` applies +only to temporary rooms. `MAX_VIEWERS_PER_ROOM` defaults to 8 and accepts 1 through 16. It is an admission limit, not evidence that the publisher can sustain that many streams. `TURN_SHARED_SECRET` stays only in the Node environment and coturn configuration; @@ -100,17 +107,50 @@ same login gate. Only `POST /api/session` accepts the password in an cookie with `HttpOnly`, `SameSite=Strict`, `Path=/`, a bounded `Max-Age`, and, under production HTTPS, `Secure` plus an `__Host-` name. The cookie contains no account or server-side session identifier. There is no -database, JWT, session map, or logout endpoint; expiry or clearing site cookies -ends access. +account database, JWT, session map, or logout endpoint; expiry or clearing site +cookies ends access. The optional room database is unrelated to access sessions. `POST /api/rooms` and the `/signal` WebSocket upgrade use that cookie when the gate is enabled. The room API does not accept a direct Bearer credential as an -alternate creation path. A created room has a random 12-digit numeric code; -viewers open `/r/{code}` or enter only the code at `/join`. There is no viewer -token or URL fragment, while the host token remains internal to the host page. -With no `ACCESS_PASSWORD`, the room code is the sole viewing capability and does -not provide strong privacy, so that mode is suitable only when public access is -acceptable or another trusted access layer exists. +alternate creation path. Viewers open `/r/{code}` or enter only the numeric code +at `/join`; there is no viewer token or URL fragment, while the host token remains +internal to the host page. + +Room allocation has two deliberately small policies: + +- With `ROOM_DATABASE_PATH`, SQLite allocates positive decimal IDs starting at + `1`. These protected rooms and links do not expire. An explicit stop or capture + track ending stops the current publication and leaves viewers waiting; it does + not delete the room. A brief signaling disconnect does not stop otherwise + healthy P2P media. +- Without `ROOM_DATABASE_PATH`, rooms use random numeric IDs and expire according + to `ROOM_TTL_SECONDS`. Stopping sharing does not immediately delete them. Public + mode always uses this policy, so anonymous clients cannot simply walk a + sequential namespace. + +The built-in `node:sqlite` database stores only each room ID (the table rowid) +and SHA-256 host-token digest. It must not contain plaintext tokens, passwords, +access cookies, SDP, ICE candidates, IP addresses, viewer state, TURN +credentials, or media. Protect and back up the file as service state. The tracked +systemd unit creates `/var/lib/screener` with `StateDirectory=screener` and mode +`0700`; the production path above is writable despite `ProtectSystem=strict`. +The database is designed for one application process, not shared storage across +multiple instances. + +For a simple consistent backup, stop `screener.service` before copying the +SQLite file, then start it again. Restore while the service is stopped, restore +ownership to the service account and mode `0600`, run SQLite +`PRAGMA integrity_check`, and only then start the service. An online backup must +use SQLite's [backup API](https://www.sqlite.org/backup.html) or `VACUUM INTO`; +do not copy only the live main file while it may have an active journal. + +Enabling persistence does not migrate rooms that existed only in memory. The +deployment restart invalidates those temporary links; the first subsequently +created persistent room receives ID `1`. + +With no `ACCESS_PASSWORD`, the random room code is the sole viewing capability +and does not provide strong privacy, so public mode is suitable only when public +access is acceptable or another trusted access layer exists. Production startup validates the configured ICE transport mix before the server listens. The baseline must contain STUN and separate `TURN_URLS` entries diff --git a/docs/project-memory.md b/docs/project-memory.md index be0e4fa..9417009 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -29,16 +29,18 @@ Last updated: 2026-08-18 - Leave codec order at the browser default in the first PoC and record the negotiated codec, encoder implementation, and power efficiency. Prefer H.264 only after target-machine measurements show that it is the hardware-efficient path; retain VP8 compatibility. - Add an Electron or native Windows sender only after browser measurements identify capture, application-audio, or encode bottlenecks. - Do not plan an SFU for the normal small-room product path. Reconsider it only if the product scope or real telemetry later invalidates the P2P envelope; never implement peer forwarding trees in the MVP. +- Keep room policy deployment-driven and small. Public and password-only deployments use random temporary rooms. A deployment that configures both the whole-site password and a SQLite path gets sequential, non-expiring protected rooms; stopping a share leaves viewers waiting and does not destroy the room. ## Current Implementation - The repository contains a single npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and a separate coturn deployment. The server defaults to an all-interface listener for LAN development and containers; the bare-metal reverse-proxy baseline explicitly binds loopback. It provides a process-only `/healthz` endpoint and requires STUN plus explicit TURN/UDP and TURN/TCP URLs before production startup. TURN/TLS is accepted but optional; configuration checks do not establish public-network reachability. - The Web PoC implements capture-before-room creation, live source replacement without renegotiating healthy peers, one independent peer connection per viewer, stable signaling reconnect identities that also work for LAN viewers on HTTP, explicit ICE restart or peer rebuild, host session generation isolation, viewer connection-generation guards for asynchronous signaling and stats, short-lived TURN credentials, three manual quality profiles, and local WebRTC statistics. -- Access is deliberately small: `ACCESS_PASSWORD` is optional and site-wide. When configured, host and viewer routes first establish a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie; room creation and WebSocket upgrade accept that cookie and no direct room-creation Bearer bypass. There are no accounts, database, JWTs, server-side access-session map, or logout flow. -- A room uses a random 12-digit numeric code. `/r/{code}` carries no viewer token or fragment, `/join` accepts only the code, and the 256-bit host token stays internal to the host signaling session. In public mode the code is the sole viewing capability and is not a strong privacy guarantee, so private Internet deployments should configure `ACCESS_PASSWORD`. +- Access is deliberately small: `ACCESS_PASSWORD` is optional, site-wide, and accepts 1 through 128 visible ASCII characters when non-empty. When configured, host and viewer routes first establish a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie; room creation and WebSocket upgrade accept that cookie and no direct room-creation Bearer bypass. There are no accounts, JWTs, server-side access-session maps, or logout flow. +- ADR-0002 accepts an optional `ROOM_DATABASE_PATH` only alongside `ACCESS_PASSWORD`. In that mode, built-in `node:sqlite` stores only an auto-incremented room ID and host-token digest; links do not expire and stopping sharing leaves the room waiting. Without the path, rooms remain random and temporary. Public mode can never use sequential persistent rooms. The branch's 102 Vitest tests and builds pass locally; `/var/lib/screener/rooms.sqlite` has not yet been deployed or verified in production. +- `/r/{code}` carries no viewer token or fragment, `/join` accepts only the numeric code, and the 256-bit host token stays internal to host authentication. In public mode the random code is the sole viewing capability and is not a strong privacy guarantee, so private Internet deployments should configure `ACCESS_PASSWORD`. - The Web control plane is deployed at `https://share.bonfire.icu` behind nginx with Node.js 24.19.0, and `turn.bonfire.icu` runs authenticated coturn 4.17.2 on standard UDP/TCP 3478. HTTPS, WSS, room authentication, certificate renewal, public STUN, authenticated TURN/UDP and TURN/TCP allocations, and relay-only bidirectional data paths are verified. TURN/TLS is intentionally not enabled. -- Automated checks and same-machine synthetic-media Chromium recovery tests pass, including lost offer/answer, one transient offer failure, signaling-only viewer reconnect, viewer-tab replacement with old-peer cleanup, cancelled-room cleanup, video/audio source changes, and public relay-only DataChannel tests. Real screen/game audio, full Screener media sessions across heterogeneous networks, mobile lifecycle handling, and latency or quality targets remain unverified. -- No infrastructure blocker remains for the current staging deployment. The next milestone is the real-device and real-media matrix in `docs/status.md`, not additional product surface or a native sender. +- Automated checks pass on the persistent-room branch with 102 Vitest tests and both production builds. Same-machine synthetic-media Chromium recovery and public relay-only DataChannel evidence remains from the deployed baseline. Production room-persistence, real screen/game audio, full Screener media sessions across heterogeneous networks, mobile lifecycle handling, and latency or quality targets remain unverified. +- No infrastructure blocker remains for the current staging deployment. The immediate control-plane milestone and the separate real-device/media matrix are bounded in `docs/status.md`; neither justifies a native sender yet. ## Provisional Quality Targets @@ -64,6 +66,7 @@ Last updated: 2026-08-18 - Minimal production-shaped deployment: `docs/deployment.md` - Research and feasibility: `docs/research/webrtc-p2p-screen-sharing.md` - Topology decision: `docs/adr/0001-p2p-first-media-topology.md` +- Persistent protected-room decision: `docs/adr/0002-persistent-protected-rooms.md` - Current phase and next step: `docs/status.md` - Maintenance and context lifecycle: `docs/maintenance.md` - Agent workflow: `AGENTS.md` and `.codex/` diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index cbc7d3f..906dbd3 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -203,9 +203,9 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 ### 最小访问模型 -当前需求只有一个部署级密码,不需要账号、数据库、JWT、服务端 session Map、逐人邀请或 logout。`ACCESS_PASSWORD` 为空时网站公开;生产模式下接受 12 至 128 位可见 ASCII 字符,避免把 Fetch 无法可靠编码的 Unicode 原文放进 Authorization header。配置时,host 和 viewer 都先通过同一登录 gate。成功登录得到 12 小时的无状态 HMAC-SHA256 cookie,使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`,HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。建房 HTTP 和 WebSocket upgrade 都只认 cookie,不允许直接给建房 API 传 Bearer 绕过 gate。异常 WebSocket 关闭会检查一次 access session;确认 cookie 失效时回到 gate,检查本身失败时继续正常网络重连。HMAC 与定长密码摘要比较使用 Node.js `crypto.createHmac()` 和 `crypto.timingSafeEqual()`;cookie 属性遵循 RFC6265bis 的语义。 +当前需求只有一个部署级密码,不需要账号数据库、JWT、服务端 session Map、逐人邀请或 logout。`ACCESS_PASSWORD` 为空时网站公开;非空值接受 1 至 128 个可见 ASCII 字符,避免把 Fetch 无法可靠编码的 Unicode 原文放进 Authorization header。配置时,host 和 viewer 都先通过同一登录 gate。成功登录得到 12 小时的无状态 HMAC-SHA256 cookie,使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`,HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。建房 HTTP 和 WebSocket upgrade 都只认 cookie,不允许直接给建房 API 传 Bearer 绕过 gate。异常 WebSocket 关闭会检查一次 access session;确认 cookie 失效时回到 gate,检查本身失败时继续正常网络重连。HMAC 与定长密码摘要比较使用 Node.js `crypto.createHmac()` 和 `crypto.timingSafeEqual()`;cookie 属性遵循 RFC6265bis 的语义。 -房间使用 12 位随机纯数字 code。观看链接是 `/r/{code}`,也可在 `/join` 只输入 code;两者都没有 viewer token 或 fragment。256-bit host token 只留在 host 页面内存并用于房间级 host 信令鉴权。在密码模式中,code 不能绕过全站 gate;在公开模式中,code 是唯一观看 capability,约 40 bit 的命名空间不构成强隐私保证,面向互联网的私密实例应配置全站密码。 +房间始终使用纯数字 code。默认临时模式生成随机 code 并设置 TTL;只有同时配置 `ACCESS_PASSWORD` 与 `ROOM_DATABASE_PATH` 时,内置 `node:sqlite` 才从 `1` 开始分配不设过期时间的持久 code。数据库只保存作为 rowid 的房间 ID 与 host token 摘要,路径在无全站密码时会被启动校验拒绝。观看链接是 `/r/{code}`,也可在 `/join` 只输入 code;两者都没有 viewer token 或 fragment。256-bit host token 只用于房间级 host 信令鉴权,服务端仅持有其 SHA-256 摘要。在密码模式中,code 不能绕过全站 gate;在公开模式中,随机 code 是唯一观看 capability,仍不构成强隐私保证,面向互联网的私密实例应配置全站密码。 来源(访问于 2026-08-18):[Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/)、[Node.js Crypto](https://nodejs.org/api/crypto.html#cryptocreatehmacalgorithm-key-options) 与 [`crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b)。 @@ -249,7 +249,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 - WebRTC 使用 DTLS-SRTP。TURN 只能看到加密后的媒体包,但仍能看到地址、房间时序和流量元数据。 - P2P 会让房间内双方得知网络地址。熟人首版可以接受,陌生人房间不能默认接受。 - TURN 必须使用短期凭据、速率限制、每用户/房间配额和出口告警,不能提供匿名公共 relay。 -- 面向互联网的私密部署应启用全站密码。公开模式的 12 位房间码只是轻量 capability,不应宣传为强私密邀请;host token 始终只用于发布权限。 +- 面向互联网的私密部署应启用全站密码。公开模式的随机房间码只是轻量 capability,不应宣传为强私密邀请;可枚举的持久房间只允许与全站密码搭配,host token 始终只用于发布权限。 - 如果未来使用 SFU 且要求服务器看不到内容,再评估 SFrame/WebRTC Encoded Transform 和群组密钥管理。 ## 参考代码优先级 diff --git a/docs/status.md b/docs/status.md index 3ef9b5d..00a130b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,39 +4,51 @@ Last updated: 2026-08-18 ## Phase -The first measurable WebRTC Web proof of concept is implemented at `https://share.bonfire.icu`, including an authenticated public TURN fallback. The current revision adds an optional whole-site password gate and simple numeric room joining. Automated checks and same-machine Chromium tests with synthetic video and audio sources pass, including live source replacement and relay-only data paths. Real display capture, game audio, a sustained 1:8 room, mobile lifecycle behavior, and performance targets remain unverified. +The WebRTC proof of concept is deployed at `https://share.bonfire.icu`. The `feat/persistent-rooms` branch adds optional protected SQLite persistence, sequential room IDs, reusable links, and a waiting state after sharing stops. Checks pass locally, but the branch is not yet deployed. Real capture, game audio, sustained 1:8 behavior, mobile lifecycle, and performance targets remain unverified. ## Established Baseline - Runtime: Node.js 24, React, TypeScript, Vite, native browser WebRTC, `ws`, Zod, and a separate coturn deployment. - Product scope: private game sharing for one broadcaster and a small friend group. Rooms default to eight viewers and accept a configured limit from 1 through 16; this is an admission limit, not a verified performance envelope. -- Client behavior: screen capture precedes room creation; live source changes preserve healthy peer connections; `/r/{code}` invitations and `/join` use a 12-digit numeric room code with no viewer token or URL fragment; every viewer has an independent `RTCPeerConnection`. -- Control plane: optional whole-site `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, in-memory rooms, an internal host token, role-bound signaling, stable reconnect identities, short-lived coturn credentials, Origin and payload checks, and explicit room expiry/closure. There are no accounts, database, JWTs, server-side access-session map, or logout flow. In public mode, the room code is the sole viewing capability and is not a strong privacy guarantee. +- Client behavior: screen capture precedes room creation; live source changes preserve healthy peer connections; `/r/{code}` invitations and `/join` use a numeric room code with no viewer token or URL fragment; every viewer has an independent `RTCPeerConnection`. +- Control plane contract: optional whole-site `ACCESS_PASSWORD` with 1 through 128 visible ASCII characters, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, an internal host token, role-bound signaling, stable reconnect identities, short-lived coturn credentials, and Origin/payload checks. There are no accounts, JWTs, server-side access-session maps, or logout flow. +- Room policies: no `ROOM_DATABASE_PATH` means random temporary rooms governed by `ROOM_TTL_SECONDS`; configuring it together with `ACCESS_PASSWORD` enables protected rooms whose numeric IDs increment from `1` and whose links do not expire. A database path without the whole-site password is invalid. Stopping sharing leaves the room available and viewers waiting. SQLite stores only room ID and host-token digest. - Media topology: direct ICE is preferred per viewer, with authenticated TURN/UDP and TURN/TCP required in production. TURN/TLS is an optional deployment capability, using standard TCP 5349 by default; mixed direct and relay paths are supported by design. - Diagnostics: per-peer path, candidate types, ICE protocol, local TURN protocol, RTT, bitrate, frame rate, dimensions, loss, jitter, codec, and quality-limitation fields are read locally from WebRTC stats. - Large public broadcasts are out of scope and should use OBS/Twitch-class services. -## Verified In This Revision +## Verification + +- On the current persistent-room branch, `npm run check` passes type checking, + 102 Vitest tests, the client production build, and the server production build. + +The remaining browser and network evidence predates this branch; it does not +verify the new storage or lifecycle contract. -- `npm run check` passes type checking, 90 Vitest tests, the client production build, and the server production build. - Headless Chromium 151 created real local peer connections from animated canvas streams. The viewer reached `connected`, received a live video track, and reported a direct UDP path. - Browser fault injection recovered after dropping the first offer, dropping the first answer, and failing the first `createOffer`. A viewer-only signaling reconnect preserved the existing healthy media peer; replacing that viewer tab rebuilt media, released the old peer, and did not enter a reconnect loop. - Delayed ViewerPeer answer, candidate flush, ICE event, and stats results are discarded after a connection generation is replaced, so an old peer cannot signal through or overwrite the new peer snapshot. -- Cancelling a delayed room A and immediately starting room B left B live, stopped only A's capture, and invalidated A's invitation after bounded cleanup. - Injecting a live picker-cancellation result preserved the old stream. Replacing synthetic video, adding synthetic audio, and removing it all kept the same connected host/viewer peer objects and did not create another offer; retired capture tracks stopped after each successful change. - Chrome 151 at 390 px completed the protected-site gate, login, host, and manual room-code join flow without horizontal overflow. The unauthenticated gate shows no application header or sharing controls. -- Production configuration fails closed when HTTPS, STUN, TURN, or sufficiently strong secrets are missing. +- Production configuration fails closed when required HTTPS, STUN, TURN, or TURN-secret constraints are missing. - Production startup rejects malformed ICE URLs and requires STUN plus explicit TURN/UDP and TURN/TCP entries. Optional TURN/TLS entries may use standard TCP 5349 or, when separately routed, TCP 443. Configuration validation remains distinct from the runtime relay checks below. - The application defaults to `LISTEN_HOST=0.0.0.0` for LAN development and containers, while the bare-metal reverse-proxy deployment explicitly uses loopback. It exposes a process-only `GET /healthz` liveness response. - Viewer session identities use `crypto.getRandomValues()` rather than the secure-context-only `crypto.randomUUID()`, so a phone can initialize the viewer over trusted LAN HTTP while the host keeps screen capture on `localhost`. - The staging host runs Debian 12, nginx, Node.js 24.19.0, and source-built coturn 4.17.2. Screener binds loopback behind nginx; authenticated STUN and TURN/UDP+TCP bind `turn.bonfire.icu:3478`. TURN/TLS remains intentionally disabled. -- Commit `8e6039d89bc8` is deployed. Production verification covered the anonymous session response, rejection of anonymous room creation and WebSocket upgrades, the `Secure`/`HttpOnly`/`SameSite=Strict` host-only cookie, 12-digit room creation, host and token-free viewer WebSocket authentication, and explicit room cleanup. Certbot's renewal dry run also passed for both the existing blog and Screener certificates; the existing blog continued returning HTTP 200. +- Commit `8e6039d89bc8` is deployed. Production verification covered the anonymous session response, rejection of anonymous room creation and WebSocket upgrades, the `Secure`/`HttpOnly`/`SameSite=Strict` host-only cookie, numeric room creation, and host/token-free viewer WebSocket authentication. Certbot's renewal dry run also passed for both the existing blog and Screener certificates; the existing blog continued returning HTTP 200. - Public STUN Binding succeeds over both UDP and TCP 3478. Chromium 151 obtained authenticated relay candidates through TURN/UDP and TURN/TCP, then two relay-only peer connections completed a bidirectional DataChannel ping/pong over each transport. Simultaneous guest conntrack samples observed both 3478 control traffic and relay-range UDP traffic, independently confirming the public TURN path through the cloud and host firewalls. - The existing TeamSpeak files, services, timers, and ports were not changed. ## Next Milestone -Execute and record the manual browser/network matrix: +First complete and record branch verification without conflating it with the deployed baseline: + +- configuration boundaries for 1 through 128 visible-ASCII access passwords and rejection of a database path without a password; +- SQLite allocation from `1`, restart recovery, data minimization, non-expiring room links, and stop-sharing waiting behavior; +- public and password-only random temporary rooms, including TTL cleanup; +- the writable `/var/lib/screener/rooms.sqlite` path under the tracked systemd hardening policy. + +Performance measurement and any topology reconsideration stay in a separate PR/ADR. Execute and record the manual browser/network matrix there: - real screen/window capture and available game or system audio on Windows Chrome and Edge; - one broadcaster with eight heterogeneous viewers for 30 minutes, while also recording lower viewer counts to locate the sustainable envelope; @@ -46,7 +58,7 @@ Execute and record the manual browser/network matrix: ## Current Blocker -- No infrastructure blocker remains for the current staging deployment. The remaining work is the real-device and real-media validation matrix above. +- No infrastructure blocker remains for the current staging deployment. Automated restart persistence is covered locally; the persistent-room branch still needs real systemd filesystem-permission and deployment verification before rollout. The real-device and real-media matrix remains separate work. ## Blocking Decisions diff --git a/docs/方案设计.md b/docs/方案设计.md index 1b5f5a5..45242e2 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -11,14 +11,14 @@ 本阶段必须形成完整的最小纵向链路: - 分享者主动选择屏幕、窗口或标签页,并请求可用音频; -- 创建有时限的数字房间,并用可选全站密码统一保护分享和观看入口; +- 创建数字房间,并用可选全站密码统一保护分享和观看入口;受保护部署可选择最小 SQLite 持久化,使房间和链接在停止分享及进程重启后仍有效; - 每名观看者拥有独立的 `RTCPeerConnection`; - 生产基线支持 STUN、TURN/UDP 和 TURN/TCP,并允许可选 TURN/TLS 配置; - 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据; - 超过部署配置上限的观看者被明确拒绝,不静默超载; - 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。 -本阶段不实现账号、数据库、Redis、SFU、simulcast/SVC、观看者转发树、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。 +本阶段不实现账号数据库、通用业务数据库、Redis、SFU、simulcast/SVC、观看者转发树、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。唯一持久化例外是可选的 `node:sqlite` 房间身份表。 ## 技术栈决策 @@ -27,6 +27,7 @@ | Web 客户端 | React 19、TypeScript、Vite 8 | 同一套响应式界面覆盖分享者与桌面/移动观看者;官方模板成熟 | | 媒体 | 浏览器原生 WebRTC 与 Screen Capture API | 不引入 PeerJS/simple-peer,保留对 ICE、sender 参数和 stats 的直接控制 | | 应用服务 | Node.js 24 LTS、TypeScript、原生 HTTP、`ws` | 服务只负责小流量控制面;前后端可共享协议类型和校验 | +| 可选房间持久化 | Node.js 内置 `node:sqlite` | 单文件、无额外依赖;只保存恢复持久房间所需的 ID 与 token 摘要 | | 运行时校验 | Zod | WebSocket 和 HTTP 输入不可信,需要与 TypeScript 类型一致的运行时边界 | | 静态文件 | `sirv` | 生产环境由同一 Node 进程提供前端构建结果,避免引入完整 Web 框架 | | NAT 中继 | coturn 4.17.2 或更新补丁版本 | 成熟的 STUN/TURN 实现,支持 WebRTC 短期凭据 | @@ -63,24 +64,27 @@ tests/ RoomStore、TURN、协议和 WebSocket 集成测试 ## 房间与访问控制 -`ACCESS_PASSWORD` 是可选的全站开关。为空时网站公开;生产配置接受 12 至 128 位可见 ASCII 字符。配置时,分享者访问根页面、观看者访问 `/join` 或 `/r/{code}` 都先进入同一个登录 gate。`POST /api/session` 校验密码后签发 12 小时的无状态 cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、数据库、JWT 或服务端 session Map,也不提供 logout 流程。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/` 与有限 `Max-Age`;HTTPS 生产环境再使用 `Secure` 和 `__Host-` 前缀。WebSocket 异常断开后会用 `/api/session` 检查一次当前 cookie;仅在确认失效时停止重连并返回 gate,网络故障仍走原重连流程。 +`ACCESS_PASSWORD` 是可选的全站开关。为空时网站公开;非空值必须包含 1 至 128 个可见 ASCII 字符(`0x21` 至 `0x7e`)。配置时,分享者访问根页面、观看者访问 `/join` 或 `/r/{code}` 都先进入同一个登录 gate。`POST /api/session` 校验密码后签发 12 小时的无状态 cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map,也不提供 logout 流程。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/` 与有限 `Max-Age`;HTTPS 生产环境再使用 `Secure` 和 `__Host-` 前缀。WebSocket 异常断开后会用 `/api/session` 检查一次当前 cookie;仅在确认失效时停止重连并返回 gate,网络故障仍走原重连流程。 建房 `POST /api/rooms` 和 WebSocket upgrade 在启用密码时都只验证该 cookie。密码只以 `Authorization: Bearer` 提交给登录接口,建房 API 不接受相同的 Bearer 旁路。密码模式下 HTTP 建房还要求精确 Origin;公开模式也会拒绝不在 allowlist 中的已提供 Origin。WebSocket 在握手完成前同时校验 Origin、容量和 access cookie。 -房间服务生成: +`ROOM_DATABASE_PATH` 是可选 SQLite 文件路径,且配置它时必须同时配置 `ACCESS_PASSWORD`,否则启动失败。由此形成三个明确配置: -- 12 位随机纯数字 `roomId`,并在当前内存房间集合中检查碰撞; -- 256-bit 随机 `hostToken`,仅用于当前 host 信令认证; -- 默认四小时、由部署配置限制的房间过期时间。 +- `ACCESS_PASSWORD` 为空、数据库路径为空:公开临时模式,生成随机数字 `roomId`,使用 `ROOM_TTL_SECONDS` 过期,随机性降低匿名枚举风险; +- 只配置 `ACCESS_PASSWORD`:受保护临时模式,房间仍使用随机 ID 和 TTL; +- 同时配置 `ACCESS_PASSWORD` 与 `ROOM_DATABASE_PATH`:受保护持久模式,SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` 从 `1` 开始分配十进制 `roomId`,房间与邀请链接不设过期时间。 -服务端只保存 host token 的 SHA-256 摘要,host token 只保留在分享者当前页面内存中。观看者没有独立 token:邀请为 `/r/{roomId}`,也可访问 `/join` 并只输入房间码。在密码模式中,两种入口都必须先通过全站 gate;公开模式中,房间码是唯一观看 capability,因此不保证强隐私,面向互联网的私密部署应配置 `ACCESS_PASSWORD`。账号、逐人邀请、逐人撤销和持久会话不在当前需求内。 +所有模式都为新房间生成 256-bit 随机 `hostToken`,只把 SHA-256 摘要留在服务端。host 浏览器把建房响应保存在同源 `localStorage`,用于刷新后重新取得发布权;清除站点数据或换设备不会从摘要恢复 token。持久模式的 SQLite 表只有作为 rowid 的房间 ID 与 32-byte host token 摘要两个应用字段;不保存全站密码、token 明文、access cookie、SDP、ICE candidate、IP 地址、观看者状态、媒体或 TURN 凭据。`node:sqlite` 由单个 Node 进程直接使用,不增加 ORM、迁移框架、Redis 或多节点同步。生产 systemd 示例通过 `StateDirectory=screener` 提供 `/var/lib/screener`,当前实例使用 `/var/lib/screener/rooms.sqlite`。 + +观看者没有独立 token:邀请为 `/r/{roomId}`,也可访问 `/join` 并只输入房间码。在受保护模式中,两种入口都必须先通过全站 gate。自增 ID 可被已登录用户枚举,这是“一个全站密码保护一组可信朋友”的已接受边界;它绝不能用于公开模式。账号、逐人邀请、逐人撤销、逐房间密码和持久 access session 不在当前需求内。 房间规则: - 一名在线分享者、默认最多八名在线观看者;部署配置只接受 1 至 16; -- 房间过期、分享者主动关闭或捕获结束时拒绝新连接并通知现有观看者; -- 定时清理过期房间,并设置全局房间上限防止匿名内存耗尽; -- 不记录 token、SDP、ICE candidate、IP 地址或 TURN 密钥。 +- 分享者明确停止或捕获轨结束时只停止当前发布并通知观看者进入等待,不删除房间或使链接失效;host 信令短断仅更新在线状态并触发恢复,不能主动关闭仍健康的 P2P 媒体; +- 临时房间到达 TTL 后才拒绝新连接、通知现有参与者并从内存清理;持久房间没有 TTL,进程启动时从 SQLite 恢复房间身份; +- 设置全局房间上限防止内存耗尽;持久房间没有自动删除策略,运维方必须保护和备份数据库文件; +- 运行时仍只在内存保存当前 host/viewer 连接与信令状态,任何模式都不记录 SDP、ICE candidate、IP 地址、媒体或 TURN 密钥。 客户端用 `sessionStorage` 保存随机的 128-bit `clientId`,作为当前标签页内稳定的参与者 key。同一 role/clientId 的新 WebSocket 会原子替换旧 socket;观看者断线后保留五秒 grace,期间不发送 `peer-left` 且 `peerId` 不变,重新鉴权时重发幂等的 `peer-joined`,超时才释放名额。host 鉴权或重连后,`authenticated` 携带包含 grace 成员的权威 viewer ID 列表供客户端删除已离开的 peer,服务端再重放当前在线观看者的 `peer-joined`,因此观看者可以先于 host 打开邀请,host 离线期间的离开也不会遗留幽灵连接。 @@ -93,13 +97,13 @@ WebSocket 使用同源 `/signal`。启用全站密码时,upgrade 必须先携 | 方向 | 消息 | 作用 | | --- | --- | --- | | client -> server | `authenticate` | host 以 room、role、内部 host token 和 clientId 建立会话;viewer 只使用 room、role 和 clientId | -| server -> client | `authenticated` | 返回角色、peerId、房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 | +| server -> client | `authenticated` | 返回角色、peerId、可为空的房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 | | server -> host | `peer-joined` / `peer-left` | 建立或关闭该观看者的独立连接 | | host -> viewer | `offer` / `candidate` | 带 `peerId` 与 `connectionId` 的定向信令 | | viewer -> host | `answer` / `candidate` | 只能回给本房间分享者 | | viewer -> host | `restart-request` | 以 `rebuild` 标志请求 ICE restart 或完整重建该 peer | | client -> server | `refresh-ice` | 鉴权会话内刷新即将到期的 TURN 凭据 | -| host -> server | `close-room` | 结束房间并使邀请失效 | +| host -> server | `stop-sharing` | 结束当前发布、清理本轮连接并让观看者等待;不删除房间或使邀请失效 | Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。 @@ -108,7 +112,7 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 分享者是唯一 offerer。每名观看者对应一个 `RTCPeerConnection`,以独立 `connectionId` 区分重建代次: 1. 分享者在开始按钮的同一用户手势内先成功调用 `getDisplayMedia()`。 -2. 只有捕获 track 已存在时才请求创建房间、连接 host WebSocket 并显示邀请;若用户在建房响应返回前取消,迟到的房间由独立、限时的鉴权连接立即关闭,且不得覆盖后续分享会话。 +2. 只有捕获 track 已存在时才请求创建房间、连接 host WebSocket 并显示邀请;若用户在建房响应返回前取消,迟到结果不得覆盖后续分享会话。尚未被客户端认领的迟到结果通过内部 `abandon-room` 清理;已经认领的房间不因正常停止捕获而删除。持久模式使用 `AUTOINCREMENT`,被清理的编号不会复用。 3. 观看者认证后,服务端向在线分享者发送 `peer-joined`;host 晚上线或重连时收到完整观看者快照。 4. 分享者创建连接、加入当前视频 track,并以现有音轨或空 sender 预留 audio transceiver,设置 sender 参数,然后创建 offer。 5. 观看者收到 offer 后创建连接、设置 remote description、创建 answer。 @@ -180,7 +184,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC - 全站 gate:仅在配置 `ACCESS_PASSWORD` 且 cookie 无效时显示一个密码输入窗口;验证后返回原目标路径。 - 分享者根页面:顶部房间状态;主区域为 16:9 本地预览;底部为开始/停止、质量档位和复制邀请;侧栏以紧凑列表显示配置范围内的观看者及统计。 -- `/join`:只输入 12 位数字房间码,成功后进入 `/r/{code}`。 +- `/join`:只输入正整数形式的数字房间码,成功后进入 `/r/{code}`。 - `/r/{code}` 观看者:主区域最大化远端视频;顶部显示房间与连接状态;提供声音、全屏和重试控制,不要求额外邀请码或 viewer token。 - 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。 @@ -200,10 +204,11 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC 自动测试: - AccessSession 的密码开关、12 小时签名 cookie、过期、篡改、cookie 属性和无服务端 session 状态; -- RoomStore 的 12 位数字房间码、host token 摘要验证、过期、关闭、默认八人上限、自定义 1 至 16 边界和超额拒绝; +- RoomStore 的随机临时房间、TTL、host token 摘要验证、停止分享等待、默认八人上限、自定义 1 至 16 边界和超额拒绝; +- SQLite 房间库从 `1` 自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败; - TURN HMAC 固定向量、TTL、生产环境缺少 STUN 或 TURN/UDP+TCP 覆盖时启动失败,并接受可选 TURN/TLS; - 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; -- HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和关闭房间; +- HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和停止分享后的等待状态; - 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知; - TypeScript 类型检查、Vite production build 和仓库 hygiene。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 3515a38..8e4f2f3 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -12,7 +12,7 @@ ### P0 - 必须实现 -- [ ] 简单房间:分享者获得 12 位随机纯数字房间码和 `/r/{code}` 链接;观看者也可在 `/join` 只输入房间码加入,链接不携带额外观看 token 或 fragment。 +- [ ] 简单房间:分享者获得纯数字房间码和 `/r/{code}` 链接;观看者也可在 `/join` 只输入房间码加入,链接不携带额外观看 token 或 fragment。启用持久受保护房间时,房间码从 `1` 开始自增;其他模式继续使用随机临时房间码防止枚举。 - [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。 - [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止和切换分享状态。 - [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。 @@ -22,7 +22,8 @@ - [ ] 质量档位:至少提供 1080p60、720p60、720p30 三档目标,并依赖 WebRTC 拥塞控制及明确的手动档位避免持续排队;自定义动态 FPS 策略必须在统计证明有必要后再实现。 - [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。 - [ ] 可观测性:采集候选类型、RTT、丢包、抖动、实际码率、帧率、编码耗时和质量受限原因。 -- [ ] 访问控制:部署者可配置一个全站 `ACCESS_PASSWORD`;为空时网站公开,配置时分享者和观看者都必须先登录。登录成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` cookie,建房 HTTP 和 WebSocket 都只使用该 cookie,不提供建房 Bearer 旁路;不引入账号、数据库、JWT、服务端 session Map 或 logout。 +- [ ] 访问控制:部署者可配置一个全站 `ACCESS_PASSWORD`;为空时网站公开,非空值必须为 1 至 128 个可见 ASCII 字符。配置时分享者和观看者都必须先登录。登录成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` cookie,建房 HTTP 和 WebSocket 都只使用该 cookie,不提供建房 Bearer 旁路;不引入账号、JWT、服务端 session Map 或 logout。 +- [ ] 持久受保护房间:`ROOM_DATABASE_PATH` 可选且必须与 `ACCESS_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID 与 host token 摘要;房间及链接不设过期时间,停止分享只让观看页进入等待状态。密码模式不配置数据库时仍使用临时房间;公开模式始终使用随机临时房间。 ### P1 - 重要功能 @@ -52,7 +53,8 @@ ## 边界与约束 - 首版不包含远程键鼠控制、公开/大规模直播、录制、社区系统或完整语音聊天;大规模分享直接使用现有直播服务。 -- 公开模式中,12 位数字房间码是唯一观看 capability,不提供强私密保证;面向互联网且要求私密的部署必须配置全站密码。 +- 公开模式中,随机数字房间码是唯一观看 capability,不提供强私密保证;面向互联网且要求私密的部署必须配置全站密码。可枚举的自增房间码只允许出现在已经由全站密码保护的持久模式中。 +- SQLite 只用于持久房间身份,不得保存全站密码、host token 明文、access cookie、SDP、ICE candidate、IP 地址、观看者状态、媒体或 TURN 凭据;首版仍是单进程、单实例控制面。 - 默认八名观看者和可配置的 1 至 16 范围是接入容量边界,不承诺任意机器、档位或网络都能稳定承载该人数,也不承诺跨洲网络下的固定低延迟。 - 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。 - 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。 @@ -61,6 +63,14 @@ ## 验收标准 +### 房间模式与生命周期 + +- Given `ACCESS_PASSWORD` 和 `ROOM_DATABASE_PATH` 都已配置,When 连续创建房间并重启应用,Then 房间码从 `1` 开始单调递增,已创建链接仍可加入,且 SQLite 的应用数据只包含房间 ID 与 host token 摘要。 +- Given 持久房间正在分享,When 分享者明确停止或捕获轨结束,Then 房间和链接保持有效,观看者进入等待状态;分享者重新发布后可继续在同一房间观看。 +- Given 分享期间只有 host 信令短暂断开,When 现有 P2P 媒体仍健康,Then 客户端继续播放媒体并恢复信令,而不是提前进入停止分享状态。 +- Given `ROOM_DATABASE_PATH` 已配置但 `ACCESS_PASSWORD` 为空,When 应用启动,Then 配置校验明确失败,不能在公开网站暴露可枚举的持久房间。 +- Given `ROOM_DATABASE_PATH` 为空,When 创建房间,Then 无论是否启用全站密码都使用随机临时房间码并遵守 `ROOM_TTL_SECONDS`;停止分享本身不立即销毁房间。 + ### P2P 建立与 TURN 兜底 - Given 两端网络允许 UDP 打洞,When 观看者加入,Then `getStats()` 显示选中的候选对不是 `relay`,且服务器没有承载媒体流量。 diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index f63b0f2..1b8e9c5 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -1,6 +1,11 @@ -import { roomCodeSchema } from "../../shared/protocol"; +import { + createRoomResponseSchema, + roomCodeSchema, + type CreateRoomResponse, +} from "../../shared/protocol"; const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; +const HOST_ROOM_STORAGE_KEY = "screener:host-room:v1"; export interface ViewerRoute { roomId: string; @@ -10,6 +15,57 @@ export function isValidRoomId(value: string): boolean { return roomCodeSchema.safeParse(value).success; } +export function isHostRoomExpired( + room: CreateRoomResponse, + now = Date.now(), +): boolean { + if (room.expiresAt === null) { + return false; + } + const expiresAt = Date.parse(room.expiresAt); + return !Number.isFinite(expiresAt) || expiresAt <= now; +} + +export function clearHostRoom(): void { + try { + window.localStorage.removeItem(HOST_ROOM_STORAGE_KEY); + } catch { + // Storage can be disabled; the current page still keeps its in-memory room. + } +} + +export function readHostRoom(now = Date.now()): CreateRoomResponse | null { + let stored: string | null; + try { + stored = window.localStorage.getItem(HOST_ROOM_STORAGE_KEY); + } catch { + return null; + } + if (!stored) { + return null; + } + + try { + const parsed = createRoomResponseSchema.safeParse(JSON.parse(stored)); + if (!parsed.success || isHostRoomExpired(parsed.data, now)) { + clearHostRoom(); + return null; + } + return parsed.data; + } catch { + clearHostRoom(); + return null; + } +} + +export function writeHostRoom(room: CreateRoomResponse): void { + try { + window.localStorage.setItem(HOST_ROOM_STORAGE_KEY, JSON.stringify(room)); + } catch { + // A storage failure must not prevent the current sharing session. + } +} + function readSessionValue(key: string): string | null { try { return window.sessionStorage.getItem(key); diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 6d34dd6..81555c8 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -22,7 +22,13 @@ import { } from "../components/StatusBadge"; import { StatsGrid } from "../components/StatsGrid"; import { ApiError, createRoom } from "../lib/api"; -import { getStableClientId } from "../lib/session"; +import { + clearHostRoom, + getStableClientId, + isHostRoomExpired, + readHostRoom, + writeHostRoom, +} from "../lib/session"; import { SignalingClient } from "../lib/signaling"; import { captureDisplay, @@ -80,9 +86,9 @@ function closeAbandonedRoom(room: CreateRoomResponse): void { }, ); signal.start(); - signal.sendThenStop({ type: "close-room" }); + signal.sendThenStop({ type: "abandon-room" }); } catch { - // The room will still expire naturally if the bounded cleanup cannot start. + // Best effort only: cancellation must not revive the obsolete generation. } } @@ -101,7 +107,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { useState("offline"); const [stream, setStream] = useState(null); const [details, setDetails] = useState(null); - const [room, setRoom] = useState(null); + const [room, setRoom] = useState(readHostRoom); const [relayAvailable, setRelayAvailable] = useState(false); const [maxViewers, setMaxViewers] = useState(null); const [peerSnapshots, setPeerSnapshots] = useState>( @@ -160,10 +166,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const signal = signalRef.current; if (signal) { if (notifyServer) { - signal.sendThenStop({ type: "close-room" }); - } else { - signal.stop(); + signal.send({ type: "stop-sharing" }); } + // Never carry a terminal message into a later authentication: the next + // sharing generation may already be reusing this room. + signal.stop(); } signalRef.current = null; peersRef.current.forEach((peer) => peer.dispose()); @@ -174,13 +181,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { retiringStreamRef.current = null; iceConfigRef.current = null; setStream(null); - setRoom(null); + setDetails(null); + setRelayAvailable(false); setMaxViewers(null); setPeerSnapshots(new Map()); setSignalStatus("offline"); setSwitchingSource(false); } + function forgetRoom(): void { + clearHostRoom(); + setRoom(null); + setCopied(false); + } + function endSharing(message: string, notifyServer = true): void { const generation = activeGenerationRef.current; if (generation === null || generationRef.current !== generation) { @@ -404,6 +418,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return; } if (message.type === "room-closed") { + forgetRoom(); endSharing( message.reason === "expired" ? "房间已过期" : "房间已关闭", false, @@ -411,11 +426,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return; } if (message.type === "error") { + if (["INVALID_TOKEN", "ROOM_EXPIRED"].includes(message.code)) { + forgetRoom(); + endSharing("房间已失效,再次点击将创建新房", false); + return; + } if ( [ "AUTH_REQUIRED", - "INVALID_TOKEN", - "ROOM_EXPIRED", "HOST_ALREADY_CONNECTED", ].includes(message.code) ) { @@ -466,19 +484,35 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { watchCaptureEnd(captured, generation); let createdRoom: CreateRoomResponse | null = null; + let claimedRoom = false; try { - createdRoom = await createRoom(); - if (!isCurrentGeneration(generation)) { - captured.getTracks().forEach((track) => track.stop()); - closeAbandonedRoom(createdRoom); - return; + let reusableRoom = room; + if (reusableRoom && isHostRoomExpired(reusableRoom)) { + forgetRoom(); + reusableRoom = null; } + createdRoom = reusableRoom ?? readHostRoom(); + if (!createdRoom) { + createdRoom = await createRoom(); + if (!isCurrentGeneration(generation)) { + captured.getTracks().forEach((track) => track.stop()); + closeAbandonedRoom(createdRoom); + return; + } + writeHostRoom(createdRoom); + setRoom(createdRoom); + claimedRoom = true; + } else { + setRoom(createdRoom); + claimedRoom = true; + } + const activeRoom = createdRoom; const signal = new SignalingClient( { - roomId: createdRoom.roomId, + roomId: activeRoom.roomId, role: "host", - token: createdRoom.hostToken, - clientId: getStableClientId("host", createdRoom.roomId), + token: activeRoom.hostToken, + clientId: getStableClientId("host", activeRoom.roomId), }, { onStatus: (status) => { @@ -521,10 +555,15 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ) { return; } - if (message.type === "authenticated") { + if (message.type === "authenticated" && message.role === "host") { + const authenticatedRoom = { + ...activeRoom, + expiresAt: message.roomExpiresAt, + }; iceConfigRef.current = message.iceConfig; setRelayAvailable(message.iceConfig.relayAvailable); - setRoom(createdRoom); + writeHostRoom(authenticatedRoom); + setRoom(authenticatedRoom); setPhase("live"); } handleSignalMessage(message, generation); @@ -534,21 +573,15 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { signalRef.current = signal; signal.start(); } catch (error) { - captured.getTracks().forEach((track) => track.stop()); if (!isCurrentGeneration(generation)) { - if (createdRoom) { + captured.getTracks().forEach((track) => track.stop()); + if (createdRoom && !claimedRoom) { closeAbandonedRoom(createdRoom); } return; } activeGenerationRef.current = null; - if (createdRoom) { - closeAbandonedRoom(createdRoom); - } - if (streamRef.current === captured) { - streamRef.current = null; - setStream(null); - } + disposeResources(false); if ( error instanceof ApiError && error.status === 401 && @@ -696,36 +729,28 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } async function copyInvite(): Promise { - if (!room) { - return; - } - const generation = activeGenerationRef.current; - if (generation === null) { + const inviteUrl = room?.inviteUrl; + if (!inviteUrl) { return; } try { - await navigator.clipboard.writeText(room.inviteUrl); - if (!isCurrentGeneration(generation)) { - return; - } + await navigator.clipboard.writeText(inviteUrl); setCopied(true); window.setTimeout(() => { - if (isCurrentGeneration(generation)) { - setCopied(false); - } + setCopied(false); }, 1_500); } catch { - if (isCurrentGeneration(generation)) { - setNotice("无法写入剪贴板,请手动复制邀请链接"); - } + setNotice("无法写入剪贴板,请手动复制邀请链接"); } } - const expiresAt = room - ? new Intl.DateTimeFormat("zh-CN", { - hour: "2-digit", - minute: "2-digit", - }).format(new Date(room.expiresAt)) + const expirationText = room + ? room.expiresAt + ? `${new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(room.expiresAt))} 过期` + : "长期有效" : null; return ( @@ -742,7 +767,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ? `${viewers.length}/${maxViewers ?? "-"} 人正在观看` : phase === "starting" ? "正在建立房间" - : "尚未开始"} + : room + ? "房间已就绪" + : "尚未开始"}

{forceRelay && 强制中继} @@ -811,7 +838,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { )} - {phase !== "live" ? ( + {phase !== "live" && (
画质 @@ -847,28 +874,27 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { 输入房间码观看
- ) : ( - room && ( -
-
- - 房间 {room.roomId} · {expiresAt} 过期 - - - {room.inviteUrl} - -
- + )} + {room && ( +
+
+ + 房间 {room.roomId} · {expirationText} + + + {room.inviteUrl} +
- ) + +
)} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 459a15f..a7ee5cb 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -50,6 +50,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) useEffect(() => { let active = true; let currentIceConfig: IceConfig | null = null; + let currentHostOnline = false; const signal = new SignalingClient( { @@ -72,11 +73,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) if (!active) { return; } - peerRef.current?.dispose(); - peerRef.current = null; - setRemoteStream(null); - setPeerSnapshot(null); - setPlaybackBlocked(false); + clearPeerState(); setStatusText(message); }, onAccessRequired: () => { @@ -93,6 +90,14 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) }, ); + function clearPeerState(): void { + peerRef.current?.dispose(); + peerRef.current = null; + setRemoteStream(null); + setPeerSnapshot(null); + setPlaybackBlocked(false); + } + function ensurePeer(): ViewerPeer | null { if (peerRef.current) { return peerRef.current; @@ -114,6 +119,14 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) }, onUpdate: (snapshot) => { if (active) { + if ( + !currentHostOnline && + snapshot.connectionState === "failed" + ) { + clearPeerState(); + setStatusText("等待分享者再次开始"); + return; + } setPeerSnapshot(snapshot); if (snapshot.connectionState === "connected") { setStatusText("已连接"); @@ -135,9 +148,19 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) async function handleMessage(message: ServerMessage): Promise { if (message.type === "authenticated") { currentIceConfig = message.iceConfig; + currentHostOnline = message.hostOnline; setRelayAvailable(message.iceConfig.relayAvailable); setHostOnline(message.hostOnline); - setStatusText(message.hostOnline ? "等待分享画面" : "等待分享者上线"); + if ( + !message.hostOnline && + message.connectionId === null && + !peerRef.current?.isConnected() + ) { + clearPeerState(); + } + setStatusText( + message.hostOnline ? "等待分享画面" : "等待分享者开始分享", + ); const peer = peerRef.current; peer?.updateIceConfig(message.iceConfig); if ( @@ -170,22 +193,34 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) return; } if (message.type === "host-status") { + currentHostOnline = message.online; setHostOnline(message.online); if (!message.online && !peerRef.current?.isConnected()) { - setStatusText("等待分享者上线"); + setStatusText("等待分享者开始分享"); + } else if (message.online && !peerRef.current?.isConnected()) { + setStatusText("等待分享画面"); } return; } + if (message.type === "sharing-stopped") { + currentHostOnline = false; + clearPeerState(); + setHostOnline(false); + setStatusText("分享已停止,等待分享者再次开始"); + return; + } if (message.type === "room-closed") { - peerRef.current?.dispose(); - peerRef.current = null; - setRemoteStream(null); - setPeerSnapshot(null); + clearPeerState(); setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束"); signal.stop(); return; } if (message.type === "error") { + if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) { + clearPeerState(); + setStatusText("等待分享者再次开始"); + return; + } if ( [ "AUTH_REQUIRED", @@ -194,10 +229,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) "ROOM_FULL", ].includes(message.code) ) { - peerRef.current?.dispose(); - peerRef.current = null; - setRemoteStream(null); - setPeerSnapshot(null); + clearPeerState(); } setStatusText(message.message); } @@ -269,7 +301,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) function retryConnection(): void { if (!peerRef.current?.requestRecovery()) { - setStatusText(hostOnline ? "等待分享画面" : "等待分享者上线"); + setStatusText(hostOnline ? "等待分享画面" : "等待分享者开始分享"); } else { setStatusText("正在恢复媒体连接"); } diff --git a/src/server/app.ts b/src/server/app.ts index 0453c1f..761c5a8 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -7,6 +7,7 @@ import type { ViteDevServer } from "vite"; import type { CreateRoomResponse } from "../shared/protocol.js"; import { AccessSession } from "./access-session.js"; import { loadConfig, type ServerConfig } from "./config.js"; +import { RoomDatabase } from "./room-database.js"; import { RoomStore, RoomStoreError } from "./room-store.js"; import { SignalingServer } from "./signaling.js"; @@ -45,6 +46,9 @@ export async function createScreenerServer( ttlMs: config.roomTtlMs, maxRooms: config.maxRooms, maxViewersPerRoom: config.maxViewersPerRoom, + database: config.roomDatabasePath + ? new RoomDatabase(config.roomDatabasePath) + : undefined, now, }); const accessSession = new AccessSession({ @@ -167,13 +171,17 @@ export async function createScreenerServer( }); }, async close() { - await signaling.close(); - if (httpServer.listening) { - await new Promise((resolve, reject) => { - httpServer.close((error) => (error ? reject(error) : resolve())); - }); + try { + await signaling.close(); + if (httpServer.listening) { + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve())); + }); + } + await vite?.close(); + } finally { + roomStore.close(); } - await vite?.close(); }, }; } diff --git a/src/server/config.ts b/src/server/config.ts index c10b4a5..28e69e8 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -3,7 +3,6 @@ import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../shared/protocol.js"; export type RuntimeEnvironment = "development" | "test" | "production"; const MAX_TURN_CREDENTIAL_TTL_SECONDS = 3_600; -const MIN_ACCESS_PASSWORD_BYTES = 12; const MAX_ACCESS_PASSWORD_BYTES = 128; const MIN_TURN_SECRET_BYTES = 32; const DEFAULT_MAX_VIEWERS_PER_ROOM = 8; @@ -16,6 +15,7 @@ export interface ServerConfig { publicBaseUrl: URL; allowedOrigins: ReadonlySet; accessPassword?: string; + roomDatabasePath?: string; roomTtlMs: number; maxRooms: number; maxViewersPerRoom: number; @@ -243,6 +243,8 @@ export function loadConfig( } const accessPassword = environment.ACCESS_PASSWORD?.trim() || undefined; + const roomDatabasePath = + environment.ROOM_DATABASE_PATH?.trim() || undefined; const turnSharedSecret = environment.TURN_SHARED_SECRET?.trim() || undefined; const stunUrls = parseIceUrlList( environment.STUN_URLS, @@ -269,12 +271,11 @@ export function loadConfig( "ACCESS_PASSWORD must contain at most 128 visible ASCII characters", ); } - if ( - nodeEnv === "production" && - accessPassword && - Buffer.byteLength(accessPassword) < MIN_ACCESS_PASSWORD_BYTES - ) { - throw new Error("ACCESS_PASSWORD must contain at least 12 bytes in production"); + if (roomDatabasePath && !accessPassword) { + throw new Error("ROOM_DATABASE_PATH requires ACCESS_PASSWORD"); + } + if (nodeEnv === "production" && roomDatabasePath === ":memory:") { + throw new Error("ROOM_DATABASE_PATH must be file-backed in production"); } if (nodeEnv === "production" && turnUrls.length === 0) { throw new Error("TURN is required in production"); @@ -303,6 +304,7 @@ export function loadConfig( publicBaseUrl.origin, ), accessPassword, + roomDatabasePath, roomTtlMs: parsePositiveInteger(environment.ROOM_TTL_SECONDS, 14_400, "ROOM_TTL_SECONDS") * 1_000, diff --git a/src/server/room-database.ts b/src/server/room-database.ts new file mode 100644 index 0000000..bcdb9c8 --- /dev/null +++ b/src/server/room-database.ts @@ -0,0 +1,113 @@ +import { DatabaseSync } from "node:sqlite"; + +const SCHEMA_VERSION = 1; +const MAX_ROOM_ID = 999_999_999_999; + +export interface StoredRoom { + roomId: string; + hostTokenDigest: Buffer; +} + +export class RoomDatabase { + private readonly database: DatabaseSync; + + constructor(path: string) { + this.database = new DatabaseSync(path, { timeout: 5_000 }); + try { + this.initialize(); + } catch (error) { + this.database.close(); + throw error; + } + } + + loadRooms(): StoredRoom[] { + const rows = this.database + .prepare( + "SELECT id, host_token_digest FROM rooms ORDER BY id", + ) + .all(); + + return rows.map((row) => { + const roomId = readSafeInteger(row.id, "room id"); + if ( + !(row.host_token_digest instanceof Uint8Array) || + row.host_token_digest.byteLength !== 32 + ) { + throw new Error("Room database contains an invalid host token digest"); + } + return { + roomId: roomId.toString(), + hostTokenDigest: Buffer.from(row.host_token_digest), + }; + }); + } + + createRoom(hostTokenDigest: Buffer): StoredRoom { + if (hostTokenDigest.byteLength !== 32) { + throw new Error("Host token digest must contain 32 bytes"); + } + + const result = this.database + .prepare("INSERT INTO rooms (host_token_digest) VALUES (?)") + .run(hostTokenDigest); + const roomId = readSafeInteger(result.lastInsertRowid, "created room id"); + return { + roomId: roomId.toString(), + hostTokenDigest: Buffer.from(hostTokenDigest), + }; + } + + deleteRoom(roomId: string): boolean { + if (!/^[1-9]\d{0,11}$/.test(roomId)) { + throw new Error("Room id must be a positive integer with at most 12 digits"); + } + return ( + this.database.prepare("DELETE FROM rooms WHERE id = ?").run(Number(roomId)) + .changes === 1 + ); + } + + close(): void { + if (this.database.isOpen) { + this.database.close(); + } + } + + private initialize(): void { + const row = this.database.prepare("PRAGMA user_version").get(); + const version = readSafeInteger(row?.user_version, "schema version"); + if (version === SCHEMA_VERSION) { + return; + } + if (version !== 0) { + throw new Error(`Unsupported room database schema version ${version}`); + } + + this.database.exec("BEGIN IMMEDIATE"); + try { + this.database.exec(` + CREATE TABLE rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT + CHECK (id BETWEEN 1 AND ${MAX_ROOM_ID}), + host_token_digest BLOB NOT NULL + CHECK (length(host_token_digest) = 32) + ) STRICT; + PRAGMA user_version = ${SCHEMA_VERSION}; + COMMIT; + `); + } catch (error) { + if (this.database.isTransaction) { + this.database.exec("ROLLBACK"); + } + throw error; + } + } +} + +function readSafeInteger(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new Error(`Room database contains an invalid ${name}`); + } + return value; +} diff --git a/src/server/room-store.ts b/src/server/room-store.ts index 62b350b..cc7d92a 100644 --- a/src/server/room-store.ts +++ b/src/server/room-store.ts @@ -5,6 +5,7 @@ import { ROOM_CODE_LENGTH, type Role, } from "../shared/protocol.js"; +import { RoomDatabase } from "./room-database.js"; export type RoomStoreErrorCode = | "INVALID_TOKEN" @@ -29,7 +30,7 @@ interface Participant { interface Room { roomId: string; hostTokenDigest: Buffer; - expiresAtMs: number; + expiresAtMs: number | null; host?: Participant; viewers: Map; } @@ -37,7 +38,7 @@ interface Room { export interface CreatedRoom { roomId: string; hostToken: string; - expiresAt: string; + expiresAt: string | null; } export type ConnectParticipantInput = @@ -59,7 +60,7 @@ export interface ConnectedParticipant { roomId: string; role: Role; peerId: string; - expiresAt: string; + expiresAt: string | null; hostOnline: boolean; replacedSessionId?: string; viewerPeerIds: readonly string[]; @@ -85,6 +86,7 @@ export interface RoomStoreOptions { ttlMs: number; maxRooms: number; maxViewersPerRoom: number; + database?: RoomDatabase; now?: () => number; random?: (size: number) => Buffer; } @@ -114,6 +116,23 @@ export class RoomStore { this.now = options.now ?? Date.now; this.random = options.random ?? randomBytes; this.maxViewersPerRoom = options.maxViewersPerRoom; + + try { + for (const storedRoom of options.database?.loadRooms() ?? []) { + if (!isRoomCode(storedRoom.roomId) || this.rooms.has(storedRoom.roomId)) { + throw new Error("Room database contains an invalid or duplicate room id"); + } + this.rooms.set(storedRoom.roomId, { + roomId: storedRoom.roomId, + hostTokenDigest: Buffer.from(storedRoom.hostTokenDigest), + expiresAtMs: null, + viewers: new Map(), + }); + } + } catch (error) { + options.database?.close(); + throw error; + } } createRoom(): CreatedRoom { @@ -121,13 +140,16 @@ export class RoomStore { throw new RoomStoreError("ROOM_LIMIT"); } - const roomId = this.uniqueRoomCode(); const hostToken = this.random(32).toString("base64url"); - const expiresAtMs = this.now() + this.options.ttlMs; + const hostTokenDigest = digest(hostToken); + const createdAtMs = this.now(); + const storedRoom = this.options.database?.createRoom(hostTokenDigest); + const roomId = storedRoom?.roomId ?? this.uniqueRoomCode(); + const expiresAtMs = storedRoom ? null : createdAtMs + this.options.ttlMs; this.rooms.set(roomId, { roomId, - hostTokenDigest: digest(hostToken), + hostTokenDigest, expiresAtMs, viewers: new Map(), }); @@ -135,7 +157,7 @@ export class RoomStore { return { roomId, hostToken, - expiresAt: new Date(expiresAtMs).toISOString(), + expiresAt: formatExpiresAt(expiresAtMs), }; } @@ -214,11 +236,14 @@ export class RoomStore { ); } - closeRoom(roomId: string): ClosedRoom | undefined { + abandonRoom(roomId: string): ClosedRoom | undefined { const room = this.rooms.get(roomId); if (!room) { return undefined; } + if (this.options.database && !this.options.database.deleteRoom(roomId)) { + throw new Error("Persistent room is missing from the room database"); + } const sessionIds = connectedSessionIds(room); this.rooms.delete(roomId); return { roomId, sessionIds }; @@ -227,7 +252,7 @@ export class RoomStore { expireRooms(nowMs = this.now()): ClosedRoom[] { const expired: ClosedRoom[] = []; for (const [roomId, room] of this.rooms) { - if (room.expiresAtMs > nowMs) { + if (room.expiresAtMs === null || room.expiresAtMs > nowMs) { continue; } expired.push({ roomId, sessionIds: connectedSessionIds(room) }); @@ -240,6 +265,10 @@ export class RoomStore { return this.rooms.size; } + close(): void { + this.options.database?.close(); + } + private connectHost( room: Room, input: ConnectParticipantInput, @@ -267,7 +296,7 @@ export class RoomStore { roomId: room.roomId, role: "host", peerId: participant.peerId, - expiresAt: new Date(room.expiresAtMs).toISOString(), + expiresAt: formatExpiresAt(room.expiresAtMs), hostOnline: true, replacedSessionId: replacedSessionId === input.sessionId ? undefined : replacedSessionId, @@ -299,7 +328,7 @@ export class RoomStore { roomId: room.roomId, role: "viewer", peerId: participant.peerId, - expiresAt: new Date(room.expiresAtMs).toISOString(), + expiresAt: formatExpiresAt(room.expiresAtMs), hostOnline: Boolean(room.host?.sessionId), replacedSessionId: replacedSessionId === input.sessionId ? undefined : replacedSessionId, @@ -313,7 +342,7 @@ export class RoomStore { // A single response avoids turning room IDs into an enumeration oracle. throw new RoomStoreError("INVALID_TOKEN"); } - if (room.expiresAtMs <= this.now()) { + if (room.expiresAtMs !== null && room.expiresAtMs <= this.now()) { throw new RoomStoreError("ROOM_EXPIRED"); } return room; @@ -329,15 +358,17 @@ export class RoomStore { } private uniqueRoomCode(): string { - const range = 10n ** BigInt(ROOM_CODE_LENGTH); + const minimum = 10n ** BigInt(ROOM_CODE_LENGTH - 1); + const range = 9n * minimum; for (let attempt = 0; attempt < 8; attempt += 1) { const bytes = this.random(8); if (bytes.length !== 8) { throw new Error("Room code random source must return 8 bytes"); } - const candidate = (bytes.readBigUInt64BE() % range) - .toString() - .padStart(ROOM_CODE_LENGTH, "0"); + const candidate = ( + minimum + + (bytes.readBigUInt64BE() % range) + ).toString(); if (!this.rooms.has(candidate)) { return candidate; } @@ -356,6 +387,17 @@ export class RoomStore { } } +function isRoomCode(value: string): boolean { + return ( + value.length <= ROOM_CODE_LENGTH && + /^[1-9]\d*$/.test(value) + ); +} + +function formatExpiresAt(expiresAtMs: number | null): string | null { + return expiresAtMs === null ? null : new Date(expiresAtMs).toISOString(); +} + function findViewerByPeerId(room: Room, peerId: string): Participant | undefined { return [...room.viewers.values()].find((viewer) => viewer.peerId === peerId); } diff --git a/src/server/signaling.ts b/src/server/signaling.ts index c099209..b07ea99 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -24,7 +24,7 @@ interface AuthenticatedSession { roomId: string; role: Role; peerId: string; - roomExpiresAtMs: number; + roomExpiresAtMs: number | null; } interface SocketState { @@ -220,7 +220,10 @@ export class SignalingServer { return; } - if (this.now() >= state.authenticated.roomExpiresAtMs) { + if ( + state.authenticated.roomExpiresAtMs !== null && + this.now() >= state.authenticated.roomExpiresAtMs + ) { this.expireRooms(); return; } @@ -274,7 +277,10 @@ export class SignalingServer { roomId: participant.roomId, role: participant.role, peerId: participant.peerId, - roomExpiresAtMs: Date.parse(participant.expiresAt), + roomExpiresAtMs: + participant.expiresAt === null + ? null + : Date.parse(participant.expiresAt), }; this.clearViewerGrace(participant.roomId, participant.peerId); const connectionId = @@ -300,7 +306,9 @@ export class SignalingServer { iceConfig: this.iceConfig( participant.roomId, participant.peerId, - Date.parse(participant.expiresAt), + participant.expiresAt === null + ? null + : Date.parse(participant.expiresAt), ), }); @@ -363,12 +371,34 @@ export class SignalingServer { ), }); return; + case "stop-sharing": case "close-room": if (authenticated.role !== "host") { - this.sendError(socket, "FORBIDDEN", "Only the host may close the room"); + this.sendError(socket, "FORBIDDEN", "Only the host may stop sharing"); return; } - this.closeRoom(authenticated.roomId); + const state = this.socketStates.get(socket); + if (state?.authenticated === authenticated) { + this.options.roomStore.disconnectParticipant( + authenticated.roomId, + authenticated.peerId, + state.sessionId, + ); + } + this.stopSharing(authenticated.roomId); + socket.close(1000, "Sharing stopped"); + return; + case "abandon-room": + if (authenticated.role !== "host") { + this.sendError(socket, "FORBIDDEN", "Only the host may abandon the room"); + return; + } + try { + this.abandonRoom(authenticated.roomId); + } catch (error) { + console.error("Room abandonment failed", error); + this.sendError(socket, "SERVER_ERROR", "Room could not be abandoned"); + } return; } } @@ -492,20 +522,28 @@ export class SignalingServer { this.viewerGraceTimers.set(key, timer); } - private closeRoom(roomId: string): void { - const closed = this.options.roomStore.closeRoom(roomId); - if (!closed) { + private stopSharing(roomId: string): void { + this.clearRoomConnectionIds(roomId); + for (const viewer of this.options.roomStore.getConnectedViewers(roomId)) { + this.sendToSession(viewer.sessionId, { type: "sharing-stopped" }); + this.sendToSession(viewer.sessionId, { type: "host-status", online: false }); + } + } + + private abandonRoom(roomId: string): void { + const abandoned = this.options.roomStore.abandonRoom(roomId); + if (!abandoned) { return; } this.clearRoomGraceTimers(roomId); this.clearRoomConnectionIds(roomId); - for (const sessionId of closed.sessionIds) { + for (const sessionId of abandoned.sessionIds) { const socket = this.socketsBySessionId.get(sessionId); if (!socket) { continue; } this.send(socket, { type: "room-closed", reason: "host-ended" }); - socket.close(1000, "Room closed"); + socket.close(1000, "Room abandoned"); } } @@ -570,12 +608,16 @@ export class SignalingServer { ); } - private iceConfig(roomId: string, peerId: string, roomExpiresAtMs: number) { + private iceConfig( + roomId: string, + peerId: string, + roomExpiresAtMs: number | null, + ) { return createIceConfig( this.options.ice, `${roomId}:${peerId}`, this.now(), - roomExpiresAtMs, + roomExpiresAtMs ?? undefined, ); } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 1bd9c14..a989670 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -18,8 +18,9 @@ const tokenSchema = z export const roomCodeSchema = z .string() - .length(ROOM_CODE_LENGTH) - .regex(/^\d+$/); + .min(1) + .max(ROOM_CODE_LENGTH) + .regex(/^[1-9]\d*$/); export const roleSchema = z.enum(["host", "viewer"]); export type Role = z.infer; @@ -115,7 +116,10 @@ export const clientMessageSchema = z.union([ }) .strict(), z.object({ type: z.literal("refresh-ice") }).strict(), + z.object({ type: z.literal("stop-sharing") }).strict(), + // Kept as a compatibility alias while previously deployed clients age out. z.object({ type: z.literal("close-room") }).strict(), + z.object({ type: z.literal("abandon-room") }).strict(), ]); export type ClientMessage = z.infer; @@ -137,7 +141,7 @@ export const serverMessageSchema = z.discriminatedUnion("type", [ type: z.literal("authenticated"), role: roleSchema, peerId: opaqueIdSchema, - roomExpiresAt: z.string().datetime(), + roomExpiresAt: z.string().datetime().nullable(), maxViewers: z.number().int().min(1).max(MAX_VIEWERS_PER_ROOM_LIMIT), hostOnline: z.boolean(), connectionId: opaqueIdSchema.nullable(), @@ -184,6 +188,7 @@ export const serverMessageSchema = z.discriminatedUnion("type", [ online: z.boolean(), }) .strict(), + z.object({ type: z.literal("sharing-stopped") }).strict(), z .object({ type: z.literal("room-closed"), @@ -205,7 +210,7 @@ export const createRoomResponseSchema = z roomId: roomCodeSchema, hostToken: tokenSchema, inviteUrl: z.string().url().max(2048), - expiresAt: z.string().datetime(), + expiresAt: z.string().datetime().nullable(), }) .strict(); export type CreateRoomResponse = z.infer; diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 7d0fd22..3f34f4a 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -7,8 +7,11 @@ import { getSession, } from "../src/client/lib/api.ts"; import { + clearHostRoom, getStableClientId, + readHostRoom, isValidRoomId, + writeHostRoom, } from "../src/client/lib/session.ts"; import { shouldReconnectSignaling, @@ -41,6 +44,55 @@ describe("client session identity", () => { expect(first).toBe("ab".repeat(16)); expect(getStableClientId("viewer", "room-id-1234")).toBe(first); }); + + it("persists one validated host room across browser sessions", () => { + const values = new Map(); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }); + const room = { + roomId: "7", + hostToken: "a".repeat(32), + inviteUrl: "https://share.test/r/7", + expiresAt: null, + }; + + writeHostRoom(room); + + expect(readHostRoom()).toEqual(room); + clearHostRoom(); + expect(readHostRoom()).toBeNull(); + }); + + it("discards expired or malformed host room records", () => { + const values = new Map(); + const removeItem = vi.fn((key: string) => values.delete(key)); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem, + }, + }); + const expired = { + roomId: "12", + hostToken: "b".repeat(32), + inviteUrl: "https://share.test/r/12", + expiresAt: "2026-08-18T00:00:00.000Z", + }; + + writeHostRoom(expired); + expect(readHostRoom(Date.parse(expired.expiresAt) + 1)).toBeNull(); + expect(removeItem).toHaveBeenCalledOnce(); + + values.set("screener:host-room:v1", "not-json"); + expect(readHostRoom()).toBeNull(); + expect(removeItem).toHaveBeenCalledTimes(2); + }); }); describe("site access API", () => { @@ -113,12 +165,36 @@ describe("site access API", () => { const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); expect(headers.has("Authorization")).toBe(false); }); + + it("accepts a persistent room without an expiry", async () => { + const room = { + roomId: "42", + hostToken: "c".repeat(32), + inviteUrl: "https://share.test/r/42", + expiresAt: null, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(room), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + await expect(createRoom()).resolves.toEqual(room); + }); }); describe("room codes", () => { - it("accepts exactly 12 decimal digits", () => { + it("accepts one to twelve decimal digits without a leading zero", () => { + expect(isValidRoomId("1")).toBe(true); expect(isValidRoomId("123456789012")).toBe(true); - expect(isValidRoomId("12345678901")).toBe(false); + expect(isValidRoomId("12345678901")).toBe(true); + expect(isValidRoomId("0")).toBe(false); + expect(isValidRoomId("012345")).toBe(false); + expect(isValidRoomId("1234567890123")).toBe(false); expect(isValidRoomId("12345678901a")).toBe(false); }); }); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index cb97008..22324d5 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -59,7 +59,23 @@ describe("client signaling protocol", () => { expect( clientMessageSchema.safeParse({ type: "authenticate", - roomId: "1234", + roomId: "1", + role: "viewer", + clientId: "client_12345678", + }).success, + ).toBe(true); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + roomId: "0123", + role: "viewer", + clientId: "client_12345678", + }).success, + ).toBe(false); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + roomId: "1".repeat(13), role: "viewer", clientId: "client_12345678", }).success, @@ -98,6 +114,18 @@ describe("client signaling protocol", () => { }).success, ).toBe(false); }); + + it("accepts explicit and legacy sharing-stop messages", () => { + expect(clientMessageSchema.safeParse({ type: "stop-sharing" }).success).toBe( + true, + ); + expect(clientMessageSchema.safeParse({ type: "close-room" }).success).toBe( + true, + ); + expect(clientMessageSchema.safeParse({ type: "abandon-room" }).success).toBe( + true, + ); + }); }); describe("server signaling protocol", () => { @@ -133,6 +161,18 @@ describe("server signaling protocol", () => { ).toBe(true); }); + it("represents persistent rooms without a room expiry", () => { + expect( + serverMessageSchema.safeParse({ + ...authenticatedMessage(8), + roomExpiresAt: null, + }).success, + ).toBe(true); + expect( + serverMessageSchema.safeParse({ type: "sharing-stopped" }).success, + ).toBe(true); + }); + it.each([0, 1.5, MAX_VIEWERS_PER_ROOM_LIMIT + 1])( "rejects viewer limit %s outside the protocol boundary", (maxViewers) => { diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts index e0a8a8f..76794fd 100644 --- a/tests/room-store.test.ts +++ b/tests/room-store.test.ts @@ -1,6 +1,11 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../src/shared/protocol.ts"; +import { RoomDatabase } from "../src/server/room-database.ts"; import { RoomStore, RoomStoreError, @@ -27,7 +32,7 @@ describe("RoomStore", () => { }); const room = store.createRoom(); - expect(room.roomId).toMatch(/^\d{12}$/); + expect(room.roomId).toMatch(/^[1-9]\d{11}$/); expect(room.hostToken).toHaveLength(43); expect(room.expiresAt).toBe(new Date(now + 14_400_000).toISOString()); expect( @@ -191,7 +196,7 @@ describe("RoomStore", () => { ); }); - it("expires, closes, and globally bounds rooms", () => { + it("expires and globally bounds transient rooms", () => { let now = 1_000; const expiringStore = new RoomStore({ ttlMs: 100, @@ -217,22 +222,77 @@ describe("RoomStore", () => { expect(expiringStore.expireRooms()).toHaveLength(1); expect(expiringStore.size).toBe(0); - const openRoom = expiringStore.createRoom(); - expect(expiringStore.closeRoom(openRoom.roomId)).toBeDefined(); - expect(expiringStore.size).toBe(0); - expectRoomError( - () => - expiringStore.connectParticipant({ - roomId: openRoom.roomId, - role: "viewer", - clientId: "viewer-client-1", - sessionId: "viewer-session-1", - }), - "INVALID_TOKEN", - ); expect(expiringStore.createRoom()).toBeDefined(); }); + it("persists permanent room identity and host authorization across restarts", () => { + const directory = mkdtempSync(join(tmpdir(), "screener-room-store-")); + const databasePath = join(directory, "rooms.sqlite"); + const now = Date.UTC(2026, 7, 18, 12); + let store: RoomStore | undefined; + + try { + store = new RoomStore({ + ttlMs: 100, + maxRooms: 10, + maxViewersPerRoom: 3, + now: () => now, + database: new RoomDatabase(databasePath), + }); + const first = store.createRoom(); + expect(first).toMatchObject({ roomId: "1", expiresAt: null }); + expect(store.expireRooms(now + 1_000_000)).toEqual([]); + store.close(); + store = undefined; + + expect(readFileSync(databasePath).includes(Buffer.from(first.hostToken))).toBe( + false, + ); + + store = new RoomStore({ + ttlMs: 100, + maxRooms: 10, + maxViewersPerRoom: 3, + now: () => now + 1_000_000, + database: new RoomDatabase(databasePath), + }); + expect( + store.connectParticipant({ + roomId: first.roomId, + role: "viewer", + clientId: "viewer-after-restart", + sessionId: "viewer-session-after-restart", + }), + ).toMatchObject({ expiresAt: null, hostOnline: false }); + expect( + store.connectParticipant({ + roomId: first.roomId, + role: "host", + token: first.hostToken, + clientId: "host-after-restart", + sessionId: "host-session-after-restart", + }), + ).toMatchObject({ expiresAt: null, hostOnline: true }); + const second = store.createRoom(); + expect(second).toMatchObject({ roomId: "2", expiresAt: null }); + expect(store.abandonRoom(first.roomId)).toBeDefined(); + expectRoomError( + () => + store!.connectParticipant({ + roomId: first.roomId, + role: "viewer", + clientId: "viewer-after-abandon", + sessionId: "viewer-session-after-abandon", + }), + "INVALID_TOKEN", + ); + expect(store.createRoom()).toMatchObject({ roomId: "3", expiresAt: null }); + } finally { + store?.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + it.each([0, 1.5, MAX_VIEWERS_PER_ROOM_LIMIT + 1])( "rejects invalid per-room viewer limit %s", (maxViewersPerRoom) => { diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index 3fac19b..44cb397 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -67,6 +67,7 @@ describe("server configuration", () => { }); expect(config.accessPassword).toBeUndefined(); + expect(config.roomDatabasePath).toBeUndefined(); expect(config.turnUrls).toHaveLength(2); expect(config.turnSharedSecret).toBe("t".repeat(32)); }); @@ -192,30 +193,55 @@ describe("server configuration", () => { ); }); - it("rejects weak production secrets", () => { - expect(() => - loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "too-short", - STUN_URLS: "stun:turn.test:3478", - TURN_URLS: "turn:turn.test:3478", - TURN_SHARED_SECRET: "t".repeat(32), - }), - ).toThrow("ACCESS_PASSWORD must contain at least 12 bytes"); + it("accepts a short production access password", () => { + const config = loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + ACCESS_PASSWORD: "short-pass1", + STUN_URLS: "stun:turn.test:3478", + TURN_URLS: requiredProductionTurnUrls, + TURN_SHARED_SECRET: "t".repeat(32), + }); + expect(config.accessPassword).toBe("short-pass1"); + }); + + it("rejects a weak production TURN secret", () => { expect(() => loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "c".repeat(12), + ACCESS_PASSWORD: "x", STUN_URLS: "stun:turn.test:3478", - TURN_URLS: "turn:turn.test:3478", + TURN_URLS: requiredProductionTurnUrls, TURN_SHARED_SECRET: "too-short", }), ).toThrow("TURN_SHARED_SECRET must contain at least 32 bytes"); }); + it("requires access protection for a persistent room database", () => { + expect(() => + loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" }), + ).toThrow("ROOM_DATABASE_PATH requires ACCESS_PASSWORD"); + + const config = loadConfig({ + ACCESS_PASSWORD: "x", + ROOM_DATABASE_PATH: "rooms.sqlite", + }); + expect(config.roomDatabasePath).toBe("rooms.sqlite"); + }); + + it("rejects an in-memory room database in production", () => { + expect(() => + loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + ACCESS_PASSWORD: "x", + ROOM_DATABASE_PATH: ":memory:", + }), + ).toThrow("ROOM_DATABASE_PATH must be file-backed in production"); + }); + it.each(["密码密码密码密码", "contains spaces", "x".repeat(129)])( "rejects an access password that cannot be carried safely in a header", (accessPassword) => { diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index 3184991..8496c63 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -1,3 +1,7 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { afterEach, describe, expect, it } from "vitest"; import { createRoomResponseSchema } from "../src/shared/protocol.ts"; @@ -11,12 +15,22 @@ import type { ServerConfig } from "../src/server/config.ts"; const allowedOrigin = "http://allowed.test"; const accessPassword = "instance-access-password"; let runningServer: ScreenerServer | undefined; +const temporaryDirectories: string[] = []; afterEach(async () => { await runningServer?.close(); runningServer = undefined; + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } }); +function temporaryDatabasePath(): string { + const directory = mkdtempSync(join(tmpdir(), "screener-http-")); + temporaryDirectories.push(directory); + return join(directory, "rooms.sqlite"); +} + function testConfig(overrides: Partial = {}): ServerConfig { return { nodeEnv: "test", @@ -253,6 +267,41 @@ describe("room HTTP API", () => { expect(response.status).toBe(201); }); + it("persists sequential protected rooms across server restarts", async () => { + const config = testConfig({ roomDatabasePath: temporaryDatabasePath() }); + let baseUrl = await start(config); + const authenticated = await login(baseUrl); + const cookie = cookiePair(authenticated); + const create = () => + fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { Cookie: cookie, Origin: allowedOrigin }, + }); + + const responses = await Promise.all([create(), create()]); + const rooms = await Promise.all( + responses.map(async (response) => + createRoomResponseSchema.parse(await response.json()), + ), + ); + expect( + rooms.map((room) => Number(room.roomId)).sort((left, right) => left - right), + ).toEqual([1, 2]); + expect(rooms.every((room) => room.expiresAt === null)).toBe(true); + + await runningServer?.close(); + runningServer = undefined; + baseUrl = await start(config); + const third = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { Cookie: cookie, Origin: allowedOrigin }, + }); + expect(createRoomResponseSchema.parse(await third.json())).toMatchObject({ + roomId: "3", + expiresAt: null, + }); + }); + it("rejects request bodies and foreign browser origins", async () => { const baseUrl = await start(testConfig({ accessPassword: undefined })); const withBody = await fetch(`${baseUrl}/api/rooms`, { diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index f9c481b..ee88929 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -14,6 +14,7 @@ import { type ScreenerServer, } from "../src/server/app.ts"; import type { ServerConfig } from "../src/server/config.ts"; +import { RoomDatabase } from "../src/server/room-database.ts"; import { RoomStore, type CreatedRoom } from "../src/server/room-store.ts"; const allowedOrigin = "http://allowed.test"; @@ -122,6 +123,7 @@ async function startHarness( maxSignalConnections?: number; maxUnauthenticatedSignalConnections?: number; accessPassword?: string; + persistent?: boolean; } = {}, ): Promise { const config = testConfig(); @@ -132,6 +134,7 @@ async function startHarness( ttlMs: config.roomTtlMs, maxRooms: config.maxRooms, maxViewersPerRoom, + database: overrides.persistent ? new RoomDatabase(":memory:") : undefined, }); const room = roomStore.createRoom(); runningServer = await createScreenerServer({ @@ -297,6 +300,21 @@ describe("WebSocket signaling", () => { }); }); + it("authenticates a persistent room without a room expiry", async () => { + const harness = await startHarness({ persistent: true }); + expect(harness.room).toMatchObject({ roomId: "1", expiresAt: null }); + + const viewer = await openClient(harness.webSocketUrl); + const authenticated = await authenticate( + viewer, + harness.room, + "viewer", + "viewer-persistent-room", + ); + expect(authenticated.roomExpiresAt).toBeNull(); + expect(authenticated.hostOnline).toBe(false); + }); + it("routes offer and answer only between the host and the targeted viewer", async () => { const harness = await startHarness(); const secondRoom = harness.roomStore.createRoom(); @@ -426,6 +444,52 @@ describe("WebSocket signaling", () => { expect((await host.inbox.next("peer-left", 500)).peerId).toBe(firstAuth.peerId); }); + it("clears the previous media generation when sharing stops", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 300 }); + const host = await openClient(harness.webSocketUrl); + await authenticate(host, harness.room, "host", "host-client-stop"); + const viewer = await openClient(harness.webSocketUrl); + const viewerAuth = await authenticate( + viewer, + harness.room, + "viewer", + "viewer-client-stop", + ); + await host.inbox.next("peer-joined"); + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: viewerAuth.peerId, + payload: { + kind: "description", + connectionId: "connection-before-stop", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await viewer.inbox.next("signal"); + + const hostClosed = new Promise((resolve) => + host.socket.once("close", (code) => resolve(code)), + ); + host.socket.send(JSON.stringify({ type: "stop-sharing" })); + await viewer.inbox.next("sharing-stopped"); + expect(await viewer.inbox.next("host-status")).toMatchObject({ online: false }); + expect(await hostClosed).toBe(1000); + await closeClient(viewer); + + const reconnected = await openClient(harness.webSocketUrl); + const reconnectedAuth = await authenticate( + reconnected, + harness.room, + "viewer", + "viewer-client-stop", + ); + expect(reconnectedAuth.connectionId).toBeNull(); + expect(reconnectedAuth.peerId).toBe(viewerAuth.peerId); + expect(reconnectedAuth.hostOnline).toBe(false); + }); + it("atomically replaces the same client socket without leave churn", async () => { const harness = await startHarness({ viewerDisconnectGraceMs: 40 }); const host = await openClient(harness.webSocketUrl); @@ -640,7 +704,7 @@ describe("WebSocket signaling", () => { }); }); - it("advertises a non-default capacity and rejects viewer N+1", async () => { + it("stops sharing without deleting the room and restricts room abandonment", async () => { const maxViewersPerRoom = 5; const harness = await startHarness({ maxViewersPerRoom }); const host = await openClient(harness.webSocketUrl); @@ -676,13 +740,63 @@ describe("WebSocket signaling", () => { ); expect((await overflow.inbox.next("error")).code).toBe("ROOM_FULL"); - host.socket.send(JSON.stringify({ type: "close-room" })); - expect(await host.inbox.next("room-closed")).toMatchObject({ reason: "host-ended" }); + const stoppedHostClosed = new Promise((resolve) => + host.socket.once("close", (code) => resolve(code)), + ); + host.socket.send(JSON.stringify({ type: "stop-sharing" })); + for (const viewer of viewers) { + expect(await viewer.inbox.next("sharing-stopped")).toEqual({ + type: "sharing-stopped", + }); + expect(await viewer.inbox.next("host-status")).toMatchObject({ online: false }); + } + expect(await stoppedHostClosed).toBe(1000); + expect(harness.roomStore.size).toBe(1); + + const legacyHost = await openClient(harness.webSocketUrl); + await authenticate( + legacyHost, + harness.room, + "host", + "host-client-stable", + ); + for (const viewer of viewers) { + expect(await viewer.inbox.next("host-status")).toMatchObject({ online: true }); + } + const legacyHostClosed = new Promise((resolve) => + legacyHost.socket.once("close", (code) => resolve(code)), + ); + legacyHost.socket.send(JSON.stringify({ type: "close-room" })); + for (const viewer of viewers) { + expect(await viewer.inbox.next("sharing-stopped")).toEqual({ + type: "sharing-stopped", + }); + expect(await viewer.inbox.next("host-status")).toMatchObject({ online: false }); + } + expect(await legacyHostClosed).toBe(1000); + expect(harness.roomStore.size).toBe(1); + + viewers[0]!.socket.send(JSON.stringify({ type: "abandon-room" })); + expect((await viewers[0]!.inbox.next("error")).code).toBe("FORBIDDEN"); + expect(harness.roomStore.size).toBe(1); + + const abandonHost = await openClient(harness.webSocketUrl); + await authenticate( + abandonHost, + harness.room, + "host", + "host-client-stable", + ); + abandonHost.socket.send(JSON.stringify({ type: "abandon-room" })); + expect(await abandonHost.inbox.next("room-closed")).toMatchObject({ + reason: "host-ended", + }); for (const viewer of viewers) { expect(await viewer.inbox.next("room-closed")).toMatchObject({ reason: "host-ended", }); } + expect(harness.roomStore.size).toBe(0); }); it("requires an allowed Origin, timely authentication, and bounded payloads", async () => {