From b76d2cc0a5a7eb2d723283f59539b0b5cdc95d95 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:47:49 +0800 Subject: [PATCH] feat(rooms): replace SQLite with leased memory rooms (#211) Implements the accepted memory-resident room and access model on the single piik-v8 Browser wire. Local repository, full Web, build, and Chrome access/privacy gates passed before merge. --- .env.example | 9 +- README.md | 13 +- deploy/systemd/screener.service.example | 2 - .../0002-memory-resident-protected-rooms.md | 18 +- docs/deployment.md | 150 +- docs/project-memory.md | 8 +- docs/session-handoff.md | 7 +- docs/status.md | 14 +- docs/todo.md | 4 +- docs/verification-status.md | 4 +- docs/方案设计.md | 8 +- docs/需求理解.md | 4 +- scripts/access-privacy-gate.ts | 23 +- scripts/native-one-viewer-gate.ts | 5 +- scripts/peer-assisted-benchmark.ts | 3 +- src/client/lib/api.ts | 10 +- src/client/lib/creation-profile.ts | 64 + src/client/lib/session.ts | 29 +- src/client/media/viewer-message-authority.ts | 2 +- src/client/pages/HostPage.tsx | 483 ++++--- src/client/pages/ViewerPage.tsx | 2 +- src/client/styles.css | 61 +- src/server/app.ts | 18 +- src/server/config.ts | 31 +- src/server/room-database.ts | 303 ---- src/server/room-store.ts | 571 ++++---- src/server/signaling.ts | 96 +- src/shared/protocol.ts | 53 +- tests/client-state.test.ts | 175 +-- tests/creation-profile.test.ts | 64 + tests/hybrid-media-router.test.ts | 16 +- tests/protocol.test.ts | 46 +- tests/room-store.test.ts | 1235 +++-------------- tests/server-config.test.ts | 40 +- tests/server-http.test.ts | 92 +- tests/server-signal.test.ts | 217 ++- tests/viewer-message-authority.test.ts | 2 +- 37 files changed, 1555 insertions(+), 2327 deletions(-) create mode 100644 src/client/lib/creation-profile.ts delete mode 100644 src/server/room-database.ts create mode 100644 tests/creation-profile.test.ts diff --git a/.env.example b/.env.example index 1f3cf14..3ef1215 100644 --- a/.env.example +++ b/.env.example @@ -9,11 +9,9 @@ ALLOWED_ORIGINS=http://localhost:8787 # Production-required site access secret. Local development/tests may leave it # empty. A non-empty value must contain 8-128 visible ASCII bytes (0x21-0x7e). SITE_ACCESS_PASSWORD= -# Optional SQLite file for persistent rooms. Requires SITE_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 +# Dormant rooms remain resumable by the exact Host token for this lease. +# Active sharing does not expire; process restart clears every room. +ROOM_LEASE_SECONDS=86400 MAX_ROOMS=1000 MAX_VIEWERS_PER_ROOM=8 # Bounded standard-WebRTC relay/SFU controller for every room when true. @@ -47,7 +45,6 @@ STUN_URLS= # Minimal production example (use independent untracked secrets): # SITE_ACCESS_PASSWORD=replace-with-an-independent-access-key -# ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite # STUN_URLS=stun:stun.example.com:3478 # Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not # replace STUN above): diff --git a/README.md b/README.md index 1c8b820..2db08a9 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 accepted media target is WebRTC P2P-first, with an SFU virtual parent feeding only the necessary roots and optional authenticated TURN for explicitly supported restrictive networks. Roots continue distributing to bounded peer descendants; this is not an always-SFU conferencing design. 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 shares a numeric room code. Rooms default to a private fragment invitation whose capability authorizes only one room; a Host may explicitly choose public watching by room code after site access. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A site-access-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Lightweight deployments may keep independent direct WebRTC connections. The flagship deployment enables the all-room controller with bounded peer descendants and one LiveKit Host publication serving admitted Viewer subscriptions; short-lived TURN may be authorized only for a controller-selected exceptional media edge. Production admits 20 viewers and deployments may configure 1 through 20, but that admission limit is not a performance promise. +The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a random four-digit room code plus an independent fragment invitation. During a share, the host can change between three ceiling profiles without reopening the source picker, pause and switch sources, and switch video codec while paused. Code-only entry defaults to `open` after site access and may instead require a room password or be disabled; a valid grant enters directly. Rooms are process-memory state with a configurable dormant lease and disappear on restart. Lightweight deployments may keep independent direct WebRTC connections. The flagship deployment enables the all-room controller with bounded peer descendants and one LiveKit Host publication serving admitted Viewer subscriptions; short-lived TURN may be authorized only for a controller-selected exceptional media edge. Production admits 20 viewers and deployments may configure 1 through 20, but that admission limit is not a performance promise. This is not yet a broadly validated product release; [Current status](./docs/status.md) owns the deployed revision and validation evidence. The deployed flagship boundary is the all-room bounded peer/SFU-UDP controller on `screener-v7`, with ordinary peer ICE kept STUN-only and short-lived TURN limited to controller-selected exceptional media edges. The optional Windows native sender remains source-only and is not a production runtime. Mobile devices are Web viewers only; ordinary Android and iOS browsers do not expose reliable screen capture for a Web Host. Real cross-network SFU/TURN media, game audio, a sustained public-network 1:20 session, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. @@ -15,7 +15,7 @@ npm ci npm run dev ``` -Production requires an independent 8-128 byte visible-ASCII `SITE_ACCESS_PASSWORD`. It protects room creation, Host role, and code-only Viewer entry. A valid room-scoped fragment grant enters directly without it; otherwise public-watch needs site access plus the room code, and private code-only entry additionally needs the room password. Local development and tests may leave it empty. +Production requires an independent 8-128 byte visible-ASCII `SITE_ACCESS_PASSWORD`. It protects room creation, Host role, and code-only Viewer entry. A valid room-scoped fragment grant enters directly without it; otherwise code-only entry needs site access before the room's `open | password | disabled` policy is evaluated. Local development and tests may leave it empty. Open `http://localhost:8787`. The server listens on `0.0.0.0` by default so a phone on the same LAN can load `http://:8787`. To generate an @@ -26,11 +26,10 @@ must remain on `localhost` or HTTPS because screen capture requires a secure context. The local default has no STUN, so cross-network use still requires HTTPS and the production ICE configuration documented separately. -Production keeps a stopped v1 SQLite backup and restore boundary as documented -in [Deployment](./docs/deployment.md). `SITE_ACCESS_PASSWORD` supplies site access; -valid private fragment grants bypass that gate, while code-only entry requires site access before -public/private room policy is evaluated. The release has no v1 parser, old cookie, or -dual-schema runtime. Ordinary ICE contains only self-hosted STUN. LiveKit serves +`SITE_ACCESS_PASSWORD` supplies site access; valid room fragment grants bypass +that gate only for the exact Viewer role, while code-only entry requires site +access before code policy is evaluated. Ordinary ICE contains only self-hosted +STUN. LiveKit serves normally one or two SFU/UDP roots; the controller may authorize short-lived TURN only for a selected exceptional media edge. HTTPS/WSS remains TLS/TCP independently. diff --git a/deploy/systemd/screener.service.example b/deploy/systemd/screener.service.example index f1d66f6..73acb0c 100644 --- a/deploy/systemd/screener.service.example +++ b/deploy/systemd/screener.service.example @@ -14,8 +14,6 @@ Restart=on-failure RestartSec=3s TimeoutStopSec=20s UMask=0077 -StateDirectory=screener -StateDirectoryMode=0700 NoNewPrivileges=true PrivateDevices=true diff --git a/docs/adr/0002-memory-resident-protected-rooms.md b/docs/adr/0002-memory-resident-protected-rooms.md index 4c8b638..95e7c4b 100644 --- a/docs/adr/0002-memory-resident-protected-rooms.md +++ b/docs/adr/0002-memory-resident-protected-rooms.md @@ -1,6 +1,6 @@ # ADR-0002: Memory-Resident Rooms And Scoped Viewer Access -- Status: Accepted; implementation pending +- Status: Accepted; source implemented, production release pending - Date: 2026-08-23 ## Context @@ -139,11 +139,11 @@ grant can create a room or become Host authority. ## Implementation Status -This ADR is the accepted target, not a claim about current source or production. -Current source still has `private-link | public-watch`, random 12-digit temporary -rooms, optional SQLite persistent rooms, `ROOM_TTL_SECONDS`, and the v3 migration -surface. Production still runs that SQLite-backed release. The implementation -must replace those surfaces atomically, delete the database code and old -configuration rather than preserve compatibility, update deployment guidance, -and pass the gates above before status reports this decision as source-complete -or deployed. +Current source implements this decision and has removed the old room modes, +12-digit codes, SQLite persistence, `ROOM_DATABASE_PATH`, `ROOM_TTL_SECONDS`, +and migration surface atomically on the single `screener-v8` wire. Focused source coverage spans allocation, +leases, orthogonal grant/code admission, rotate/revoke, password policy, local +profile replay, restart loss, HTTP, signaling, and browser storage privacy. +Production still runs the older SQLite-backed release; do not report this +decision as deployed until the new source is released and its deployment checks +pass. diff --git a/docs/deployment.md b/docs/deployment.md index 150f07b..2ae1f30 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -61,9 +61,9 @@ browser <---------- DTLS-SRTP/UDP ----------> LiveKit :7882 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. Enable operating system time synchronization and keep the Node application port reachable only from its -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. +reverse proxy. A single Node process is intentional. Rooms, participants, and +signaling remain in process memory; a restart intentionally invalidates every +room and does not restore live WebRTC connections. Build and validate the exact revision on a build host before starting it: @@ -152,6 +152,15 @@ instance/hostname and keep the old release unchanged for rollback. If the candidate fails, roll back the release or instance; do not add a permanent dual-transport branch. +A memory-room release changes the application environment atomically: remove +`ROOM_DATABASE_PATH` and `ROOM_TTL_SECONDS`, add `ROOM_LEASE_SECONDS`, and do +not migrate or import SQLite rooms. Starting the candidate invalidates every +current production room, Host token, invitation, password verifier, and route; +open pages must refresh or explicitly create a replacement room. Keep the +recorded old release plus its SQLite environment/config backup unchanged for +rollback. Rolling back restores that exact old environment and database with the +old binary; it does not attempt to carry candidate rooms into SQLite. + A shared-public-IP instance can test candidate behavior while the old TURN service stays live, but it cannot prove the clean-port boundary or approve broad migration. The room-1 smoke uses this shape: nginx owns 443, LiveKit 1.13.5 owns @@ -206,8 +215,7 @@ PORT=8787 PUBLIC_BASE_URL=https://share.example.com ALLOWED_ORIGINS=https://share.example.com SITE_ACCESS_PASSWORD= -ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite -ROOM_TTL_SECONDS=14400 +ROOM_LEASE_SECONDS=86400 MAX_ROOMS=1000 MAX_VIEWERS_PER_ROOM=20 ENDPOINT_MEDIA_COPY_CAPACITY=2 @@ -248,7 +256,8 @@ production release remains on its recorded configuration until the four-value tuple is supplied atomically with current source. Only a controller-selected edge may receive a short-lived grant. Roll back by removing the application tuple before changing coturn or firewall state. -Credentials never enter URLs, logs, browser persistence, room rows, or SQLite. +Credentials never enter URLs, logs, durable server storage, or browser +`localStorage`. Application release does not prove deletion of the matching physical coturn allocation. Keep the credential TTL short and configure coturn `user-quota` and @@ -284,9 +293,11 @@ LiveKit, TLS, TURN, or another service. It authorizes room creation, Host role, and code-only Viewer attempts; it does not replace a private room grant or password. Local development and tests may omit it. Supplying a removed access key, even blank, fails startup. -`ROOM_DATABASE_PATH` is optional but requires `SITE_ACCESS_PASSWORD`. -Omit the database path to keep random temporary rooms; `ROOM_TTL_SECONDS` -applies only to those rooms. +`ROOM_LEASE_SECONDS` defaults to 86,400 seconds and owns the dormant room +lifetime. An actively connected Host prevents expiry; explicit stop or Host +disconnect starts the lease, and only the exact Host token renews it before +expiry. Viewer activity never renews ownership. `ROOM_DATABASE_PATH` and +`ROOM_TTL_SECONDS` fail startup even when blank. Production `6b87732` accepts 1 through 20 and explicitly selects 20. This is an admission limit, not evidence that every publisher, network, or quality profile can sustain that many streams. @@ -303,11 +314,12 @@ TURN allocation admission. Supplying the removed `MAX_PEER_RELAY_DOWNSTREAM_EDGES`, even blank, fails startup. -Production release `6b87732` runs the current source server and Browser assets -atomically on `screener-v7`; stale Browser and executable-sender wires fail -before room authority. Native senders and helpers are outside this release. -Restore the exact prior environment, LiveKit configuration, database backup, -server, and Web assets together when rolling back. +Production release `6b87732` runs the deployed server and Browser assets +atomically on `screener-v7`. The memory-room candidate advances server and +Browser assets together to `screener-v8`; stale v7 Browser and executable-sender +wires fail before room authority. Native senders and helpers are outside this +release. Restore the exact prior environment, LiveKit configuration, database +backup, server, and Web assets together when rolling back. The four `LIVEKIT_*` values must either all be absent or all be present, and a complete tuple requires `PEER_ASSISTED_MEDIA=true` plus explicit positive @@ -334,11 +346,11 @@ committed, and draining generations remain charged until `DeleteRoom` succeeds and a follow-up lookup proves absence. Run only one application process until a shared atomic admission and lifecycle owner exists. -For the first candidate canary, use an isolated instance and a protected -persistent room whose ID is stable across restarts. Restart the application and -verify startup removes a seeded stale managed LiveKit room before serving -traffic. Hold the application port with another process and verify a competing -startup makes zero LiveKit calls. Verify that both the persistent room and a +For the first candidate canary, use an isolated instance and one recorded room. +Restart the application, verify startup removes the now-stale managed LiveKit +room before serving traffic, and create a fresh application room after startup. +Hold the application port with another process and verify a competing +startup makes zero LiveKit calls. Verify that both the replacement room and a second normal room contain `mediaMode: "peer-assisted"`, with independent route revisions and no shared room state or capacity bypass. Exercise join, offer and answer, stale-token @@ -367,87 +379,54 @@ Only `POST /api/site-access` accepts the site access secret in an is no account database, JWT, session map, or logout endpoint. Before an access-boundary release, set `CHROME_PATH` to a local Chrome or -Chromium executable and run `npm run gate:access-privacy`. The gate creates only -a loopback room and temporary SQLite/profile state, navigates the fragment -through CDP rather than a process argument, disposes its ephemeral browser -context, and emits only fixed booleans and counts. It also checks the tracked -nginx logging shape and runs the complete signaling suite containing the strong -rotate/revoke teardown case. The room password enters only at the RoomStore -boundary, so this run proves its raw SQLite non-persistence, not its WebSocket or -log ingress. It never connects to production or accepts a production credential; -exact production request/nginx/journal/database inspection remains separate. +Chromium executable and run `npm run gate:access-privacy`. The gate creates a +loopback in-memory room with an isolated browser profile, navigates the fragment +through CDP rather than a process argument, disposes that context, and emits only +fixed booleans and counts. It checks tracked request/nginx/application logs, +browser storage, fragment cleanup, absence of a persistent room file, and runs +the complete signaling suite containing the strong rotate/revoke teardown case. +It never connects to production or accepts a production credential; exact +production request/nginx/journal inspection remains separate. nginx limits this exact endpoint per source at `5r/m` with `burst=5 nodelay` and returns 429 when exhausted. The limiter uses nginx shared memory; do not add the secret, Authorization header, request body, or a new source-address field to logs. `POST /api/rooms` accepts only the site-access cookie and strict JSON -with an explicit Viewer policy; a Bearer header is not an alternate creation +with an explicit code-entry policy and optional room password; a Bearer header +is not an alternate creation path. `/signal` still admits a cookie-free browser after Origin and capacity checks and records the cookie state at upgrade. Host requires it with the room Host token. A Viewer may omit it only when presenting a valid, unexpired grant -for that exact room; otherwise the server rejects before evaluating public-watch -or a room password. +for that exact room; otherwise the server rejects before evaluating code entry. -Private rooms are the default. Their invitation is +Every room receives an independent invitation `/r/{code}#v={room-scoped-grant}`; the fragment does not enter HTTP or WebSocket request targets. The page validates it, writes it only to that room's `sessionStorage`, and immediately replaces the visible URL with `/r/{code}`. -An independent tab without the fragment must first pass site access. A private -room then requires its room password, while explicit `public-watch` accepts the -numeric room code. Neither policy -lets a Viewer create a room or authenticate as Host. +The Host may update or revoke that grant without changing code-entry policy. -Room allocation has two deliberately small policies: +Room allocation and lifetime use one deliberately small model: -- With `ROOM_DATABASE_PATH`, SQLite allocates positive decimal IDs starting at - `1`. These rooms do not expire, while each private Viewer invitation expires - after seven days unless rotated earlier. 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`; a private grant cannot outlive its room. Stopping sharing - does not immediately delete it. Public-watch remains available but explicit. +- random unallocated codes come only from `1000` through `9999`, never duplicate + an active code, respect `MAX_ROOMS <= 9000`, and return on room release; +- active Host sharing does not expire; stop, capture-track end, or Host + disconnect starts `ROOM_LEASE_SECONDS`; +- only the exact Host token resumes and renews before expiry, Viewer activity + cannot, and expiry invalidates the code, credentials, verifier, participants, + and routes; and +- process restart has the same fail-closed effect. -The built-in `node:sqlite` schema v3 stores each room ID, a SHA-256 Host token -digest, one nullable SHA-256 Viewer-grant digest, and optional validated Viewer -password material in the same `STRICT` row. `NULL` means public-watch or no -password; checked BLOBs mean private-link/password state. It must not contain -plaintext tokens or grants, passwords, cookies, names, participants, SDP, ICE -candidates, IP addresses, 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. +Code-only entry is independent of the invitation: after site access, `open` +accepts the four-digit code without a room password; `password` also requires +the room password; and `disabled` rejects code-only entry while a valid grant +still enters. -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. - -The current release migrates schema v1 or v2 to v3 and has no dual-schema -runtime. For an existing deployment, stop the service and take a named, -immutable copy before installing or starting the new binary. Confirm the -stopped source reports `PRAGMA user_version = 1` or `2`, retain the backup -outside the release directory, and record its checksum. On first v3 startup, -one `BEGIN IMMEDIATE` transaction first gives v1 rows a checked nullable Viewer -grant digest with a fresh fail-closed value, then gives v1/v2 rows the checked -nullable password material and advances `user_version` to `3`. - -After startup, verify `PRAGMA integrity_check`, `user_version = 3`, service -health, Host reclaim, invitation rotation, password update/removal, and a new -Viewer join before removing the maintenance boundary. If rollback is required, -stop the v3 service, preserve the v3 database separately for diagnosis, restore -the exact pre-migration backup with service ownership and mode `0600`, verify -its integrity and original user version, and only then start the matching old -binary. Never point an old binary at the migrated v3 file. - -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`. +The server keeps only process-memory digests/verifiers for Host tokens, Viewer +grants, and optional room passwords. There is no room database, schema, +migration, writable room directory, or backup/restore step. The same browser may +replay its Host display-name, code-entry, and optional-password creation profile +when it explicitly creates a replacement room; this is local convenience, not +server-side identity or cross-restart recovery. Production startup requires one to eight syntactically valid `stun:` URLs before the server listens. This validates shape only; it does not prove DNS, @@ -507,7 +486,8 @@ Keep the proxy's access-log retention bounded and access controlled. Requests to `/r/{code}` put the room code in the path, so access logs can contain room codes as well as network metadata. They must not be treated as public artifacts. For a private room, the grant remains in the fragment and is not part of that -request target. A public-watch code still requires prior site access. +request target. A code-only entry still requires prior site access unless its +policy is disabled; a valid grant remains the direct Viewer path. For a process-level liveness probe, send `GET /healthz`. A running process returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other diff --git a/docs/project-memory.md b/docs/project-memory.md index 69a1a66..b19ac69 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -13,7 +13,7 @@ Last updated: 2026-08-23 - Keep routing automatic and media distributed. Direct/peer UDP is preferred; centralized media is fallback infrastructure, not the default topology. - Every non-server endpoint uses one server-authoritative steady outbound media-copy capacity: default `2`, statically configurable as `1`, `2`, or `3`. A peer child or the Host publication consumes one slot; upstream receive is free, selected TURN replaces the same copy's transport, and SFU subscriber egress is accounted at the server. Browser role/UA/visibility does not create a separate tier, and clients cannot raise the deployment value. - The stable routing invariants are one authoritative upstream per Viewer, an acyclic source-reachable active graph, exact authenticated route attempts, endpoint and server admission, one room-serial child operation, and bounded success, wait, or failure. -- One room controller reconciles allocation, child reparenting, and relay drain through the same operation. It finds one waiting Viewer or invalid edge and builds one deterministic tuple list. The operation owns its cursor, current candidate and reservations, fact version, plus one total deadline; only its current candidate may produce media. A `screener-v7` prepare names that exact child, selected transport, and server-issued candidate connection ID. A usable old edge remains until the exact candidate child decodes its first new video frame. `settleReady` synchronously commits admission before graph promotion; success commits prepare revision `P`, while failure releases resources, broadcasts rollback `R > P` from one monotonic allocator, and advances the cursor without resetting the deadline. Exhaustion retires hard-invalid, departed-parent, or overflow edges into explicit waiting but retains a healthy bootstrap edge. Bounded-gap preflights server resources, replans the untried suffix after retirement, and appends one ordinary restore candidate when it cut healthy media. A relay with a bad ingress reparents itself as a child and retains its subtree. A disconnected endpoint, or one whose effective downstream capacity falls below its current child count, accepts no excess children; its direct or overflow children enter the same operation and a disconnected endpoint is removed when childless. Authoritative pause aborts the operation, suppresses decoded-progress stall decisions, and resumes from a fresh reconciliation. +- One room controller reconciles allocation, child reparenting, and relay drain through the same operation. It finds one waiting Viewer or invalid edge and builds one deterministic tuple list. The operation owns its cursor, current candidate and reservations, fact version, plus one total deadline; only its current candidate may produce media. A `screener-v8` prepare names that exact child, selected transport, and server-issued candidate connection ID. A usable old edge remains until the exact candidate child decodes its first new video frame. `settleReady` synchronously commits admission before graph promotion; success commits prepare revision `P`, while failure releases resources, broadcasts rollback `R > P` from one monotonic allocator, and advances the cursor without resetting the deadline. Exhaustion retires hard-invalid, departed-parent, or overflow edges into explicit waiting but retains a healthy bootstrap edge. Bounded-gap preflights server resources, replans the untried suffix after retirement, and appends one ordinary restore candidate when it cut healthy media. A relay with a bad ingress reparents itself as a child and retains its subtree. A disconnected endpoint, or one whose effective downstream capacity falls below its current child count, accepts no excess children; its direct or overflow children enter the same operation and a disconnected endpoint is removed when childless. Authoritative pause aborts the operation, suppresses decoded-progress stall decisions, and resumes from a fresh reconciliation. - One Host publication may serve all SFU subscriptions. Its exact generation owns one ingress handle and exact Viewer subscription handles own egress; later SFU children reuse the publication, while reserved, committed, and draining handles remain charged until the managed room is deleted and proven absent. Actual SFU/TURN usage release wakes only rooms that previously lost a candidate to global admission. SFU-fed and peer-fed endpoints use the same provisional-child transaction, and selected TURN changes only the transport of an authorized logical edge. Parent choice hard-filters authority, reachability, cycles, effective capacity, and producer transition, then orders by shallowest result, remaining steady sender capacity, stable join order, and peer identity; server admission is checked at the current cursor. Acyclicity and room admission bound maximum depth; depth is observed but has no separate hard cap. Raw IP, claimed NAT type, UA, geography, all-pairs probes, weighted scores, and periodic room rebalancing are not route inputs. - Ordinary peer ICE is STUN-only. Credentials, room secrets, candidate details, and diagnostic data remain private and narrowly scoped. - Browser peers use existing periodic WebRTC/LiveKit stats to maintain one exact-route decoded-progress timestamp. New frames or route/connection changes reset it; a non-paused deadline reports the edge once. Track availability, bitrate, FPS, blur, sender stats, and SFU layer remain framework or diagnostic inputs. @@ -27,8 +27,8 @@ Last updated: 2026-08-23 ## Current Source And Production - Canonical source is the root `main` branch. Audit/integration boundaries require it to be clean, and new branches/worktrees start from its exact commit only after accepted truth is merged. -- Current source admits up to `20` Viewers and implements the uniform `1/2/3` endpoint cap, single Browser `screener-v7` wire, exact prepare candidate, first-decoded-frame commit, strict rollback revision, decoded-progress stall, and one event-driven room controller. Older Browser and executable-sender wires fail before room authority. SFU admission uses exact publication and Viewer-subscription handles with explicit no-default ingress/egress capacities and a listener-fenced dedicated LiveKit owner. Selected TURN uses its own explicit no-default deployment capacity and exact logical-allocation ledger shared by independent peer edges and Host-SFU ingress. -- The accepted room model is not implemented on current source. Source still exposes mutually exclusive `private-link | public-watch`, random 12-digit temporary rooms, optional SQLite persistent rooms, and `ROOM_TTL_SECONDS`; production also remains SQLite-backed. Do not report four-digit leases, orthogonal code/grant access, local preference replay, or SQLite removal as shipped until that scoped implementation is validated and deployed. +- Current source admits up to `20` Viewers and implements the uniform `1/2/3` endpoint cap, single Browser `screener-v8` wire, exact prepare candidate, first-decoded-frame commit, strict rollback revision, decoded-progress stall, and one event-driven room controller. Older Browser and executable-sender wires fail before room authority. SFU admission uses exact publication and Viewer-subscription handles with explicit no-default ingress/egress capacities and a listener-fenced dedicated LiveKit owner. Selected TURN uses its own explicit no-default deployment capacity and exact logical-allocation ledger shared by independent peer edges and Host-SFU ingress. +- Current source implements the memory-resident room model: random free four-digit codes, the configurable dormant lease, exact-Host resume, restart loss, local creation-preference replay, orthogonal Viewer grants and `open | password | disabled` code entry, and direct invitation/policy controls. SQLite room persistence, `ROOM_DATABASE_PATH`, and `ROOM_TTL_SECONDS` are removed. Production still runs the older SQLite-backed release until this source is deployed. - Production runs exact `6b87732b5f97b7836f628a2c333d0c3f05990c99`, release `6b87732`, wire `screener-v7`; `9461e20` is the rollback release. - Production implements the current endpoint-cap and route-controller contract, admits 20 Viewers, uses LiveKit admission of one publication ingress plus 20 subscription egress handles on a dedicated non-auto-create instance, and bounds selected TURN to two logical allocations. - Production retains access controls, self-check, diagnostic export, signaling watchdog behavior, 64/128/256 audio choices, and selected-edge transport. Real heterogeneous-network and SFU/TURN media evidence remains open. @@ -38,7 +38,7 @@ Last updated: 2026-08-23 ## Current Priority 1. Keep this truth set and [the TODO ledger](./todo.md) concise and internally consistent. -2. Release live audio and paused video-codec switching next, follow with evidence-led H.264 diagnosis, the strict-NAT emulator gate, connection feedback, the accepted memory-resident room/access slice, and the remaining functional roadmap. Distribution packaging starts only after those TODOs are complete. Parallel branches may finish earlier but do not change this merge/deployment order. +2. Release live audio and paused video-codec switching next, follow with evidence-led H.264 diagnosis, the strict-NAT emulator gate, connection feedback, then the source-complete memory-resident room/access slice and the remaining functional roadmap. Distribution packaging starts only after those TODOs are complete. Parallel branches may finish earlier but do not change this merge/deployment order. ## Working Rules diff --git a/docs/session-handoff.md b/docs/session-handoff.md index 02a7ae0..cd72cf1 100644 --- a/docs/session-handoff.md +++ b/docs/session-handoff.md @@ -36,11 +36,12 @@ Current high-signal boundary: and SFU/TURN media evidence remains open. Do not rerun broad or executable/native test suites unless the current acceptance boundary specifically requires them. -- ADR-0002 owns the newly accepted room model: one process-memory RoomStore, +- ADR-0002 owns the source-implemented room model: one process-memory RoomStore, random free four-digit codes, configurable 24-hour dormant leases, restart loss, local Host preference replay, an independent expiring Viewer grant, and - open/password/disabled code entry. SQLite removal and the new access/UI model - are documented but not implemented or deployed. + open/password/disabled code entry. Current source uses the single + `screener-v8` wire and has removed SQLite; production has not deployed this + room/access/UI slice and remains on the older v7 SQLite boundary. - docs/maintenance.md owns the reusable mechanism-simplification review. SQLite is its reference case because the bounded restart-experience loss did not justify the persistence surface; accepting that tradeoff changed the contract, diff --git a/docs/status.md b/docs/status.md index 4129cd5..be3dab7 100644 --- a/docs/status.md +++ b/docs/status.md @@ -16,20 +16,20 @@ This is the current execution index. Git history owns completed timelines; [veri - Canonical source is the root `main` branch; auxiliary branches and worktrees do not own current truth. Audit/integration boundaries require a clean root, and dependent work starts only from its exact commit. - Current source implements one steady outbound media-copy cap for every non-server endpoint: default `2`, static `1/2/3`, a peer child or Host publication consumes one slot, upstream receive is free, a committed selected TURN transport replaces the same copy while hidden carry consumes another, and no Browser/UA/visibility tier exists. One shared guard owns the accounting and transition work is bounded by `min(C + 1, 3)`. - Current source and production admission support one Host plus up to `20` Viewers. Focused controller coverage includes `C=1/2/3`, a 20-Viewer graph, and the passed local 20-Viewer Browser smoke; real external-network media and resource measurements remain open. -- The single Browser `screener-v7` boundary rejects stale Browser and executable-sender wires before room authority and carries the exact deployment cap plus exact prepare candidate in the authenticated route contract. +- The single Browser `screener-v8` source boundary rejects stale Browser and executable-sender wires before room authority and carries the exact deployment cap plus exact prepare candidate in the authenticated route contract. Production remains on `screener-v7` until this incompatible room slice is atomically released. - Current source also implements explicit no-default SFU ingress/egress capacities and exact `reserved | committed | draining` accounting. The listener owner reconciles its dedicated non-auto-create LiveKit namespace before serving, creates each generation before token issuance, releases only after delete-plus-absence proof, and reclaims Host-offline generations through bounded exact-participant checks. Selected TURN has an independent explicit no-default deployment capacity; one process-local ledger charges every exact peer-selected edge and Host-SFU ingress through reserve, commit, drain, and release. - Current source implements automatic P2P-first routing with one active upstream, an acyclic source-reachable graph, exact Browser v7 prepare tuple, first-decoded-frame make-before-break, strict rollback revision, and bounded wait or failure. - One event-driven controller owns the committed graph and at most one room-serial child operation. The operation owns one deterministic tuple list/cursor, one current candidate and reservations, fact version, and one total deadline. Failed tuples are operation-local; exhaustion retires invalid/overflow edges but retains healthy bootstrap media. Bounded-gap preflights resources, replans the remaining suffix, and restores a healthy retired edge through one final ordinary candidate. Admission commits synchronously before graph promotion; release uses the resource-set difference and room disposal returns all resources. One Host publication serves exact Viewer subscription handles, selected TURN changes only transport, and actual global resource release wakes registered waiting rooms. - Parent selection is deterministic rather than random or score-based: hard eligibility filters, then shallowest result, remaining sender capacity, stable join order, and peer identity. The exact candidate child's first new decoded frame is the only application media-ready event. Moderate bitrate, FPS, or visual degradation remains with stock WebRTC/LiveKit adaptation and diagnostics; only hard connection failure or a non-paused decoded-frame stall invalidates a route edge. Viewer connection feedback and the strict-NAT emulator gate are accepted follow-up work, not deployed behavior. - Browser initial capture and source switching already request available share audio through one shared capture boundary and visibly retain video-only sharing when no audio track is returned. - Live 64/128/256 audio-ceiling mutation and paused-share video codec switching are accepted but not yet implemented on canonical `main`. H.264 startup blur/low-FPS reports remain an evidence-led diagnosis, not a presumed codec defect. Public-server and fully local distribution packages are later work after the functional, NAT, and real-network ledger is complete. -- The accepted room target is one process-memory RoomStore with random four-digit codes, a default 24-hour dormant lease, local Host preference replay, independent token invitations and `open | password | disabled` code entry, and no SQLite. Current source and production still use the old 12-digit/SQLite and `private-link | public-watch` surfaces; the target is documented and queued, not implemented or deployed. +- Current source implements one process-memory RoomStore with random four-digit codes, a default 24-hour dormant lease, exact-Host resume, restart loss, local Host preference replay, independent token invitations and `open | password | disabled` code entry, and no SQLite. The Host UI separates the invitation, room-code entry policy, password, grant update, and grant revoke controls. Production still uses the old 12-digit/SQLite and `private-link | public-watch` surfaces until this source is deployed. ## Current Milestone 1. Release live audio-ceiling mutation and paused-share codec switching on the deployed generation/transition boundary. 2. Reproduce the H.264 startup report before any codec-specific repair, and build the strict-NAT emulator gate before any manually approved canary in an operator-owned test room. -3. Add first-frame-driven Viewer connection stages, then implement the accepted memory-resident room/access slice and finish the remaining accepted functional and real-network work. +3. Add first-frame-driven Viewer connection stages, then release the source-complete memory-resident room/access slice and finish the remaining accepted functional and real-network work. 4. Validate and deploy each coherent slice in this order with its focused browser/network/resource and rollback gates; distribution packaging remains outside this milestone. ## Active Boundaries @@ -37,11 +37,11 @@ This is the current execution index. Git history owns completed timelines; [veri - `fix/configurable-relay-cap` is an old, incomplete draft and must not be merged as-is. - Open PR #192 and the Native stack are evidence/research, not pending product releases. Native senders, capture helpers, shared-encode executables, and their test binaries are outside the current Browser milestone and are not built or run. - Deployed surfaces and retained candidates that still need product decisions are indexed only in [the TODO ledger](./todo.md); do not extend or roll them back automatically. -- Room entry, sharing controls, ID lifetime, and storage semantics are settled in the owning requirements/design/ADR and queued after the route boundary. Current source remains divergent until that complete slice replaces the old access and persistence model atomically. +- Room entry, sharing controls, ID lifetime, and storage semantics are implemented in current source. Production remains divergent until that complete `screener-v8` slice atomically replaces the old access and persistence model at its accepted release boundary. - Real SFU/TURN recovery, heterogeneous networks, mobile lifecycle, audio/A-V device behavior, and endurance/resource measurements remain external acceptance evidence, not blockers for unrelated reversible work. ## Current Hold -The Browser route is implemented and deployed. Later TODO work is intentionally paused until the -owner chooses the next slice; do not start live audio, codec switching, room implementation, route -rollback, Native/executable work, or broad cleanup without a new instruction. +The Browser route is implemented and deployed, and the room source slice is complete but not +deployed. Later TODO work is intentionally paused until the owner chooses the next slice; do not +start deployment, route rollback, Native/executable work, or broad cleanup without a new instruction. diff --git a/docs/todo.md b/docs/todo.md index 6c6fc27..4b9d078 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -12,9 +12,9 @@ Only items in **Now** are executable after their stated decision gate. A branch 4. **Gate the strict-NAT guessed-candidate experiment.** Build only the isolated endpoint-dependent NAT emulator and browser harness first. Standard ICE must fail in the owned fixture, the capped candidate set must pass the documented repeatability, deadline, privacy, and cleanup gates in stable Chrome and Firefox, and fallback must remain unchanged. Only then may one manually approved, operator-owned test room run a default-off, kill-switched canary with explicit consent from both endpoints and exact room/edge/generation/budget bounds; failure closes the candidate. Do not ship guessed candidates to ordinary rooms or infer NAT type from IP, UA, carrier, or two STUN observations. 5. **Make Viewer connection progress explicit.** Derive one stable stage from existing access, route, peer/SFU, media, autoplay, and bounded-failure state. Keep the overlay until a composited current-generation frame, show `Play` only for actual autoplay rejection, preserve a still-valid old frame during recovery, and expose retry only through the bounded controller. Measure the existing token-free SFU prewarm and route stages before adding another connection mechanism. -## Queued After Route +## Queued After Connection Feedback -1. **Implement memory-resident rooms and clear sharing controls.** Replace the two current room modes with one in-memory model: uniformly random free codes from `1000..9999`, `ROOM_LEASE_SECONDS=86400` by default, no expiry while Host sharing, dormant renewal only by the exact Host token, release after expiry, and total loss on process restart. Remove SQLite, `ROOM_DATABASE_PATH`, `ROOM_TTL_SECONDS`, schema/migration code, and compatibility surfaces atomically. Make Viewer grant independent of `open | password | disabled` code entry; default to `open` with no room password while the primary share action copies the token-bearing invitation. Persist the Host's display name, code-entry policy, and optional room password as a same-origin local creation profile and atomically reapply it to a newly created room after expiry/restart. Give room code, invitation link, grant rotation/revocation, and code/password policy distinct direct labels and familiar icons. Current source still implements the old model; do not report this item complete until source, tests, deployment guidance, and browser behavior agree. +1. **Release memory-resident rooms and clear sharing controls.** The `screener-v8` source implementation and local gates are complete. At this accepted release boundary, run production preflight, atomically replace `ROOM_DATABASE_PATH` / `ROOM_TTL_SECONDS` with `ROOM_LEASE_SECONDS`, deploy server and Browser assets together, verify old v7 clients fail before room authority, confirm restart-wide room loss and same-browser preference replay, then run postflight and rollback verification. Do not migrate SQLite rooms or preserve a v7 compatibility path. ## Accepted Later Roadmap diff --git a/docs/verification-status.md b/docs/verification-status.md index 9408155..0c8bd9b 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -22,6 +22,7 @@ Capacity and routing entries below describe the exact tested source or release o - Exact Wave B implementation source `815c6174d411c55648daf5cd6ac4c42f64effff9`, based on `d1ba2e52919225e67ad0c8dd2b660f333fbc88fd`, passed its 372-test SFU-admission gate, all 690 Web tests, TypeScript typecheck, and client/server builds. Focused tests cover reserved/committed/draining accounting, reserve-before-token enforcement, explicit room lifecycle, listener-owner startup fencing, restart cleanup, stale-token replay boundaries, Host-offline reclamation, shutdown races, and two-room global admission. This is fake-control-plane and local state-machine evidence, not a real LiveKit process/JWT replay, packet-flow, multi-process, deployment-capacity, or production proof. - Exact route source `4e0a922` passed repository hygiene, TypeScript typecheck, and 84 focused controller, runtime, protocol, transition, decoded-stall, and SFU-admission tests. During the same convergence, the full 557-test Web suite and client/server builds passed before the final no-consumer cleanup. The two independent route audits' model and wire findings are incorporated, and final static review found no remaining P0/P1. No Browser, Native, Go, helper, hardware, or executable test ran in the final pass; this is source and state-machine evidence, not production or heterogeneous-network proof. - Exact source `6b87732` passed the required one-Host/20-Viewer local Browser smoke on Windows Node 24.19.0 / Chrome 151.0.7922.174, using 720p30 synthetic canvas media, endpoint cap `2`, a 30-second measurement window, and report SHA-256 `1347E036BB9AC35D51B4ED9B215290E742715128C7F3056D8FEB185CC84456D6`. All six checks passed: 20/20 Viewers had live clean upstreams and increasing decoded frames, Host and relay sender counts stayed at two, topology was coherent, and no orphan SFU publication appeared. Maximum authenticate-to-first-decoded latency was 958 ms; the final graph had two Host children and eighteen peer descendants. The application Node process consumed about 0.95 additional CPU seconds over the 54-second observation window, peaked near 284 MiB RSS, and settled near 162 MiB; isolated Chromium averaged 761% CPU over its valid 25.97-second process interval. The harness did not configure LiveKit or TURN, so their observed usage was zero and this loopback is not a public-network, SFU/TURN, quality, or endurance claim. +- Exact memory-room source `2ba155782bd5c4a193484c007ee69007e562ddb9` passed repository hygiene, TypeScript typecheck, all 548 Web tests, and client/server builds. The focused room/access set contributed 239 passing tests across allocation, dormant leases, grant/code orthogonality, password KDF bounds, stale generation atomicity, protocol v8, HTTP/signaling, local ownership, and creation-profile replay. A fresh Chrome access/privacy gate also passed all fixed checks: pre-DOM fragment consumption, room-scoped session grant, zero request/nginx/application-log secret hits, zero persistent room files, strong grant rotate/revoke coverage, and cleanup. This is local source, state-machine, and loopback-browser evidence; it is not production deployment, restart/rollback, heterogeneous-network, or media-quality evidence. - Current source TURN-admission unit and signaling tests cover independent peer and Host-SFU logical edges, shared capacity exhaustion, exact reserve/commit/drain/release identity, current-share cleanup, and graceful shutdown. This is local authorization evidence, not real coturn, network-media, or production-capacity proof. - Chrome 151 synthetic `1/3/5/8` and 720p30 runs preserved bounded fanout and decoding. A `9461e20`-era three-Viewer rerun kept Host2/Browser Viewer1, decoded every Viewer, and recovered a hard first-level relay departure in 5,380 ms; it did not exercise quality-triggered MBB. Sixteen-Viewer cap2/cap3 loopbacks decoded all Viewers and a ten-second follow-up recorded guarded sender and aggregate Chromium CPU evidence, but resolution/FPS remained low and the ordinary-PC data cannot rank caps, select product policy, or prove sustainable heterogeneous-network quality. See [peer-assisted media](./research/peer-assisted-media.md). - Chrome 151 with local LiveKit 1.13.5/client 2.22.0 proved one SFU/UDP root after `q,f` -> `q,h`: decoded frames advanced 3 -> 23, rendered frames reached 26, Host used one edge, leaves were clean, and TURN was absent. This is functional evidence only. See [low-server media routes](./research/low-server-media-routes.md). @@ -29,7 +30,6 @@ Capacity and routing entries below describe the exact tested source or release o - The built-in peer TURN canary was rejected after allocation passed but the direct Host/Pion Viewer path failed before forced relay. Full rollback restored STUN-only client ICE and zero allocations. See [built-in peer ICE TURN](./research/built-in-peer-ice-turn.md). - Chrome H.264 loopback proves negotiation and decode interoperability, not encoder performance or hardware attribution. Native WGC/MF has a one-Viewer Windows proof with 203 rendered 1280x720 frames, 500 Opus packets, and nonzero process/LUID-correlated `VideoEncode`. See [Native H.264](./research/native-h264-hardware-decision.md) and [Native shared encode](./research/native-shared-encode-sender.md). - Native Windows 11 process audio is source-only and default-off. A retained run isolated the target by about 4018x and delivered 495 Opus packets to one Viewer; package download, game sync, Windows 10, and other routes remain open. See [ADR-0008](./adr/0008-window-scoped-audio-capture.md). -- The local access gate proves the current source's pre-DOM fragment clearing, room-scoped session isolation, no site/grant transport-log hits, no raw room-password SQLite sentinel, and rotate/revoke behavior. It predates the accepted in-memory room replacement and is headless loopback evidence only. See [ADR-0002](./adr/0002-memory-resident-protected-rooms.md). ## Open Proof Boundaries @@ -42,7 +42,7 @@ Capacity and routing entries below describe the exact tested source or release o - Observability: the UI and local stats plumbing are deployed, including identity-bound relayed-detail retention and export freshness guards, but target-browser availability and actual values remain unverified for Host SFU sender evidence, codec/fmtp and encoder implementation/power efficiency, media-source and encoded FPS, A/V playout/jitter/concealment, selected candidate endpoints, and bounded same-pair STUN-response observation. These fields diagnose and have no recovery authority; they do not by themselves prove hardware encode, synchronization quality, or route quality. - Mobile: Android/iOS remain Viewer-only. Autoplay, rotation, iOS lock-screen/page reclamation, background reconnection, and network changes need device evidence. AirPlay/system mirroring is Viewer-local output, not a portable in-app WebRTC output contract. - Native: ADR-0006 has one-Viewer evidence only. Viewer2/FIFO, hardware diversity, endurance, downloaded-package/public-host, game A/V, and browser diversity remain open. -- Access and operations: run headful/production checks for fragment consumption, rotate/revoke, and request/nginx/journal/SQLite leakage. The isolated VM/IP gate remains required before clean-port migration; shared-IP smoke cannot approve it. +- Access and operations: the v8 candidate still needs production preflight/postflight for atomic environment replacement, stale-v7 rejection before authority, restart-wide room loss, same-browser recreation, fragment/rotate/revoke behavior, and exact request/nginx/journal inspection. Preserve the stopped v7 SQLite backup only as the rollback boundary; the v8 runtime must create no room database or writable room directory. The isolated VM/IP gate remains required before clean-port migration; shared-IP smoke cannot approve it. ## Evidence Rules diff --git a/docs/方案设计.md b/docs/方案设计.md index 68e44a0..c3fa2f9 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -125,7 +125,7 @@ WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site- Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。 -当前唯一 wire 是 `screener-v7`,不做版本协商、旧 parser、双写或 translator。任何旧页面或 executable sender 的首条消息在角色进入 room lookup 前得到同一个 fatal refresh 终态并停止自动重连。缺失、过期、跨房、已 rotate 或 revoked 的当前 Viewer grant 使用同一个通用“邀请无效或已失效”结果,不区分房间不存在与凭据错误。 +当前唯一 source wire 是 `screener-v8`,不做版本协商、旧 parser、双写或 translator。任何旧页面或 executable sender 的首条消息在角色进入 room lookup 前得到同一个 fatal refresh 终态并停止自动重连。缺失、过期、跨房、已 rotate 或 revoked 的当前 Viewer grant 使用同一个通用“邀请无效或已失效”结果,不区分房间不存在与凭据错误。 ## WebRTC 协商 @@ -327,7 +327,7 @@ Token-free SFU standby prewarm 保持可选且不创建 participant、publicatio - RoomStore 的四位空闲码随机分配/释放、活动分享免过期、dormant lease/续租、Host token/Viewer grant 摘要验证、`open/password/disabled` 正交 policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、Viewer 上限和超额拒绝; - 进程重启后 room state 全空、旧 Host/grant/password fail closed、释放号码复用时旧 grant 仍失败;`ROOM_LEASE_SECONDS` 的默认/边界与 `MAX_ROOMS <= 9000` 校验,已删除的 `ROOM_DATABASE_PATH`/`ROOM_TTL_SECONDS` fail-fast; - STUN/TURN UDP URL 严格校验、生产 STUN 必需、server-assisted tuple 完整性/TTL/secret 独立性、issuer HMAC/expiry/opaque tag、当前 route/session/revision/connection generation guard,以及 ordinary peer、未授权房间和 Native 的 STUN-only isolation;LiveKit `sfu-config` 保持独立边界; -- 单一 `screener-v7` 信令 literal、任意旧客户端 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; +- 单一 `screener-v8` 信令 literal、任意旧客户端 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;screen audio SDP helper 覆盖 CRLF、existing/missing fmtp、unknown key preservation、幂等、payload number、rejected/non-audio,以及 empty/no-Opus/ambiguous/parser 异常原对象 fallback,`ViewerPeer` 覆盖 initial/restart/rebuild 时同一结果的 local/signaling 一致性,SFU publisher 精确断言 high-quality stereo/forceStereo/DTX 且不覆盖 RED; - HTTP/WS 集成:nginx per-source site-access `limit_req` 与统一错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured site access 与 Host token,Viewer 要么持 exact-room grant,要么先有 site access 再按 `open/password/disabled`。覆盖原子创建 profile、同浏览器重建偏好、fragment/sessionStorage/replaceState、无 site access 的 code-only 统一拒绝、通过 site access 后的 open/password/disabled 行为、raw grant/密码不进入 URL/日志/服务端持久状态,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; - 客户端策略与 stats 解析:会话替换不重连,ordinary ICE、LiveKit 与 selected-edge TURN protocol 分离,远端 TURN transport 保持未知; @@ -360,7 +360,7 @@ Token-free SFU standby prewarm 保持可选且不创建 participant、publicatio 8. 当前发布只验收 Web Host、Viewer 和 browser relay。Native sender、capture helper 与 shared-encode executable 排在整体 roadmap 最后,不参与当前构建、路由验收或发布。 9. 当前功能、NAT 与真实网络 TODO 完成后,再分别设计和交付公网服务器一键部署包与完全不依赖外部服务的本地 Host-server 包。 -生产已运行 `screener-v7` route release `6b87732`。内部不兼容 wire/schema 原子切换,Git commit 与部署备份承担回滚,不保留兼容实现。 +生产仍运行 `screener-v7` route release `6b87732`;当前房间 source 使用 `screener-v8`。内部不兼容 wire/schema 原子切换,Git commit 与部署备份承担回滚,不保留兼容实现。 任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。 @@ -374,7 +374,7 @@ Token-free SFU standby prewarm 保持可选且不创建 participant、publicatio 首次需要 SFU 但 Host 已满时不建立另一条 rescue 流程。若 affected child 已是 Host direct child,就用它;否则用稳定加入顺序中最新的 connected Host direct child。`reconcileRoom` 为该 child 建立普通 SFU candidate,创建唯一 publication,保持其 subtree,并在该 child 首帧后提交。Host publication 取代释放的 peer slot,随后同一 fixed-point scan 再处理原 waiting/failed child。若旧 Host peer sender 在首帧前仍保留,candidate 必须持有同一 `TransitionOverlapSlot`。没有 overlap 时先 preflight server resource,再退休 exact producer;operation 保持原总 deadline、按新 graph 重算未尝试 suffix,并为被切断的 healthy direct edge追加一个最后 restore tuple。Forward candidate 与 restore 都失败才进入 waiting,不得产生第四份 endpoint copy或另一套恢复状态。 -所有非服务器端点共享一个 server-authoritative steady outbound media-copy cap `C`:唯一配置 `ENDPOINT_MEDIA_COPY_CAPACITY` 默认 `2`,只接受 `1`、`2` 或 `3`,不兼容旧键。服务端将 endpoint 当前广告的 `0..C` 可用值 clamp 为唯一 `effectiveDownstreamCapacity`;该值只表示当前可用 sender slots,不建立角色、浏览器、UA、设备或页面可见性 tier。peer child 或 Host publication 各占一份,upstream receive 免费,committed selected TURN 只替换同一份 edge transport;SFU subscriber egress 与 TURN allocation 使用独立 server admission。Browser `screener-v7` 的 `authenticated.endpointMediaCopyCapacity` 把部署 `C` 交给 Host 与 Viewer;两端用该值校验稳态 assignment,并让 active+pending 的物理 copy 只在 controller 已授权 overlap 时达到 `min(C + 1, 3)`。代码通过一个 shared accounting guard 执行,不重复 magic number;旧 wire 与 Native executable 在 authority 前拒绝,不保留兼容路径。Selected TURN 使用显式无默认值的正 safe-integer deployment capacity;同一个进程内 ledger 对每条 exact peer-selected edge 和 Host-SFU ingress 在 credential 签发前 reserve `1`,并让 reserved、committed、draining authorization 持续计费至 exact logical release。`C=3` 满载时先收到 active bounded-gap retirement再收到 prepare;任何会产生第四份 endpoint media copy 的 token/grant都不得签发。同一 edge 改用 TURN 不增加稳态 endpoint copy,但仍需独立 TURN admission。 +所有非服务器端点共享一个 server-authoritative steady outbound media-copy cap `C`:唯一配置 `ENDPOINT_MEDIA_COPY_CAPACITY` 默认 `2`,只接受 `1`、`2` 或 `3`,不兼容旧键。服务端将 endpoint 当前广告的 `0..C` 可用值 clamp 为唯一 `effectiveDownstreamCapacity`;该值只表示当前可用 sender slots,不建立角色、浏览器、UA、设备或页面可见性 tier。peer child 或 Host publication 各占一份,upstream receive 免费,committed selected TURN 只替换同一份 edge transport;SFU subscriber egress 与 TURN allocation 使用独立 server admission。Browser `screener-v8` 的 `authenticated.endpointMediaCopyCapacity` 把部署 `C` 交给 Host 与 Viewer;两端用该值校验稳态 assignment,并让 active+pending 的物理 copy 只在 controller 已授权 overlap 时达到 `min(C + 1, 3)`。代码通过一个 shared accounting guard 执行,不重复 magic number;旧 wire 与 Native executable 在 authority 前拒绝,不保留兼容路径。Selected TURN 使用显式无默认值的正 safe-integer deployment capacity;同一个进程内 ledger 对每条 exact peer-selected edge 和 Host-SFU ingress 在 credential 签发前 reserve `1`,并让 reserved、committed、draining authorization 持续计费至 exact logical release。`C=3` 满载时先收到 active bounded-gap retirement再收到 prepare;任何会产生第四份 endpoint media copy 的 token/grant都不得签发。同一 edge 改用 TURN 不增加稳态 endpoint copy,但仍需独立 TURN admission。 SFU admission 在应用单进程中只有一个注入实例。LiveKit credential tuple 完整时,必须同时显式配置私有 `LIVEKIT_API_URL`、正 safe integer `SFU_INGRESS_CAPACITY` 与 `SFU_EGRESS_CAPACITY`;容量没有产品默认值,也不从 Viewer 数、endpoint cap 或历史 root 数推导。专用 LiveKit 实例设置 `room.auto_create: false`。账本只保存全局已用计数,以及 exact room/share/publication generation 下的一份 publication handle 和按 Viewer identity 索引的 subscription handle。Publication 的 `reserved | committed | draining` 占 ingress `1`,每份 subscription 的同类状态占 egress `1`;离开 route 的 subscription 在该 generation 删除并确认 absent 前保持 draining,同一 Viewer handle 再次启用不重复计数。首个 SFU child 先 reserve publication 与自己的 subscription,再通过 `RoomService.CreateRoom` 创建 exact-generation room,成功后才 prepare 和签发 token;后续 child 只在同 generation reserve subscription 并复用 Host publication。exact candidate child 的 first decoded frame 后 commit 自己的资源;publication replacement 将旧 generation 及其全部 subscription 一起转入 draining,失败、超时、失联、分享换代、停止或删房按资源所有权进入同一生命周期。只有 `RoomService.DeleteRoom` 成功且 follow-up lookup 证明 room 不存在,账本才扣除该 generation 的 ingress/egress;auto-create 关闭后,旧 token 不能重建已删除 room。进程启动在接受流量前拒绝任何非 Screener room,删除全部遗留 Screener room 并确认 namespace 为空;关闭时先 drain 再遗忘计数。Host 信令离线期间若 exact room 中的 Host participant 仍存在就继续计费并保留健康媒体,participant 消失后在一个有界轮询周期内回收。稳态 reserve/commit/drain bookkeeping 仍是 Map lookup 与整数加减;只有 generation 创建、删除、启动清场和 Host 离线检查访问 LiveKit control plane。TURN allocation 使用独立的进程内 logical ledger;多应用进程部署必须先增加共享原子 admission/lifecycle owner。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 9a8f3b6..a3e7ed6 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -24,7 +24,7 @@ SFU/TURN 的 publication、subscription、transport、server-resource accounting - 观看端本地音量:播放控制栏提供 `0%` 至 `100%` 音量条和静音按钮,只改变当前浏览器的媒体元素,不修改 Host 音轨、编码、房间状态或其他观看者。切流、换父和 ICE 恢复保留当前页面内的值;刷新后不持久化。静音保留上次非零音量,拖动到非零值自动取消静音。 - 本地昵称与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名,界面统一称为“昵称”;默认以只读文本显示,铅笔编辑按钮紧贴昵称,点击后才进入编辑。Viewer 顶部标题直接显示“{Host 名称} 的屏幕”,不得在下方重复一行 Host 名称。信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写服务端持久存储或日志。空值使用通用角色 fallback。Web Host 与 Viewer 都必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份默认只显示名称;仅当房内出现同名时追加当前房间 `peerId` 的至少六字符短后缀,并只在同名项后缀碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。Web Host 与 Viewer 通过同一 opt-in participant roster 看到 Host 名称;Native/executable sender 不在当前发布面,也不接收 Web roster。 - 一对多观看:每个房间允许一名 Host 和最多 `20` 名 authenticated Viewer;房间 admission 与端点下游容量、SFU/TURN admission 分别配置,超额加入必须明确拒绝。Production 当前默认 8、显式接受 1 至 16 是部署事实;本轮 route release 把 source hard limit 提升到 20,并以一个 20-Viewer smoke 验收,不从 endpoint cap 推导房间人数。 -- 端点发送容量:所有非服务器端点使用同一个服务端权威稳态 outbound media-copy capacity,由唯一部署项 `ENDPOINT_MEDIA_COPY_CAPACITY` 配置,默认 `2` 且只接受 `1`、`2` 或 `3`;不读取旧键或保留第二套 tier。peer child 或 Host 的唯一 SFU publication 各消耗一份,上游接收不消耗该预算,committed selected TURN 只替换同一份 logical edge 的 transport。客户端只能声明不高于部署值的可用容量,角色、浏览器、UA、设备类型和页面可见性不产生不同上限。服务端在每次 `authenticated` 权威快照中返回最终 `endpointMediaCopyCapacity`;普通非 peer-assisted 模式也只授权稳定加入顺序中的至多 `C` 个 Host child,超额 Viewer 保持入房等待并在槽位释放后提升,信令不能绕过该集合。Browser Host 与 Viewer 严格校验该值并用它约束稳态 assignment;并行产生媒体的 candidate 必须持有唯一 `TransitionOverlapSlot`,active+pending 物理 copy 只可达到 `min(C + 1, 3)` 并在 commit/abort 后释放。`C=3` 满载时必须先收到 active bounded-gap retirement 再 prepare,绝不产生第四份 copy。SFU subscriber egress 与 TURN allocation 由独立 server admission 记账。该不兼容能力以 `screener-v7` 原子升级;缺失/非法 cap、旧 wire 与 executable sender都在取得room/route authority前fail closed。 +- 端点发送容量:所有非服务器端点使用同一个服务端权威稳态 outbound media-copy capacity,由唯一部署项 `ENDPOINT_MEDIA_COPY_CAPACITY` 配置,默认 `2` 且只接受 `1`、`2` 或 `3`;不读取旧键或保留第二套 tier。peer child 或 Host 的唯一 SFU publication 各消耗一份,上游接收不消耗该预算,committed selected TURN 只替换同一份 logical edge 的 transport。客户端只能声明不高于部署值的可用容量,角色、浏览器、UA、设备类型和页面可见性不产生不同上限。服务端在每次 `authenticated` 权威快照中返回最终 `endpointMediaCopyCapacity`;普通非 peer-assisted 模式也只授权稳定加入顺序中的至多 `C` 个 Host child,超额 Viewer 保持入房等待并在槽位释放后提升,信令不能绕过该集合。Browser Host 与 Viewer 严格校验该值并用它约束稳态 assignment;并行产生媒体的 candidate 必须持有唯一 `TransitionOverlapSlot`,active+pending 物理 copy 只可达到 `min(C + 1, 3)` 并在 commit/abort 后释放。`C=3` 满载时必须先收到 active bounded-gap retirement 再 prepare,绝不产生第四份 copy。SFU subscriber egress 与 TURN allocation 由独立 server admission 记账。当前不兼容房间合同以 `screener-v8` 原子升级;缺失/非法 cap、旧 wire 与 executable sender都在取得room/route authority前fail closed。 - P2P 优先:每条普通逻辑媒体边先尝试 direct/peer UDP,服务器不得默认中转可直连媒体。更多观看者优先沿满足统一端点容量的无环 peer 图分布;每名 Viewer 同时至多一个 active upstream,失败时只影响需要重选的 child。任何 source-reachable endpoint 在 effective capacity 有空位时都可作为 parent candidate;Peer-fed 与 SFU-fed endpoint 使用同一个 provisional-child transaction,并在 candidate child 解码出第一帧新画面后独立提交该 edge。Host 已满且首次需要 SFU 时,同一 reconcile 将 affected Host direct child(若无则稳定顺序中最新的 connected Host direct child)作为本次 child operation,创建唯一 Host publication 并把该 child 变为首个 SFU subscriber,subtree 不动;publication 取代释放的 Host peer slot,下一次扫描再处理原受限 Viewer,不建立 bootstrap 状态机。 - NAT 与传输边界:STUN 必须可用,所有 ordinary peer `RTCPeerConnection` 只接收 STUN ICE servers,普通边不得预发 TURN candidate。所有恢复和候选授权必须绑定 authenticated room/endpoint session、share generation、base/pending route revision 与 candidate connection identity;成功即停止,失败、超时或绑定失效即撤销,全部可用路径耗尽后明确等待或失败。已接受的顺序是保持既有 logical edge,必要时 direct/STUN 后使用 exact selected TURN;只有 logical ingress 不可用时才使用当前 Host publication 的 SFU subscription。Host 无 direct first-level path 时优先一条可经 TURN 的 Host-SFU publication,而不是逐 Viewer 建 Host-TURN peer 边。凭据不得进入应用页面 URL、日志或服务端持久存储;TURN 凭据和 Screener route grant 不进入任何 URL,LiveKit join JWT 仅按协议进入不得记录的 WebSocket request target。HTTPS/WSS 继续独立使用 TLS/TCP。 - 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,三档与高级设置初始值统一采用 `balanced`(平衡)与 `automatic`(自动编码),`maintain-resolution`(清晰优先)、`maintain-framerate`(流畅优先)和 H.264/VP8 保留为显式选择。分享期间可直接换视频档位和 64/128/256 kbps 音频 sender ceiling,不重新选择来源或重建健康连接;视频 codec 首个可交付切换边界是在分享已显式暂停时执行有界重协商,未暂停热切换是后续复用同一事务的扩展。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,具体降级由浏览器决定;必须读回实际 sender 参数并通过 stats 观测结果、显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。 @@ -132,7 +132,7 @@ SFU/TURN 的 publication、subscription、transport、server-resource accounting ### 一对多与带宽保护 -- Given `ENDPOINT_MEDIA_COPY_CAPACITY=C`,其中 `C` 只能是 `1`、`2` 或 `3` 且默认 `2`,When Browser Host 或任一 Browser Viewer 建立、恢复、重鉴权或迁移媒体路径,Then peer child 与 Host publication 的稳态实际发送副本总数不超过 `C`,upstream receive 不计数,committed selected TURN 不重复计数,角色、浏览器和 UA 不改变 `C`;每个 `authenticated` 快照都携带同一个 `C`,并行候选只有持有 `TransitionOverlapSlot` 时才可达到 `min(C + 1, 3)`,并在 commit/abort 后释放。When 此能力合同上线,Then server 与 Browser assets 在同一 `screener-v7` release 原子升级,缺失或越界的 `C`、旧 wire 与 executable sender 都在房间授权前得到明确 mismatch,且 `C=3` 满载 Host 在取得会产生第四份 endpoint media copy 的 publication/token/grant 前明确等待或失败。 +- Given `ENDPOINT_MEDIA_COPY_CAPACITY=C`,其中 `C` 只能是 `1`、`2` 或 `3` 且默认 `2`,When Browser Host 或任一 Browser Viewer 建立、恢复、重鉴权或迁移媒体路径,Then peer child 与 Host publication 的稳态实际发送副本总数不超过 `C`,upstream receive 不计数,committed selected TURN 不重复计数,角色、浏览器和 UA 不改变 `C`;每个 `authenticated` 快照都携带同一个 `C`,并行候选只有持有 `TransitionOverlapSlot` 时才可达到 `min(C + 1, 3)`,并在 commit/abort 后释放。When 当前房间合同上线,Then server 与 Browser assets 在同一 `screener-v8` release 原子升级,缺失或越界的 `C`、旧 wire 与 executable sender 都在房间授权前得到明确 mismatch,且 `C=3` 满载 Host 在取得会产生第四份 endpoint media copy 的 publication/token/grant 前明确等待或失败。 - Given peer-assisted 路径分别运行 1、5、10、20 名观看者,When 用户加入、容量释放、节点离开、重鉴权或当前 edge 失效,Then 服务端只为新 Viewer、等待 Viewer 或失去 parent 的 subtree root 选择一个 active parent;每名 Viewer 同时至多一个 active upstream,活动图无环,其余健康边保持 sticky。没有可用 parent 时明确等待或拒绝,不主动重平衡或引入常驻多源媒体。 - Given 一名分享者和 20 名观看者,When 运行 bounded browser smoke,Then 全部 Viewer 取得明确媒体或 bounded wait/failure,非服务器端点不超过 `C`,并记录 route depth、首帧时间、实际 sender 数、应用服务器 CPU/内存以及 SFU/TURN 用量。该 smoke 验证 admission、控制面效能和资源上界,不单独证明任意公网画质或 30 分钟 endurance。 - Given candidate 改变房间 admission 默认值或上限,When 在目标数量与代表性路径上运行规定时长并注入 relay departure、重连和一个弱路径,Then 每名观看者持续解码、每个非服务器端点不超过配置容量、深度与故障半径有界、未受影响 subtree 不迁移,并记录端点与中央服务的 CPU、GPU、内存、上行、出口、帧率、丢包率、冻结和恢复;任一门槛失败时不得切换默认值。 diff --git a/scripts/access-privacy-gate.ts b/scripts/access-privacy-gate.ts index 4f9d56e..a3ed26f 100644 --- a/scripts/access-privacy-gate.ts +++ b/scripts/access-privacy-gate.ts @@ -31,7 +31,7 @@ interface GateReport { otherSessionGrantMatches: number; localGrantMatches: number; cookieGrantMatches: number; resourceGrantMatches: number; requestLeakCount: number; nginxLogLeakCount: number; nginxConfigSafe: boolean; - appLogLeakCount: number; sqliteLeakCount: number; leakAuditCompleted: boolean; + appLogLeakCount: number; persistentRoomFileCount: number; leakAuditCompleted: boolean; rotateRevokeCasePresent: boolean; cleanupPassed: boolean; } interface BrowserAudit { @@ -52,9 +52,8 @@ function diagnosticText(values: readonly unknown[]): string { try { return JSON.stringify(value) ?? ""; } catch { return ""; } }).join(" "); } -async function sqliteFiles(profile: string): Promise { - const names = (await readdir(profile)).filter((name) => name.startsWith("rooms.sqlite")); - return Promise.all(names.map((name) => readFile(join(profile, name)))); +async function persistentRoomFiles(profile: string): Promise { + return (await readdir(profile)).filter((name) => name.startsWith("rooms.sqlite")); } async function main(): Promise { const report: GateReport = { @@ -65,7 +64,7 @@ async function main(): Promise { canonicalRoomPath: false, roomSessionGrantMatches: 0, otherSessionGrantMatches: 0, localGrantMatches: 0, cookieGrantMatches: 0, resourceGrantMatches: 0, requestLeakCount: 0, nginxLogLeakCount: 0, - nginxConfigSafe: false, appLogLeakCount: 0, sqliteLeakCount: 0, + nginxConfigSafe: false, appLogLeakCount: 0, persistentRoomFileCount: 0, leakAuditCompleted: false, rotateRevokeCasePresent: false, cleanupPassed: false, }; @@ -96,8 +95,8 @@ async function main(): Promise { const config: ServerConfig = { nodeEnv: "production", port: appPort, listenHost: "127.0.0.1", publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]), - siteAccessPassword: sitePassword, roomDatabasePath: join(profile, "rooms.sqlite"), - roomTtlMs: 14_400_000, maxRooms: 4, maxViewersPerRoom: 2, + siteAccessPassword: sitePassword, roomLeaseMs: 86_400_000, + maxRooms: 4, maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2, peerAssistedMedia: false, stunUrls: [], }; server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") }); @@ -116,12 +115,12 @@ async function main(): Promise { const createdResponse = await fetch(`${baseUrl}/api/rooms`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie, Origin: baseUrl }, - body: JSON.stringify({ viewerPolicy: "private-link" }), + body: JSON.stringify({ codeEntryPolicy: "open" }), }); const room = createRoomResponseSchema.parse(await createdResponse.json()); const invite = new URL(room.inviteUrl); const grant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? ""; - if (!grant) throw new Error("Private room did not return a Viewer grant"); + if (!grant) throw new Error("Room creation did not return a Viewer grant"); server.roomStore.connectParticipant({ roomId: room.roomId, role: "host", token: room.hostToken, clientId: "privacy-gate-host", sessionId: "privacy-gate-host-session", @@ -175,8 +174,8 @@ async function main(): Promise { report.requestLeakCount = leakCount(requestRecords, transportSecrets); report.nginxLogLeakCount = leakCount(nginxLines, transportSecrets); report.nginxConfigSafe = !/\$(?:http_authorization|request_body)(?:\b|_)/.test(nginxConfig); - report.appLogLeakCount = leakCount(appLogs, transportSecrets); - report.sqliteLeakCount = leakCount(await sqliteFiles(profile), [...transportSecrets, roomPassword]); + report.appLogLeakCount = leakCount(appLogs, [...transportSecrets, roomPassword]); + report.persistentRoomFileCount = (await persistentRoomFiles(profile)).length; report.leakAuditCompleted = true; } catch { // The report intentionally exposes no exception text or secret-bearing diagnostics. @@ -197,7 +196,7 @@ async function main(): Promise { report.cookieGrantMatches === 0 && report.resourceGrantMatches === 0 && report.leakAuditCompleted && report.requestLeakCount === 0 && report.nginxLogLeakCount === 0 && report.nginxConfigSafe && - report.appLogLeakCount === 0 && report.sqliteLeakCount === 0 && + report.appLogLeakCount === 0 && report.persistentRoomFileCount === 0 && report.rotateRevokeCasePresent && report.cleanupPassed; process.stdout.write(`${JSON.stringify(report)}\n`); process.exitCode = report.passed ? 0 : 1; diff --git a/scripts/native-one-viewer-gate.ts b/scripts/native-one-viewer-gate.ts index 8660d0d..e83d5c2 100644 --- a/scripts/native-one-viewer-gate.ts +++ b/scripts/native-one-viewer-gate.ts @@ -376,12 +376,11 @@ async function main(): Promise { PUBLIC_BASE_URL: baseUrl, ALLOWED_ORIGINS: baseUrl, SITE_ACCESS_PASSWORD: accessKey, - ROOM_DATABASE_PATH: "", PEER_ASSISTED_MEDIA: "true", STUN_URLS: "", }); const roomStore = new ObservedRoomStore({ - ttlMs: config.roomTtlMs, + leaseMs: config.roomLeaseMs, maxRooms: config.maxRooms, maxViewersPerRoom: config.maxViewersPerRoom, }, () => senderLedger.record({ hostWssAuthenticated: true })); @@ -416,7 +415,7 @@ async function main(): Promise { const version = await waitForVersion(debugPort, chrome); cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5_000); report.stages.preflight = { - serverInMemory: config.roomDatabasePath === undefined, + serverInMemory: true, peerAssistedEnabled: config.peerAssistedMedia, oneViewerOnly: true, videoSource: gateVideoSource, diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index 704a8bb..d767832 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -2657,7 +2657,6 @@ export async function main(): Promise { LISTEN_HOST: "127.0.0.1", PUBLIC_BASE_URL: baseUrl, ALLOWED_ORIGINS: baseUrl, - ROOM_DATABASE_PATH: "", PEER_ASSISTED_MEDIA: "true", ENDPOINT_MEDIA_COPY_CAPACITY: String(config.expectedEndpointCap), MAX_VIEWERS_PER_ROOM: String(Math.max( @@ -2669,7 +2668,7 @@ export async function main(): Promise { server = await createScreenerServer({ config: serverConfig, roomStore: new RoomStore({ - ttlMs: serverConfig.roomTtlMs, + leaseMs: serverConfig.roomLeaseMs, maxRooms: serverConfig.maxRooms, maxViewersPerRoom: serverConfig.maxViewersPerRoom, }), diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index ca4fae9..9b5e34c 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -1,7 +1,7 @@ import { createRoomResponseSchema, type CreateRoomResponse, - type ViewerAccessPolicy, + type CodeEntryPolicy, } from "../../shared/protocol"; export interface SiteAccessStatus { @@ -102,7 +102,8 @@ export async function authenticateSiteAccess( } export async function createRoom( - viewerPolicy: ViewerAccessPolicy, + codeEntryPolicy: CodeEntryPolicy, + roomPassword: string | null, ): Promise { const response = await fetch("/api/rooms", { method: "POST", @@ -110,7 +111,10 @@ export async function createRoom( Accept: "application/json", "Content-Type": "application/json", }, - body: JSON.stringify({ viewerPolicy }), + body: JSON.stringify({ + codeEntryPolicy, + ...(roomPassword === null ? {} : { roomPassword }), + }), }); const body = await responseBody(response); diff --git a/src/client/lib/creation-profile.ts b/src/client/lib/creation-profile.ts new file mode 100644 index 0000000..8b17475 --- /dev/null +++ b/src/client/lib/creation-profile.ts @@ -0,0 +1,64 @@ +import { + codeEntryPolicySchema, + viewerPasswordSchema, + type CodeEntryPolicy, +} from "../../shared/protocol"; + +const CREATION_PROFILE_STORAGE_KEY = "screener:host-creation-profile:v1"; + +export interface HostCreationProfile { + codeEntryPolicy: CodeEntryPolicy; + roomPassword: string | null; +} + +export function readCreationProfile(): HostCreationProfile { + try { + const stored = window.localStorage.getItem(CREATION_PROFILE_STORAGE_KEY); + if (!stored) { + return { codeEntryPolicy: "open", roomPassword: null }; + } + const parsed = JSON.parse(stored) as { + codeEntryPolicy?: unknown; + roomPassword?: unknown; + }; + if ( + !codeEntryPolicySchema.safeParse(parsed.codeEntryPolicy).success || + (parsed.codeEntryPolicy === "password" && + (parsed.roomPassword === null || parsed.roomPassword === undefined)) || + (parsed.roomPassword !== null && + parsed.roomPassword !== undefined && + !viewerPasswordSchema.safeParse(parsed.roomPassword).success) + ) { + window.localStorage.removeItem(CREATION_PROFILE_STORAGE_KEY); + return { codeEntryPolicy: "open", roomPassword: null }; + } + return { + codeEntryPolicy: parsed.codeEntryPolicy as CodeEntryPolicy, + roomPassword: + parsed.roomPassword === null || parsed.roomPassword === undefined + ? null + : (parsed.roomPassword as string), + }; + } catch { + return { codeEntryPolicy: "open", roomPassword: null }; + } +} + +export function saveCreationProfile(profile: HostCreationProfile): void { + if ( + !codeEntryPolicySchema.safeParse(profile.codeEntryPolicy).success || + (profile.roomPassword !== null && + !viewerPasswordSchema.safeParse(profile.roomPassword).success) || + (profile.codeEntryPolicy === "password" && profile.roomPassword === null) + ) { + return; + } + try { + window.localStorage.setItem( + CREATION_PROFILE_STORAGE_KEY, + JSON.stringify(profile), + ); + } catch { + // The current room can still use the operator's in-memory choices. + } +} diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index 78f5ec3..7db66c6 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -3,7 +3,7 @@ import { roomCodeSchema, viewerGrantSchema, type CreateRoomResponse, - type ViewerAccessPolicy, + type CodeEntryPolicy, } from "../../shared/protocol"; import { createOpaqueId } from "./opaque-id"; @@ -22,7 +22,7 @@ export type HostRoomIdentity = Pick< > & { canonicalUrl: string }; export interface HostRoomState extends HostRoomIdentity { - viewerPolicy: ViewerAccessPolicy | null; + codeEntryPolicy: CodeEntryPolicy | null; inviteUrl: string | null; } @@ -40,17 +40,6 @@ export function roomRouteFromInput(value: string): string | null { return isValidRoomId(roomId) ? `/r/${roomId}` : null; } -export function isHostRoomExpired( - room: HostRoomIdentity, - 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); @@ -59,7 +48,7 @@ export function clearHostRoom(): void { } } -export function readHostRoom(now = Date.now()): HostRoomIdentity | null { +export function readHostRoom(): HostRoomIdentity | null { let stored: string | null; try { stored = window.localStorage.getItem(HOST_ROOM_STORAGE_KEY); @@ -82,10 +71,6 @@ export function readHostRoom(now = Date.now()): HostRoomIdentity | null { expiresAt: parsed.data.expiresAt, canonicalUrl: canonicalViewerUrl(parsed.data.inviteUrl), }; - if (isHostRoomExpired(room, now)) { - clearHostRoom(); - return null; - } return room; } catch { clearHostRoom(); @@ -211,16 +196,14 @@ export function mergeAuthenticatedHostRoom( current: HostRoomState | null, activeRoomId: string, roomExpiresAt: string | null, - viewerPolicy: ViewerAccessPolicy, + codeEntryPolicy: CodeEntryPolicy, ): HostRoomState | null { if (!current || current.roomId !== activeRoomId) { return current; } let inviteUrl = current.inviteUrl; - if (viewerPolicy === "public-watch") { - inviteUrl = current.canonicalUrl; - } else if (current.viewerPolicy === null) { + if (inviteUrl === null) { const viewerGrant = readViewerGrant(current.roomId); if (viewerGrant) { const restoredInvite = new URL(current.canonicalUrl); @@ -232,7 +215,7 @@ export function mergeAuthenticatedHostRoom( return { ...current, expiresAt: roomExpiresAt, - viewerPolicy, + codeEntryPolicy, inviteUrl, }; } diff --git a/src/client/media/viewer-message-authority.ts b/src/client/media/viewer-message-authority.ts index 13b7e76..35ef1f8 100644 --- a/src/client/media/viewer-message-authority.ts +++ b/src/client/media/viewer-message-authority.ts @@ -4,7 +4,7 @@ const AUTHORITY_MESSAGE_TYPES = new Set([ "authenticated", "host-status", "sharing-stopped", - "viewer-access-revoked", + "viewer-grant-revoked", "room-closed", "error", ]); diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index e784b4f..323dc4a 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -1,7 +1,6 @@ import { Check, Copy, - Globe2, Hash, KeyRound, LockKeyhole, @@ -29,7 +28,7 @@ import { type ParticipantPresenceEntry, type PreparedRouteCandidate, type ServerMessage, - type ViewerAccessPolicy, + type CodeEntryPolicy, } from "../../shared/protocol"; import { AppHeader } from "../components/AppHeader"; import { ConnectionSelfCheck } from "../components/ConnectionSelfCheck"; @@ -48,6 +47,11 @@ import { StatsGrid } from "../components/StatsGrid"; import { TopologyView } from "../components/TopologyView"; import { hasPeerRouteEvidence } from "../components/status-badge-model"; import { ApiError, createRoom } from "../lib/api"; +import { + readCreationProfile, + saveCreationProfile, + type HostCreationProfile, +} from "../lib/creation-profile"; import { createOpaqueId } from "../lib/opaque-id"; import { downloadDiagnosticReport, type DiagnosticConnectionInput } from "../lib/diagnostic-export"; import { @@ -57,11 +61,9 @@ import { } from "../lib/display-name"; import { clearHostRoom, - clearViewerGrant, getStableClientId, type HostRoomIdentity, type HostRoomState, - isHostRoomExpired, mergeAuthenticatedHostRoom, readHostRoom, replaceViewerInvite, @@ -189,7 +191,7 @@ function hostRoomFromStored(room: HostRoomIdentity | null): HostRoomState | null return room ? { ...room, - viewerPolicy: null, + codeEntryPolicy: null, inviteUrl: null, } : null; @@ -205,7 +207,7 @@ function hostRoomFromCreated(room: CreateRoomResponse): HostRoomState { hostToken: room.hostToken, expiresAt: room.expiresAt, canonicalUrl: canonicalUrl.toString(), - viewerPolicy: room.viewerPolicy, + codeEntryPolicy: room.codeEntryPolicy, inviteUrl: room.inviteUrl, }; } @@ -230,10 +232,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [room, setRoom] = useState(() => hostRoomFromStored(readHostRoom()), ); - const [newRoomViewerPolicy, setNewRoomViewerPolicy] = - useState("private-link"); - const [viewerAccessUpdating, setViewerAccessUpdating] = useState(false); + const [creationProfile, setCreationProfile] = + useState(readCreationProfile); + const creationProfileRef = useRef(creationProfile); + const [viewerGrantUpdating, setViewerGrantUpdating] = useState(false); const [viewerPasswordEnabled, setViewerPasswordEnabled] = useState(false); + useEffect(() => { + creationProfileRef.current = creationProfile; + }, [creationProfile]); const [viewerPasswordDraft, setViewerPasswordDraft] = useState(""); const [viewerPasswordUpdating, setViewerPasswordUpdating] = useState(false); const [maxViewers, setMaxViewers] = useState(null); @@ -267,7 +273,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const signalRef = useRef(null); const displayNameRef = useRef(displayName); const hostClientIdRef = useRef(null); - const viewerPasswordActionRef = useRef<"set" | "remove" | null>(null); + const viewerPasswordActionRef = useRef(undefined); const iceConfigRef = useRef(null); const peersRef = useRef(new Map()); const retiredConnectionsRef = useRef(new Map()); @@ -1136,21 +1142,18 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { discardPreparedHostChild(); hostPeerIdRef.current = message.peerId; endpointMediaCopyCapacityRef.current = message.endpointMediaCopyCapacity; - setViewerAccessUpdating(false); + setViewerGrantUpdating(false); clearAllViewerQualityEvidence(); setSfuStandbyUrl( "sfuStandbyUrl" in message ? message.sfuStandbyUrl : null, ); setMaxViewers(message.maxViewers); - if (message.viewerPolicy === "public-watch") { - clearViewerGrant(activeRoomId); - } setRoom((current) => mergeAuthenticatedHostRoom( current, activeRoomId, message.roomExpiresAt, - message.viewerPolicy, + message.codeEntryPolicy, ), ); if ( @@ -1202,36 +1205,62 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } return; } - if (message.type === "viewer-access-updated") { - setViewerAccessUpdating(false); + if (message.type === "code-entry-policy-updated") { + const profile = { + codeEntryPolicy: message.codeEntryPolicy, + roomPassword: creationProfileRef.current.roomPassword, + }; + saveCreationProfile(profile); + setCreationProfile(profile); + setRoom((current) => + current + ? { ...current, codeEntryPolicy: message.codeEntryPolicy } + : current, + ); + setNotice( + message.codeEntryPolicy === "open" + ? "已允许仅凭房间号加入" + : message.codeEntryPolicy === "password" + ? "房间号加入已要求密码" + : "已关闭仅凭房间号加入", + ); + return; + } + if (message.type === "viewer-grant-updated") { + setViewerGrantUpdating(false); replaceViewerInvite(activeRoomId, message.inviteUrl); setRoom((current) => current ? { ...current, - viewerPolicy: message.viewerPolicy, inviteUrl: message.inviteUrl, } : current, ); setNotice( - message.viewerPolicy === "public-watch" - ? "已允许仅凭房间号观看" - : message.inviteUrl - ? "已生成新的私密邀请,旧邀请已失效" - : "已撤销当前私密邀请", + message.inviteUrl + ? "已生成新的邀请链接,旧邀请已失效" + : "已撤销当前邀请链接", ); return; } if (message.type === "viewer-password-updated") { const action = viewerPasswordActionRef.current; viewerPasswordActionRef.current = null; + if (action !== undefined) { + const profile = { + codeEntryPolicy: creationProfileRef.current.codeEntryPolicy, + roomPassword: action, + }; + saveCreationProfile(profile); + setCreationProfile(profile); + } setViewerPasswordEnabled(message.enabled); setViewerPasswordUpdating(false); setViewerPasswordDraft(""); - if (action) { + if (action !== undefined) { setNotice( - action === "remove" ? "房间密码已移除" : "房间密码已更新", + action === null ? "房间密码已移除" : "房间密码已更新", ); } return; @@ -1354,7 +1383,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return; } if (message.type === "error") { - setViewerAccessUpdating(false); + setViewerGrantUpdating(false); setViewerPasswordUpdating(false); viewerPasswordActionRef.current = null; if (["INVALID_TOKEN", "ROOM_EXPIRED"].includes(message.code)) { @@ -1421,13 +1450,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { let claimedRoom = false; try { let reusableRoom = room; - if (reusableRoom && isHostRoomExpired(reusableRoom)) { - forgetRoom(); - reusableRoom = null; - } createdRoom = reusableRoom ?? hostRoomFromStored(readHostRoom()); if (!createdRoom) { - const response = await createRoom(newRoomViewerPolicy); + const response = await createRoom( + creationProfileRef.current.codeEntryPolicy, + creationProfileRef.current.roomPassword, + ); createdRoom = hostRoomFromCreated(response); if (!isCurrentGeneration(generation)) { captured.getTracks().forEach((track) => track.stop()); @@ -1441,75 +1469,132 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setRoom(createdRoom); claimedRoom = true; } - const activeRoom = createdRoom; - const hostClientId = getStableClientId("host", activeRoom.roomId); - hostClientIdRef.current = hostClientId; - const hostFallback = defaultHostDisplayName(hostClientId); - const initialDisplayName = readDisplayName(hostFallback); - displayNameRef.current = initialDisplayName; - setDisplayName(initialDisplayName); - setDisplayNameDraft(initialDisplayName); - setDisplayNameError(null); - const signal = new SignalingClient( - { - roomId: activeRoom.roomId, - role: "host", - token: activeRoom.hostToken, - clientId: hostClientId, - shareGeneration, - sharingPaused: false, - viewerPresence: true, - viewerPasswordSettings: true, - displayName: initialDisplayName, - }, - { - onStatus: (status) => { - if ( - isCurrentGeneration(generation) && - signalRef.current === signal - ) { - setSignalStatus(status); - } + let replacementAttempted = false; + const connectSignal = (activeRoom: HostRoomState): SignalingClient => { + let authenticated = false; + const hostClientId = getStableClientId("host", activeRoom.roomId); + hostClientIdRef.current = hostClientId; + const hostFallback = defaultHostDisplayName(hostClientId); + const initialDisplayName = readDisplayName(hostFallback); + displayNameRef.current = initialDisplayName; + setDisplayName(initialDisplayName); + setDisplayNameDraft(initialDisplayName); + setDisplayNameError(null); + const signal = new SignalingClient( + { + roomId: activeRoom.roomId, + role: "host", + token: activeRoom.hostToken, + clientId: hostClientId, + shareGeneration, + sharingPaused: false, + viewerPresence: true, + viewerPasswordSettings: true, + displayName: initialDisplayName, }, - onTerminated: (message) => { - if ( - isCurrentGeneration(generation) && - signalRef.current === signal - ) { - endSharing(message, false); - } + { + onStatus: (status) => { + if ( + isCurrentGeneration(generation) && + signalRef.current === signal + ) { + setSignalStatus(status); + } + }, + onTerminated: (message) => { + if ( + isCurrentGeneration(generation) && + signalRef.current === signal + ) { + endSharing(message, false); + } + }, + onAccessRequired: () => { + if ( + isCurrentGeneration(generation) && + signalRef.current === signal + ) { + endSharing("站点访问已失效,请重新验证", false); + onAuthorizationRequired?.(); + } + }, + onMessage: (message) => { + if ( + !isCurrentGeneration(generation) || + signalRef.current !== signal + ) { + return; + } + if ( + !authenticated && + message.type === "error" && + (message.code === "INVALID_TOKEN" || + message.code === "ROOM_EXPIRED") && + !replacementAttempted + ) { + replacementAttempted = true; + signalRef.current = null; + signal.stop(); + setSignalStatus("offline"); + clearHostRoom(); + setRoom(null); + void createReplacementRoom(); + return; + } + if (message.type === "authenticated" && message.role === "host") { + authenticated = true; + iceConfigRef.current = message.iceConfig; + peersRef.current.forEach((peer) => + peer.updateIceConfig(message.iceConfig), + ); + writeHostRoom({ + ...activeRoom, + expiresAt: message.roomExpiresAt, + }); + setPhase("live"); + } + handleSignalMessage(message, generation, activeRoom.roomId); + }, }, - onAccessRequired: () => { - if ( - isCurrentGeneration(generation) && - signalRef.current === signal - ) { - endSharing("站点访问已失效,请重新验证", false); - onAuthorizationRequired?.(); - } - }, - onMessage: (message) => { - if ( - !isCurrentGeneration(generation) || - signalRef.current !== signal - ) { - return; - } - if (message.type === "authenticated" && message.role === "host") { - iceConfigRef.current = message.iceConfig; - peersRef.current.forEach((peer) => - peer.updateIceConfig(message.iceConfig), - ); - writeHostRoom({ - ...activeRoom, - expiresAt: message.roomExpiresAt, - }); - setPhase("live"); - } - handleSignalMessage(message, generation, activeRoom.roomId); - }, - }, - ); + ); + return signal; + }; + const createReplacementRoom = async (): Promise => { + try { + const response = await createRoom( + creationProfileRef.current.codeEntryPolicy, + creationProfileRef.current.roomPassword, + ); + const replacement = hostRoomFromCreated(response); + if (!isCurrentGeneration(generation)) { + captured.getTracks().forEach((track) => track.stop()); + closeAbandonedRoom(replacement); + return; + } + writeHostRoom(replacement); + setRoom(replacement); + const replacementSignal = connectSignal(replacement); + signalRef.current = replacementSignal; + replacementSignal.start(); + } catch (error) { + if (!isCurrentGeneration(generation)) { + return; + } + activeGenerationRef.current = null; + disposeResources(false); + if ( + error instanceof ApiError && + error.status === 401 && + onAuthorizationRequired + ) { + onAuthorizationRequired(); + return; + } + setNotice(readableError(error)); + setPhase("error"); + } + }; + const signal = connectSignal(createdRoom); signalRef.current = signal; signal.start(); } catch (error) { @@ -1702,18 +1787,41 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } } - function changeViewerAccess( - action: "public-watch" | "rotate" | "revoke", - ): void { + function changeCodeEntryPolicy(policy: CodeEntryPolicy): void { if ( - viewerAccessUpdating || + (viewerGrantUpdating || viewerPasswordUpdating) || phase !== "live" || - !signalRef.current?.send({ type: "set-viewer-access", action }) + (policy === "password" && !viewerPasswordEnabled) || + !signalRef.current?.send({ + type: "set-code-entry-policy", + policy, + }) ) { - setNotice("开始分享并连接后才能修改观看权限"); + setNotice( + policy === "password" && !viewerPasswordEnabled + ? "请先设置房间密码,再开启密码加入" + : "开始分享并连接后才能修改房间号加入方式", + ); return; } - setViewerAccessUpdating(true); + setNotice(null); + } + + function changeViewerGrant(action: "rotate" | "revoke"): void { + if ( + viewerGrantUpdating || + phase !== "live" || + !signalRef.current?.send({ + type: + action === "rotate" + ? "rotate-viewer-grant" + : "revoke-viewer-grant", + }) + ) { + setNotice("开始分享并连接后才能修改邀请链接"); + return; + } + setViewerGrantUpdating(true); setNotice(null); } @@ -1735,7 +1843,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setNotice("开始分享并连接后才能修改房间密码"); return; } - viewerPasswordActionRef.current = password === null ? "remove" : "set"; + viewerPasswordActionRef.current = password; setViewerPasswordUpdating(true); setViewerPasswordDraft(""); setNotice(null); @@ -1766,8 +1874,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { hour: "2-digit", minute: "2-digit", }).format(new Date(room.expiresAt))} 过期` - : "长期有效" + : "分享中不会过期" : null; + const activeCodeEntryPolicy = + room?.codeEntryPolicy ?? creationProfile.codeEntryPolicy; const codecLockNotice = videoCodecLockNotice(phase); const audioQualityLockNotice = screenAudioQualityLockNotice(phase); @@ -2042,23 +2152,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
- {!room && ( - - )}
推荐画质
@@ -2308,75 +2401,79 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{room && (
-
- - 房间 {room.roomId} · {expirationText} ·{" "} - {room.viewerPolicy === "public-watch" - ? "公开观看" - : "私密链接"} - +
+ 邀请链接 - {room.inviteUrl ?? "当前没有有效邀请,请轮换生成新链接"} + {room.inviteUrl ?? "当前没有有效邀请,请更新生成新链接"} -
-
- - {room.viewerPolicy === "public-watch" ? ( +
- ) : ( - <> - - - - - )} + + +
- {room.viewerPolicy === "private-link" && ( +
+
+ 房间号加入 + {expirationText} +
+
+ {( + [ + ["open", "开放"], + ["password", "密码"], + ["disabled", "关闭"], + ] as const + ).map(([policy, label]) => ( + + ))} +
{ @@ -2421,16 +2518,24 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { )}
- )} +
)} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index e924f5b..8ab196d 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -1240,7 +1240,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setStatusText("等待开始分享"); return; } - if (message.type === "viewer-access-revoked") { + if (message.type === "viewer-grant-revoked") { if ( viewerAuthorizationGeneration !== message.viewerAuthorizationGeneration diff --git a/src/client/styles.css b/src/client/styles.css index 948826d..4a50654 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -509,23 +509,6 @@ h3 { min-width: 0; } -.viewer-policy-toggle { - display: inline-flex; - min-height: 36px; - gap: 9px; - align-items: center; - margin-bottom: 12px; - color: var(--muted); - font-size: 13px; -} - -.viewer-policy-toggle input { - width: 18px; - height: 18px; - margin: 0; - accent-color: var(--green-strong); -} - .advanced-quality { margin-top: 10px; border-top: 1px solid var(--border); @@ -783,11 +766,11 @@ h3 { } .invite-bar { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(280px, 3fr) minmax(260px, 2fr); min-width: 0; gap: 12px; - align-items: center; + align-items: start; margin-top: 18px; padding: 11px 12px; border: 1px solid var(--border); @@ -795,15 +778,42 @@ h3 { background: var(--surface); } -.invite-copy { +.invite-primary, +.room-entry-policy { min-width: 0; - flex: 1; } .invite-actions { display: flex; - flex: 0 0 auto; + flex-wrap: wrap; gap: 6px; + margin-top: 8px; +} + +.invite-action { + min-height: 34px; + padding: 6px 9px; +} + +.room-entry-heading { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; +} + +.room-policy-control { + width: 100%; + margin-top: 8px; +} + +.room-policy-control button { + flex: 1; +} + +.room-lease-note { + color: var(--muted); + font-size: 12px; } .viewer-password-control { @@ -830,7 +840,8 @@ h3 { width: 100%; } -.invite-copy .field-label { +.invite-primary .field-label, +.room-entry-heading .field-label { margin-bottom: 3px; } @@ -1439,7 +1450,7 @@ h3 { } .invite-bar { - align-items: flex-end; + grid-template-columns: minmax(0, 1fr); } .viewer-password-control { diff --git a/src/server/app.ts b/src/server/app.ts index 9c74a51..da0f242 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -19,7 +19,6 @@ import type { SfuTokenIssuer } from "./livekit-token.js"; import type { SfuRoomControl } from "./sfu-room-control.js"; import { SfuResourceAdmission } from "./sfu-resource-admission.js"; import { TurnAllocationAdmission } from "./turn-allocation-admission.js"; -import { RoomDatabase } from "./room-database.js"; import { RoomStore, RoomStoreError } from "./room-store.js"; import { SignalingServer, type SignalingOptions } from "./signaling.js"; @@ -94,12 +93,9 @@ export async function createScreenerServer( const roomStore = options.roomStore ?? new RoomStore({ - ttlMs: config.roomTtlMs, + leaseMs: config.roomLeaseMs, maxRooms: config.maxRooms, maxViewersPerRoom: config.maxViewersPerRoom, - database: config.roomDatabasePath - ? new RoomDatabase(config.roomDatabasePath) - : undefined, now, }); const siteAccess = new SiteAccess({ @@ -398,9 +394,9 @@ async function handleRequest( } try { - const room = roomStore.createRoom( - parsedRequest.data.viewerPolicy, - parsedRequest.data.hostClaimTtlSeconds, + const room = await roomStore.createRoom( + parsedRequest.data.codeEntryPolicy, + parsedRequest.data.roomPassword ?? null, ); const inviteUrl = new URL(`/r/${room.roomId}`, config.publicBaseUrl); if (room.viewerGrant) { @@ -410,7 +406,7 @@ async function handleRequest( roomId: room.roomId, hostToken: room.hostToken, inviteUrl: inviteUrl.toString(), - viewerPolicy: room.viewerPolicy, + codeEntryPolicy: room.codeEntryPolicy, viewerGrantExpiresAt: room.viewerGrantExpiresAt, expiresAt: room.expiresAt, }; @@ -420,6 +416,10 @@ async function handleRequest( sendJson(response, 503, { error: "Room capacity reached" }); return; } + if (error instanceof RoomStoreError && error.code === "INVALID_TOKEN") { + sendJson(response, 400, { error: "Invalid room request" }); + return; + } throw error; } return; diff --git a/src/server/config.ts b/src/server/config.ts index 7f112ac..cec4a10 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -19,6 +19,8 @@ const MAX_SELECTED_EDGE_TURN_SECRET_BYTES = 128; const MIN_SELECTED_EDGE_TURN_TTL_SECONDS = 60; const MAX_SELECTED_EDGE_TURN_TTL_SECONDS = 10 * 60; const DEFAULT_MAX_VIEWERS_PER_ROOM = 8; +const DEFAULT_ROOM_LEASE_SECONDS = 86_400; +const MAX_ROOMS = 9_000; const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/; const REMOVED_ENVIRONMENT_VARIABLES = [ "TURN_URLS", @@ -31,6 +33,8 @@ const REMOVED_ENVIRONMENT_VARIABLES = [ "HOST_ADMISSION_PASSWORD", "MAX_PEER_RELAY_DOWNSTREAM_EDGES", "MAX_SFU_ROOTS_PER_ROOM", + "ROOM_DATABASE_PATH", + "ROOM_TTL_SECONDS", ] as const; export interface LiveKitFallbackConfig { @@ -56,8 +60,7 @@ export interface ServerConfig { publicBaseUrl: URL; allowedOrigins: ReadonlySet; siteAccessPassword?: string; - roomDatabasePath?: string; - roomTtlMs: number; + roomLeaseMs: number; maxRooms: number; maxViewersPerRoom: number; peerAssistedMedia: boolean; @@ -404,8 +407,6 @@ export function loadConfig( environment.SITE_ACCESS_PASSWORD === "" ? undefined : environment.SITE_ACCESS_PASSWORD; - const roomDatabasePath = - environment.ROOM_DATABASE_PATH?.trim() || undefined; const stunUrls = parseStunUrlList(environment.STUN_URLS); const maxViewersPerRoom = parseBoundedInteger( environment.MAX_VIEWERS_PER_ROOM, @@ -463,12 +464,6 @@ export function loadConfig( if (nodeEnv === "production" && !siteAccessPassword) { throw new Error("SITE_ACCESS_PASSWORD is required in production"); } - if (roomDatabasePath && !siteAccessPassword) { - throw new Error("ROOM_DATABASE_PATH requires SITE_ACCESS_PASSWORD"); - } - if (nodeEnv === "production" && roomDatabasePath === ":memory:") { - throw new Error("ROOM_DATABASE_PATH must be file-backed in production"); - } if (nodeEnv === "production" && stunUrls.length === 0) { throw new Error("STUN is required in production"); } @@ -482,11 +477,19 @@ export function loadConfig( publicBaseUrl.origin, ), siteAccessPassword, - roomDatabasePath, - roomTtlMs: - parsePositiveInteger(environment.ROOM_TTL_SECONDS, 14_400, "ROOM_TTL_SECONDS") * + roomLeaseMs: + parsePositiveInteger( + environment.ROOM_LEASE_SECONDS, + DEFAULT_ROOM_LEASE_SECONDS, + "ROOM_LEASE_SECONDS", + ) * 1_000, + maxRooms: parseBoundedInteger( + environment.MAX_ROOMS, 1_000, - maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"), + "MAX_ROOMS", + 1, + MAX_ROOMS, + ), maxViewersPerRoom, peerAssistedMedia, endpointMediaCopyCapacity, diff --git a/src/server/room-database.ts b/src/server/room-database.ts deleted file mode 100644 index 5b37a85..0000000 --- a/src/server/room-database.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { DatabaseSync } from "node:sqlite"; - -const SCHEMA_VERSION = 3; -const MAX_ROOM_ID = 999_999_999_999; - -export interface StoredRoom { - roomId: string; - hostTokenDigest: Buffer; - viewerGrantDigest: Buffer | null; - viewerPasswordMaterial: Buffer | null; -} - -export class RoomDatabase { - private readonly database: DatabaseSync; - private readonly random: (size: number) => Buffer; - - constructor(path: string, random: (size: number) => Buffer = randomBytes) { - this.random = random; - 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, viewer_grant_digest, - viewer_password_material - 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), - viewerGrantDigest: readViewerGrantDigest(row.viewer_grant_digest), - viewerPasswordMaterial: readNullableBytes( - row.viewer_password_material, - 48, - "viewer password material", - ), - }; - }); - } - - createRoom( - hostTokenDigest: Buffer, - viewerGrantDigestForRoom: (roomId: string) => Buffer | null, - ): StoredRoom { - assertDigest(hostTokenDigest, "Host token"); - - this.database.exec("BEGIN IMMEDIATE"); - try { - const result = this.database - .prepare( - "INSERT INTO rooms (host_token_digest, viewer_grant_digest) VALUES (?, NULL)", - ) - .run(hostTokenDigest); - const roomId = readSafeInteger(result.lastInsertRowid, "created room id"); - const roomIdText = roomId.toString(); - const viewerGrantDigest = viewerGrantDigestForRoom(roomIdText); - if (viewerGrantDigest !== null) { - assertDigest(viewerGrantDigest, "Viewer grant"); - const updated = this.database - .prepare("UPDATE rooms SET viewer_grant_digest = ? WHERE id = ?") - .run(viewerGrantDigest, roomId).changes; - if (updated !== 1) { - throw new Error("Created room could not be updated"); - } - } - this.database.exec("COMMIT"); - return { - roomId: roomIdText, - hostTokenDigest: Buffer.from(hostTokenDigest), - viewerGrantDigest: - viewerGrantDigest === null ? null : Buffer.from(viewerGrantDigest), - viewerPasswordMaterial: null, - }; - } catch (error) { - if (this.database.isTransaction) { - this.database.exec("ROLLBACK"); - } - throw error; - } - } - - updateViewerGrantDigest( - roomId: string, - viewerGrantDigest: Buffer | null, - ): void { - const numericRoomId = parseRoomId(roomId); - if (viewerGrantDigest !== null) { - assertDigest(viewerGrantDigest, "Viewer grant"); - } - const changes = this.database - .prepare("UPDATE rooms SET viewer_grant_digest = ? WHERE id = ?") - .run(viewerGrantDigest, numericRoomId).changes; - if (changes !== 1) { - throw new Error("Persistent room is missing from the room database"); - } - } - - updateViewerPassword( - roomId: string, - material: Buffer | null, - ): void { - const numericRoomId = parseRoomId(roomId); - if (material) { - assertBytes(material, 48, "Viewer password material"); - } - const changes = this.database - .prepare( - `UPDATE rooms - SET viewer_password_material = ? - WHERE id = ?`, - ) - .run(material, numericRoomId).changes; - if (changes !== 1) { - throw new Error("Persistent room is missing from the room database"); - } - } - - deleteRoom(roomId: string): boolean { - const numericRoomId = parseRoomId(roomId); - return ( - this.database.prepare("DELETE FROM rooms WHERE id = ?").run(numericRoomId) - .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 === 2) { - this.migrateV2(); - return; - } - if (version === 1) { - this.migrateV1(); - 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), - viewer_grant_digest BLOB NULL - CHECK ( - viewer_grant_digest IS NULL OR - length(viewer_grant_digest) = 32 - ), - viewer_password_material BLOB NULL - CHECK ( - viewer_password_material IS NULL OR - length(viewer_password_material) = 48 - ) - ) STRICT; - PRAGMA user_version = ${SCHEMA_VERSION}; - COMMIT; - `); - } catch (error) { - if (this.database.isTransaction) { - this.database.exec("ROLLBACK"); - } - throw error; - } - } - - private migrateV1(): void { - this.database.exec("BEGIN IMMEDIATE"); - try { - this.database.exec(` - ALTER TABLE rooms ADD COLUMN viewer_grant_digest BLOB NULL - CHECK ( - viewer_grant_digest IS NULL OR - length(viewer_grant_digest) = 32 - ); - ALTER TABLE rooms ADD COLUMN viewer_password_material BLOB NULL - CHECK ( - viewer_password_material IS NULL OR - length(viewer_password_material) = 48 - ); - `); - const roomIds = this.database.prepare("SELECT id FROM rooms ORDER BY id").all(); - const lockRoom = this.database.prepare( - "UPDATE rooms SET viewer_grant_digest = ? WHERE id = ?", - ); - for (const row of roomIds) { - const roomId = readSafeInteger(row.id, "room id"); - const lockedDigest = this.random(32); - assertDigest(lockedDigest, "Migration random source"); - if (lockRoom.run(lockedDigest, roomId).changes !== 1) { - throw new Error("Room database migration lost a room row"); - } - } - this.database.exec(`PRAGMA user_version = ${SCHEMA_VERSION}; COMMIT;`); - } catch (error) { - if (this.database.isTransaction) { - this.database.exec("ROLLBACK"); - } - throw error; - } - } - - private migrateV2(): void { - this.database.exec("BEGIN IMMEDIATE"); - try { - this.database.exec(` - ALTER TABLE rooms ADD COLUMN viewer_password_material BLOB NULL - CHECK ( - viewer_password_material IS NULL OR - length(viewer_password_material) = 48 - ); - PRAGMA user_version = ${SCHEMA_VERSION}; - COMMIT; - `); - } catch (error) { - if (this.database.isTransaction) { - this.database.exec("ROLLBACK"); - } - throw error; - } - } -} - -function assertDigest(value: Buffer, name: string): void { - if (value.byteLength !== 32) { - throw new Error(`${name} digest must contain 32 bytes`); - } -} - -function assertBytes(value: Buffer, length: number, name: string): void { - if (value.byteLength !== length) { - throw new Error(`${name} must contain ${length} bytes`); - } -} - -function readNullableBytes( - value: unknown, - length: number, - name: string, -): Buffer | null { - if (value === null) { - return null; - } - if (!(value instanceof Uint8Array) || value.byteLength !== length) { - throw new Error(`Room database contains an invalid ${name}`); - } - return Buffer.from(value); -} - -function readViewerGrantDigest(value: unknown): Buffer | null { - if (value === null) { - return null; - } - if (!(value instanceof Uint8Array) || value.byteLength !== 32) { - throw new Error("Room database contains an invalid viewer grant digest"); - } - return Buffer.from(value); -} - -function parseRoomId(roomId: string): number { - if (!/^[1-9]\d{0,11}$/.test(roomId)) { - throw new Error("Room id must be a positive integer with at most 12 digits"); - } - return Number(roomId); -} - -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 26ca58d..598798b 100644 --- a/src/server/room-store.ts +++ b/src/server/room-store.ts @@ -6,15 +6,13 @@ import { } from "node:crypto"; import { - MAX_HOST_CLAIM_TTL_SECONDS, MAX_VIEWERS_PER_ROOM_LIMIT, ROOM_CODE_LENGTH, viewerGrantSchema, viewerPasswordSchema, + type CodeEntryPolicy, type Role, - type ViewerAccessPolicy, } from "../shared/protocol.js"; -import { RoomDatabase } from "./room-database.js"; export type RoomStoreErrorCode = | "INVALID_TOKEN" @@ -23,7 +21,9 @@ export type RoomStoreErrorCode = | "HOST_ALREADY_CONNECTED" | "ROOM_LIMIT"; -const PERSISTENT_VIEWER_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1_000; +const VIEWER_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1_000; +const ROOM_CODE_COUNT = 9_000; +const ROOM_CODE_FIRST = 1_000; const VIEWER_PASSWORD_SALT_BYTES = 16; const VIEWER_PASSWORD_VERIFIER_BYTES = 32; const VIEWER_PASSWORD_MATERIAL_BYTES = @@ -51,16 +51,19 @@ interface Participant { clientId: string; peerId: string; sessionId?: string; + admittedBy: "grant" | "code"; } interface Room { roomId: string; hostTokenDigest: Buffer; - viewerGrantDigest: Buffer | null; + viewerGrantDigest: Buffer; + viewerGrantExpiresAtMs: number | null; viewerPasswordMaterial: Buffer | null; viewerAuthorizationGeneration: string; - expiresAtMs: number | null; - provisionalHostExpiresAtMs: number | null; + codeEntryPolicy: CodeEntryPolicy; + sharingActive: boolean; + leaseExpiresAtMs: number | null; host?: Participant; viewers: Map; } @@ -68,7 +71,7 @@ interface Room { export interface CreatedRoom { roomId: string; hostToken: string; - viewerPolicy: ViewerAccessPolicy; + codeEntryPolicy: CodeEntryPolicy; viewerGrant: string | null; viewerGrantExpiresAt: string | null; expiresAt: string | null; @@ -105,7 +108,7 @@ export interface ConnectedParticipant { hostOnline: boolean; replacedSessionId?: string; viewerPeerIds: readonly string[]; - viewerPolicy: ViewerAccessPolicy; + codeEntryPolicy: CodeEntryPolicy; viewerPasswordEnabled: boolean; viewerAuthorizationGeneration: string; } @@ -131,8 +134,7 @@ export interface RevokedViewer { sessionId?: string; } -export interface ViewerAccessUpdate { - viewerPolicy: ViewerAccessPolicy; +export interface ViewerGrantUpdate { viewerGrant: string | null; viewerGrantExpiresAt: string | null; viewerAuthorizationGeneration: string; @@ -140,27 +142,41 @@ export interface ViewerAccessUpdate { revokedViewers: readonly RevokedViewer[]; } +export interface CodeEntryUpdate { + codeEntryPolicy: CodeEntryPolicy; + viewerPasswordEnabled: boolean; +} + export interface RoomStoreOptions { - ttlMs: number; + leaseMs: number; maxRooms: number; maxViewersPerRoom: number; - database?: RoomDatabase; now?: () => number; random?: (size: number) => Buffer; } export class RoomStore { private readonly rooms = new Map(); + private readonly freeRoomCodes = Array.from( + { length: ROOM_CODE_COUNT }, + (_, index) => (ROOM_CODE_FIRST + index).toString(), + ); private readonly now: () => number; private readonly random: (size: number) => Buffer; readonly maxViewersPerRoom: number; constructor(private readonly options: RoomStoreOptions) { - if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs <= 0) { - throw new Error("Room TTL must be a positive integer"); + if (!Number.isSafeInteger(options.leaseMs) || options.leaseMs <= 0) { + throw new Error("Room lease must be a positive integer"); } - if (!Number.isSafeInteger(options.maxRooms) || options.maxRooms <= 0) { - throw new Error("Room limit must be a positive integer"); + if ( + !Number.isSafeInteger(options.maxRooms) || + options.maxRooms <= 0 || + options.maxRooms > ROOM_CODE_COUNT + ) { + throw new Error( + `Room limit must be an integer between 1 and ${ROOM_CODE_COUNT}`, + ); } if ( !Number.isSafeInteger(options.maxViewersPerRoom) || @@ -174,148 +190,93 @@ 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), - viewerGrantDigest: - storedRoom.viewerGrantDigest === null - ? null - : Buffer.from(storedRoom.viewerGrantDigest), - viewerPasswordMaterial: - storedRoom.viewerPasswordMaterial === null - ? null - : Buffer.from(storedRoom.viewerPasswordMaterial), - viewerAuthorizationGeneration: this.newAuthorizationGeneration(), - expiresAtMs: null, - provisionalHostExpiresAtMs: null, - viewers: new Map(), - }); - } - } catch (error) { - options.database?.close(); - throw error; - } } - createRoom( - viewerPolicy: ViewerAccessPolicy = "private-link", - hostClaimTtlSeconds?: number, - ): CreatedRoom { - if ( - hostClaimTtlSeconds !== undefined && - hostClaimTtlSeconds !== MAX_HOST_CLAIM_TTL_SECONDS - ) { - throw new Error( - `Provisional Host TTL must be ${MAX_HOST_CLAIM_TTL_SECONDS} seconds`, - ); - } + async createRoom( + codeEntryPolicy: CodeEntryPolicy = "open", + roomPassword?: string | null, + ): Promise { if (this.rooms.size >= this.options.maxRooms) { throw new RoomStoreError("ROOM_LIMIT"); } + if (codeEntryPolicy === "password" && !roomPassword) { + throw new RoomStoreError("INVALID_TOKEN"); + } - const hostToken = this.random(32).toString("base64url"); - const hostTokenDigest = digest(hostToken); - const viewerAuthorizationGeneration = this.newAuthorizationGeneration(); const createdAtMs = this.now(); - const provisionalHostExpiresAtMs = - hostClaimTtlSeconds === undefined - ? null - : createdAtMs + hostClaimTtlSeconds * 1_000; - if ( - provisionalHostExpiresAtMs !== null && - !Number.isSafeInteger(provisionalHostExpiresAtMs) - ) { - throw new Error("Provisional Host expiry exceeds the supported time range"); + const viewerGrantExpiresAtMs = toEpochSecondsMs( + createdAtMs + VIEWER_GRANT_TTL_MS, + ); + const viewerPasswordMaterial = roomPassword + ? await this.createViewerPasswordMaterial(roomPassword) + : null; + if (this.rooms.size >= this.options.maxRooms) { + throw new RoomStoreError("ROOM_LIMIT"); } - let viewerGrant: string | null = null; - let viewerGrantExpiresAtMs: number | null = null; - const storedRoom = - provisionalHostExpiresAtMs === null - ? this.options.database?.createRoom( - hostTokenDigest, - (roomId) => { - if (viewerPolicy === "public-watch") { - return null; - } - const persistentGrantExpiresAtMs = toEpochSecondsMs( - createdAtMs + PERSISTENT_VIEWER_GRANT_TTL_MS, - ); - viewerGrantExpiresAtMs = persistentGrantExpiresAtMs; - viewerGrant = this.createViewerGrant( - roomId, - persistentGrantExpiresAtMs, - ); - return digest(viewerGrant); - }, - ) - : undefined; - const roomId = storedRoom?.roomId ?? this.uniqueRoomCode(); - const expiresAtMs = storedRoom ? null : createdAtMs + this.options.ttlMs; - if (!storedRoom && viewerPolicy === "private-link") { - const temporaryGrantExpiresAtMs = toEpochSecondsMs( - createdAtMs + this.options.ttlMs, + const roomId = this.takeRoomCode(); + try { + const hostTokenBytes = this.random(32); + if (hostTokenBytes.byteLength !== 32) { + throw new Error("Host token random source must return 32 bytes"); + } + const hostToken = hostTokenBytes.toString("base64url"); + const viewerGrant = this.createViewerGrant( + roomId, + viewerGrantExpiresAtMs, ); - viewerGrantExpiresAtMs = temporaryGrantExpiresAtMs; - viewerGrant = this.createViewerGrant(roomId, temporaryGrantExpiresAtMs); + const viewerAuthorizationGeneration = this.newAuthorizationGeneration(); + + this.rooms.set(roomId, { + roomId, + hostTokenDigest: digest(hostToken), + viewerGrantDigest: digest(viewerGrant), + viewerGrantExpiresAtMs, + viewerPasswordMaterial, + viewerAuthorizationGeneration, + codeEntryPolicy, + sharingActive: false, + leaseExpiresAtMs: createdAtMs + this.options.leaseMs, + viewers: new Map(), + }); + + return { + roomId, + hostToken, + codeEntryPolicy, + viewerGrant, + viewerGrantExpiresAt: formatExpiresAt(viewerGrantExpiresAtMs), + expiresAt: formatExpiresAt(createdAtMs + this.options.leaseMs), + }; + } catch (error) { + this.releaseRoomCode(roomId); + throw error; } - const viewerGrantDigest = - storedRoom?.viewerGrantDigest ?? - (viewerGrant === null ? null : digest(viewerGrant)); - - this.rooms.set(roomId, { - roomId, - hostTokenDigest, - viewerGrantDigest, - viewerPasswordMaterial: null, - viewerAuthorizationGeneration, - expiresAtMs, - provisionalHostExpiresAtMs, - viewers: new Map(), - }); - - return { - roomId, - hostToken, - viewerPolicy, - viewerGrant, - viewerGrantExpiresAt: formatExpiresAt(viewerGrantExpiresAtMs), - expiresAt: formatExpiresAt(expiresAtMs), - }; } connectParticipant(input: ConnectParticipantInput): ConnectedParticipant { - let room: Room; - try { - room = this.getAvailableRoom(input.roomId); - } catch (error) { - if (input.role === "viewer" && error instanceof RoomStoreError) { + const room = this.getAvailableRoom(input.roomId); + if (input.role === "host") { + if (!verifyDigest(input.token, room.hostTokenDigest)) { throw new RoomStoreError("INVALID_TOKEN"); } - throw error; - } - if (input.role === "host" && !verifyDigest(input.token, room.hostTokenDigest)) { - throw new RoomStoreError("INVALID_TOKEN"); - } - - if (input.role === "host") { return this.connectHost(room, input); } - if (!this.viewerMayEnter(room, input.viewerGrant)) { + + if (input.viewerGrant) { + if (!this.viewerGrantIsValid(room, input.viewerGrant)) { + throw new RoomStoreError("INVALID_TOKEN"); + } + return this.connectViewer(room, input, "grant"); + } + if (room.codeEntryPolicy !== "open") { throw new RoomStoreError("INVALID_TOKEN"); } - return this.connectViewer(room, input); + return this.connectViewer(room, input, "code"); } viewerGrantMayEnter(roomId: string, grant: string | undefined): boolean { try { - const room = this.getAvailableRoom(roomId); - return room.viewerGrantDigest !== null && this.viewerMayEnter(room, grant); + return this.viewerGrantIsValid(this.getAvailableRoom(roomId), grant); } catch (error) { if (error instanceof RoomStoreError) { return false; @@ -340,23 +301,12 @@ export class RoomStore { throw error; } } - if (room?.viewerGrantDigest === null) { - if (!mayConnect()) { - throw new RoomStoreError("INVALID_TOKEN"); - } - return this.connectViewer(room, { ...input, role: "viewer" }); - } - - const expectedMaterial = Buffer.from( - room?.viewerPasswordMaterial ?? DUMMY_VIEWER_PASSWORD_MATERIAL, - ); - const expectedSalt = expectedMaterial.subarray( - 0, - VIEWER_PASSWORD_SALT_BYTES, - ); - const expectedVerifier = expectedMaterial.subarray( - VIEWER_PASSWORD_SALT_BYTES, - ); + const expectedMaterial = + room?.codeEntryPolicy === "password" && room.viewerPasswordMaterial + ? Buffer.from(room.viewerPasswordMaterial) + : DUMMY_VIEWER_PASSWORD_MATERIAL; + const expectedSalt = expectedMaterial.subarray(0, VIEWER_PASSWORD_SALT_BYTES); + const expectedVerifier = expectedMaterial.subarray(VIEWER_PASSWORD_SALT_BYTES); const derived = await deriveViewerPassword( input.password, expectedSalt, @@ -376,15 +326,11 @@ export class RoomStore { } throw error; } - if (currentRoom.viewerGrantDigest === null) { - if (!mayConnect()) { - throw new RoomStoreError("INVALID_TOKEN"); - } - return this.connectViewer(currentRoom, { ...input, role: "viewer" }); - } if ( !room || currentRoom !== room || + currentRoom.codeEntryPolicy !== "password" || + currentRoom.viewerPasswordMaterial === null || !matches || !sameBytes(currentRoom.viewerPasswordMaterial, expectedMaterial) || !mayConnect() @@ -392,7 +338,7 @@ export class RoomStore { throw new RoomStoreError("INVALID_TOKEN"); } try { - return this.connectViewer(currentRoom, { ...input, role: "viewer" }); + return this.connectViewer(currentRoom, { ...input, role: "viewer" }, "code"); } catch (error) { if (error instanceof RoomStoreError && error.code === "ROOM_FULL") { throw new RoomStoreError("INVALID_TOKEN"); @@ -411,95 +357,86 @@ export class RoomStore { throw new RoomStoreError("INVALID_TOKEN"); } - let nextPasswordMaterial: Buffer | null = null; - if (password !== null) { - if (!viewerPasswordSchema.safeParse(password).success) { - throw new RoomStoreError("INVALID_TOKEN"); - } - const salt = this.random(VIEWER_PASSWORD_SALT_BYTES); - if (salt.byteLength !== VIEWER_PASSWORD_SALT_BYTES) { - throw new Error("Viewer password salt source must return 16 bytes"); - } - const verifier = await deriveViewerPassword(password, salt, () => { - const currentRoom = this.rooms.get(roomId); - return ( - currentRoom === room && - currentRoom.host?.sessionId === hostSessionId - ); - }); - if (verifier === null) { - throw new RoomStoreError("INVALID_TOKEN"); - } - nextPasswordMaterial = Buffer.concat([salt, verifier]); - } + const nextPasswordMaterial = password + ? await this.createViewerPasswordMaterial(password, () => { + const currentRoom = this.rooms.get(roomId); + return ( + currentRoom === room && currentRoom.host?.sessionId === hostSessionId + ); + }) + : null; const currentRoom = this.getAvailableRoom(roomId); if ( currentRoom !== room || - currentRoom.host?.sessionId !== hostSessionId + currentRoom.host?.sessionId !== hostSessionId || + (password !== null && nextPasswordMaterial === null) || + (password === null && currentRoom.codeEntryPolicy === "password") ) { throw new RoomStoreError("INVALID_TOKEN"); } - if (currentRoom.expiresAtMs === null) { - this.options.database?.updateViewerPassword(roomId, nextPasswordMaterial); - } currentRoom.viewerPasswordMaterial = nextPasswordMaterial; return nextPasswordMaterial !== null; } - setViewerAccess( + setCodeEntryPolicy( roomId: string, - action: "public-watch" | "rotate" | "revoke", - ): ViewerAccessUpdate { + policy: CodeEntryPolicy, + hostSessionId: string, + ): CodeEntryUpdate { const room = this.getAvailableRoom(roomId); + if (room.host?.sessionId !== hostSessionId) { + throw new RoomStoreError("INVALID_TOKEN"); + } + if (policy === "password" && room.viewerPasswordMaterial === null) { + throw new RoomStoreError("INVALID_TOKEN"); + } + room.codeEntryPolicy = policy; + return { + codeEntryPolicy: policy, + viewerPasswordEnabled: room.viewerPasswordMaterial !== null, + }; + } + + setViewerGrant( + roomId: string, + action: "rotate" | "revoke", + hostSessionId: string, + ): ViewerGrantUpdate { + const room = this.getAvailableRoom(roomId); + if (room.host?.sessionId !== hostSessionId) { + throw new RoomStoreError("INVALID_TOKEN"); + } const previousViewerAuthorizationGeneration = room.viewerAuthorizationGeneration; - - if (action === "public-watch") { - if (room.expiresAtMs === null) { - this.options.database?.updateViewerGrantDigest(roomId, null); - } - room.viewerGrantDigest = null; - return { - viewerPolicy: "public-watch", - viewerGrant: null, - viewerGrantExpiresAt: null, - viewerAuthorizationGeneration: room.viewerAuthorizationGeneration, - previousViewerAuthorizationGeneration, - revokedViewers: [], - }; - } - const viewerGrantExpiresAtMs = action === "rotate" - ? toEpochSecondsMs( - room.expiresAtMs ?? this.now() + PERSISTENT_VIEWER_GRANT_TTL_MS, - ) + ? toEpochSecondsMs(this.now() + VIEWER_GRANT_TTL_MS) : null; const viewerGrant = viewerGrantExpiresAtMs === null ? null : this.createViewerGrant(room.roomId, viewerGrantExpiresAtMs); - const nextDigest = - viewerGrant === null ? this.randomDigest() : digest(viewerGrant); - const nextViewerAuthorizationGeneration = - this.newAuthorizationGeneration(); - // Persistence is the commit point for durable rooms. Provisional native - // rooms deliberately never enter the configured database. - if (room.expiresAtMs === null) { - this.options.database?.updateViewerGrantDigest(roomId, nextDigest); + const revokedViewers = [...room.viewers.values()] + .filter((viewer) => viewer.admittedBy === "grant") + .map((viewer) => ({ + peerId: viewer.peerId, + ...(viewer.sessionId ? { sessionId: viewer.sessionId } : {}), + })); + const viewerGrantDigest = viewerGrant + ? digest(viewerGrant) + : this.randomDigest(); + const viewerAuthorizationGeneration = this.newAuthorizationGeneration(); + room.viewerGrantDigest = viewerGrantDigest; + room.viewerGrantExpiresAtMs = viewerGrantExpiresAtMs; + room.viewerAuthorizationGeneration = viewerAuthorizationGeneration; + for (const [clientId, viewer] of room.viewers) { + if (viewer.admittedBy === "grant") { + room.viewers.delete(clientId); + } } - - const revokedViewers = [...room.viewers.values()].map((viewer) => ({ - peerId: viewer.peerId, - ...(viewer.sessionId ? { sessionId: viewer.sessionId } : {}), - })); - room.viewerGrantDigest = nextDigest; - room.viewerAuthorizationGeneration = nextViewerAuthorizationGeneration; - room.viewers.clear(); return { - viewerPolicy: "private-link", viewerGrant, viewerGrantExpiresAt: formatExpiresAt(viewerGrantExpiresAtMs), viewerAuthorizationGeneration: room.viewerAuthorizationGeneration, @@ -523,10 +460,8 @@ export class RoomStore { return undefined; } room.host.sessionId = undefined; - if (room.provisionalHostExpiresAtMs !== null) { - room.provisionalHostExpiresAtMs = - this.now() + MAX_HOST_CLAIM_TTL_SECONDS * 1_000; - } + room.sharingActive = false; + room.leaseExpiresAtMs = this.now() + this.options.leaseMs; return { roomId, role: "host", peerId }; } @@ -565,9 +500,7 @@ export class RoomStore { getViewerPeerIds(roomId: string): string[] { const room = this.rooms.get(roomId); - return room - ? [...room.viewers.values()].map((viewer) => viewer.peerId) - : []; + return room ? [...room.viewers.values()].map((viewer) => viewer.peerId) : []; } getConnectedViewers(roomId: string): ConnectedPeer[] { @@ -587,15 +520,9 @@ export class RoomStore { if (!room) { return undefined; } - if ( - room.expiresAtMs === null && - 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); + this.releaseRoomCode(roomId); return { roomId, sessionIds }; } @@ -605,15 +532,9 @@ export class RoomStore { if (!roomIsExpired(room, nowMs)) { continue; } - if ( - room.expiresAtMs === null && - this.options.database && - !this.options.database.deleteRoom(roomId) - ) { - throw new Error("Persistent room is missing from the room database"); - } expired.push({ roomId, sessionIds: connectedSessionIds(room) }); this.rooms.delete(roomId); + this.releaseRoomCode(roomId); } return expired; } @@ -623,7 +544,7 @@ export class RoomStore { } close(): void { - this.options.database?.close(); + // Room state is process-memory only and disappears with the process. } private connectHost( @@ -631,43 +552,37 @@ export class RoomStore { input: ConnectParticipantInput, ): ConnectedParticipant { const current = room.host; - if ( - current?.sessionId && - current.clientId !== input.clientId - ) { + if (current?.sessionId && current.clientId !== input.clientId) { throw new RoomStoreError("HOST_ALREADY_CONNECTED"); } const isNewParticipant = !current || current.clientId !== input.clientId; - const participant = isNewParticipant + const participant: Participant = isNewParticipant ? { clientId: input.clientId, peerId: this.newPeerId(room), + admittedBy: "code", } : current; const replacedSessionId = participant.sessionId; participant.sessionId = input.sessionId; room.host = participant; + room.sharingActive = true; + room.leaseExpiresAtMs = null; - return { - roomId: room.roomId, - role: "host", - peerId: participant.peerId, - expiresAt: formatExpiresAt(room.expiresAtMs), - hostOnline: true, - replacedSessionId: - replacedSessionId === input.sessionId ? undefined : replacedSessionId, - // Disconnected viewers remain room members until their grace period ends. - viewerPeerIds: [...room.viewers.values()].map((viewer) => viewer.peerId), - viewerPolicy: viewerPolicy(room), - viewerPasswordEnabled: room.viewerPasswordMaterial !== null, - viewerAuthorizationGeneration: room.viewerAuthorizationGeneration, - }; + return this.connectedParticipant( + room, + participant.peerId, + "host", + replacedSessionId, + input.sessionId, + ); } private connectViewer( room: Room, input: ConnectParticipantInput, + admittedBy: "grant" | "code", ): ConnectedParticipant { const current = room.viewers.get(input.clientId); if (!current && room.viewers.size >= this.maxViewersPerRoom) { @@ -679,21 +594,41 @@ export class RoomStore { ({ clientId: input.clientId, peerId: this.newPeerId(room), + admittedBy, } satisfies Participant); const replacedSessionId = participant.sessionId; participant.sessionId = input.sessionId; + participant.admittedBy = admittedBy; room.viewers.set(input.clientId, participant); + return this.connectedParticipant( + room, + participant.peerId, + "viewer", + replacedSessionId, + input.sessionId, + ); + } + private connectedParticipant( + room: Room, + peerId: string, + role: Role, + replacedSessionId: string | undefined, + sessionId: string, + ): ConnectedParticipant { return { roomId: room.roomId, - role: "viewer", - peerId: participant.peerId, - expiresAt: formatExpiresAt(room.expiresAtMs), + role, + peerId, + expiresAt: formatExpiresAt(room.leaseExpiresAtMs), hostOnline: Boolean(room.host?.sessionId), replacedSessionId: - replacedSessionId === input.sessionId ? undefined : replacedSessionId, - viewerPeerIds: [], - viewerPolicy: viewerPolicy(room), + replacedSessionId === sessionId ? undefined : replacedSessionId, + viewerPeerIds: + role === "host" + ? [...room.viewers.values()].map((viewer) => viewer.peerId) + : [], + codeEntryPolicy: room.codeEntryPolicy, viewerPasswordEnabled: room.viewerPasswordMaterial !== null, viewerAuthorizationGeneration: room.viewerAuthorizationGeneration, }; @@ -702,7 +637,6 @@ export class RoomStore { private getAvailableRoom(roomId: string): Room { const room = this.rooms.get(roomId); if (!room) { - // A single response avoids turning room IDs into an enumeration oracle. throw new RoomStoreError("INVALID_TOKEN"); } if (roomIsExpired(room, this.now())) { @@ -720,28 +654,43 @@ export class RoomStore { ); } - private uniqueRoomCode(): string { - const minimum = 10n ** BigInt(ROOM_CODE_LENGTH - 1); - const range = 9n * minimum; - for (let attempt = 0; attempt < 8; attempt += 1) { + private takeRoomCode(): string { + if (this.freeRoomCodes.length === 0) { + throw new RoomStoreError("ROOM_LIMIT"); + } + const range = BigInt(this.freeRoomCodes.length); + const randomLimit = (1n << 64n) - ((1n << 64n) % range); + for (let attempt = 0; attempt < 16; attempt += 1) { const bytes = this.random(8); - if (bytes.length !== 8) { + if (bytes.byteLength !== 8) { throw new Error("Room code random source must return 8 bytes"); } - const candidate = ( - minimum + - (bytes.readBigUInt64BE() % range) - ).toString(); - if (!this.rooms.has(candidate)) { - return candidate; + const value = bytes.readBigUInt64BE(); + if (value >= randomLimit) { + continue; } + const index = Number(value % range); + const roomId = this.freeRoomCodes[index]!; + const lastRoomId = this.freeRoomCodes.pop()!; + if (index < this.freeRoomCodes.length) { + this.freeRoomCodes[index] = lastRoomId; + } + return roomId; } - throw new Error("Unable to allocate a unique room code"); + throw new Error("Unable to select a room code uniformly"); + } + + private releaseRoomCode(roomId: string): void { + this.freeRoomCodes.push(roomId); } private uniqueId(size: number, exists: (candidate: string) => boolean): string { for (let attempt = 0; attempt < 8; attempt += 1) { - const candidate = this.random(size).toString("base64url"); + const value = this.random(size); + if (value.byteLength !== size) { + throw new Error(`Identifier random source must return ${size} bytes`); + } + const candidate = value.toString("base64url"); if (!exists(candidate)) { return candidate; } @@ -749,10 +698,7 @@ export class RoomStore { throw new Error("Unable to allocate a unique identifier"); } - private viewerMayEnter(room: Room, grant: string | undefined): boolean { - if (room.viewerGrantDigest === null) { - return true; - } + private viewerGrantIsValid(room: Room, grant: string | undefined): boolean { if (!grant || !viewerGrantSchema.safeParse(grant).success) { return false; } @@ -761,6 +707,8 @@ export class RoomStore { parsed && parsed.roomId === room.roomId && parsed.expiresAtMs > this.now() && + room.viewerGrantExpiresAtMs !== null && + parsed.expiresAtMs <= room.viewerGrantExpiresAtMs && verifyDigest(grant, room.viewerGrantDigest), ); } @@ -774,10 +722,25 @@ export class RoomStore { return `g1.${roomId}.${expiresAtSeconds}.${secret.toString("base64url")}`; } + private async createViewerPasswordMaterial( + password: string, + mayStart: () => boolean = () => true, + ): Promise { + if (!viewerPasswordSchema.safeParse(password).success) { + throw new RoomStoreError("INVALID_TOKEN"); + } + const salt = this.random(VIEWER_PASSWORD_SALT_BYTES); + if (salt.byteLength !== VIEWER_PASSWORD_SALT_BYTES) { + throw new Error("Viewer password salt source must return 16 bytes"); + } + const verifier = await deriveViewerPassword(password, salt, mayStart); + return verifier && Buffer.concat([salt, verifier]); + } + private randomDigest(): Buffer { const value = this.random(32); if (value.byteLength !== 32) { - throw new Error("Viewer lock random source must return 32 bytes"); + throw new Error("Viewer grant random source must return 32 bytes"); } return Buffer.from(value); } @@ -785,7 +748,9 @@ export class RoomStore { private newAuthorizationGeneration(): string { const value = this.random(16); if (value.byteLength !== 16) { - throw new Error("Authorization generation random source must return 16 bytes"); + throw new Error( + "Authorization generation random source must return 16 bytes", + ); } return value.toString("base64url"); } @@ -793,18 +758,14 @@ export class RoomStore { function roomIsExpired(room: Room, nowMs: number): boolean { return ( - (room.expiresAtMs !== null && room.expiresAtMs <= nowMs) || - (!room.host?.sessionId && - room.provisionalHostExpiresAtMs !== null && - room.provisionalHostExpiresAtMs <= nowMs) + !room.sharingActive && + room.leaseExpiresAtMs !== null && + room.leaseExpiresAtMs <= nowMs ); } function isRoomCode(value: string): boolean { - return ( - value.length <= ROOM_CODE_LENGTH && - /^[1-9]\d*$/.test(value) - ); + return value.length === ROOM_CODE_LENGTH && /^[1-9]\d{3}$/.test(value); } function formatExpiresAt(expiresAtMs: number | null): string | null { @@ -815,7 +776,10 @@ function toEpochSecondsMs(value: number): number { return Math.floor(value / 1_000) * 1_000; } -function findViewerByPeerId(room: Room, peerId: string): Participant | undefined { +function findViewerByPeerId( + room: Room, + peerId: string, +): Participant | undefined { return [...room.viewers.values()].find((viewer) => viewer.peerId === peerId); } @@ -854,7 +818,8 @@ function deriveViewerPassword( salt, VIEWER_PASSWORD_VERIFIER_BYTES, VIEWER_PASSWORD_SCRYPT_OPTIONS, - (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)), + (error, derivedKey) => + error ? reject(error) : resolve(derivedKey), ); }), mayStart, @@ -912,10 +877,6 @@ const viewerPasswordKdfGate = new AsyncGate( VIEWER_PASSWORD_KDF_PENDING_LIMIT, ); -function viewerPolicy(room: Room): ViewerAccessPolicy { - return room.viewerGrantDigest === null ? "public-watch" : "private-link"; -} - function parseViewerGrant( value: string, ): { roomId: string; expiresAtMs: number } | null { diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 198928e..9205de3 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -16,6 +16,7 @@ import { VIEWER_QUALITY_EVIDENCE_EXPIRY_MS, decodeClientMessage, type ClientMessage, + type CodeEntryPolicy, type QualitySettings, type Role, type ServerMessage, @@ -591,7 +592,7 @@ export class SignalingServer { ? [...this.ordinaryHostChildPeerIds(participant.roomId)] : [...participant.viewerPeerIds], iceConfig: this.iceConfig(), - viewerPolicy: participant.viewerPolicy, + codeEntryPolicy: participant.codeEntryPolicy, viewerAuthorizationGeneration: participant.viewerAuthorizationGeneration, } satisfies Extract; @@ -868,17 +869,34 @@ export class SignalingServer { authenticated.displayName = message.displayName; this.sendViewerPresence(authenticated.roomId); return; - case "set-viewer-access": + case "set-code-entry-policy": if (authenticated.role !== "host") { this.sendError( socket, "FORBIDDEN", - "Only the host may change Viewer access", + "Only the host may change code entry", ); return; } - this.updateViewerAccess(socket, authenticated, message.action); + this.updateCodeEntryPolicy(socket, authenticated, message.policy); return; + case "rotate-viewer-grant": + case "revoke-viewer-grant": { + if (authenticated.role !== "host") { + this.sendError( + socket, + "FORBIDDEN", + "Only the host may change the invitation", + ); + return; + } + this.updateViewerGrant( + socket, + authenticated, + message.type === "rotate-viewer-grant" ? "rotate" : "revoke", + ); + return; + } case "set-viewer-password": { if ( authenticated.role !== "host" || @@ -992,36 +1010,64 @@ export class SignalingServer { } } - private updateViewerAccess( + private updateCodeEntryPolicy( hostSocket: WebSocket, authenticated: AuthenticatedSession, - action: "public-watch" | "rotate" | "revoke", + policy: CodeEntryPolicy, + ): void { + try { + const update = this.options.roomStore.setCodeEntryPolicy( + authenticated.roomId, + policy, + this.socketStates.get(hostSocket)?.sessionId ?? "", + ); + this.send(hostSocket, { + type: "code-entry-policy-updated", + codeEntryPolicy: update.codeEntryPolicy, + viewerPasswordEnabled: update.viewerPasswordEnabled, + }); + } catch { + console.error("Code entry policy update failed"); + this.sendError( + hostSocket, + "SERVER_ERROR", + "Code entry policy could not be updated", + ); + } + } + + private updateViewerGrant( + hostSocket: WebSocket, + authenticated: AuthenticatedSession, + action: "rotate" | "revoke", ): void { let update; try { - update = this.options.roomStore.setViewerAccess( + update = this.options.roomStore.setViewerGrant( authenticated.roomId, action, + this.socketStates.get(hostSocket)?.sessionId ?? "", + ); + } catch { + console.error("Viewer grant update failed"); + this.sendError( + hostSocket, + "SERVER_ERROR", + "Viewer grant could not be updated", ); - } catch (error) { - console.error("Viewer access update failed", error); - this.sendError(hostSocket, "SERVER_ERROR", "Viewer access could not be updated"); return; } if (update.revokedViewers.length > 0) { - this.clearRoomGraceTimers(authenticated.roomId); - this.clearRoomConnectionIds(authenticated.roomId); - this.ordinaryActiveHostChildrenByRoom.delete(authenticated.roomId); - this.deferredViewerPresenceRooms.add(authenticated.roomId); try { for (const viewer of update.revokedViewers) { + this.clearViewerState(authenticated.roomId, viewer.peerId); if (!viewer.sessionId) { continue; } this.sendToSession(viewer.sessionId, { - type: "viewer-access-revoked", + type: "viewer-grant-revoked", viewerAuthorizationGeneration: update.previousViewerAuthorizationGeneration, }); @@ -1063,8 +1109,7 @@ export class SignalingServer { ? null : this.viewerInviteUrl(authenticated.roomId, update.viewerGrant); this.send(hostSocket, { - type: "viewer-access-updated", - viewerPolicy: update.viewerPolicy, + type: "viewer-grant-updated", viewerAuthorizationGeneration: update.viewerAuthorizationGeneration, inviteUrl, viewerGrantExpiresAt: update.viewerGrantExpiresAt, @@ -1968,6 +2013,23 @@ export class SignalingServer { } } + private clearViewerState(roomId: string, peerId: string): void { + this.clearViewerGrace(roomId, peerId); + this.deleteViewerConnectionId(roomId, peerId); + + const readyByViewer = this.viewerMediaReadyByRoom.get(roomId); + readyByViewer?.delete(peerId); + if (readyByViewer?.size === 0) { + this.viewerMediaReadyByRoom.delete(roomId); + } + + const ordinaryChildren = this.ordinaryActiveHostChildrenByRoom.get(roomId); + ordinaryChildren?.delete(peerId); + if (ordinaryChildren?.size === 0) { + this.ordinaryActiveHostChildrenByRoom.delete(roomId); + } + } + private clearRoomGraceTimers(roomId: string): void { const prefix = `${roomId}:`; for (const [key, timer] of this.viewerGraceTimers) { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 3e1f795..9269174 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -6,8 +6,8 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 20; export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1; export const MAX_SIGNAL_BYTES = 64 * 1024; -export const SIGNALING_PROTOCOL = "screener-v7"; -export const ROOM_CODE_LENGTH = 12; +export const SIGNALING_PROTOCOL = "screener-v8"; +export const ROOM_CODE_LENGTH = 4; export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER; export const MAX_SFU_TOKEN_LENGTH = 8 * 1024; export const MAX_ICE_SERVER_URLS = 8; @@ -114,7 +114,7 @@ export const viewerGrantSchema = z .string() .min(50) .max(96) - .regex(/^g1\.[1-9]\d{0,11}\.[1-9]\d{0,12}\.[A-Za-z0-9_-]{43}$/); + .regex(/^g1\.[1-9]\d{3}\.[1-9]\d{0,12}\.[A-Za-z0-9_-]{43}$/); export const viewerPasswordSchema = z .string() @@ -122,11 +122,12 @@ export const viewerPasswordSchema = z .max(MAX_VIEWER_PASSWORD_LENGTH) .regex(/^[\x21-\x7e]+$/); -export const viewerAccessPolicySchema = z.enum([ - "private-link", - "public-watch", +export const codeEntryPolicySchema = z.enum([ + "open", + "password", + "disabled", ]); -export type ViewerAccessPolicy = z.infer; +export type CodeEntryPolicy = z.infer; const liveKitWebSocketUrlSchema = z .string() @@ -143,7 +144,7 @@ const liveKitWebSocketUrlSchema = z export const roomCodeSchema = z .string() - .min(1) + .length(ROOM_CODE_LENGTH) .max(ROOM_CODE_LENGTH) .regex(/^[1-9]\d*$/); @@ -619,8 +620,18 @@ export const clientMessageSchema = z.union([ .strict(), z .object({ - type: z.literal("set-viewer-access"), - action: z.enum(["public-watch", "rotate", "revoke"]), + type: z.literal("set-code-entry-policy"), + policy: codeEntryPolicySchema, + }) + .strict(), + z + .object({ + type: z.literal("rotate-viewer-grant"), + }) + .strict(), + z + .object({ + type: z.literal("revoke-viewer-grant"), }) .strict(), z @@ -675,7 +686,7 @@ const authenticatedMessageShape = { connectionId: opaqueIdSchema.nullable(), viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS_PER_ROOM_LIMIT), iceConfig: iceConfigSchema, - viewerPolicy: viewerAccessPolicySchema, + codeEntryPolicy: codeEntryPolicySchema, viewerAuthorizationGeneration: opaqueIdSchema, }; @@ -845,8 +856,14 @@ export const serverMessageSchema = z.union([ }), z .object({ - type: z.literal("viewer-access-updated"), - viewerPolicy: viewerAccessPolicySchema, + type: z.literal("code-entry-policy-updated"), + codeEntryPolicy: codeEntryPolicySchema, + viewerPasswordEnabled: z.boolean(), + }) + .strict(), + z + .object({ + type: z.literal("viewer-grant-updated"), viewerAuthorizationGeneration: opaqueIdSchema, inviteUrl: z.string().url().max(2048).nullable(), viewerGrantExpiresAt: z.string().datetime().nullable(), @@ -854,7 +871,7 @@ export const serverMessageSchema = z.union([ .strict(), z .object({ - type: z.literal("viewer-access-revoked"), + type: z.literal("viewer-grant-revoked"), viewerAuthorizationGeneration: opaqueIdSchema, }) .strict(), @@ -886,19 +903,17 @@ export const createRoomResponseSchema = z roomId: roomCodeSchema, hostToken: tokenSchema, inviteUrl: z.string().url().max(2048), - viewerPolicy: viewerAccessPolicySchema, + codeEntryPolicy: codeEntryPolicySchema, viewerGrantExpiresAt: z.string().datetime().nullable(), expiresAt: z.string().datetime().nullable(), }) .strict(); export type CreateRoomResponse = z.infer; -export const MAX_HOST_CLAIM_TTL_SECONDS = 5 * 60; - export const createRoomRequestSchema = z .object({ - viewerPolicy: viewerAccessPolicySchema, - hostClaimTtlSeconds: z.literal(MAX_HOST_CLAIM_TTL_SECONDS).optional(), + codeEntryPolicy: codeEntryPolicySchema, + roomPassword: viewerPasswordSchema.nullable().optional(), }) .strict(); diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 0e1564f..d792faf 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -266,10 +266,10 @@ describe("client session identity", () => { }, }); const room = { - roomId: "7", + roomId: "1234", hostToken: "a".repeat(32), - inviteUrl: `https://share.test/r/7#v=g1.7.1893456000.${"b".repeat(43)}`, - viewerPolicy: "private-link" as const, + inviteUrl: `https://share.test/r/1234#v=g1.1234.1893456000.${"b".repeat(43)}`, + codeEntryPolicy: "open" as const, viewerGrantExpiresAt: "2026-08-25T00:00:00.000Z", expiresAt: null, }; @@ -277,20 +277,20 @@ describe("client session identity", () => { writeHostRoom(room); expect(readHostRoom()).toEqual({ - roomId: "7", + roomId: "1234", hostToken: "a".repeat(32), - canonicalUrl: "https://share.test/r/7", + canonicalUrl: "https://share.test/r/1234", expiresAt: null, }); expect(values.get("screener:host-room:v1")).toBe( JSON.stringify({ - roomId: "7", + roomId: "1234", hostToken: "a".repeat(32), expiresAt: null, - inviteUrl: "https://share.test/r/7", + inviteUrl: "https://share.test/r/1234", }), ); - expect([...values.values()].join(" ")).not.toContain("g1.7."); + expect([...values.values()].join(" ")).not.toContain("g1.1234."); clearHostRoom(); expect(readHostRoom()).toBeNull(); }); @@ -300,9 +300,9 @@ describe("client session identity", () => { [ "screener:host-room:v1", JSON.stringify({ - roomId: "9", + roomId: "1234", hostToken: "h".repeat(32), - inviteUrl: "https://share.test/r/9", + inviteUrl: "https://share.test/r/1234", expiresAt: null, }), ], @@ -316,14 +316,14 @@ describe("client session identity", () => { }); expect(readHostRoom()).toEqual({ - roomId: "9", + roomId: "1234", hostToken: "h".repeat(32), - canonicalUrl: "https://share.test/r/9", + canonicalUrl: "https://share.test/r/1234", expiresAt: null, }); }); - it("discards expired or malformed host room records", () => { + it("keeps expired host records for server judgment and discards malformed records", () => { const values = new Map(); const removeItem = vi.fn((key: string) => values.delete(key)); vi.stubGlobal("window", { @@ -334,27 +334,27 @@ describe("client session identity", () => { }, }); const expired = { - roomId: "12", + roomId: "1234", hostToken: "b".repeat(32), - canonicalUrl: "https://share.test/r/12", + canonicalUrl: "https://share.test/r/1234", expiresAt: "2026-08-18T00:00:00.000Z", }; writeHostRoom(expired); - expect(readHostRoom(Date.parse(expired.expiresAt) + 1)).toBeNull(); - expect(removeItem).toHaveBeenCalledOnce(); + expect(readHostRoom()).toMatchObject({ roomId: "1234" }); + expect(removeItem).not.toHaveBeenCalled(); values.set("screener:host-room:v1", "not-json"); expect(readHostRoom()).toBeNull(); - expect(removeItem).toHaveBeenCalledTimes(2); + expect(removeItem).toHaveBeenCalledOnce(); }); it("consumes a Viewer grant fragment once into room-scoped session storage", () => { const values = new Map(); const replaceState = vi.fn(); - const grant = `g1.7.1893456000.${"c".repeat(43)}`; + const grant = `g1.1234.1893456000.${"c".repeat(43)}`; vi.stubGlobal("window", { - location: new URL(`https://share.test/r/7#v=${grant}`), + location: new URL(`https://share.test/r/1234#v=${grant}`), history: { state: { navigation: 1 }, replaceState }, sessionStorage: { getItem: (key: string) => values.get(key) ?? null, @@ -363,22 +363,22 @@ describe("client session identity", () => { }, }); - expect(readViewerRoute()).toEqual({ roomId: "7", viewerGrant: grant }); - expect(values.get("screener:viewer-grant:7")).toBe(grant); + expect(readViewerRoute()).toEqual({ roomId: "1234", viewerGrant: grant }); + expect(values.get("screener:viewer-grant:1234")).toBe(grant); expect(replaceState).toHaveBeenCalledWith( { navigation: 1 }, "", - "/r/7", + "/r/1234", ); expect(readViewerGrant("8")).toBeNull(); }); it("fails malformed Viewer fragments closed without leaking across rooms", () => { const values = new Map(); - const oldGrant = `g1.7.1893456000.${"d".repeat(43)}`; - values.set("screener:viewer-grant:7", oldGrant); + const oldGrant = `g1.1234.1893456000.${"d".repeat(43)}`; + values.set("screener:viewer-grant:1234", oldGrant); vi.stubGlobal("window", { - location: new URL("https://share.test/r/7#v=malformed"), + location: new URL("https://share.test/r/1234#v=malformed"), history: { state: null, replaceState: vi.fn() }, sessionStorage: { getItem: (key: string) => values.get(key) ?? null, @@ -387,13 +387,13 @@ describe("client session identity", () => { }, }); - expect(readViewerRoute()).toEqual({ roomId: "7" }); - expect(readViewerGrant("7")).toBeNull(); + expect(readViewerRoute()).toEqual({ roomId: "1234" }); + expect(readViewerGrant("1234")).toBeNull(); replaceViewerInvite( "8", - `https://share.test/r/8#v=g1.7.1893456000.${"e".repeat(43)}`, + `https://share.test/r/8#v=g1.1234.1893456000.${"e".repeat(43)}`, ); - expect(readViewerGrant("7")).toBeNull(); + expect(readViewerGrant("1234")).toBeNull(); expect(readViewerGrant("8")).toBeNull(); }); @@ -406,17 +406,17 @@ describe("client session identity", () => { removeItem: (key: string) => values.delete(key), }, }); - const firstGrant = `g1.7.1893456000.${"g".repeat(43)}`; - const rotatedGrant = `g1.7.1893456000.${"h".repeat(43)}`; + const firstGrant = `g1.1234.1893456000.${"g".repeat(43)}`; + const rotatedGrant = `g1.1234.1893456000.${"h".repeat(43)}`; - replaceViewerInvite("7", `https://share.test/r/7#v=${firstGrant}`); - replaceViewerInvite("7", `https://share.test/r/7#v=${rotatedGrant}`); + replaceViewerInvite("1234", `https://share.test/r/1234#v=${firstGrant}`); + replaceViewerInvite("1234", `https://share.test/r/1234#v=${rotatedGrant}`); - expect(readViewerGrant("7")).toBe(rotatedGrant); - expect(readViewerGrant("7")).toBe(rotatedGrant); + expect(readViewerGrant("1234")).toBe(rotatedGrant); + expect(readViewerGrant("1234")).toBe(rotatedGrant); - replaceViewerInvite("7", null); - expect(readViewerGrant("7")).toBeNull(); + replaceViewerInvite("1234", null); + expect(readViewerGrant("1234")).toBeNull(); }); it("keeps the latest rotated or revoked invite through Host re-authentication", () => { @@ -428,15 +428,15 @@ describe("client session identity", () => { removeItem: (key: string) => values.delete(key), }, }); - const oldInvite = `https://share.test/r/7#v=g1.7.1893456000.${"i".repeat(43)}`; - const rotatedInvite = `https://share.test/r/7#v=g1.7.1893456000.${"j".repeat(43)}`; - replaceViewerInvite("7", oldInvite); + const oldInvite = `https://share.test/r/1234#v=g1.1234.1893456000.${"i".repeat(43)}`; + const rotatedInvite = `https://share.test/r/1234#v=g1.1234.1893456000.${"j".repeat(43)}`; + replaceViewerInvite("1234", oldInvite); const activeRoom = { - roomId: "7", + roomId: "1234", hostToken: "h".repeat(32), - canonicalUrl: "https://share.test/r/7", + canonicalUrl: "https://share.test/r/1234", expiresAt: null, - viewerPolicy: "private-link" as const, + codeEntryPolicy: "open" as const, inviteUrl: oldInvite, }; @@ -445,7 +445,7 @@ describe("client session identity", () => { { ...activeRoom, inviteUrl: rotatedInvite }, activeRoom.roomId, null, - "private-link", + "open", )?.inviteUrl, ).toBe(rotatedInvite); expect( @@ -453,14 +453,23 @@ describe("client session identity", () => { { ...activeRoom, inviteUrl: null }, activeRoom.roomId, null, - "private-link", + "open", + )?.inviteUrl, + ).toBe(oldInvite); + replaceViewerInvite(activeRoom.roomId, null); + expect( + mergeAuthenticatedHostRoom( + { ...activeRoom, inviteUrl: null }, + activeRoom.roomId, + null, + "open", )?.inviteUrl, ).toBeNull(); }); it("removes an expired Viewer grant from room-scoped session storage", () => { const values = new Map([ - ["screener:viewer-grant:7", `g1.7.1.${"f".repeat(43)}`], + ["screener:viewer-grant:1234", `g1.1234.1.${"f".repeat(43)}`], ]); const removeItem = vi.fn((key: string) => values.delete(key)); vi.stubGlobal("window", { @@ -471,8 +480,8 @@ describe("client session identity", () => { }, }); - expect(readViewerGrant("7")).toBeNull(); - expect(removeItem).toHaveBeenCalledWith("screener:viewer-grant:7"); + expect(readViewerGrant("1234")).toBeNull(); + expect(removeItem).toHaveBeenCalledWith("screener:viewer-grant:1234"); }); }); @@ -541,23 +550,23 @@ describe("site access API", () => { ); vi.stubGlobal("fetch", fetchMock); - await expect(createRoom("private-link")).rejects.toBeInstanceOf(ApiError); + await expect(createRoom("open", null)).rejects.toBeInstanceOf(ApiError); const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); expect(headers.has("Authorization")).toBe(false); expect(fetchMock.mock.calls[0][1]?.body).toBe( - JSON.stringify({ viewerPolicy: "private-link" }), + JSON.stringify({ codeEntryPolicy: "open" }), ); }); - it("accepts a persistent room without an expiry", async () => { + it("accepts an active room with a lease deadline", async () => { const room = { - roomId: "42", + roomId: "1234", hostToken: "c".repeat(32), - inviteUrl: `https://share.test/r/42#v=g1.42.1893456000.${"f".repeat(43)}`, - viewerPolicy: "private-link", + inviteUrl: `https://share.test/r/1234#v=g1.1234.1893456000.${"f".repeat(43)}`, + codeEntryPolicy: "open", viewerGrantExpiresAt: "2026-08-25T00:00:00.000Z", - expiresAt: null, + expiresAt: "2026-08-24T00:00:00.000Z", }; vi.stubGlobal( "fetch", @@ -569,21 +578,21 @@ describe("site access API", () => { ), ); - await expect(createRoom("private-link")).resolves.toEqual(room); + await expect(createRoom("open", null)).resolves.toEqual(room); }); }); describe("room codes", () => { - 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(true); + it("accepts exactly four decimal digits without a leading zero", () => { + expect(isValidRoomId("1000")).toBe(true); + expect(isValidRoomId("9999")).toBe(true); + expect(isValidRoomId("123")).toBe(false); + expect(isValidRoomId("12345")).toBe(false); expect(isValidRoomId("0")).toBe(false); - expect(isValidRoomId("012345")).toBe(false); - expect(isValidRoomId("1234567890123")).toBe(false); - expect(isValidRoomId("12345678901a")).toBe(false); - expect(roomRouteFromInput(" 42 ")).toBe("/r/42"); - expect(roomRouteFromInput("042")).toBeNull(); + expect(isValidRoomId("0123")).toBe(false); + expect(isValidRoomId("123a")).toBe(false); + expect(roomRouteFromInput(" 1234 ")).toBe("/r/1234"); + expect(roomRouteFromInput("0123")).toBeNull(); }); }); @@ -615,13 +624,13 @@ describe("client signaling recovery policy", () => { } vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal("window", { - location: new URL("https://share.test/r/123456789012"), + location: new URL("https://share.test/r/1234"), setTimeout, clearTimeout, }); const signal = new SignalingClient( { - roomId: "123456789012", + roomId: "1234", role: "viewer", clientId: "viewer-client", }, @@ -640,7 +649,7 @@ describe("client signaling recovery policy", () => { Object.defineProperty(authenticated, "data", { value: JSON.stringify({ type: "authenticated", - protocol: "screener-v7", + protocol: "screener-v8", role: "viewer", peerId: "viewer_12345678", roomExpiresAt: null, @@ -650,7 +659,7 @@ describe("client signaling recovery policy", () => { connectionId: null, viewerPeerIds: [], iceConfig: { iceServers: [] }, - viewerPolicy: "private-link", + codeEntryPolicy: "open", viewerAuthorizationGeneration: "viewer_generation_12345678", }), }); @@ -683,7 +692,7 @@ describe("client signaling recovery policy", () => { } vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal("window", { - location: new URL("https://share.test/r/123456789012"), + location: new URL("https://share.test/r/1234"), setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, }); @@ -692,7 +701,7 @@ describe("client signaling recovery policy", () => { const onTerminated = vi.fn(); const signal = new SignalingClient( { - roomId: "123456789012", + roomId: "1234", role: "host", token: "h".repeat(43), clientId: "host-client", @@ -714,7 +723,7 @@ describe("client signaling recovery policy", () => { socket.dispatchEvent(new Event("open")); receive(socket, { type: "authenticated", - protocol: "screener-v7", + protocol: "screener-v8", role: "host", peerId: "host_12345678", roomExpiresAt: null, @@ -724,7 +733,7 @@ describe("client signaling recovery policy", () => { connectionId: null, viewerPeerIds: [], iceConfig: { iceServers: [] }, - viewerPolicy: "private-link", + codeEntryPolicy: "open", viewerAuthorizationGeneration: "viewer_generation_12345678", }); }; @@ -806,14 +815,14 @@ describe("client signaling recovery policy", () => { vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal("document", documentTarget); vi.stubGlobal("window", { - location: new URL("https://share.test/r/123456789012"), + location: new URL("https://share.test/r/1234"), setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, }); const onMessage = vi.fn(); const signal = new SignalingClient( { - roomId: "123456789012", + roomId: "1234", role: "viewer", clientId: "viewer-client", }, @@ -839,7 +848,7 @@ describe("client signaling recovery policy", () => { sockets[0]!.dispatchEvent(new Event("open")); receive({ type: "authenticated", - protocol: "screener-v7", + protocol: "screener-v8", role: "viewer", peerId: "viewer_12345678", roomExpiresAt: null, @@ -849,7 +858,7 @@ describe("client signaling recovery policy", () => { connectionId: "connection_12345678", viewerPeerIds: [], iceConfig: { iceServers: [] }, - viewerPolicy: "private-link", + codeEntryPolicy: "open", viewerAuthorizationGeneration: "viewer_generation_12345678", mediaMode: "peer-assisted", mediaAssignment: { parentPeerId: null, childPeerIds: [] }, @@ -942,14 +951,14 @@ describe("client signaling recovery policy", () => { } vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal("window", { - location: new URL("https://share.test/r/123456789012"), + location: new URL("https://share.test/r/1234"), setTimeout, clearTimeout, }); const onTerminated = vi.fn(); const signal = new SignalingClient( { - roomId: "123456789012", + roomId: "1234", role: "viewer", clientId: "viewer-client", }, @@ -967,7 +976,7 @@ describe("client signaling recovery policy", () => { JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])), ).toMatchObject({ type: "authenticate", - protocol: "screener-v7", + protocol: "screener-v8", }); const message = new Event("message"); Object.defineProperty(message, "data", { value: payload }); @@ -1003,7 +1012,7 @@ describe("client signaling recovery policy", () => { } vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal("window", { - location: new URL("https://share.test/r/123456789012"), + location: new URL("https://share.test/r/1234"), setTimeout, clearTimeout, }); @@ -1012,7 +1021,7 @@ describe("client signaling recovery policy", () => { const onMessage = vi.fn(); const signal = new SignalingClient( { - roomId: "123456789012", + roomId: "1234", role: "host", token: "h".repeat(43), clientId: "host-client", diff --git a/tests/creation-profile.test.ts b/tests/creation-profile.test.ts new file mode 100644 index 0000000..30c503f --- /dev/null +++ b/tests/creation-profile.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + readCreationProfile, + saveCreationProfile, +} from "../src/client/lib/creation-profile.ts"; + +describe("Host creation profile", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("defaults to open code entry without a room password", () => { + vi.stubGlobal("window", { + localStorage: { getItem: () => null }, + }); + + expect(readCreationProfile()).toEqual({ + codeEntryPolicy: "open", + roomPassword: null, + }); + }); + + it("persists the accepted creation preferences in same-origin storage", () => { + 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), + }, + }); + + saveCreationProfile({ + codeEntryPolicy: "password", + roomPassword: "room-password", + }); + + expect(readCreationProfile()).toEqual({ + codeEntryPolicy: "password", + roomPassword: "room-password", + }); + expect(values.get("screener:host-creation-profile:v1")).not.toContain( + "SITE_ACCESS_PASSWORD", + ); + }); + + it("discards malformed or incomplete password-policy records", () => { + const removeItem = vi.fn(); + vi.stubGlobal("window", { + localStorage: { + getItem: () => + JSON.stringify({ codeEntryPolicy: "password", roomPassword: null }), + removeItem, + }, + }); + + expect(readCreationProfile()).toEqual({ + codeEntryPolicy: "open", + roomPassword: null, + }); + expect(removeItem).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/hybrid-media-router.test.ts b/tests/hybrid-media-router.test.ts index 790ead1..26f6c2b 100644 --- a/tests/hybrid-media-router.test.ts +++ b/tests/hybrid-media-router.test.ts @@ -6,7 +6,7 @@ import { type AuthenticatedRouteParticipant, type HybridAuthenticationState, } from "../src/server/hybrid-media-router.ts"; -import { RoomStore } from "../src/server/room-store.ts"; +import { RoomStore, type CreatedRoom } from "../src/server/room-store.ts"; import { SfuResourceAdmission } from "../src/server/sfu-resource-admission.ts"; import { TurnAllocationAdmission } from "../src/server/turn-allocation-admission.ts"; import { FakeSfuRoomControl } from "./fake-sfu-room-control.ts"; @@ -15,13 +15,13 @@ const SHARE_GENERATION = "share_generation_12345678"; function createStore(maxViewersPerRoom = 20) { return new RoomStore({ - ttlMs: 60_000, + leaseMs: 60_000, maxRooms: 4, maxViewersPerRoom, }); } -function connectHost(store: RoomStore, room: ReturnType) { +function connectHost(store: RoomStore, room: CreatedRoom) { const connected = store.connectParticipant({ roomId: room.roomId, role: "host", @@ -39,7 +39,7 @@ function connectHost(store: RoomStore, room: ReturnType function connectViewer( store: RoomStore, - room: ReturnType, + room: CreatedRoom, suffix: string, ): AuthenticatedRouteParticipant { const connected = store.connectParticipant({ @@ -160,7 +160,7 @@ function harness( describe("HybridMediaRouter v7 runtime", () => { it("orders exact prepare, commits only child proof, and rolls back above P", async () => { const { store, sent, router } = harness(2); - const room = store.createRoom(); + const room = await store.createRoom(); const host = connectHost(store, room); complete(router, host); const first = connectViewer(store, room, "first"); @@ -221,7 +221,7 @@ describe("HybridMediaRouter v7 runtime", () => { it("creates one Host publication and reuses exact Viewer subscriptions", async () => { const { store, sent, admission, router } = harness(1, true); - const room = store.createRoom(); + const room = await store.createRoom(); const host = connectHost(store, room); complete(router, host); const first = connectViewer(store, room, "sfu_first"); @@ -272,7 +272,7 @@ describe("HybridMediaRouter v7 runtime", () => { it("uses selected TURN only as the exact Viewer-parent edge transport", async () => { const { store, sent, turnAdmission, router } = harness(1, true, true); - const room = store.createRoom(); + const room = await store.createRoom(); const host = connectHost(store, room); complete(router, host); const parent = connectViewer(store, room, "selected_parent"); @@ -338,7 +338,7 @@ describe("HybridMediaRouter v7 runtime", () => { it("uses selected TURN as one Host-SFU ingress after direct ingress fails", async () => { const { store, sent, turnAdmission, router } = harness(1, true, true); - const room = store.createRoom(); + const room = await store.createRoom(); const host = connectHost(store, room); complete(router, host); const first = connectViewer(store, room, "ingress_first"); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 61929fc..764d7c3 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -6,7 +6,6 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_VIEWER_DISPLAY_NAME, MAX_DISPLAY_NAME_CODE_POINTS, - MAX_HOST_CLAIM_TTL_SECONDS, MAX_MEDIA_ROUTE_REVISION, MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES, MAX_SFU_TOKEN_LENGTH, @@ -24,7 +23,7 @@ import { } from "../src/shared/protocol.js"; const token = "a".repeat(43); -const roomId = "123456789012"; +const roomId = "1234"; const viewerGrant = `g1.${roomId}.1787076000.${"b".repeat(43)}`; const qualitySettings = { resolution: "1080p", @@ -85,7 +84,7 @@ describe("client signaling protocol", () => { "utf8", ); - expect(SIGNALING_PROTOCOL).toBe("screener-v7"); + expect(SIGNALING_PROTOCOL).toBe("screener-v8"); expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/); }); @@ -122,24 +121,23 @@ describe("client signaling protocol", () => { } }); - it("accepts only the fixed provisional Host lease", () => { + it("accepts an atomic room creation profile", () => { expect( - createRoomRequestSchema.parse({ viewerPolicy: "private-link" }), - ).toEqual({ viewerPolicy: "private-link" }); + createRoomRequestSchema.parse({ codeEntryPolicy: "open" }), + ).toEqual({ codeEntryPolicy: "open" }); expect( createRoomRequestSchema.parse({ - viewerPolicy: "private-link", - hostClaimTtlSeconds: MAX_HOST_CLAIM_TTL_SECONDS, + codeEntryPolicy: "password", + roomPassword: "room-password", }), ).toEqual({ - viewerPolicy: "private-link", - hostClaimTtlSeconds: MAX_HOST_CLAIM_TTL_SECONDS, + codeEntryPolicy: "password", + roomPassword: "room-password", }); - for (const hostClaimTtlSeconds of [0, 1, 299, 301, 1.5]) { + for (const codeEntryPolicy of ["private-link", "public-watch", 1]) { expect( createRoomRequestSchema.safeParse({ - viewerPolicy: "private-link", - hostClaimTtlSeconds, + codeEntryPolicy, }).success, ).toBe(false); } @@ -265,18 +263,24 @@ describe("client signaling protocol", () => { }).success, ).toBe(false); } - for (const action of ["public-watch", "rotate", "revoke"]) { + for (const policy of ["open", "password", "disabled"]) { expect( clientMessageSchema.safeParse({ - type: "set-viewer-access", - action, + type: "set-code-entry-policy", + policy, }).success, ).toBe(true); } + expect( + clientMessageSchema.safeParse({ type: "rotate-viewer-grant" }).success, + ).toBe(true); + expect( + clientMessageSchema.safeParse({ type: "revoke-viewer-grant" }).success, + ).toBe(true); expect( clientMessageSchema.safeParse({ - type: "set-viewer-access", - action: "private-link", + type: "set-code-entry-policy", + policy: "private-link", }).success, ).toBe(false); }); @@ -379,7 +383,7 @@ describe("client signaling protocol", () => { clientMessageSchema.safeParse({ type: "authenticate", protocol: SIGNALING_PROTOCOL, - roomId: "1", + roomId: "1234", role: "viewer", clientId: "client_12345678", }).success, @@ -397,7 +401,7 @@ describe("client signaling protocol", () => { clientMessageSchema.safeParse({ type: "authenticate", protocol: SIGNALING_PROTOCOL, - roomId: "1".repeat(13), + roomId: "1".repeat(5), role: "viewer", clientId: "client_12345678", }).success, @@ -839,7 +843,7 @@ describe("server signaling protocol", () => { hostOnline: true, connectionId: null, viewerPeerIds, - viewerPolicy: "private-link", + codeEntryPolicy: "open", viewerAuthorizationGeneration: "viewer_generation_12345678", iceConfig: { iceServers: [], diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts index c0b860c..2757cf5 100644 --- a/tests/room-store.test.ts +++ b/tests/room-store.test.ts @@ -1,655 +1,177 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it } from "vitest"; -import { describe, expect, it, vi } from "vitest"; +import { RoomStore, RoomStoreError } from "../src/server/room-store.ts"; -import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../src/shared/protocol.ts"; -import { RoomDatabase } from "../src/server/room-database.ts"; -import { - RoomStore, - RoomStoreError, -} from "../src/server/room-store.ts"; - -function expectRoomError(action: () => unknown, code: RoomStoreError["code"]): void { - try { - action(); - throw new Error("Expected RoomStoreError"); - } catch (error) { - expect(error).toBeInstanceOf(RoomStoreError); - expect((error as RoomStoreError).code).toBe(code); - } +interface MutableClock { + nowMs: number; } -async function expectRoomErrorAsync( - action: () => Promise, - code: RoomStoreError["code"], -): Promise { - await expect(action()).rejects.toMatchObject({ code }); +function store(options: Partial[0]> = {}) { + const clock: MutableClock = { nowMs: 0 }; + return { + clock, + store: new RoomStore({ + leaseMs: 1_000, + maxRooms: 2, + maxViewersPerRoom: 2, + now: () => clock.nowMs, + ...options, + }), + }; } -function viewerGrant(room: { viewerGrant: string | null }): string { - if (!room.viewerGrant) { - throw new Error("Expected a private room Viewer grant"); - } - return room.viewerGrant; +function hostInput(roomId: string, hostToken: string, sessionId = "host-session") { + return { + roomId, + role: "host" as const, + token: hostToken, + clientId: "host-client", + sessionId, + }; +} + +function viewerInput( + roomId: string, + sessionId: string, + viewerGrant?: string, + clientId = `viewer-${sessionId}`, +) { + return { + roomId, + role: "viewer" as const, + ...(viewerGrant ? { viewerGrant } : {}), + clientId, + sessionId, + }; +} + +function expectRoomError(run: () => unknown, code: string) { + expect(run).toThrow(new RoomStoreError(code as never)); } describe("RoomStore", () => { - it("creates a four-hour room and authenticates both roles", () => { - const now = Date.UTC(2026, 7, 18, 12); - const store = new RoomStore({ - ttlMs: 14_400_000, - maxRooms: 10, - maxViewersPerRoom: 3, - now: () => now, - }); - const room = store.createRoom(); - - 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( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "viewer-client-1", - sessionId: "viewer-session-1", - }), - ).toMatchObject({ role: "viewer", hostOnline: false }); - expect( - store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "host-client-1", - sessionId: "host-session-1", - }), - ).toMatchObject({ - role: "host", - hostOnline: true, - viewerPeerIds: expect.any(Array), - }); - }); - - it("rejects an invalid host token without exposing which value failed", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); - const room = store.createRoom(); - - expectRoomError( - () => - store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: "wrong-host-token".padEnd(32, "x"), - clientId: "host-client-1", - sessionId: "host-session-1", - }), - "INVALID_TOKEN", - ); - }); - - it("keeps peer identity stable and ignores disconnect from a replaced socket", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); - const room = store.createRoom(); - const first = store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "stable-viewer-client", - sessionId: "viewer-session-old", - }); - const replacement = store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "stable-viewer-client", - sessionId: "viewer-session-new", + it("allocates every free four-digit room code and recycles releases", async () => { + const { store: roomStore } = store({ + maxRooms: 9_000, + random: (size) => Buffer.alloc(size), }); - expect(replacement.peerId).toBe(first.peerId); - expect(replacement.replacedSessionId).toBe("viewer-session-old"); - expect( - store.disconnectParticipant(room.roomId, first.peerId, "viewer-session-old"), - ).toBeUndefined(); - expect(store.getConnectedViewer(room.roomId, first.peerId)?.sessionId).toBe( - "viewer-session-new", - ); - }); - - it("reserves the configured viewer slots until a disconnected viewer is released", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 2, - }); - const room = store.createRoom(); - const viewers = [1, 2].map((number) => - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: `viewer-client-${number}`, - sessionId: `viewer-session-${number}`, - }), - ); - - expectRoomError( - () => - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "viewer-client-3", - sessionId: "viewer-session-3", - }), - "ROOM_FULL", - ); - - store.disconnectParticipant(room.roomId, viewers[0].peerId, "viewer-session-1"); - expectRoomError( - () => - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "viewer-client-3", - sessionId: "viewer-session-3", - }), - "ROOM_FULL", - ); - expect(store.removeDisconnectedViewer(room.roomId, viewers[0].peerId)).toBe(true); - expect( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "viewer-client-3", - sessionId: "viewer-session-3", - }).role, - ).toBe("viewer"); - expect(store.maxViewersPerRoom).toBe(2); - }); - - it("admits one Host plus the 20-Viewer room ceiling", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: MAX_VIEWERS_PER_ROOM_LIMIT, - }); - const room = store.createRoom(); - - expect( - store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "host-client", - sessionId: "host-session", - }).role, - ).toBe("host"); - - for (let index = 1; index <= MAX_VIEWERS_PER_ROOM_LIMIT; index += 1) { - expect( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: `viewer-client-${index}`, - sessionId: `viewer-session-${index}`, - }).role, - ).toBe("viewer"); + const rooms = []; + for (let index = 0; index < 9_000; index += 1) { + rooms.push(await roomStore.createRoom()); } + expect(new Set(rooms.map((room) => room.roomId)).size).toBe(9_000); + expect(rooms.every((room) => /^[1-9]\d{3}$/.test(room.roomId))).toBe(true); + await expect(roomStore.createRoom()).rejects.toEqual( + new RoomStoreError("ROOM_LIMIT"), + ); - expectRoomError( - () => - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: `viewer-client-${MAX_VIEWERS_PER_ROOM_LIMIT + 1}`, - sessionId: `viewer-session-${MAX_VIEWERS_PER_ROOM_LIMIT + 1}`, - }), - "ROOM_FULL", + const released = rooms[4_321]!.roomId; + expect(roomStore.abandonRoom(released)?.roomId).toBe(released); + expect((await roomStore.createRoom()).roomId).toBe(released); + }); + + it("rejects room limits beyond the four-digit code space", () => { + expect(() => store({ maxRooms: 9_001 }).store).toThrow( + "Room limit must be an integer between 1 and 9000", ); }); - it("allows only the same host identity to replace an online host", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); - const room = store.createRoom(); - const first = store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "stable-host-client", - sessionId: "host-session-old", - }); - const replacement = store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "stable-host-client", - sessionId: "host-session-new", - }); - - expect(replacement.peerId).toBe(first.peerId); - expect(replacement.replacedSessionId).toBe("host-session-old"); - expectRoomError( - () => - store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "different-host-client", - sessionId: "different-host-session", - }), - "HOST_ALREADY_CONNECTED", + it("keeps active sharing alive and renews only with the exact Host token", async () => { + const { clock, store: roomStore } = store({ leaseMs: 1_000 }); + const room = await roomStore.createRoom(); + const host = roomStore.connectParticipant( + hostInput(room.roomId, room.hostToken), ); - }); + clock.nowMs = 2_000; + expect(roomStore.expireRooms()).toEqual([]); - it("expires and globally bounds transient rooms", () => { - let now = 1_000; - const expiringStore = new RoomStore({ - ttlMs: 100, - maxRooms: 1, - maxViewersPerRoom: 3, - now: () => now, - }); - const room = expiringStore.createRoom(); - expectRoomError(() => expiringStore.createRoom(), "ROOM_LIMIT"); - - now = 1_100; - expectRoomError( - () => - expiringStore.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "host-client-1", - sessionId: "host-session-1", - }), - "ROOM_EXPIRED", - ); - expect(expiringStore.expireRooms()).toHaveLength(1); - expect(expiringStore.size).toBe(0); - - 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, - ); - expect( - readFileSync(databasePath).includes(Buffer.from(viewerGrant(first))), - ).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", - viewerGrant: viewerGrant(first), - 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", - viewerGrant: viewerGrant(first), - 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("keeps provisional native rooms in memory when persistence is configured", () => { - let now = Date.UTC(2026, 7, 18, 12); - const directory = mkdtempSync(join(tmpdir(), "screener-provisional-room-")); - const databasePath = join(directory, "rooms.sqlite"); - const database = new RoomDatabase(databasePath); - const store = new RoomStore({ - ttlMs: 14_400_000, - maxRooms: 3, - maxViewersPerRoom: 3, - now: () => now, - database, - }); - try { - const provisional = store.createRoom("private-link", 300); - expect(provisional.expiresAt).not.toBeNull(); - expect(database.loadRooms()).toEqual([]); - - const persistent = store.createRoom(); - expect(persistent).toMatchObject({ roomId: "1", expiresAt: null }); - expect(database.loadRooms()).toHaveLength(1); - - now += 300_001; - expect(store.expireRooms()).toEqual([ - { roomId: provisional.roomId, sessionIds: [] }, - ]); - expect(database.loadRooms()).toHaveLength(1); - } finally { - store.close(); - rmSync(directory, { recursive: true, force: true }); - } - }); - - it("reclaims only the disconnected current Host after the provisional lease", () => { - let now = Date.UTC(2026, 7, 18, 12); - const store = new RoomStore({ - ttlMs: 14_400_000, - maxRooms: 2, - maxViewersPerRoom: 3, - now: () => now, - }); - const room = store.createRoom("private-link", 300); - const first = store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "native-host", - sessionId: "native-session-1", - }); - - now += 301_000; - expect(store.expireRooms()).toEqual([]); - const replacement = store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "native-host", - sessionId: "native-session-2", - }); - expect(replacement.replacedSessionId).toBe("native-session-1"); - expect( - store.disconnectParticipant(room.roomId, first.peerId, "native-session-1"), - ).toBeUndefined(); - - now += 301_000; - expect(store.expireRooms()).toEqual([]); - expect( - store.disconnectParticipant( - room.roomId, - replacement.peerId, - "native-session-2", - ), - ).toMatchObject({ role: "host" }); - expect(store.expireRooms(now + 299_999)).toEqual([]); - expect(store.expireRooms(now + 300_001)).toEqual([ - { roomId: room.roomId, sessionIds: [] }, - ]); - - const ttlRoom = store.createRoom("private-link", 300); - const ttlHost = store.connectParticipant({ - roomId: ttlRoom.roomId, - role: "host", - token: ttlRoom.hostToken, - clientId: "native-ttl-host", - sessionId: "native-ttl-session", - }); - expect(store.expireRooms(now + 14_400_001)).toEqual([ - { roomId: ttlRoom.roomId, sessionIds: ["native-ttl-session"] }, - ]); - expect(ttlHost.hostOnline).toBe(true); - }); - - it("never restores a provisional native room from SQLite after restart", () => { - const directory = mkdtempSync(join(tmpdir(), "screener-provisional-restart-")); - const databasePath = join(directory, "rooms.sqlite"); - let store: RoomStore | undefined; - try { - store = new RoomStore({ - ttlMs: 14_400_000, - maxRooms: 2, - maxViewersPerRoom: 3, - database: new RoomDatabase(databasePath), - }); - const room = store.createRoom("private-link", 300); - store.close(); - store = new RoomStore({ - ttlMs: 14_400_000, - maxRooms: 2, - maxViewersPerRoom: 3, - database: new RoomDatabase(databasePath), - }); - expectRoomError( - () => - store!.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "native-after-restart", - sessionId: "native-after-restart", - }), - "INVALID_TOKEN", - ); - } finally { - store?.close(); - rmSync(directory, { recursive: true, force: true }); - } - }); - - it("fails private Viewer access closed and scopes grants to one room", () => { - const now = Date.UTC(2026, 7, 18, 12); - const store = new RoomStore({ - ttlMs: 14_400_000, - maxRooms: 10, - maxViewersPerRoom: 3, - now: () => now, - }); - const first = store.createRoom(); - const second = store.createRoom(); - - for (const candidate of [undefined, "not-a-grant", viewerGrant(second)]) { - expectRoomError( - () => - store.connectParticipant({ - roomId: first.roomId, - role: "viewer", - ...(candidate ? { viewerGrant: candidate } : {}), - clientId: `viewer-${candidate ?? "missing"}`, - sessionId: `session-${candidate ?? "missing"}`, - }), - "INVALID_TOKEN", - ); - } - - expect( - store.connectParticipant({ - roomId: first.roomId, - role: "viewer", - viewerGrant: viewerGrant(first), - clientId: "authorized-viewer", - sessionId: "authorized-session", - }), - ).toMatchObject({ viewerPolicy: "private-link" }); - }); - - it("allows grant-free viewing only for an explicitly public room", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); - const room = store.createRoom("public-watch"); - - expect(room).toMatchObject({ - viewerPolicy: "public-watch", - viewerGrant: null, - viewerGrantExpiresAt: null, - }); - expect( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - clientId: "public-viewer", - sessionId: "public-session", - }), - ).toMatchObject({ viewerPolicy: "public-watch" }); - }); - - it("accepts, changes, and removes a private room password", async () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 5, - }); - const room = store.createRoom(); - const host = store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "password-host", - sessionId: "password-host-session", - }); - expect(host.viewerPasswordEnabled).toBe(false); - - await expect( - store.setViewerPassword( - room.roomId, - "easy-password", - "password-host-session", - ), - ).resolves.toBe(true); - await expect( - store.connectViewerWithPassword({ - roomId: room.roomId, - password: "easy-password", - clientId: "password-viewer-1", - sessionId: "password-viewer-session-1", - }), - ).resolves.toMatchObject({ role: "viewer", viewerPasswordEnabled: true }); - await expectRoomErrorAsync( - () => - store.connectViewerWithPassword({ - roomId: room.roomId, - password: "wrong-password", - clientId: "wrong-password-viewer", - sessionId: "wrong-password-session", - }), - "INVALID_TOKEN", - ); - - await store.setViewerPassword( + roomStore.disconnectParticipant( room.roomId, - "new-password", - "password-host-session", + host.peerId, + "host-session", ); - await expectRoomErrorAsync( - () => - store.connectViewerWithPassword({ - roomId: room.roomId, - password: "easy-password", - clientId: "old-password-viewer", - sessionId: "old-password-session", - }), + clock.nowMs = 2_500; + expect(roomStore.expireRooms()).toEqual([]); + + expectRoomError( + () => roomStore.connectParticipant(hostInput(room.roomId, "wrong-token")), "INVALID_TOKEN", ); - await expect( - store.connectViewerWithPassword({ - roomId: room.roomId, - password: "new-password", - clientId: "password-viewer-2", - sessionId: "password-viewer-session-2", - }), - ).resolves.toMatchObject({ role: "viewer" }); + const resumed = roomStore.connectParticipant( + hostInput(room.roomId, room.hostToken, "host-session-2"), + ); + expect(resumed.expiresAt).toBeNull(); - await expect( - store.setViewerPassword(room.roomId, null, "password-host-session"), - ).resolves.toBe(false); - await expectRoomErrorAsync( + roomStore.disconnectParticipant( + room.roomId, + resumed.peerId, + "host-session-2", + ); + roomStore.connectParticipant( + viewerInput(room.roomId, "viewer-session", room.viewerGrant!), + ); + clock.nowMs = 3_501; + expect(roomStore.expireRooms()).toHaveLength(1); + expectRoomError( () => - store.connectViewerWithPassword({ - roomId: room.roomId, - password: "new-password", - clientId: "removed-password-viewer", - sessionId: "removed-password-session", - }), + roomStore.connectParticipant( + hostInput(room.roomId, room.hostToken, "late-host"), + ), + "INVALID_TOKEN", + ); + }); + + it("keeps Viewer grants independent from code-entry policy", async () => { + const { store: roomStore } = store({ maxRooms: 3 }); + const room = await roomStore.createRoom("disabled"); + + expectRoomError( + () => roomStore.connectParticipant(viewerInput(room.roomId, "code-only")), "INVALID_TOKEN", ); expect( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "grant-still-works", - sessionId: "grant-still-works-session", - }).role, + roomStore.connectParticipant( + viewerInput(room.roomId, "granted", room.viewerGrant!), + ).role, ).toBe("viewer"); }); - it("bounds pending password derivations and skips stale waiters", async () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); + it("supports open, password, and disabled code entry", async () => { + const { store: roomStore } = store({ maxRooms: 3 }); + const open = await roomStore.createRoom("open"); + expect( + roomStore.connectParticipant(viewerInput(open.roomId, "open")).role, + ).toBe("viewer"); + + const password = await roomStore.createRoom("password", "room-password"); + expectRoomError( + () => roomStore.connectParticipant(viewerInput(password.roomId, "wrong")), + "INVALID_TOKEN", + ); + expect( + await roomStore.connectViewerWithPassword({ + roomId: password.roomId, + password: "room-password", + clientId: "password-viewer", + sessionId: "password-session", + }), + ).toMatchObject({ role: "viewer" }); + + const disabled = await roomStore.createRoom("disabled"); + expectRoomError( + () => roomStore.connectParticipant(viewerInput(disabled.roomId, "closed")), + "INVALID_TOKEN", + ); + }); + + it("bounds password derivations and uses the same path for unknown rooms", async () => { + const { store: roomStore } = store(); let mayStartCalls = 0; const attempts = Array.from({ length: 40 }, (_, index) => - store.connectViewerWithPassword( + roomStore.connectViewerWithPassword( { - roomId: "999999", + roomId: "9999", password: "bounded-password", clientId: `bounded-client-${index}`, sessionId: `bounded-session-${index}`, @@ -673,427 +195,112 @@ describe("RoomStore", () => { ).toBe(true); }); - it("does not reveal a full private room after a correct password", async () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 1, - }); - const room = store.createRoom(); - store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "full-password-host", - sessionId: "full-password-host-session", - }); - await store.setViewerPassword( + it("rotates and revokes Viewer grants without changing code entry", async () => { + const { store: roomStore } = store(); + const room = await roomStore.createRoom("open"); + roomStore.connectParticipant(hostInput(room.roomId, room.hostToken)); + + const rotated = roomStore.setViewerGrant( room.roomId, - "full-password", - "full-password-host-session", - ); - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "full-room-viewer", - sessionId: "full-room-viewer-session", - }); - - await expectRoomErrorAsync( - () => - store.connectViewerWithPassword({ - roomId: room.roomId, - password: "full-password", - clientId: "full-password-viewer", - sessionId: "full-password-viewer-session", - }), - "INVALID_TOKEN", - ); - }); - - it("does not commit a password after the Host session is replaced", async () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); - const room = store.createRoom(); - store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "same-host", - sessionId: "old-host-session", - }); - - const pending = store.setViewerPassword( - room.roomId, - "stale-password", - "old-host-session", - ); - store.connectParticipant({ - roomId: room.roomId, - role: "host", - token: room.hostToken, - clientId: "same-host", - sessionId: "new-host-session", - }); - - await expect(pending).rejects.toMatchObject({ code: "INVALID_TOKEN" }); - await expectRoomErrorAsync( - () => - store.connectViewerWithPassword({ - roomId: room.roomId, - password: "stale-password", - clientId: "stale-password-viewer", - sessionId: "stale-password-viewer-session", - }), - "INVALID_TOKEN", - ); - }); - - it("treats public-to-private as a strong Viewer authorization rotation", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); - const room = store.createRoom("public-watch"); - const connected = store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - clientId: "public-viewer", - sessionId: "public-session", - }); - - const update = store.setViewerAccess(room.roomId, "rotate"); - - expect(update).toMatchObject({ - viewerPolicy: "private-link", - revokedViewers: [ - { peerId: connected.peerId, sessionId: "public-session" }, - ], - }); - expect(update.viewerGrant).toBeTruthy(); - expect(update.viewerAuthorizationGeneration).not.toBe( - connected.viewerAuthorizationGeneration, + "rotate", + "host-session", ); expectRoomError( () => - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - clientId: "grant-free-after-rotation", - sessionId: "grant-free-session", - }), + roomStore.connectParticipant( + viewerInput(room.roomId, "old-grant", room.viewerGrant!), + ), "INVALID_TOKEN", ); - expect( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(update), - clientId: "private-viewer", - sessionId: "private-session", - }), - ).toMatchObject({ - viewerPolicy: "private-link", - viewerAuthorizationGeneration: update.viewerAuthorizationGeneration, - }); - }); - - it("keeps old authorization and connected Viewers when persistence fails", () => { - const database = new RoomDatabase(":memory:"); - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - database, - }); - const room = store.createRoom(); - const connected = store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "existing-viewer", - sessionId: "existing-session", - }); - vi.spyOn(database, "updateViewerGrantDigest").mockImplementation(() => { - throw new Error("disk write failed"); - }); - - expect(() => store.setViewerAccess(room.roomId, "rotate")).toThrow( - "disk write failed", + const grantedViewer = roomStore.connectParticipant( + viewerInput(room.roomId, "new-grant", rotated.viewerGrant!), ); - expect(store.getConnectedViewer(room.roomId, connected.peerId)).toEqual({ - peerId: connected.peerId, - sessionId: "existing-session", - }); - expect( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "new-viewer", - sessionId: "new-session", - }), - ).toMatchObject({ - viewerAuthorizationGeneration: connected.viewerAuthorizationGeneration, - }); - store.close(); - }); - - it("does not persist a room when its authorization generation cannot be created", () => { - const database = new RoomDatabase(":memory:"); - let randomCall = 0; - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - database, - random: (size) => { - randomCall += 1; - return Buffer.alloc(randomCall === 2 ? size - 1 : size, randomCall); - }, - }); - - expect(() => store.createRoom()).toThrow( - "Authorization generation random source must return 16 bytes", + const codeViewer = roomStore.connectParticipant( + viewerInput(room.roomId, "still-open"), + ); + + const revoked = roomStore.setViewerGrant( + room.roomId, + "revoke", + "host-session", + ); + expect(revoked.viewerGrant).toBeNull(); + expect(revoked.revokedViewers.map((viewer) => viewer.peerId)).toEqual([ + grantedViewer.peerId, + ]); + expect(codeViewer.role).toBe("viewer"); + expect( + roomStore.getConnectedViewer(room.roomId, codeViewer.peerId), + ).toMatchObject({ sessionId: "still-open" }); + expectRoomError( + () => + roomStore.connectParticipant( + viewerInput(room.roomId, "revoked", rotated.viewerGrant!), + ), + "INVALID_TOKEN", ); - expect(database.loadRooms()).toEqual([]); - store.close(); }); - it("keeps persisted authorization and Viewers when generation creation fails", () => { - const database = new RoomDatabase(":memory:"); - let randomValue = 0; + it("keeps the old grant and viewers when rotation cannot create a generation", async () => { let failGeneration = false; - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - database, - random: (size) => { - randomValue += 1; - return Buffer.alloc( + let randomValue = 0; + const { store: roomStore } = store({ + random: (size) => + Buffer.alloc( failGeneration && size === 16 ? size - 1 : size, - randomValue, - ); - }, + ++randomValue, + ), }); - const room = store.createRoom(); - const connected = store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "existing-viewer", - sessionId: "existing-session", - }); - const persistedDigest = database.loadRooms()[0].viewerGrantDigest; + const room = await roomStore.createRoom("disabled"); + roomStore.connectParticipant(hostInput(room.roomId, room.hostToken)); + const viewer = roomStore.connectParticipant( + viewerInput(room.roomId, "existing", room.viewerGrant!), + ); failGeneration = true; - expect(() => store.setViewerAccess(room.roomId, "rotate")).toThrow( - "Authorization generation random source must return 16 bytes", - ); + expect(() => + roomStore.setViewerGrant(room.roomId, "rotate", "host-session"), + ).toThrow("Authorization generation random source must return 16 bytes"); failGeneration = false; - expect(database.loadRooms()[0].viewerGrantDigest).toEqual(persistedDigest); - expect(store.getConnectedViewer(room.roomId, connected.peerId)).toEqual({ - peerId: connected.peerId, - sessionId: "existing-session", - }); expect( - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "new-viewer", - sessionId: "new-session", - }), - ).toMatchObject({ - viewerAuthorizationGeneration: connected.viewerAuthorizationGeneration, - }); - store.close(); + roomStore.getConnectedViewer(room.roomId, viewer.peerId), + ).toMatchObject({ sessionId: "existing" }); + expect( + roomStore.connectParticipant( + viewerInput(room.roomId, "old-grant-still-valid", room.viewerGrant!), + ).role, + ).toBe("viewer"); }); - it("commits a strong rotation before invalidating old Viewer authority", () => { - const store = new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom: 3, - }); - const room = store.createRoom(); - const connected = store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "old-viewer", - sessionId: "old-session", - }); + it("clears every credential on process restart", async () => { + const { store: firstStore } = store(); + const room = await firstStore.createRoom("password", "room-password"); + const { store: secondStore } = store(); - const update = store.setViewerAccess(room.roomId, "rotate"); - expect(update.viewerAuthorizationGeneration).not.toBe( - connected.viewerAuthorizationGeneration, - ); - expect(update.revokedViewers).toEqual([ - { peerId: connected.peerId, sessionId: "old-session" }, - ]); - expect(store.getConnectedViewer(room.roomId, connected.peerId)).toBeUndefined(); + const replacement = await secondStore.createRoom("open"); + expect(replacement.roomId).toMatch(/^[1-9]\d{3}$/); expectRoomError( () => - store.connectParticipant({ - roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(room), - clientId: "old-grant-viewer", - sessionId: "old-grant-session", - }), + secondStore.connectParticipant( + hostInput(room.roomId, room.hostToken), + ), "INVALID_TOKEN", ); - expect( - store.connectParticipant({ + expectRoomError( + () => + secondStore.connectParticipant( + viewerInput(room.roomId, "old-grant", room.viewerGrant!), + ), + "INVALID_TOKEN", + ); + await expect( + secondStore.connectViewerWithPassword({ roomId: room.roomId, - role: "viewer", - viewerGrant: viewerGrant(update), - clientId: "new-grant-viewer", - sessionId: "new-grant-session", + password: "room-password", + clientId: "viewer", + sessionId: "session", }), - ).toMatchObject({ - viewerAuthorizationGeneration: update.viewerAuthorizationGeneration, - }); + ).rejects.toThrow(new RoomStoreError("INVALID_TOKEN")); }); - - it("migrates every v1 room to a distinct locked private digest", () => { - const directory = mkdtempSync(join(tmpdir(), "screener-room-migration-")); - const databasePath = join(directory, "rooms.sqlite"); - try { - createV1Database(databasePath, 2); - let randomIndex = 0; - const database = new RoomDatabase(databasePath, (size) => - Buffer.alloc(size, ++randomIndex), - ); - - const rooms = database.loadRooms(); - expect(rooms).toHaveLength(2); - expect(rooms.every((room) => room.viewerGrantDigest?.byteLength === 32)).toBe( - true, - ); - expect(rooms[0].viewerGrantDigest).not.toEqual(rooms[1].viewerGrantDigest); - expect(rooms.every((room) => room.viewerPasswordMaterial === null)).toBe( - true, - ); - database.close(); - } finally { - rmSync(directory, { recursive: true, force: true }); - } - }); - - it("migrates v2 rooms to a nullable checked password material column", () => { - const directory = mkdtempSync(join(tmpdir(), "screener-room-v2-migration-")); - const databasePath = join(directory, "rooms.sqlite"); - try { - createV2Database(databasePath); - const database = new RoomDatabase(databasePath); - expect(database.loadRooms()).toMatchObject([ - { roomId: "1", viewerPasswordMaterial: null }, - ]); - database.updateViewerPassword("1", Buffer.alloc(48, 7)); - expect(database.loadRooms()[0].viewerPasswordMaterial).toEqual( - Buffer.alloc(48, 7), - ); - expect(() => - database.updateViewerPassword("1", Buffer.alloc(47)), - ).toThrow("Viewer password material must contain 48 bytes"); - database.close(); - } finally { - rmSync(directory, { recursive: true, force: true }); - } - }); - - it("rolls a failed v1 migration back to the untouched v1 schema", () => { - const directory = mkdtempSync(join(tmpdir(), "screener-room-rollback-")); - const databasePath = join(directory, "rooms.sqlite"); - try { - createV1Database(databasePath, 2); - let calls = 0; - expect( - () => - new RoomDatabase(databasePath, (size) => { - calls += 1; - return Buffer.alloc(calls === 2 ? size - 1 : size, calls); - }), - ).toThrow("Migration random source digest must contain 32 bytes"); - - const inspection = new DatabaseSync(databasePath); - expect(inspection.prepare("PRAGMA user_version").get()).toEqual({ - user_version: 1, - }); - expect( - inspection - .prepare("PRAGMA table_info(rooms)") - .all() - .map((column) => column.name), - ).toEqual(["id", "host_token_digest"]); - expect(inspection.prepare("SELECT COUNT(*) AS count FROM rooms").get()).toEqual( - { count: 2 }, - ); - inspection.close(); - } finally { - 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) => { - expect( - () => - new RoomStore({ - ttlMs: 10_000, - maxRooms: 10, - maxViewersPerRoom, - }), - ).toThrow( - `Room viewer limit must be an integer between 1 and ${MAX_VIEWERS_PER_ROOM_LIMIT}`, - ); - }, - ); }); - -function createV1Database(path: string, roomCount: number): void { - const database = new DatabaseSync(path); - database.exec(` - CREATE TABLE rooms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_token_digest BLOB NOT NULL CHECK (length(host_token_digest) = 32) - ) STRICT; - PRAGMA user_version = 1; - `); - const insert = database.prepare( - "INSERT INTO rooms (host_token_digest) VALUES (?)", - ); - for (let index = 0; index < roomCount; index += 1) { - insert.run(Buffer.alloc(32, index + 1)); - } - database.close(); -} - -function createV2Database(path: string): void { - const database = new DatabaseSync(path); - database.exec(` - CREATE TABLE rooms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_token_digest BLOB NOT NULL CHECK (length(host_token_digest) = 32), - viewer_grant_digest BLOB NULL - CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32) - ) STRICT; - PRAGMA user_version = 2; - `); - database - .prepare( - "INSERT INTO rooms (host_token_digest, viewer_grant_digest) VALUES (?, ?)", - ) - .run(Buffer.alloc(32, 1), Buffer.alloc(32, 2)); - database.close(); -} diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index ad25084..4e5a58a 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -517,30 +517,32 @@ describe("server configuration", () => { expect(config.siteAccessPassword).toBe("easy-key"); }); - it("requires site access protection for a persistent room database", () => { - expect(() => loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" })).toThrow( - "ROOM_DATABASE_PATH requires SITE_ACCESS_PASSWORD", + it("defaults the room lease to 24 hours", () => { + expect(loadConfig({}).roomLeaseMs).toBe(86_400_000); + expect(loadConfig({ ROOM_LEASE_SECONDS: "3600" }).roomLeaseMs).toBe( + 3_600_000, + ); + expect(() => loadConfig({ ROOM_LEASE_SECONDS: "0" })).toThrow( + "ROOM_LEASE_SECONDS must be a positive integer", ); - - const config = loadConfig({ - SITE_ACCESS_PASSWORD: "host-password-12", - 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", - SITE_ACCESS_PASSWORD: "host-password-12", - ROOM_DATABASE_PATH: ":memory:", - STUN_URLS: "stun:stun.test:3478", - }), - ).toThrow("ROOM_DATABASE_PATH must be file-backed in production"); + it("bounds MAX_ROOMS to the four-digit code space", () => { + expect(loadConfig({ MAX_ROOMS: "9000" }).maxRooms).toBe(9_000); + expect(() => loadConfig({ MAX_ROOMS: "9001" })).toThrow( + "MAX_ROOMS must be between 1 and 9000", + ); }); + it.each(["ROOM_DATABASE_PATH", "ROOM_TTL_SECONDS"] as const)( + "rejects removed room persistence configuration even when %s is blank", + (name) => { + expect(() => loadConfig({ [name]: "" })).toThrow( + `${name} is no longer supported`, + ); + }, + ); + it.each(["x".repeat(7), "密码密码密码密码", "contains spaces", "x".repeat(129)])( "rejects a site access password outside the visible ASCII boundary", (siteAccessPassword) => { diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index 3f78f50..dae134a 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -1,7 +1,3 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -19,22 +15,11 @@ import { FakeSfuRoomControl } from "./fake-sfu-room-control.ts"; const allowedOrigin = "http://allowed.test"; const siteAccessPassword = "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", @@ -43,7 +28,7 @@ function testConfig(overrides: Partial = {}): ServerConfig { publicBaseUrl: new URL("https://share.example.test"), allowedOrigins: new Set([allowedOrigin]), siteAccessPassword, - roomTtlMs: 14_400_000, + roomLeaseMs: 86_400_000, maxRooms: 10, maxViewersPerRoom: 8, peerAssistedMedia: false, @@ -94,8 +79,8 @@ async function login(baseUrl: string): Promise { async function createRoom( baseUrl: string, cookie?: string, - viewerPolicy: "private-link" | "public-watch" = "private-link", - hostClaimTtlSeconds?: number, + codeEntryPolicy: "open" | "password" | "disabled" = "open", + roomPassword?: string, ): Promise { return fetch(`${baseUrl}/api/rooms`, { method: "POST", @@ -105,8 +90,8 @@ async function createRoom( ...(cookie ? { Cookie: cookie } : {}), }, body: JSON.stringify({ - viewerPolicy, - ...(hostClaimTtlSeconds === undefined ? {} : { hostClaimTtlSeconds }), + codeEntryPolicy, + ...(roomPassword === undefined ? {} : { roomPassword }), }), }); } @@ -264,12 +249,12 @@ describe("room HTTP API", () => { "Content-Type": "application/json", Origin: allowedOrigin, }, - body: JSON.stringify({ viewerPolicy: "private-link" }), + body: JSON.stringify({ codeEntryPolicy: "open" }), }); expect(bearerBypass.status).toBe(401); }); - it("creates a private room with a fragment-only Viewer grant", async () => { + it("creates a room with an independent fragment-only Viewer grant", async () => { const baseUrl = await start(); const authenticated = await login(baseUrl); const response = await createRoom(baseUrl, cookiePair(authenticated)); @@ -278,15 +263,16 @@ describe("room HTTP API", () => { expect(response.headers.get("cache-control")).toBe("no-store"); const body = createRoomResponseSchema.parse(await response.json()); const invite = new URL(body.inviteUrl); - expect(body.roomId).toMatch(/^\d{12}$/); + expect(body.roomId).toMatch(/^[1-9]\d{3}$/); expect(invite.origin).toBe("https://share.example.test"); expect(invite.pathname).toBe(`/r/${body.roomId}`); expect(invite.hash).toMatch( new RegExp(`^#v=g1\\.${body.roomId}\\.\\d+\\.[A-Za-z0-9_-]{43}$`), ); expect(invite.search).toBe(""); - expect(body.viewerPolicy).toBe("private-link"); + expect(body.codeEntryPolicy).toBe("open"); expect(body.viewerGrantExpiresAt).toBeTruthy(); + expect(body.expiresAt).toBeTruthy(); expect("iceConfig" in body).toBe(false); }); @@ -304,42 +290,41 @@ describe("room HTTP API", () => { ); }); - it("accepts only the admitted fixed provisional Host lease", async () => { + it("applies a password creation profile atomically", async () => { const baseUrl = await start(); - expect((await createRoom(baseUrl, undefined, "private-link", 300)).status).toBe( - 401, - ); - const authenticated = await login(baseUrl); const cookie = cookiePair(authenticated); - expect((await createRoom(baseUrl, cookie, "private-link", 300)).status).toBe( - 201, - ); - expect((await createRoom(baseUrl, cookie, "private-link", 301)).status).toBe( - 400, - ); - expect((await createRoom(baseUrl, cookie, "private-link", 299)).status).toBe( - 400, + const response = await createRoom( + baseUrl, + cookie, + "password", + "room-password", ); + expect(response.status).toBe(201); + expect(createRoomResponseSchema.parse(await response.json())).toMatchObject({ + codeEntryPolicy: "password", + }); + + const missingPassword = await createRoom(baseUrl, cookie, "password"); + expect(missingPassword.status).toBe(400); }); - it("allows explicit public-watch creation without site access in local mode", async () => { + it("allows explicit open creation without site access in local mode", async () => { const baseUrl = await start( testConfig({ siteAccessPassword: undefined }), ); - const response = await createRoom(baseUrl, undefined, "public-watch"); + const response = await createRoom(baseUrl, undefined, "open"); expect(response.status).toBe(201); const body = createRoomResponseSchema.parse(await response.json()); expect(body).toMatchObject({ - viewerPolicy: "public-watch", - viewerGrantExpiresAt: null, + codeEntryPolicy: "open", + viewerGrantExpiresAt: expect.any(String), }); - expect(new URL(body.inviteUrl).hash).toBe(""); + expect(new URL(body.inviteUrl).hash).toContain("#v="); }); - it("persists sequential protected rooms across server restarts", async () => { - const config = testConfig({ roomDatabasePath: temporaryDatabasePath() }); - let baseUrl = await start(config); + it("allocates unique four-digit codes concurrently", async () => { + const baseUrl = await start(); const authenticated = await login(baseUrl); const cookie = cookiePair(authenticated); const create = () => createRoom(baseUrl, cookie); @@ -350,19 +335,8 @@ describe("room HTTP API", () => { 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 createRoom(baseUrl, cookie); - expect(createRoomResponseSchema.parse(await third.json())).toMatchObject({ - roomId: "3", - expiresAt: null, - }); + expect(new Set(rooms.map((room) => room.roomId)).size).toBe(2); + expect(rooms.every((room) => room.expiresAt !== null)).toBe(true); }); it("rejects malformed room requests and foreign browser origins", async () => { @@ -382,7 +356,7 @@ describe("room HTTP API", () => { const foreign = await fetch(`${baseUrl}/api/rooms`, { method: "POST", headers: { "Content-Type": "application/json", Origin: "https://foreign.test" }, - body: JSON.stringify({ viewerPolicy: "private-link" }), + body: JSON.stringify({ codeEntryPolicy: "open" }), }); expect(foreign.status).toBe(403); }); diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index ed3105e..e7036eb 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -15,7 +15,6 @@ 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"; @@ -111,7 +110,6 @@ interface SignalHarness { webSocketUrl: string; roomStore: RoomStore; room: CreatedRoom; - database?: RoomDatabase; } function testConfig(): ServerConfig { @@ -121,7 +119,7 @@ function testConfig(): ServerConfig { listenHost: "127.0.0.1", publicBaseUrl: new URL("https://share.example.test"), allowedOrigins: new Set([allowedOrigin]), - roomTtlMs: 14_400_000, + roomLeaseMs: 86_400_000, maxRooms: 10, maxViewersPerRoom: 8, peerAssistedMedia: false, @@ -138,8 +136,6 @@ async function startHarness( maxSignalConnections?: number; maxUnauthenticatedSignalConnections?: number; siteAccessPassword?: string; - persistent?: boolean; - provisionalHostClaimSeconds?: number; peerAssistedMedia?: boolean; endpointMediaCopyCapacity?: number; stunUrls?: readonly string[]; @@ -154,18 +150,13 @@ async function startHarness( config.stunUrls = overrides.stunUrls ?? []; const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; config.maxViewersPerRoom = maxViewersPerRoom; - const database = overrides.persistent ? new RoomDatabase(":memory:") : undefined; const roomStore = new RoomStore({ - ttlMs: config.roomTtlMs, + leaseMs: config.roomLeaseMs, maxRooms: config.maxRooms, maxViewersPerRoom, - database, now: overrides.now, }); - const room = roomStore.createRoom( - "private-link", - overrides.provisionalHostClaimSeconds, - ); + const room = await roomStore.createRoom(); runningServer = await createScreenerServer({ config, roomStore, @@ -185,7 +176,6 @@ async function startHarness( webSocketUrl: `ws://127.0.0.1:${port}/signal`, roomStore, room, - database, }; } @@ -264,6 +254,7 @@ async function authenticate( viewerPresence?: true; viewerPasswordSettings?: true; viewerPassword?: string; + codeOnly?: true; sharingPaused?: boolean; } = {}, ) { @@ -297,7 +288,7 @@ async function authenticate( clientId, ...(presence.viewerPassword ? { viewerPassword: presence.viewerPassword } - : room.viewerGrant + : !presence.codeOnly && room.viewerGrant ? { viewerGrant: room.viewerGrant } : {}), ...(presence.displayName @@ -477,6 +468,17 @@ describe("WebSocket signaling", () => { type: "viewer-password-updated", enabled: true, }); + host.socket.send( + JSON.stringify({ + type: "set-code-entry-policy", + policy: "password", + }), + ); + expect(await host.inbox.next("code-entry-policy-updated")).toEqual({ + type: "code-entry-policy-updated", + codeEntryPolicy: "password", + viewerPasswordEnabled: true, + }); const wrongViewer = await openClient(harness.webSocketUrl); wrongViewer.socket.send( @@ -507,6 +509,17 @@ describe("WebSocket signaling", () => { ).resolves.toMatchObject({ role: "viewer" }); await host.inbox.next("peer-joined"); + host.socket.send( + JSON.stringify({ + type: "set-code-entry-policy", + policy: "open", + }), + ); + expect(await host.inbox.next("code-entry-policy-updated")).toEqual({ + type: "code-entry-policy-updated", + codeEntryPolicy: "open", + viewerPasswordEnabled: true, + }); host.socket.send( JSON.stringify({ type: "set-viewer-password", password: null }), ); @@ -822,7 +835,7 @@ describe("WebSocket signaling", () => { await closeClient(viewer); }); - it("publishes one empty Viewer roster after ordinary rotate and revoke", async () => { + it("strongly rotates and revokes every Viewer generation and media edge", async () => { const harness = await startHarness(); const host = await openClient(harness.webSocketUrl); const hostAuth = await authenticate( @@ -864,9 +877,9 @@ describe("WebSocket signaling", () => { firstViewer.socket.once("close", (code) => resolve(code)), ); host.socket.send( - JSON.stringify({ type: "set-viewer-access", action: "rotate" }), + JSON.stringify({ type: "rotate-viewer-grant" }), ); - expect(await firstViewer.inbox.next("viewer-access-revoked")).toMatchObject({ + expect(await firstViewer.inbox.next("viewer-grant-revoked")).toMatchObject({ viewerAuthorizationGeneration: firstViewerAuth.viewerAuthorizationGeneration, }); @@ -877,7 +890,7 @@ describe("WebSocket signaling", () => { expect( viewerPresenceEntries(await host.inbox.next("viewer-presence")), ).toEqual([]); - const rotated = await host.inbox.next("viewer-access-updated"); + const rotated = await host.inbox.next("viewer-grant-updated"); await host.inbox.expectNone(30); const rotatedGrant = new URLSearchParams( @@ -909,9 +922,9 @@ describe("WebSocket signaling", () => { secondViewer.socket.once("close", (code) => resolve(code)), ); host.socket.send( - JSON.stringify({ type: "set-viewer-access", action: "revoke" }), + JSON.stringify({ type: "revoke-viewer-grant" }), ); - await secondViewer.inbox.next("viewer-access-revoked"); + await secondViewer.inbox.next("viewer-grant-revoked"); expect(await secondClosed).toBe(4004); expect((await host.inbox.next("peer-left")).peerId).toBe( secondViewerAuth.peerId, @@ -919,20 +932,111 @@ describe("WebSocket signaling", () => { expect( viewerPresenceEntries(await host.inbox.next("viewer-presence")), ).toEqual([]); - expect(await host.inbox.next("viewer-access-updated")).toMatchObject({ - viewerPolicy: "private-link", + expect(await host.inbox.next("viewer-grant-updated")).toMatchObject({ inviteUrl: null, }); await host.inbox.expectNone(30); }); + it("rotates a grant without disturbing code-admitted Viewer media", async () => { + const harness = await startHarness(); + const host = await openClient(harness.webSocketUrl); + const hostAuth = await authenticate( + host, + harness.room, + "host", + "mixed-access-host", + 2, + undefined, + { viewerPresence: true }, + ); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toEqual([]); + + const grantViewer = await openClient(harness.webSocketUrl); + const grantAuth = await authenticate( + grantViewer, + harness.room, + "viewer", + "mixed-grant-viewer", + 1, + undefined, + { displayName: "邀请观众" }, + ); + await host.inbox.next("peer-joined"); + await host.inbox.next("viewer-presence"); + + const codeViewer = await openClient(harness.webSocketUrl); + const codeAuth = await authenticate( + codeViewer, + harness.room, + "viewer", + "mixed-code-viewer", + 1, + undefined, + { codeOnly: true, displayName: "房间号观众" }, + ); + await host.inbox.next("peer-joined"); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toHaveLength(2); + + const grantClosed = new Promise((resolve) => + grantViewer.socket.once("close", (code) => resolve(code)), + ); + host.socket.send(JSON.stringify({ type: "rotate-viewer-grant" })); + await grantViewer.inbox.next("viewer-grant-revoked"); + expect(await grantClosed).toBe(4004); + expect((await host.inbox.next("peer-left")).peerId).toBe(grantAuth.peerId); + + const remaining = viewerPresenceEntries( + await host.inbox.next("viewer-presence"), + ); + expect(remaining).toEqual([ + { + role: "viewer", + peerId: codeAuth.peerId, + displayName: "房间号观众", + upstream: { kind: "peer", peerId: hostAuth.peerId }, + }, + ]); + expect(codeViewer.socket.readyState).toBe(WebSocket.OPEN); + expect( + harness.roomStore.getConnectedViewer(harness.room.roomId, codeAuth.peerId), + ).toBeDefined(); + + await closeClient(codeViewer); + await closeClient(host); + }); + it("requires site access for code-only Viewers while accepting room grants", async () => { const siteAccessPassword = "protected-instance-password"; const harness = await startHarness({ siteAccessPassword }); const viewer = await openClient(harness.webSocketUrl); + viewer.socket.send( + JSON.stringify({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId: harness.room.roomId, + role: "viewer", + clientId: "code-only-viewer-without-site-access", + }), + ); + await expect(viewer.inbox.next("error")).resolves.toMatchObject({ + code: "INVALID_TOKEN", + }); + + const grantedViewer = await openClient(harness.webSocketUrl); await expect( - authenticate(viewer, harness.room, "viewer", "viewer-client-protected"), + authenticate( + grantedViewer, + harness.room, + "viewer", + "grant-viewer-without-site-access", + 1, + ), ).resolves.toMatchObject({ role: "viewer" }); const unauthorizedHost = await openClient(harness.webSocketUrl); @@ -961,20 +1065,19 @@ describe("WebSocket signaling", () => { expect(cookie).toBeTruthy(); const viewerWithHostCookie = await openClient(harness.webSocketUrl, cookie); - viewerWithHostCookie.socket.send( - JSON.stringify({ - type: "authenticate", - protocol: SIGNALING_PROTOCOL, - roomId: harness.room.roomId, - role: "viewer", - clientId: "viewer-with-host-cookie", - }), - ); - await expect(viewerWithHostCookie.inbox.next("error")).resolves.toMatchObject({ - code: "INVALID_TOKEN", - }); + await expect( + authenticate( + viewerWithHostCookie, + harness.room, + "viewer", + "viewer-with-host-cookie", + 1, + undefined, + { codeOnly: true }, + ), + ).resolves.toMatchObject({ role: "viewer" }); - const publicRoom = harness.roomStore.createRoom("public-watch"); + const publicRoom = await harness.roomStore.createRoom("open"); const publicWithoutSiteAccess = await openClient(harness.webSocketUrl); publicWithoutSiteAccess.socket.send( JSON.stringify({ @@ -1011,21 +1114,15 @@ describe("WebSocket signaling", () => { publicRoom, "viewer", "public-viewer-with-site-access", + 1, + undefined, + { codeOnly: true }, ), ).resolves.toMatchObject({ role: "viewer" }); - const passwordRoom = harness.roomStore.createRoom("private-link"); - harness.roomStore.connectParticipant({ - roomId: passwordRoom.roomId, - role: "host", - token: passwordRoom.hostToken, - clientId: "password-room-setup", - sessionId: "password-room-setup-session", - }); - await harness.roomStore.setViewerPassword( - passwordRoom.roomId, + const passwordRoom = await harness.roomStore.createRoom( + "password", "room-password", - "password-room-setup-session", ); const passwordWithoutSiteAccess = await openClient(harness.webSocketUrl); @@ -1062,42 +1159,38 @@ describe("WebSocket signaling", () => { ).resolves.toMatchObject({ role: "host" }); }); - it("rearms an in-memory provisional room lease only after Host disconnect", async () => { + it("does not expire an active room and arms its lease after Host disconnect", async () => { let now = Date.UTC(2026, 7, 20, 12); const harness = await startHarness({ - persistent: true, - provisionalHostClaimSeconds: 300, now: () => now, }); - expect(harness.database!.loadRooms()).toEqual([]); const host = await openClient(harness.webSocketUrl); const authenticated = await authenticate( host, harness.room, "host", - "provisional-host", + "active-lease-host", ); const viewer = await openClient(harness.webSocketUrl); await authenticate(viewer, harness.room, "viewer", "provisional-viewer"); await host.inbox.next("peer-joined"); - now += 301_000; + now += 86_401_000; expect(harness.roomStore.expireRooms()).toEqual([]); await closeClient(host); await viewer.inbox.next("host-status"); expect(harness.roomStore.getConnectedHost(harness.room.roomId)).toBeUndefined(); - expect(harness.roomStore.expireRooms(now + 299_999)).toEqual([]); - const expired = harness.roomStore.expireRooms(now + 300_001); + expect(harness.roomStore.expireRooms(now + 86_399_999)).toEqual([]); + const expired = harness.roomStore.expireRooms(now + 86_401_000); expect(expired).toHaveLength(1); expect(expired[0]).toMatchObject({ roomId: harness.room.roomId }); expect(expired[0]?.sessionIds).toHaveLength(1); await closeClient(viewer); expect(authenticated.role).toBe("host"); - expect(harness.database!.loadRooms()).toEqual([]); }); - it("leaves established authorization and media untouched when access persistence fails", async () => { - const harness = await startHarness({ persistent: true }); + it("leaves established authorization and media untouched when a grant update fails", async () => { + const harness = await startHarness(); const host = await openClient(harness.webSocketUrl); const hostAuth = await authenticate( host, @@ -1127,16 +1220,14 @@ describe("WebSocket signaling", () => { await viewer.inbox.next("signal"); const update = vi - .spyOn(harness.roomStore, "setViewerAccess") + .spyOn(harness.roomStore, "setViewerGrant") .mockImplementationOnce(() => { throw new Error("simulated database write failure"); }); - host.socket.send( - JSON.stringify({ type: "set-viewer-access", action: "rotate" }), - ); + host.socket.send(JSON.stringify({ type: "rotate-viewer-grant" })); expect(await host.inbox.next("error")).toMatchObject({ code: "SERVER_ERROR" }); await expect( - viewer.inbox.next("viewer-access-revoked", 30), + viewer.inbox.next("viewer-grant-revoked", 30), ).rejects.toThrow("Timed out"); host.socket.send( diff --git a/tests/viewer-message-authority.test.ts b/tests/viewer-message-authority.test.ts index 17b87b2..1e4b31a 100644 --- a/tests/viewer-message-authority.test.ts +++ b/tests/viewer-message-authority.test.ts @@ -34,7 +34,7 @@ describe("ViewerMessageAuthority", () => { const authority = new ViewerMessageAuthority(); const authenticated = authority.tokenFor(message("authenticated")); - authority.tokenFor(message("viewer-access-revoked")); + authority.tokenFor(message("viewer-grant-revoked")); expect(authority.owns(authenticated)).toBe(false); });