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.
This commit is contained in:
AAA authored and GitHub committed 2026-08-23 21:47:49 +08:00
1 parent 23c163e65e
commit b76d2cc0a5
37 files changed
+1555 -2327

No files matched your search

+3 -6
View File
@@ -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):
+6 -7
View File
@@ -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://<computer-lan-ip>: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.
-2
View File
@@ -14,8 +14,6 @@ Restart=on-failure
RestartSec=3s
TimeoutStopSec=20s
UMask=0077
StateDirectory=screener
StateDirectoryMode=0700
NoNewPrivileges=true
PrivateDevices=true
@@ -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.
+65 -85
View File
@@ -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=<INDEPENDENT_8_TO_128_BYTE_ACCESS_KEY>
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
+4 -4
View File
@@ -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
+4 -3
View File
@@ -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,
+7 -7
View File
@@ -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.
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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。
+2 -2
View File
@@ -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、内存、上行、出口、帧率、丢包率、冻结和恢复;任一门槛失败时不得切换默认值。
+11 -12
View File
@@ -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<Buffer[]> {
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<string[]> {
return (await readdir(profile)).filter((name) => name.startsWith("rooms.sqlite"));
}
async function main(): Promise<void> {
const report: GateReport = {
@@ -65,7 +64,7 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
+2 -3
View File
@@ -376,12 +376,11 @@ async function main(): Promise<void> {
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<void> {
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,
+1 -2
View File
@@ -2657,7 +2657,6 @@ export async function main(): Promise<number> {
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<number> {
server = await createScreenerServer({
config: serverConfig,
roomStore: new RoomStore({
ttlMs: serverConfig.roomTtlMs,
leaseMs: serverConfig.roomLeaseMs,
maxRooms: serverConfig.maxRooms,
maxViewersPerRoom: serverConfig.maxViewersPerRoom,
}),
+7 -3
View File
@@ -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<CreateRoomResponse> {
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);
+64
View File
@@ -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.
}
}
+6 -23
View File
@@ -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,
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ const AUTHORITY_MESSAGE_TYPES = new Set<ServerMessage["type"]>([
"authenticated",
"host-status",
"sharing-stopped",
"viewer-access-revoked",
"viewer-grant-revoked",
"room-closed",
"error",
]);
+294 -189
View File
@@ -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<HostRoomState | null>(() =>
hostRoomFromStored(readHostRoom()),
);
const [newRoomViewerPolicy, setNewRoomViewerPolicy] =
useState<ViewerAccessPolicy>("private-link");
const [viewerAccessUpdating, setViewerAccessUpdating] = useState(false);
const [creationProfile, setCreationProfile] =
useState<HostCreationProfile>(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<number | null>(null);
@@ -267,7 +273,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const signalRef = useRef<SignalingClient | null>(null);
const displayNameRef = useRef(displayName);
const hostClientIdRef = useRef<string | null>(null);
const viewerPasswordActionRef = useRef<"set" | "remove" | null>(null);
const viewerPasswordActionRef = useRef<string | null | undefined>(undefined);
const iceConfigRef = useRef<IceConfig | null>(null);
const peersRef = useRef(new Map<string, HostPeer>());
const retiredConnectionsRef = useRef(new Map<string, string>());
@@ -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<void> => {
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 = {}) {
<div className="setup-controls">
<div className="quality-controls">
{!room && (
<label className="viewer-policy-toggle">
<input
type="checkbox"
checked={newRoomViewerPolicy === "public-watch"}
disabled={phase === "starting"}
onChange={(event) =>
setNewRoomViewerPolicy(
event.target.checked
? "public-watch"
: "private-link",
)
}
/>
<span>允许仅凭房间号观看</span>
</label>
)}
<fieldset className="control-group">
<legend>推荐画质</legend>
<div className="segmented-control">
@@ -2308,75 +2401,79 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
</div>
{room && (
<div className="invite-bar">
<div className="invite-copy">
<span className="field-label">
房间 {room.roomId} · {expirationText} ·{" "}
{room.viewerPolicy === "public-watch"
? "公开观看"
: "私密链接"}
</span>
<div className="invite-primary">
<span className="field-label">邀请链接</span>
<span className="invite-url" title={room.inviteUrl ?? undefined}>
{room.inviteUrl ?? "当前没有有效邀请,请轮换生成新链接"}
{room.inviteUrl ?? "当前没有有效邀请,请更新生成新链接"}
</span>
</div>
<div className="invite-actions">
<button
className="icon-button"
type="button"
title="复制邀请链接"
aria-label="复制邀请链接"
disabled={!room.inviteUrl}
onClick={() => void copyInvite()}
>
{copied ? <Check size={18} /> : <Copy size={18} />}
</button>
{room.viewerPolicy === "public-watch" ? (
<div className="invite-actions">
<button
className="icon-button"
className="button button-secondary invite-action"
type="button"
title="设为私密并生成新邀请"
aria-label="设为私密并生成新邀请"
disabled={viewerAccessUpdating || phase !== "live"}
onClick={() => changeViewerAccess("rotate")}
disabled={!room.inviteUrl}
onClick={() => void copyInvite()}
>
<LockKeyhole size={18} />
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "已复制" : "复制邀请链接"}
</button>
) : (
<>
<button
className="icon-button"
type="button"
title="轮换私密邀请"
aria-label="轮换私密邀请"
disabled={viewerAccessUpdating || phase !== "live"}
onClick={() => changeViewerAccess("rotate")}
>
<RefreshCw size={18} />
</button>
<button
className="icon-button"
type="button"
title="撤销当前邀请"
aria-label="撤销当前邀请"
disabled={viewerAccessUpdating || phase !== "live"}
onClick={() => changeViewerAccess("revoke")}
>
<LockKeyhole size={18} />
</button>
<button
className="icon-button"
type="button"
title="允许仅凭房间号观看"
aria-label="允许仅凭房间号观看"
disabled={viewerAccessUpdating || phase !== "live"}
onClick={() => changeViewerAccess("public-watch")}
>
<Globe2 size={18} />
</button>
</>
)}
<button
className="button button-secondary invite-action"
type="button"
disabled={viewerGrantUpdating || phase !== "live"}
onClick={() => changeViewerGrant("rotate")}
>
<RefreshCw size={16} />
更新邀请
</button>
<button
className="button button-danger invite-action"
type="button"
disabled={
!room.inviteUrl || viewerGrantUpdating || phase !== "live"
}
onClick={() => changeViewerGrant("revoke")}
>
<LockKeyhole size={16} />
撤销邀请
</button>
</div>
</div>
{room.viewerPolicy === "private-link" && (
<div className="room-entry-policy">
<div className="room-entry-heading">
<span className="field-label">房间号加入</span>
<span className="room-lease-note">{expirationText}</span>
</div>
<div className="segmented-control room-policy-control">
{(
[
["open", "开放"],
["password", "密码"],
["disabled", "关闭"],
] as const
).map(([policy, label]) => (
<button
key={policy}
type="button"
className={
activeCodeEntryPolicy === policy
? "is-selected"
: undefined
}
aria-pressed={
activeCodeEntryPolicy === policy
}
disabled={
viewerGrantUpdating ||
viewerPasswordUpdating ||
phase !== "live" ||
(policy === "password" && !viewerPasswordEnabled)
}
onClick={() => changeCodeEntryPolicy(policy)}
>
{label}
</button>
))}
</div>
<form
className="viewer-password-control"
onSubmit={(event) => {
@@ -2421,16 +2518,24 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
<button
className="icon-button"
type="button"
title="移除房间密码"
title={
activeCodeEntryPolicy === "password"
? "请先将房间号加入改为开放或关闭"
: "移除房间密码"
}
aria-label="移除房间密码"
disabled={viewerPasswordUpdating || phase !== "live"}
disabled={
viewerPasswordUpdating ||
phase !== "live" ||
activeCodeEntryPolicy === "password"
}
onClick={() => changeViewerPassword(null)}
>
<Trash2 size={18} />
</button>
)}
</form>
)}
</div>
</div>
)}
</section>
+1 -1
View File
@@ -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
+36 -25
View File
@@ -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 {
+9 -9
View File
@@ -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;
+17 -14
View File
@@ -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<string>;
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,
-303
View File
@@ -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;
}
+266 -305
View File
@@ -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<string, Participant>;
}
@@ -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<string, Room>();
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<CreatedRoom> {
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<Buffer | 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, 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 {
+79 -17
View File
@@ -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<ServerMessage, { type: "authenticated" }>;
@@ -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) {
+34 -19
View File
@@ -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<typeof viewerAccessPolicySchema>;
export type CodeEntryPolicy = z.infer<typeof codeEntryPolicySchema>;
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<typeof createRoomResponseSchema>;
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();
+92 -83
View File
@@ -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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>([
["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",
+64
View File
@@ -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<string, string>();
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();
});
});
+8 -8
View File
@@ -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<RoomStore["createRoom"]>) {
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<RoomStore["createRoom"]>
function connectViewer(
store: RoomStore,
room: ReturnType<RoomStore["createRoom"]>,
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");
+25 -21
View File
@@ -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: [],
+221 -1014
View File
File diff suppressed because it is too large. Load diff
+21 -19
View File
@@ -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) => {
+33 -59
View File
@@ -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> = {}): ServerConfig {
return {
nodeEnv: "test",
@@ -43,7 +28,7 @@ function testConfig(overrides: Partial<ServerConfig> = {}): 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<Response> {
async function createRoom(
baseUrl: string,
cookie?: string,
viewerPolicy: "private-link" | "public-watch" = "private-link",
hostClaimTtlSeconds?: number,
codeEntryPolicy: "open" | "password" | "disabled" = "open",
roomPassword?: string,
): Promise<Response> {
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);
});
+154 -63
View File
@@ -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<number>((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(
+1 -1
View File
@@ -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);
});