diff --git a/.env.example b/.env.example index 3d5df7e..3fb4ae6 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,10 @@ PORT=8787 PUBLIC_BASE_URL=http://localhost:8787 # Include both the host browser origin and PUBLIC_BASE_URL when they differ. ALLOWED_ORIGINS=http://localhost:8787 -# Production-required Host admission secret. Local development/tests may leave it +# 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). -HOST_ADMISSION_PASSWORD= -# Optional SQLite file for persistent rooms. Requires HOST_ADMISSION_PASSWORD. +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. @@ -40,7 +40,7 @@ MAX_SFU_ROOTS_PER_ROOM=2 STUN_URLS= # Minimal production example (use independent untracked secrets): -# HOST_ADMISSION_PASSWORD=replace-with-an-independent-access-key +# 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 diff --git a/README.md b/README.md index ae20ebb..b61350f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its accepted media target is WebRTC P2P-first, with an SFU virtual parent feeding only the necessary roots and optional authenticated TURN for explicitly supported restrictive networks. Roots continue distributing to bounded peer descendants; this is not an always-SFU conferencing design. Viewers should be able to join from a desktop or mobile browser without installing the sender application. -The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code. Rooms default to a private fragment invitation whose capability authorizes only one room; a Host may explicitly choose public watching by room code. 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 Host-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Default routing gives each viewer an independent direct WebRTC connection; the exact-room controller can instead use bounded peer descendants and LiveKit SFU roots. The default room capacity is eight viewers and deployments may configure 1 through 16, 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 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. Default routing gives each viewer an independent direct WebRTC connection; the exact-room controller can instead use bounded peer descendants and LiveKit SFU roots. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise. This is not yet a broadly validated production release. Production runs `d4bc421` with room-scoped Viewer access, neutral anonymous entry, initial-connect recovery, and exact-room quality reparenting; it uses self-hosted STUN-only ordinary ICE plus a separate LiveKit SFU/UDP domain. Current source also contains a no-go Native v2 candidate with no production runtime gate. Real cross-network media, game audio, a sustained 1:8 room, 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 `HOST_ADMISSION_PASSWORD`. It is a simple deployment access key for room creation and Host role, not an account password; invited Viewers never need it. Local development and tests may leave it empty. Private rooms use an expiring room-scoped fragment grant, while explicit public-watch rooms accept only the numeric room code. +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. Open `http://localhost:8787`. The server listens on `0.0.0.0` by default so a phone on the same LAN can load `http://:8787`. To generate an @@ -27,9 +27,9 @@ 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). `HOST_ADMISSION_PASSWORD` authorizes only creation and Host role; -private Viewers use an expiring room-scoped fragment grant, while explicit -public-watch accepts a room code. The release has no v1 parser, old cookie, or +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 normally one or two SFU/UDP roots, and exhausted paths fail clearly after bounded recovery. Optional generation-bound TURN for a selected exceptional edge is not diff --git a/deploy/nginx/share.bonfire.icu.conf.example b/deploy/nginx/share.bonfire.icu.conf.example index 8b05ba9..188a76a 100644 --- a/deploy/nginx/share.bonfire.icu.conf.example +++ b/deploy/nginx/share.bonfire.icu.conf.example @@ -3,7 +3,7 @@ map $http_upgrade $screener_connection_upgrade { '' close; } -limit_req_zone $binary_remote_addr zone=screener_host_admission:10m rate=5r/m; +limit_req_zone $binary_remote_addr zone=screener_site_access:10m rate=5r/m; server { listen 80; @@ -84,8 +84,8 @@ server { return 404; } - location = /api/host-admission { - limit_req zone=screener_host_admission burst=5 nodelay; + location = /api/site-access { + limit_req zone=screener_site_access burst=5 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1; diff --git a/docs/README.md b/docs/README.md index fd1d6d9..50bcb72 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ - [Display name and presence boundaries](./research/display-name-presence.md): browser-local storage failure behavior, Unicode normalization, control-character policy, and the Web-only presence capability boundary. - [Agent context governance research](./research/agent-context-governance.md): official Codex, Claude Code, Hermes Agent, and GitHub practices adopted by this repository. - [ADR-0001](./adr/0001-p2p-first-media-topology.md): accepted P2P-first media topology and its consequences. -- [ADR-0002](./adr/0002-persistent-protected-rooms.md): Host admission, room-scoped private Viewer grants, explicit public-watch, rotation, and minimal SQLite persistence. +- [ADR-0002](./adr/0002-persistent-protected-rooms.md): site access, room-scoped private Viewer grants, explicit public-watch, rotation, and minimal SQLite persistence. - [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): proposed, experiment-only standard-WebRTC peer-assisted spike with hard fanout and failure gates. - [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): accepted direct/peer UDP -> SFU-root -> optional exceptional-edge TURN direction, including the historical room-`1` smoke and broader migration gates. - [ADR-0006](./adr/0006-fixed-high-native-sender-canary.md): proposed fixed-`HIGH` native canary, current no-go result, and staged revalidation boundary. diff --git a/docs/adr/0002-persistent-protected-rooms.md b/docs/adr/0002-persistent-protected-rooms.md index b0ac405..ef86ab0 100644 --- a/docs/adr/0002-persistent-protected-rooms.md +++ b/docs/adr/0002-persistent-protected-rooms.md @@ -1,19 +1,18 @@ # ADR-0002: Persistent Rooms And Scoped Viewer Access -- Status: Accepted; grant boundary deployed, room-password extension source-only +- Status: Accepted; room access deployed, site-access ordering source-only - Date: 2026-08-19 ## Context -The trusted-friends workflow needs two different boundaries. Creating and -publishing a room can consume meaningful endpoint or central-media resources, -so the deployment owner must control Host admission. Watching should remain a -normal-browser, link-first action and should not reveal the deployment's Host -password to every invited friend. +The trusted-friends workflow uses a site-access boundary and a room-access +boundary. Creating or publishing and navigating by room code require site +access. A room-scoped fragment grant remains the link-first path and does not +require sharing the site password with invited friends. The superseded runtime used one optional `ACCESS_PASSWORD`, one stateless cookie, and the same WebSocket upgrade gate for both roles. The deployed replacement now -separates Host admission from default private fragment grants and explicit +separates site access from default private fragment grants and explicit public-watch. The accepted source-only extension adds optional room-password entry without reopening the old site-wide Viewer gate. Persistent sequential IDs make a room-scoped boundary especially important. @@ -22,35 +21,38 @@ Accounts, a user database, per-person ACLs, password recovery, and a server session table would exceed the product need. A room-scoped bearer capability remains the lowest-friction invitation, while an optional low-policy room password lets a trusted friend enter from a numeric room code without weakening -Host admission. Media topology remains independent and is governed by +site access. Media topology remains independent and is governed by ADR-0001/0005. ## Decision -Separate Host admission from Viewer authorization in one atomic release: +Keep site access and room authorization as two ordered checks: -- Replace `ACCESS_PASSWORD` with `HOST_ADMISSION_PASSWORD`. Production startup +- Replace `ACCESS_PASSWORD` with `SITE_ACCESS_PASSWORD`. Production startup requires it; local development and tests may explicitly disable it. It accepts 8 through 128 visible ASCII bytes, must be independent from other deployment secrets, and issues a 12-hour stateless HMAC `HttpOnly`, `SameSite=Strict`, `Path=/` cookie (`Secure` and `__Host-` in - HTTPS production). It authorizes only room creation and an attempted Host - role. The room's independent Host token is still required to publish. + HTTPS production). It authorizes room creation and an attempted Host role, + and permits code-only Viewer attempts. The room's independent Host token is + still required to publish. - The atomic deployment change applies nginx per-source `limit_req` at `5r/m`, - `burst=5 nodelay` to `/api/host-admission`. It uses nginx shared memory and + `burst=5 nodelay` to `/api/site-access`. It uses nginx shared memory and introduces no IP persistence/log field. Throttling returns generic 429; allowed failures use constant-time comparison and generic 401. Node gains no IP-keyed identity/LRU/session store; existing connection caps remain. - Remove the old environment variable, `/api/session`, old cookie name, and old client gate in the same release. Supplying `ACCESS_PASSWORD` is a startup - error. The replacement endpoint is `/api/host-admission`; `POST /api/rooms` + error. The replacement endpoint is `/api/site-access`; `POST /api/rooms` accepts only its cookie, not a Bearer shortcut. - A WebSocket upgrade cannot know the future role. `/signal` therefore admits a - browser without a Host cookie after the existing exact Origin, empty-query, + browser without a site-access cookie after the existing exact Origin, empty-query, total-capacity, and unauthenticated-capacity checks. The server records whether - the upgrade carried a valid Host-admission cookie. The first Host + the upgrade carried a valid site-access cookie. The first Host `authenticate` must pass both that recorded state and the room Host token. A - Viewer ignores Host admission and is checked only against the room policy. + Viewer may bypass site access only with a valid, unexpired grant for that + exact room; otherwise site access is required before any room lookup policy + or password verification can authorize it. - Move the single wire literal from `screener-v1` to `screener-v2`. There is no negotiation, v1 parser, dual write, or translator. A v1 first message receives the universal fatal refresh outcome before room lookup and does not reconnect. @@ -66,16 +68,17 @@ Every room has one of two Viewer policies: the immediate removal operation. Seven days is a configuration-free first release bound: reusable across short friend sessions, but not a permanent capability attached to a persistent sequential room. -- `public-watch` is an explicit Host choice. A room code alone can acquire only - the Viewer role. It creates no room directory and remains bounded by existing +- `public-watch` is an explicit Host choice. Site access plus a room code can + acquire only the Viewer role. It creates no room directory and remains bounded by existing per-room Viewer, global connection, unauthenticated connection, route, and central-egress limits. Sequential public room IDs deliberately provide no privacy and must be labelled accordingly. A private room may also have one optional Viewer password. An existing valid -fragment grant still enters directly. A code-only Viewer sees a neutral password -form and, on success, authenticates only that current Viewer session in that -room. The Host can set, replace, or remove it. The input accepts 1 through 64 +fragment grant still enters directly. A code-only Viewer passes neutral site +access before seeing the neutral room-password form and, on success, +authenticates only that current Viewer session in that room. The Host can set, +replace, or remove it. The input accepts 1 through 64 visible ASCII characters without composition rules; this is a convenience boundary for trusted friends, not an account credential or recovery system. @@ -136,7 +139,7 @@ healthy media. ## Persistence And Migration `ROOM_DATABASE_PATH` remains optional and now requires -`HOST_ADMISSION_PASSWORD`. Without it, rooms use random numeric IDs and +`SITE_ACCESS_PASSWORD`. Without it, rooms use random numeric IDs and `ROOM_TTL_SECONDS`. With it, SQLite allocates persistent decimal IDs using `INTEGER PRIMARY KEY AUTOINCREMENT`. @@ -156,7 +159,7 @@ viewer_password_material BLOB NULL For the grant, `NULL` means public-watch and 32 bytes mean private-link. Password material is either `NULL` or one 48-byte BLOB containing a 16-byte salt followed by a 32-byte scrypt verifier, so salt/verifier cannot be half-written. SQLite -stores no raw Host-admission or room password, Host token, Viewer grant, cookie, +stores no raw site-access or room password, Host token, Viewer grant, cookie, display name, participant, `clientId`, `peerId`, IP address, SDP, ICE candidate, media, or TURN credential. There is no user, invitation, or session table. @@ -209,18 +212,20 @@ SQLite or participates in authorization, routing, or quality decisions. Room-scoped `sessionStorage` is enough for the required refresh/reconnect. - Plaintext SQLite grants, per-Viewer grants, ACLs, accounts, JWTs, or a session table: unnecessary credential exposure/state without a current consumer. -- Letting a Host cookie satisfy private Viewer admission: collapses the two - authorization scopes and makes room privacy depend on deployment trust. +- Letting a site-access cookie satisfy private Viewer admission: collapses the + two authorization scopes. It permits the room attempt but never replaces a + private room grant or password. - Keeping v1 compatibility: risks ambiguous old-tab behavior and leaves a second security model in product code; Git history and database backup own rollback. ## Acceptance Gates -- The auth matrix proves production Host admission, Viewer-without-Host-cookie, +- The auth matrix proves production site access, grant-without-site-cookie, 8-byte minimum/independent secret, ingress rate limit, exact-room/role grant scope, correct/wrong/replaced/removed room passwords, non-enumerating failures, - public code-only entry, and v2 Native wire isolation. + public code-only rejection before site access, public entry after site access, + and v2 Native wire isolation. - Leak tests cover HTTP/WS targets, Referrer, browser storage, SQLite, application and proxy logs, and errors; fragment consumption immediately clears the URL. - Rotation/revoke/public-to-private proves commit-before-disconnect, complete @@ -233,11 +238,10 @@ SQLite or participates in authorization, routing, or quality decisions. ## Implementation Status The deployed 2026-08-20 boundary implements the single-version v2 runtime, -private/public room policy, fragment consumption, Host admission, and -commit-first grant teardown on SQLite v2. The current source-only candidate adds -the optional room password, capability-isolated Web Host controls, asynchronous -session rechecks, and the v1/v2-to-v3 single-BLOB migration. Focused automated -tests cover password set/change/remove, grant continuity, public code-only entry, -Native wire isolation, protocol bounds, and both migration origins. Password -deployment, browser UX/leak inspection, production-copy migration rehearsal, -and post-feature target-machine KDF benchmarking remain acceptance work. +private/public room policy, fragment consumption, room passwords and +commit-first grant teardown on SQLite v3. The current source-only candidate adds +the ordered site-access check for every code-only Viewer and atomically renames +the environment, HTTP endpoint, cookie, Web/Native consumers and nginx limiter. +Focused tests cover valid grant direct entry, public/password denial without +site access, public/password entry with site access, config fail-fast and the +Native wire contract. Deployment and production request/log inspection remain. diff --git a/docs/adr/0006-fixed-high-native-sender-canary.md b/docs/adr/0006-fixed-high-native-sender-canary.md index b327514..f4c28dd 100644 --- a/docs/adr/0006-fixed-high-native-sender-canary.md +++ b/docs/adr/0006-fixed-high-native-sender-canary.md @@ -39,7 +39,7 @@ merged as product code from this state. ## 2026-08-20 Current-Wire Checkpoint A clean local delivery candidate now targets only `screener-v2`: it exchanges -`HOST_ADMISSION_PASSWORD` for the bounded HttpOnly Host-admission cookie, uses +`SITE_ACCESS_PASSWORD` for the bounded HttpOnly site-access cookie, uses that cookie to create an explicit `private-link` room, carries it on the Host WebSocket upgrade, and strictly decodes the current ordinary authenticated and read-only Viewer-evidence shapes. There is no v1 parser, translator, raw-grant @@ -64,7 +64,7 @@ after a bounded 20-second Sender-start interval without observing the combined Sender-ready and Go source-RTP condition. The timeout path failed to preserve its final Sender DOM/counter sample. It -therefore cannot distinguish capture selection, Host admission/create, WSS +therefore cannot distinguish capture selection, site access/create, WSS authentication, local bridge, first encoded output, or Go ingest as the first runtime break. None is a proven product failure. No retry, timeout adjustment, Viewer, second Viewer, or FIFO run followed. A separately authorized rerun must @@ -87,7 +87,7 @@ cleanup failure. One separately authorized second run then used that frozen gate with the same bounded local topology. Its retained ledger proves `getDisplayMedia` request and -resolution, Host admission and room creation, Host WSS authentication, local +resolution, site access and room creation, Host WSS authentication, local bridge readiness and fixed-`HIGH` config acceptance, exactly one encoder object, and entry into the first WebCodecs output callback. It serialized zero media counters without retaining whether any diagnostics arrived, so those values do diff --git a/docs/deployment.md b/docs/deployment.md index 2f4ff1d..629a85c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -179,7 +179,7 @@ LISTEN_HOST=127.0.0.1 PORT=8787 PUBLIC_BASE_URL=https://share.example.com ALLOWED_ORIGINS=https://share.example.com -HOST_ADMISSION_PASSWORD= +SITE_ACCESS_PASSWORD= ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite ROOM_TTL_SECONDS=14400 MAX_ROOMS=1000 @@ -217,12 +217,13 @@ issued only to a current controller-selected edge. There is no browser control, percentage rollout, or second router. `ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. -`HOST_ADMISSION_PASSWORD` is required in production and must contain 8 through +`SITE_ACCESS_PASSWORD` is required in production and must contain 8 through 128 visible ASCII bytes (`0x21` through `0x7e`). It must not be reused for -LiveKit, TLS, TURN, or another service. It authorizes room creation and Host -role only; it is not a Viewer password. Local development and tests may omit it. -Supplying the removed `ACCESS_PASSWORD` key, even blank, fails startup. -`ROOM_DATABASE_PATH` is optional but requires `HOST_ADMISSION_PASSWORD`. +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. `MAX_VIEWERS_PER_ROOM` defaults to 8 and accepts 1 through 16. It is an admission @@ -233,7 +234,7 @@ complete tuple requires `PEER_ASSISTED_MEDIA=true`. An empty tuple keeps the optional SDK and server path dormant. `LIVEKIT_URL` must be a plain `ws:` or `wss:` origin with no `/rtc` suffix; production requires `wss:`. `LIVEKIT_API_SECRET` must contain at least 32 bytes and must not reuse -`HOST_ADMISSION_PASSWORD`. `MAX_SFU_ROOTS_PER_ROOM` defaults to +`SITE_ACCESS_PASSWORD`. `MAX_SFU_ROOTS_PER_ROOM` defaults to 2 and accepts only 1 or 2; it is ignored when LiveKit is not configured. These credentials authorize short-lived LiveKit room tokens and do not provide E2EE: the LiveKit operator can access ordinary SFU media. @@ -255,7 +256,7 @@ TURN wire is restored. During migration, remove the retired room-ID variable in the same coherent change; the process flag explicitly enables the all-room controller. -Only `POST /api/host-admission` accepts the Host admission secret in an +Only `POST /api/site-access` accepts the site access secret in an `Authorization: Bearer` header from an exact allowed Origin. Success returns a 12-hour stateless HMAC-SHA256 cookie with `HttpOnly`, `SameSite=Strict`, `Path=/`, bounded `Max-Age`, and, under production HTTPS, `Secure` plus an @@ -265,18 +266,21 @@ is no account database, JWT, session map, or logout endpoint. 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 Host-admission cookie and strict JSON +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 path. `/signal` still admits a cookie-free browser after Origin and capacity -checks, records the cookie state at upgrade, and requires it only when the first -v2 message requests Host role. Viewer role never uses this deployment secret. +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. Private rooms are the default. Their invitation is `/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 fails closed. `public-watch` must be an -explicit Host choice and accepts the numeric room code alone. Neither policy +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. Room allocation has two deliberately small policies: @@ -332,7 +336,7 @@ Production completed this migration at 2026-08-20 03:06 +08 on exact `3C09AF81BCE68B51DD9E36E1C253A880D8D6F24BB498A090CF815AB196C392AA`. A stopped, read-only-verified v1 backup retained all four rooms; the v2 integrity/schema and locked-private digest checks passed, including room -`1`. Host admission and a cookie-free private Viewer denial also passed. An +`1`. site access and a cookie-free private Viewer denial also passed. An earlier artifact attempt was rolled back after hard-linked dependencies let a permission change make the rollback release unreadable for 3m11s; the final release has an independent dependency tree and zero shared regular-file inodes. @@ -439,7 +443,7 @@ TURN/UDP URL with a 120-second TTL. Ordinary peer ICE remained STUN-only; the coturn, nginx, LiveKit, firewall, and listener baselines were preserved, while the application environment changed only for the selected tuple and retained root ownership and mode 0600. Local/public health, the built asset, three neutral -routes, and the anonymous Host-admission boolean passed. SQLite v3 integrity, +routes, and the anonymous site-access boolean passed. SQLite v3 integrity, owner/mode, checksum, five-room count, and room `1` were unchanged. Screener, LiveKit, coturn, and nginx were active/running with `NRestarts=0` at the final audit. The selected-edge tuple was not exercised @@ -529,7 +533,7 @@ 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; for public-watch, the current code is intentionally sufficient. +request target. A public-watch code still requires prior site access. For a process-level liveness probe, send `GET /healthz`. A running process returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other diff --git a/docs/project-memory.md b/docs/project-memory.md index 90ec0fe..a179238 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -12,7 +12,7 @@ Last updated: 2026-08-21 - Two-tree packet/layer striping may reduce endpoint upload toward one stream bitrate, but needs a bounded multi-parent, loss, sync, churn, and latency experiment; it is not in the current full-stream chains. - Direct/peer keeps per-PC stock GCC. SFU paths start at a `HIGH` ceiling and share at most one `LOW`; next test LiveKit exactly-two built-in BWE on zero-descendant leaves. If it passes, no app media selector is built. Explicit quality, manual activation, then custom/native are later fallbacks; always-on `LOW` needs resource gates. - A later explicit quality fallback evacuates children under a generation guard first. Autonomous BWE enters `suspect`; confirmation evacuates children, and no confirmed `FALLBACK` parent remains. Root-with-children impact is a default-on gate; capacity returns after longer recovery plus cooldown. Self-report alone never triggers it. -- Separate abuse control from watching: Host admission protects creation/Host; private Viewer entry uses a room grant or optional room password, while public-watch accepts the code. Persist neither raw credential; keep accounts, ACLs, users, and session tables out. +- Site access protects room creation, Host publication, and every code-only Viewer entry. A valid room-scoped fragment grant bypasses that site gate; otherwise site access is checked before public-watch or a private room password. Persist neither raw credential; keep accounts, ACLs, users, and session tables out. - Keep decisions, snapshots, research, code, `AGENTS.md`, and `.codex/` in Git; rewrite memory/status in place. Research current primary sources before material work and reject speculative machinery. - Migrate client, server, and deployment atomically. After a canary, delete superseded config/wire/parsers/tests; do not retain compatibility layers, dual writes, or a second architecture without a current consumer. Git history owns the old implementation. - Autonomously deploy each coherent low-risk milestone after narrow tests, independent review, one full gate, CI and rollback preflight. Keep protocol/database migrations atomic rather than folding them into routine UI/media updates. @@ -46,7 +46,7 @@ Last updated: 2026-08-21 - Production serves exact `c27df2235ecc0be17816f642ca49028e30380a34` at `https://share.bonfire.icu`. The immutable artifact is 912,999 bytes with SHA-256 `1c7f06e2608414f0a4facf8577c8b49a8fefb831e20826c0e23fa0d1e85240d6`; the 2026-08-20T19:03:10Z UTC cutover held the deployment lock for 662 ms and reached local health in 552 ms from stop (543 ms from symlink switch). Exact `cf149df2411798cd632cc92a562b0a35146e0c1b` is the immediate rollback release. - The current Screener, LiveKit, coturn, and nginx services are active/running with `NRestarts=0`; local and public health are 200. SQLite v3 is intact with five rooms including room `1`, and the DB owner/mode remain `screener:screener`/0600. - Production ICE remains ordinary STUN-only. `PEER_ASSISTED_MEDIA=true` enables the controller for every normal room, and selected-edge TURN is now configured for the single UDP tuple with TTL 120; the coturn daemon and nginx/LiveKit/firewall/listener baselines were unchanged. No real TURN/SFU media canary or quality claim is implied. -- Production requires an independent `HOST_ADMISSION_PASSWORD` only for creation/Host role. Default private fragment grants, room passwords, and explicit public-watch authorize Viewers; anonymous routes remain neutral until authorization. There are no accounts/JWT/session rows; SQLite v3 stores room identity only. +- The current source requires the independent site-access secret through `SITE_ACCESS_PASSWORD`. Its stateless cookie authorizes creation/Host and permits code-only Viewer attempts; valid private fragment grants remain direct. Public-watch accepts site access plus code, while private code-only entry additionally requires the room password. The atomic boundary and naming migration are not yet deployed; anonymous failures stay neutral, and there are no accounts/JWT/session rows. - Production uses nginx, Node.js 24.19.0, coturn 4.17.2, and LiveKit 1.13.5 on UDP 7882 (TCP fallback off). Ordinary peer ICE receives no TURN; the selected-edge tuple is configured only for the current controller edge, with no credential pre-advertised to ordinary peers. Coturn retains the old authenticated-relay ports. Services are healthy with zero restarts. - ADR-0005 source rejects `PEER_ASSISTED_ROOM_IDS`; `PEER_ASSISTED_MEDIA=true` enables every room, while ordinary peer ICE stays STUN-only. A stale room-ID variable fails startup. `screener-v2` is the only deployed signaling literal; there is no v1 parser or translator. - Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence. @@ -77,8 +77,4 @@ These are measurement gates, not performance claims. ## Source Of Truth -- Documentation index and current phase: `docs/README.md` and `docs/status.md` -- Requirements and design: `docs/需求理解.md` and `docs/方案设计.md` -- Media research: `docs/research/`, indexed by `docs/README.md` -- Architecture: ADR-0001/0002, proposed ADR-0004, accepted-but-unverified ADR-0005, no-go proposed ADR-0006, accepted ADR-0007, and rejected/superseded ADR-0003 -- Deployment and maintenance: `docs/deployment.md`, `docs/maintenance.md`, `AGENTS.md`, and `.codex/` +- Current phase is in `docs/status.md`; requirements/design, ADRs, research, deployment and maintenance are indexed by `docs/README.md`. diff --git a/docs/research/native-shared-encode-sender.md b/docs/research/native-shared-encode-sender.md index c818f5f..2dcf4d5 100644 --- a/docs/research/native-shared-encode-sender.md +++ b/docs/research/native-shared-encode-sender.md @@ -152,7 +152,7 @@ The bounded Android data path is: ```text MediaProjection Surface -> one hardware MediaCodec fixed-HIGH encoder - -> existing Host admission / room / WSS / standard WebRTC + -> existing site access / room / WSS / standard WebRTC -> one or two unmodified Web viewers ``` @@ -205,7 +205,7 @@ audio or automatic `LOW`. This is a proposed test boundary, not current product behavior or hardware-encoder evidence. The 2026-08-20 local delivery checkpoint rebases that candidate onto the sole -current access contract: `screener-v2`, Host-admission Cookie authentication, +current access contract: `screener-v2`, site-access Cookie authentication, explicit `private-link` creation, fragment-only Viewer grants returned to the local Host, and STUN-only ordinary ICE. Native creation alone requests a fixed 300-second provisional room. It remains random and memory-only even with SQLite @@ -263,7 +263,7 @@ rechecked 2026-08-20. One separately authorized second current-wire run used the frozen gate once and did not retry or adjust a threshold. Its append-and-flush ledger reached sequence -7 and retained positive booleans for capture request/resolution, Host admission, +7 and retained positive booleans for capture request/resolution, site access, private room creation, Host WSS authentication, bridge readiness, fixed VP8 1280x720@30/3 Mbps config acceptance, one encoder object, and entry into the first WebCodecs output callback. The ledger serialized zero media counters but diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index 921f593..0d93cf0 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -228,7 +228,7 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 ### 最小访问模型 -旧 production 的 `ACCESS_PASSWORD`、同一 Host/Viewer cookie gate 和 code-only Viewer 是已部署事实,不再是接受的目标;仓库 access candidate 已把“谁可以建立/发布房间”和“谁可以看这一间房”分开。production 必配的 `HOST_ADMISSION_PASSWORD` 无状态 cookie 只允许建房及尝试 Host role,房间 Host token 仍独立验证;默认 private-link 使用一个 room-scoped Viewer bearer grant,public-watch 才接受 code-only Viewer。原研究结论不要求人类房间密码;后续已接受的最小扩展增加可选的逐房间 Viewer 密码作为 code-only 私密入口,但仍不引入账号、JWT、服务端 session Map 或逐人 ACL。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。 +当前 access boundary 使用 production 必配的 `SITE_ACCESS_PASSWORD` 无状态 cookie 保护建房、Host role 和所有 code-only Viewer 入口;合法未过期的 exact-room fragment grant 可直接观看。没有 grant 时,服务端先要求 site access,再检查 public-watch 或 private room password;房间码只定位,cookie 也不替代私密房间授权。WebSocket upgrade 仍只做 Origin/容量检查并记录 cookie 状态,首条 `authenticate` 完成上述 role/room 授权。该边界不引入账号、JWT、服务端 session Map 或逐人 ACL。 RFC 3986 的规范事实是 fragment 在 URI dereference 前由 user agent 分离;WHATWG WebSockets 进一步规定含 fragment 的 constructor URL 必须抛 `SyntaxError`。因此把 256-bit room grant 放在 `/r/{code}#v=...`,再由页面在首个 WSS application message 发送,可以使它不进入 HTTP 或 WebSocket request-target。RFC 6750 对 OAuth bearer query 的警告并不直接规定本产品,但它提供了适用的安全类比:URI query 高概率被日志记录,不应承载此 grant。W3C Referrer Policy 的算法会从 referrer URL 移除 fragment,production 的 `no-referrer` header 再禁止整个 header;这是传输边界,不是“不会泄漏”的保证。 @@ -278,7 +278,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 - WebRTC 使用 DTLS-SRTP。TURN 只能看到加密后的媒体包,但仍能看到地址、房间时序和流量元数据。 - P2P 会让房间内双方得知网络地址。熟人首版可以接受,陌生人房间不能默认接受。 - TURN 必须使用短期凭据、速率限制、每用户/房间配额和出口告警,不能提供匿名公共 relay。 -- Host admission password 只控制建房/Host role,不能作为私密观看凭据;private-link 接受 room-scoped grant 或可选逐房间 Viewer 密码,public-watch 的 room code 则明确不提供隐私。这些入口都不改变媒体 fanout/egress 上限。 +- site access password 只控制建房/Host role,不能作为私密观看凭据;private-link 接受 room-scoped grant 或可选逐房间 Viewer 密码,public-watch 的 room code 则明确不提供隐私。这些入口都不改变媒体 fanout/egress 上限。 - raw Viewer grant 与 Host token 等同访问凭据:只保存摘要,禁止日志/遥测/错误/Referrer/SQLite 明文。显示名、room-scoped peer 后缀和 IP 诊断均不参与授权。 - 如果未来使用 SFU 且要求服务器看不到内容,再评估 SFrame/WebRTC Encoded Transform 和群组密钥管理。 diff --git a/docs/status.md b/docs/status.md index 6e8b61c..02eb7af 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,20 +4,10 @@ Last updated: 2026-08-21 ## Phase -`https://share.bonfire.icu` currently serves exact `c27df2235ecc0be17816f642ca49028e30380a34`. -The 912,999-byte artifact has SHA-256 -`1c7f06e2608414f0a4facf8577c8b49a8fefb831e20826c0e23fa0d1e85240d6`. -The 2026-08-20T19:03:10.569Z cutover held the lock 662 ms; local health -returned 552 ms after stop and 543 ms after the symlink switch. -SQLite v3 passed integrity checks with five rooms including room `1`; the -application/database stayed healthy. Screener, LiveKit, coturn, and nginx are -active/running with `NRestarts=0`, and local/public health are 200. -Ordinary ICE remains STUN-only; `PEER_ASSISTED_MEDIA=true` enables the -bounded peer/SFU-UDP controller for every room (at most two roots), while the -selected-edge UDP tuple is configured with TTL 120. Room `1` is historical -smoke only. No real TURN/SFU media canary has run. Web Host names/audio hint are -deployed. Native H.264 and Windows x64 evaluation packaging are source-only; -there is no Native release/deployment, and Web defaults VP8. +`https://share.bonfire.icu` serves exact `c27df2235ecc0be17816f642ca49028e30380a34`; the 912,999-byte artifact SHA-256 is `1c7f06e2608414f0a4facf8577c8b49a8fefb831e20826c0e23fa0d1e85240d6`. +The cutover held the lock 662 ms and returned local health 552 ms after stop. SQLite v3 has five rooms; Screener, LiveKit, coturn and nginx are healthy with zero restarts. +Ordinary ICE is STUN-only; all rooms use the bounded peer/SFU-UDP controller and selected-edge UDP TTL 120. Room `1` is historical smoke and no real TURN/SFU media canary has run. +Web Host names/audio hint are deployed. Native H.264 and Windows x64 packaging are source-only; Web defaults VP8. ## Execution Principle @@ -26,7 +16,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo ## Current Snapshot - Capture precedes room creation; source/quality changes preserve peers and pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. -- Production access is `screener-v2`: Host admission, private grants/passwords, and public codes authorize Viewers. SQLite v3 retains five rooms and checked material; raw credentials are never stored. +- Production access is `screener-v2`: a valid private fragment grant enters directly; every code-only Viewer must first hold site access, after which public-watch accepts the code and private rooms still require their room password. The source fix and atomic `SITE_ACCESS_PASSWORD`/`/api/site-access`/site-access-cookie rename are not deployed; raw credentials are never stored. - Web Hosts can set their local/session display name, and the current release advertises it through the existing presence wire. The name is not an account identity. - Native v2 remains source-only (memory rooms, 300s reclaim); production does not run it. - PR #44's STUN-only/SFU-UDP router and token-free fallback prewarm are process-enabled for all normal rooms: roots <=2, browser relay <=1, bounded failure. Room `1` is historical smoke; mobile/iPad viewers are leaves, healthy edges sticky, and HTTPS/WSS stays TLS/TCP. @@ -62,26 +52,11 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo ## Next Milestone -Repeat room 1 and use the bounded SFU stage to fix only the -identified connect/source/video-publish/sender-config/audio/transport layer, -then retain selected UDP, a decoded/rendered frame, edge caps and resource deltas. -The recovery target uses one ICE restart, one same-parent rebuild, one alternate peer, then -SFU; it never runs three identical retries or abandons progressing P2P early. - -Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root -evacuation. Native source accepts increasing capture timestamps shorter than -duration; VP8 and H.264 Viewer decode/render are recorded. Preference is not -proof. Package one-Viewer VP8/H.264/Opus first; WGC/MF hardware then -Viewer2/FIFO are functional slices. The 2s Pion sample is later diagnostic -cleanup. Test real-game A/V later without displacing P0. -Ordinary Peer ICE stays STUN-only; participant-wide TURN is removed. The -selected-edge tuple is configured in production, but Host ingress still needs one -forced-relay canary before any media-success claim. Retained performance/resource -benchmarks follow functional landing. - -ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, -resource, quality, latency, recovery, and browser/mobile-leaf matrix. A separate -20-viewer gate must pass before the current default eight changes to target 20. +Repeat room `1` and fix only the identified SFU connect/source/publish/audio/transport layer; retain selected UDP, a rendered frame, edge caps and resource deltas. +Recovery uses one ICE restart, one same-parent rebuild, one alternate peer, then SFU. Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root evacuation. +Package one-Viewer VP8/H.264/Opus first; WGC/MF hardware then Viewer2/FIFO are functional slices. Real-game A/V and resource benchmarks follow functional landing. +Ordinary Peer ICE stays STUN-only. Selected-edge Host ingress still needs one forced-relay canary before a media-success claim. +ADR-0004 still needs the 30-minute `1/3/5/8` matrix; a separate 20-viewer gate must pass before changing the default eight. ## Blockers And Decisions diff --git a/docs/方案设计.md b/docs/方案设计.md index eaaac5a..90343a3 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -11,7 +11,7 @@ 本阶段必须形成完整的最小纵向链路: - 分享者主动选择屏幕、窗口或标签页,并请求可用音频; -- 创建数字房间;production Host admission 密码严格控制建房/发布,默认 room-scoped capability link 让 Viewer 免站点密码观看,部署可选择最小 SQLite 持久化; +- 创建数字房间;production site access 密码严格控制建房/发布,默认 room-scoped capability link 让 Viewer 免站点密码观看,部署可选择最小 SQLite 持久化; - 每名观看者拥有独立的 `RTCPeerConnection`; - 当前 production 要求 STUN,普通 peer ICE 只发 STUN servers;SFU 只开放 LiveKit ICE/UDP; - 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据; @@ -66,25 +66,25 @@ tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试 ## 房间与访问控制 -`HOST_ADMISSION_PASSWORD` 是简单的部署访问口令,不是 Viewer 密码或账号凭据。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 8 至 128 个可见 ASCII bytes(`0x21` 至 `0x7e`),并作为不复用 TURN、LiveKit、TLS 或其他凭据的独立 secret。`POST /api/host-admission` 只在严格允许的 Origin 下接收 `Authorization: Bearer`,成功后签发 12 小时的无状态 Host-admission cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`;HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。`POST /api/rooms` 只认该 cookie,不接受同一 Bearer 旁路。原子 deployment change 在 nginx 为该 admission endpoint 配置 per-source `limit_req`,初始边界为 `5r/m`、`burst=5 nodelay`;超限返回通用 429,允许到达 Node 的错误 secret 使用常量时间比较和统一 401。limiter 只使用 nginx shared memory,不新增 IP 持久化/日志字段;Node 不建立 IP key/LRU/session table,并保留现有全局/未鉴权连接上限。删除旧 `ACCESS_PASSWORD`、`/api/session` 与旧 cookie;旧环境变量出现时 fail-fast。 +`SITE_ACCESS_PASSWORD` 是简单的部署访问口令,不是 Viewer 密码或账号凭据。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 8 至 128 个可见 ASCII bytes(`0x21` 至 `0x7e`),并作为不复用 TURN、LiveKit、TLS 或其他凭据的独立 secret。`POST /api/site-access` 只在严格允许的 Origin 下接收 `Authorization: Bearer`,成功后签发 12 小时的无状态 site-access cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`;HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。`POST /api/rooms` 只认该 cookie,不接受同一 Bearer 旁路。原子 deployment change 在 nginx 为该 admission endpoint 配置 per-source `limit_req`,初始边界为 `5r/m`、`burst=5 nodelay`;超限返回通用 429,允许到达 Node 的错误 secret 使用常量时间比较和统一 401。limiter 只使用 nginx shared memory,不新增 IP 持久化/日志字段;Node 不建立 IP key/LRU/session table,并保留现有全局/未鉴权连接上限。删除旧 `ACCESS_PASSWORD`、`/api/session` 与旧 cookie;旧环境变量出现时 fail-fast。 -WebSocket upgrade 无法预知后续 role,因此不能再用 Host cookie 拒绝全部 Viewer。它仍在握手前严格校验 `/signal` 无 query、Origin allowlist、总连接数和未鉴权连接数,并把当次 request 的 Host cookie 校验结果只保存到 socket state。五秒内的第一条 `authenticate` 才完成授权:Host 必须同时拥有 upgrade 时有效的 Host-admission 状态和当前房间 `hostToken`;Viewer 只按目标房间 policy 校验。Host cookie 不能替代私密 Viewer grant,Viewer grant 也不能用于 HTTP 建房、Host role、其他房间或其他操作。Host cookie 过期不踢掉已鉴权 socket,但其下一次 reconnect 必须重新通过 Host admission。 +WebSocket upgrade 无法预知后续 role,因此仍允许没有 site-access cookie 的合法 grant Viewer 完成握手;握手前严格校验 `/signal` 无 query、Origin allowlist、总连接数和未鉴权连接数,并把当次 cookie 校验结果保存到 socket state。五秒内的第一条 `authenticate` 完成授权:Host 必须同时拥有 upgrade 时有效的 site-access 状态和当前房间 `hostToken`;Viewer 必须拥有目标房间合法未过期 grant,或者先通过 site access 再按 public/private policy 校验。site-access cookie 只允许尝试私密房间密码,不能替代 grant/password;Viewer grant 也不能用于 HTTP 建房、Host role、其他房间或其他操作。 新房间默认 `private-link`,也允许 Host 显式选择 `public-watch`: - `private-link`:服务端用 `randomBytes(32)` 生成 256-bit 随机量,并把到期信息和随机量组成严格、room-scoped 的 opaque Viewer grant;只保存完整 grant 的 SHA-256 摘要并用定长比较验证。临时房间 grant 不晚于 room TTL;持久房间 grant 固定七天。这个无配置首版边界允许熟人跨短期游戏会话复用链接,但不让可枚举持久房间产生永久邀请;只有真实使用证据才调整。到期在首次鉴权/重连时拒绝,不定时踢出已鉴权 Viewer;即时清退使用 rotate/revoke。 -- `public-watch`:纯 `/r/{roomId}` 或 `/join` 房间码允许 Viewer 进入,不签发 Viewer grant。这是有意公开的观看 policy,不是私密链接;它仍受房间 Viewer、全局/未鉴权连接和中央 egress/admission 上限保护,不提供房间目录。 +- `public-watch`:通过 site access 后,`/r/{roomId}` 或 `/join` 房间码允许 Viewer 进入,不签发 Viewer grant。房间码只定位;该 policy 仍受房间 Viewer、全局/未鉴权连接和中央 egress/admission 上限保护,不提供房间目录。 - `private-link` 另可设置一个逐房间 Viewer 密码。有效 fragment grant 保持直达;只有房间号时显示中性密码表单,正确密码仅鉴权当前 Viewer 会话。Host 可设置、改写或移除,密码接受 1 至 64 个可见 ASCII 字符且不要求组合复杂度。逐人邀请、账号、找回、ACL 和通用授权服务仍不做。 房间密码明文只短暂存在于当前 React 状态、WebSocket 鉴权消息和异步派生调用内存;不进入 URL/query/fragment、cookie、`localStorage`、`sessionStorage`、日志、错误或 SQLite。服务端使用 Node 标准异步 `scrypt` 固定 `N=16384,r=8,p=1`、16-byte 随机 salt 和 32-byte verifier;进程级 gate 最多执行 2 个、等待 16 个派生,队满即有界失败。等待任务取槽后先复核当前 Host/Viewer session,stale work 不启动 KDF;实际派生后再复核 room material 与连接代次。功能部署后的目标机 benchmark 再校准参数,不阻塞最小闭环。不存在房间、未配置密码、错误密码与正确密码后的满房都使用通用拒绝,不形成存在性 oracle。 私密邀请使用 `/r/{roomId}#v={grant}`。RFC 3986 要求 fragment 在 dereference 前由 user agent 分离,因此 HTTP request-target 与反向代理 access log 看不到它;WHATWG WebSocket 还明确拒绝含 fragment 的 URL。客户端只从页面 URL 严格解析一次,把 grant 写入 `screener:viewer-grant:{roomId}` 的 `sessionStorage`,随即以 `history.replaceState` 把地址栏替换为 canonical `/r/{roomId}`;刷新和页面内 WSS 重连继续使用该页面会话的值,独立且无 fragment/room key 的页面只进入中性密码路径,没有正确房间密码仍 fail closed。Host 建房/rotate 收到的 raw grant 同样只在内存和 room-scoped `sessionStorage` 中保留,用它临时构造复制链接;写入 `localStorage` 的 room ownership 对象只能包含 canonical URL、room ID、Host token 和期限。grant 不进入 cookie、query、WebSocket URL、Referrer、日志或错误。生产继续发送 `Referrer-Policy: no-referrer`;fragment 与该 header 降低传输泄漏,但不能防止原邀请消息、浏览器扩展、截图或用户转发,UI 必须把链接称为访问凭据。 -`ROOM_DATABASE_PATH` 仍是可选 SQLite 文件路径,但必须同时配置 `HOST_ADMISSION_PASSWORD`。无数据库时房间使用随机数字 ID 和 `ROOM_TTL_SECONDS`;有数据库时 SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` 从 `1` 开始分配持久 room ID。所有房间继续生成 256-bit Host token,服务端只保存 SHA-256 摘要;Host 浏览器只把 room ownership 明文留在同源 `localStorage`。SQLite schema v3 在同一个 `rooms` row 上保存 `id`、32-byte `host_token_digest`、nullable 32-byte `viewer_grant_digest` 和 nullable 48-byte `viewer_password_material`;后者是 16-byte salt 与 32-byte scrypt verifier 的单一 BLOB,`CHECK` 只允许 `NULL` 或准确长度 48,避免半写状态。没有明文房间密码、用户、成员或 session 表。 +`ROOM_DATABASE_PATH` 仍是可选 SQLite 文件路径,但必须同时配置 `SITE_ACCESS_PASSWORD`。无数据库时房间使用随机数字 ID 和 `ROOM_TTL_SECONDS`;有数据库时 SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` 从 `1` 开始分配持久 room ID。所有房间继续生成 256-bit Host token,服务端只保存 SHA-256 摘要;Host 浏览器只把 room ownership 明文留在同源 `localStorage`。SQLite schema v3 在同一个 `rooms` row 上保存 `id`、32-byte `host_token_digest`、nullable 32-byte `viewer_grant_digest` 和 nullable 48-byte `viewer_password_material`;后者是 16-byte salt 与 32-byte scrypt verifier 的单一 BLOB,`CHECK` 只允许 `NULL` 或准确长度 48,避免半写状态。没有明文房间密码、用户、成员或 session 表。 schema v1/v2 -> v3 使用单个 `BEGIN IMMEDIATE` 事务。v1 先新增 checked `viewer_grant_digest` 并为旧行写入没有对应 raw grant 的随机 locked digest;v1 与 v2 都新增 checked `viewer_password_material`,默认 `NULL`,最后更新 `user_version`。读取畸形非空值时 fail closed。旧 Host token 能认领房间;旧 code-only Viewer 不能 fail-open。部署前备份数据库;由于新 release 不读写双 schema,回滚旧 binary 必须停服并恢复备份。临时内存房间随部署重启结束。 -rotate、revoke 或从 public-watch 切回 private-link 都是当前 Viewer 的强撤销:服务端先原子提交新 digest/authorization generation;成功后清除所有 Viewer grace、连接代次与路由状态,向 Viewer 发送通用访问已撤销终态使其立即关闭接收 `RTCPeerConnection`/媒体并停止重连,同时向每个 Host/relay/SFU upstream owner 发送权威旧边移除使其关闭对应发送边,最后关闭 Viewer socket。不能只断 WSS,因为健康 P2P 可在信令断开后继续播放。rotate 与 public-to-private 都生成并仅返回一次新 grant;revoke 写入未生成对应 grant 的 fresh random locked digest,不再允许旧 grant,但已配置的房间密码仍是独立的以后进入方式。数据库写失败时,内存摘要、现有 Viewer、媒体和拓扑保持不变。切到 public-watch 只放宽以后 Viewer 的 code-only 鉴权,不需要迁移健康媒体。服务端永不返回现有 grant 明文;丢失 Host 标签页中的 grant 时只能 rotate。 +rotate、revoke 或从 public-watch 切回 private-link 都是当前 Viewer 的强撤销:服务端先原子提交新 digest/authorization generation;成功后清除所有 Viewer grace、连接代次与路由状态,向 Viewer 发送通用访问已撤销终态使其立即关闭接收 `RTCPeerConnection`/媒体并停止重连,同时向每个 Host/relay/SFU upstream owner 发送权威旧边移除使其关闭对应发送边,最后关闭 Viewer socket。不能只断 WSS,因为健康 P2P 可在信令断开后继续播放。rotate 与 public-to-private 都生成并仅返回一次新 grant;revoke 写入未生成对应 grant 的 fresh random locked digest,不再允许旧 grant,但已配置的房间密码仍是独立的以后进入方式。数据库写失败时,内存摘要、现有 Viewer、媒体和拓扑保持不变。切到 public-watch 只放宽已通过 site access 的房间级鉴权,不需要迁移健康媒体。服务端永不返回现有 grant 明文;丢失 Host 标签页中的 grant 时只能 rotate。 房间规则: @@ -104,7 +104,7 @@ Web Host 在 `authenticate` 中显式发送 `viewerPresence: true` capability, ## 信令协议 -WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 Host-admission cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v2` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 Host admission;Web Host 可选发 `viewerPasswordSettings: true` capability,Native v2 不发送且不接收任何密码设置新消息。Viewer 消息携带 room、role、clientId,以及 private room 的 Viewer grant 或当前尝试的房间密码。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。 +WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site-access cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v2` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 site access;Web Host 可选发 `viewerPasswordSettings: true` capability,Native v2 不发送且不接收任何密码设置新消息。Viewer 消息携带 room、role、clientId,以及 private room 的 Viewer grant 或当前尝试的房间密码。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。 主要消息: @@ -211,7 +211,7 @@ iOS 都没有可靠的 `getDisplayMedia()`;Web Host 入口必须检查运行 优先级保持 P1 Windows native sender 与当前 deployment,移动 Host 不得抢跑。 之后的 P2 Android 14/API 34+ 整屏 spike 只做一条窄纵向链路:系统 `MediaProjection` selection -> `mediaProjection` foreground service -> 一个经筛选 -的硬件 `MediaCodec` fixed-`HIGH` encoder -> 现有 Host admission/room/WSS/标准 +的硬件 `MediaCodec` fixed-`HIGH` encoder -> 现有 site access/room/WSS/标准 WebRTC -> 一至两名未修改 Web Viewer。它不增加移动端专属信令、拓扑、`LOW`、 AV1、codec ladder 或第三条 Host 下游边。`MediaCodecInfo.isHardwareAccelerated()` 是厂商声明而非充分证明,所以还必须用真实设备持续测量 codec、encode time、 @@ -317,10 +317,10 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。 -- 访问入口:未鉴权根路径和 `/join` 只显示中性的“访问验证/访问口令”,未知路径只显示“无法访问”,HTML metadata 不说明站点用途。有效 `/r/{code}#v={grant}` 或 public-watch `/r/{code}` 先完成 Viewer 鉴权并直接进入;private room 仅有房间号时在同一中性边界显示“访问密码”输入,成功后才渲染房间 UI。无效、过期、错误密码或满员只显示通用拒绝。Viewer 页面不显示或泄露部署访问口令或站点用途。这是降低偶然访客信息量的展示边界,不把浏览器可下载的 bundle 当成秘密。 +- 访问入口:未鉴权根路径、`/join` 和 code-only `/r/{code}` 只显示中性的站点访问验证,未知路径只显示“无法访问”。有效 `/r/{code}#v={grant}` 直接进入;其余 Viewer 通过 site access 后,public-watch 进入房间,private room 再显示中性房间密码输入。无效、过期、错误密码或满员只显示通用拒绝,不泄露房间存在性或 policy。 - 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。 -- `/join`:只输入正整数房间码并进入 `/r/{code}`;public-watch 直接通过,private room 缺失 grant 时显示中性房间密码表单。 -- `/r/{code}` 观看者:未鉴权时不渲染产品用途;private room 首次解析 fragment grant 后立即清理地址栏并直达,仅有 code 时可用房间密码;public-watch 只需 code。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。 +- `/join`:通过 site access 后只输入正整数房间码并进入 `/r/{code}`;public-watch 直接通过,private room 缺失 grant 时显示中性房间密码表单。 +- `/r/{code}` 观看者:private room 首次解析合法 fragment grant 后立即清理地址栏并直达;仅有 code 时先通过 site access,再按 public/private policy 进入。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。 - 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。 两端后续默认显示紧凑在线 roster;Host 与 Viewer 均可修改本机显示名,展示为名称加 room-scoped `peerId` 短后缀。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见。用户名/roster 是独立 PR,不进入 access credential 或 SQLite。 @@ -334,7 +334,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) ## 安全与故障边界 - 生产仅允许 HTTPS/WSS,并校验 WebSocket `Origin` allowlist。 -- Host admission 密码以定长摘要配合常量时间比较;Host-admission cookie 由服务端 HMAC 签名且不含账号或可查询 session ID。Host token 与 Viewer grant 只保存 SHA-256 摘要并以定长比较验证;房间密码只保存带随机 salt 的 scrypt verifier material,并对派生结果做定长比较。Host admission 与建房 HTTP 响应设置 `Cache-Control: no-store`。 +- site access 密码以定长摘要配合常量时间比较;site-access cookie 由服务端 HMAC 签名且不含账号或可查询 session ID。Host token 与 Viewer grant 只保存 SHA-256 摘要并以定长比较验证;房间密码只保存带随机 salt 的 scrypt verifier material,并对派生结果做定长比较。site access 与建房 HTTP 响应设置 `Cache-Control: no-store`。 - Fragment 只避免其进入 HTTP/WebSocket request-target,不能把 capability 变成非敏感数据。客户端首次读取后立即清理地址栏,只在 room-scoped `sessionStorage` 留存;服务端和代理日志不得记录 WebSocket payload、Authorization、cookie、raw grant 或响应 body,异常只能记录安全路径、状态码和分类。 - HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。 - 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 @@ -345,13 +345,13 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 自动测试: -- HostAdmission 的 production 必配、8 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态; +- SiteAccess 的 production 必配、8 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、已移除 access key fail-fast 和无服务端 session 状态; - RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝; -- SQLite 房间库从 `1` 自增、重启恢复、v1/v2 -> v3 单事务迁移、nullable 32-byte grant 与 48-byte password material CHECK、畸形值 fail-closed、rotate/revoke/password 写失败原子性和数据最小化;数据库路径无 Host admission 时启动失败; +- SQLite 房间库从 `1` 自增、重启恢复、v1/v2 -> v3 单事务迁移、nullable 32-byte grant 与 48-byte password material CHECK、畸形值 fail-closed、rotate/revoke/password 写失败原子性和数据最小化;数据库路径无 site access 时启动失败; - STUN/TURN UDP URL 严格校验、生产 STUN 必需、selected-edge tuple 完整性/TTL/secret 独立性、issuer HMAC/expiry/opaque tag、当前 route/session/revision/old-new connection generation 与 one-use rebuild guard,以及 ordinary peer、未命中房间和 Native 的 STUN-only isolation;LiveKit `sfu-config` 保持独立边界; - 单一 `screener-v2` 信令 literal、v1 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示; -- HTTP/WS 集成:nginx per-source admission `limit_req` 与统一错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 无 Host cookie 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured admission 与 Host token,Viewer 只按 room policy;Host cookie 不绕过 private grant/password,grant/password 不能建房、取得 Host 或跨房。覆盖 private fragment/sessionStorage/replaceState、房间号+正确/错误/已替换/已移除密码、不含明文于 URL/浏览器持久存储/SQLite/log、public code-only、v2 Native 无 capability 不收新 wire,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; +- 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 再按 room policy;site-access cookie 不绕过 private grant/password,grant/password 不能建房、取得 Host 或跨房。覆盖 fragment/sessionStorage/replaceState、无 site access 的 public/password code-only 统一拒绝、通过 site access 后的 public/password 进入、不含明文于 URL/浏览器持久存储/SQLite/log、v2 Native wire,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; - 客户端策略与 stats 解析:会话替换不重连,ordinary ICE、LiveKit 与 selected-edge TURN protocol 分离,远端 TURN transport 保持未知; - TypeScript 类型检查、Vite production build 和仓库 hygiene。 @@ -377,7 +377,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 4. 完成连接恢复和 stats 面板;随后以 STUN-only snapshot 替换旧全房 TURN credential/refresh wire。 5. 完成 automatic peer/SFU controller,并让 room `1` 仅作为历史 smoke fixture。 6. 可选 selected-edge TURN tuple、revision-bound one-use grant 与 parent/child rebuild 已在 source 和 production 配置闭环;下一功能验收只需一条 forced relay 媒体 smoke,完整网络/资源 benchmark 后置。ordinary STUN-only、SFU/UDP、coturn rollout 仍保持独立边界。 -7. 在媒体主线阶段验收后,用一个原子 PR 迁移 Host admission、`screener-v2`、room-scoped Viewer grant 和 SQLite v2;同一部署切换客户端/服务端/环境和数据库备份,随后删除 v1 配置、wire 与测试。用户名/roster 保持独立。 +7. 在媒体主线阶段验收后,用一个原子 PR 迁移 site access、`screener-v2`、room-scoped Viewer grant 和 SQLite v2;同一部署切换客户端/服务端/环境和数据库备份,随后删除 v1 配置、wire 与测试。用户名/roster 保持独立。 8. P1 继续完成 Windows native fixed-`HIGH` staged gate 与 deployment;移动 Host 不阻断或先于它。 9. P2 只在上述门槛后运行 Android 14/API 34+ fixed-`HIGH` 原生 sender spike;失败即停,不扩展为跨平台 framework。 10. iOS 27 ScreenCaptureKit final 后再决定是否运行独立 P2 spike;不实现 ReplayKit compatibility path。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 887a19b..4c1429a 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -16,8 +16,8 @@ ### P0 - 必须实现 -- [ ] 首屏双入口:分享入口通过 Host admission 后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;观看者不需要知道 Host admission 密码。点击后直接进入现有分享或房间加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。 -- [ ] 简单房间:分享者获得纯数字房间码。房间默认是私密模式,现有 `/r/{code}#v={grant}` 邀请继续直接进入;Host 也可给该房间设置低门槛观看密码,使仅有 `/r/{code}` 或 `/join` 房间码的 Viewer 在中性页面输入密码后进入。Host 可显式选择 public-watch,使纯房间码直接观看。房间密码入口已部署。所有入口只授予该房间 Viewer,不能建房、发布、冒充 Host 或跨房。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作,Host 保留复制完整邀请链接;持久房间码从 `1` 开始自增,其他模式继续使用随机临时房间码。 +- [ ] 首屏双入口:分享入口通过站点访问后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;持合法房间 grant 的观看者不需要知道站点访问密码。点击后直接进入现有分享或房间加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。 +- [ ] 简单房间:分享者获得纯数字房间码,但房间码只负责定位。现有 `/r/{code}#v={grant}` 合法未过期邀请继续直接进入;仅有 `/r/{code}` 或 `/join` 房间码的 Viewer 必须先通过站点访问验证,再按房间 policy 进入:public-watch 接受房间码,private room 还需正确观看密码。所有入口只授予该房间 Viewer,不能建房、发布、冒充 Host 或跨房。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作,Host 保留复制完整邀请链接;持久房间码从 `1` 开始自增,其他模式继续使用随机临时房间码。 - [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。 - [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。Host 本地预览必须直接使用 capture stream,不得为预览建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。 - [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。 @@ -33,13 +33,13 @@ - [ ] 断线恢复:临时网络变化时按不同层级做一次性恢复,而不是把同一动作机械重复三次。`failed` 边先做一次 ICE restart,再重建一次同 parent 的 `RTCPeerConnection`,随后尝试一个不同的合格 peer parent;只有 peer 路径耗尽才进入 SFU。`connecting`/`checking` 先显示不改变拓扑的慢连接状态,硬超时必须由真实移动网络证据确定,不能为了更快使用服务器而过早放弃最终可成功的 P2P。任一步恢复即停止后续动作;所有异步结果受 session、route revision、assignment/connection generation 约束。active SFU 只允许一次 fresh-token 重试,再失败即有界回到 peer baseline 或明确失败,并向 Host 显示不含 URL、地址、token 或原始错误的失败阶段。 - [ ] 可观测性:按目标 RTP transport 与不透明连接/ICE 代次关联 selected path、`iceGatheringState`/ICE/connection state、事件推导的建立时长、generation-baselined pair-change delta,以及 RTT、RTP 丢包率、抖动、码率、帧率、编码耗时和受限原因。候选与错误的 wire、日志和导出只使用[低服务器成本媒体路由](./research/low-server-media-routes.md)的脱敏字段和 code bucket,禁止 raw SDP、candidate、地址、URL、`errorText` 或网络标识。只有创建该 `RTCPeerConnection` 的本机可在折叠详情中显示浏览器对 selected local/remote candidate 实际暴露的地址与端口;该值不上传、不持久化、不用于地理/NAT 推断,缺失或被隐私保护时显示未知。 - [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。 -- [ ] Host admission:生产部署必须配置独立的 `HOST_ADMISSION_PASSWORD` 控制建房和 Host 信令角色;只有本地开发/测试可显式关闭。它只是防止随手滥用的部署访问口令,不是账号密码;非空值接受 8 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。未鉴权根路径、`/join` 和未知路径只显示中性的访问验证或通用拒绝,不说明站点用途;有效 private-link grant 或 public-watch 直链仍直接进入目标房间,正确房间密码从中性表单进入。这只是界面最小暴露,不把可下载的前端代码误当保密边界。建房 HTTP 只认该 cookie且不提供 Bearer 旁路;Host admission endpoint 由部署层/nginx 做 per-source `limit_req`,失败只返回统一结果,不在 Node 新增 IP 身份库/LRU/session 表或持久化来源地址。WebSocket upgrade 继续校验 Origin 与全局/未鉴权容量并记录 cookie 状态,首条 Host `authenticate` 必须同时通过该状态与房间 `hostToken`。Viewer 不经过此 gate,Host cookie 也不能绕过私密房间的 Viewer grant/password。不引入账号、JWT、服务端 session Map 或通用 logout。 -- [ ] 房间级观看授权:新房间默认生成含 256-bit 随机量、限时且可撤销的 room-scoped Viewer grant;服务端只保存完整 grant 的 SHA-256 摘要。到期阻止以后鉴权/重连,但不为已鉴权 Viewer 设置踢出定时器;即时清退使用 rotate/revoke。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续。私密房间缺失 grant 时只有正确的该房间观看密码可进入;两者都没有必须 fail closed。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可 rotate 或 revoke;revoke 只锁定 grant 路径,已配置的房间密码仍是独立的后续进入方式。新 grant 摘要成功提交后必须清除 Viewer grace/路由、终止旧授权代次的信令和媒体并断开全部旧 Viewer,数据库写失败则不得改变连接。public-watch 是显式、可逆的 room policy,只放宽观看,不放宽建房或发布。 -- [ ] 最小持久化:`ROOM_DATABASE_PATH` 可选且必须与 `HOST_ADMISSION_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID、Host token 摘要、nullable Viewer grant 摘要,以及 nullable 48-byte Viewer-password material(16-byte salt + 32-byte scrypt verifier);数据库 `CHECK` 约束非空 BLOB 长度,不新增用户、成员或 session 表。明文房间密码只短暂存在于当前页面和鉴权/派生调用内存,不进入 URL、cookie、浏览器持久存储、日志或 SQLite。持久房间停止后仍显示等待状态;私密邀请按期限或 Host rotate 更新。旧 schema 行在原子迁移时必须 fail-closed 为没有可用明文 grant 的锁定私密状态,Host rotate 后才能生成新邀请。 +- [ ] 站点访问:生产部署必须配置独立的 `SITE_ACCESS_PASSWORD` 控制建房、Host 信令角色和所有 code-only Viewer 入口;只有本地开发/测试可显式关闭。非空值接受 8 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。`POST /api/site-access` 验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` site-access cookie。有效 room-scoped fragment grant 可绕过站点 gate 直接进入对应房间;其余 `/r/{code}`、`/join` 和 public-watch 必须先持有效站点 cookie,之后才检查房间 public/private policy 和房间密码。失败统一且不得泄露房间是否存在或 policy;建房 HTTP 只认该 cookie。已移除的旧环境变量、endpoint 和 cookie 不保留兼容。不引入账号、JWT、服务端 session Map 或通用 logout。 +- [ ] 房间级观看授权:新房间默认生成含 256-bit 随机量、限时且可撤销的 room-scoped Viewer grant;服务端只保存完整 grant 的 SHA-256 摘要。到期阻止以后鉴权/重连,但不为已鉴权 Viewer 设置踢出定时器;即时清退使用 rotate/revoke。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续。私密房间缺失 grant 时必须先通过站点访问,再提供正确房间观看密码;任一缺失都 fail closed。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可 rotate 或 revoke;revoke 只锁定 grant 路径,已配置的房间密码仍是独立的后续进入方式。新 grant 摘要成功提交后必须清除 Viewer grace/路由、终止旧授权代次的信令和媒体并断开全部旧 Viewer,数据库写失败则不得改变连接。public-watch 是显式、可逆的 room policy,只放宽房间级观看,不绕过站点访问。 +- [ ] 最小持久化:`ROOM_DATABASE_PATH` 可选且必须与 `SITE_ACCESS_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID、Host token 摘要、nullable Viewer grant 摘要,以及 nullable 48-byte Viewer-password material(16-byte salt + 32-byte scrypt verifier);数据库 `CHECK` 约束非空 BLOB 长度,不新增用户、成员或 session 表。明文房间密码只短暂存在于当前页面和鉴权/派生调用内存,不进入 URL、cookie、浏览器持久存储、日志或 SQLite。持久房间停止后仍显示等待状态;私密邀请按期限或 Host rotate 更新。旧 schema 行在原子迁移时必须 fail-closed 为没有可用明文 grant 的锁定私密状态,Host rotate 后才能生成新邀请。 ### P1 - 重要功能 -- [ ] 私密房间双入口:在不引入账号、密码找回或通用服务端 session 的前提下,private room 同时接受“房间号 + 该房间观看密码”和现有 `/r/{code}#v={grant}` 私密邀请;有效 grant 直接进入目标房间,没有 grant 的 `/join` 或 `/r/{code}` 只显示中性的房间密码输入。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符,不要求字符组合复杂度;服务端先以固定保守参数的 Node 异步 scrypt 落地,全局最多执行 2 个、等待 16 个派生,取槽后跳过 stale session,目标 1 GiB 部署机的参数 benchmark 与优化在功能部署后进行。房间密码、grant 与 verifier 的权限都只能是该房间 Viewer;明文密码不得进入 URL/query/cookie、`localStorage`、`sessionStorage`、日志或 SQLite,也不能取得 Host admission、建房、发布或跨房权限。不存在、错误密码、队满和正确密码后的满房都返回通用结果。该最小闭环已部署。 +- [ ] 私密房间双入口:private room 接受现有 `/r/{code}#v={grant}` 合法邀请直接进入,或“站点访问 + 房间号 + 该房间观看密码”。没有 grant 的 `/join` 或 `/r/{code}` 先显示中性的站点访问验证,通过后才显示中性的房间密码输入。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符;异步 scrypt 全局最多执行 2 个、等待 16 个派生。站点口令和房间密码都不得进入 URL/query/storage/log/SQLite;不存在、错误密码、队满和正确密码后的满房都返回通用结果。 - [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。 @@ -61,7 +61,7 @@ ### P2 - 可选功能 - [ ] 原生 Windows 捕获辅助程序,使用 WGC/DDA、WASAPI 和硬件编码器降低复制与编码开销。 -- [ ] Android 原生分享端有界实验:只在 Windows native/deploy 的 P1 门槛完成后,以 Android 14/API 34+ 整屏捕获为首个范围,使用 `MediaProjection`、`mediaProjection` foreground service 和经实测合格的硬件 `MediaCodec` 发送一份 fixed-`HIGH` 给一至两名普通 Web Viewer;单应用窗口选择只在 Android 14 QPR2+ 验证。复用现有 room、Host admission、WSS 与标准 WebRTC 契约,不新增移动端专属拓扑、`LOW`、AV1、codec ladder 或软件编码兜底。系统选源、授权和持续前台指示必须保留;旋转、锁屏终止、Wi-Fi/蜂窝切换、持续热降频、资源释放和真机画质/延迟都是硬门槛。 +- [ ] Android 原生分享端有界实验:只在 Windows native/deploy 的 P1 门槛完成后,以 Android 14/API 34+ 整屏捕获为首个范围,使用 `MediaProjection`、`mediaProjection` foreground service 和经实测合格的硬件 `MediaCodec` 发送一份 fixed-`HIGH` 给一至两名普通 Web Viewer;单应用窗口选择只在 Android 14 QPR2+ 验证。复用现有 room、site access、WSS 与标准 WebRTC 契约,不新增移动端专属拓扑、`LOW`、AV1、codec ladder 或软件编码兜底。系统选源、授权和持续前台指示必须保留;旋转、锁屏终止、Wi-Fi/蜂窝切换、持续热降频、资源释放和真机画质/延迟都是硬门槛。 - [ ] iOS 原生分享端有界实验:当前不实现 ReplayKit/broadcast-extension 兼容栈。待 iOS 27 与对应 ScreenCaptureKit SDK 脱离 beta 并稳定后,只评估 ScreenCaptureKit system picker + fixed-`HIGH` + 一至两名 Web Viewer 的窄路径;VideoToolbox 必须要求并读回硬件编码,无法证明时 fail closed,不增加 ReplayKit fallback、`LOW`、AV1 或新拓扑。 - [ ] 多地区 SFU-root 调度,以及可选 TURN 兼容层的按地区、运营商可达率。 @@ -71,7 +71,7 @@ - 启播:观看者请求观看后,除用户授权耗时外,3 秒内出现首帧。 - 流畅性:选择 60 fps 档位时,不能在没有降档或提示的情况下连续 5 秒低于 50 个解码帧每秒。 - 成本:direct/peer UDP 不经过应用服务器;SFU 正常只承担 publisher ingress 与一至两个 root egress,另行准入的极端 viewer 必须受独立中央 egress cap 约束。候选筛选使用[低服务器成本媒体路由](./research/low-server-media-routes.md)的 `B`/`B_pub`/`B_i`、`B_exc,j`、`N=R+D+E` 模型;验收必须保留 host/root/exception NIC、每条可选 TURN ingress/egress、SFU ingress/egress 和供应商计费的真实 hop bytes,不得用有效载荷近似折叠物理流量。 -- 安全:应用控制面全程 HTTPS/WSS;Host admission、Host token 与 room-scoped Viewer grant 权限正交,任何一个 Viewer grant 只授予一个房间的观看角色。P2P/TURN 边在 WebRTC 端点间使用 DTLS-SRTP,普通 SFU 是逐跳 DTLS-SRTP;只有另行实现并验收应用层 E2EE 才能对 SFU operator 保持媒体端到端机密。仓库、数据库、日志和错误不得包含明文密码或 grant。 +- 安全:应用控制面全程 HTTPS/WSS;site access、Host token 与 room-scoped Viewer grant 权限正交,任何一个 Viewer grant 只授予一个房间的观看角色。P2P/TURN 边在 WebRTC 端点间使用 DTLS-SRTP,普通 SFU 是逐跳 DTLS-SRTP;只有另行实现并验收应用层 E2EE 才能对 SFU operator 保持媒体端到端机密。仓库、数据库、日志和错误不得包含明文密码或 grant。 - 隐私:P2P 会向房间内好友暴露网络地址,首版威胁模型仅覆盖可信熟人;若要求隐藏 IP,必须切换为服务器转发。 - 可维护性:代码、需求、调研、项目记忆、`AGENTS.md` 和 `.codex/` 均在 Git 中跟踪;开发配置不得依赖单一操作系统的绝对路径。 - 迁移完整性:同一 release 的客户端、服务端和部署配置原子迁移;canary 结束后删除被替代的配置、wire、parser、双写与测试,不为无当前消费者的旧版本保留兼容层或第二套架构。Git 历史承担旧实现追溯。 @@ -80,8 +80,8 @@ ## 边界与约束 - 首版不包含远程键鼠控制、公开/大规模直播、录制、社区系统或完整语音聊天;大规模分享直接使用现有直播服务。 -- public-watch 中房间码本身就是有意公开的观看入口,不提供隐私保证、目录或防转发承诺;随机/自增房间码均不得被描述为私密邀请。现有每房 Viewer 上限、全局/未鉴权连接上限、房间上限和中央 egress/admission cap 仍适用,public-watch 不能创建无界服务器 fanout。 -- SQLite 只用于持久房间身份与最小授权材料:房间 ID、Host token 摘要、nullable Viewer grant 摘要和 nullable 48-byte Viewer-password salt+verifier。不得保存 Host admission 密码、房间观看密码、token/grant 明文、cookie、显示名、成员、`clientId`、`peerId`、SDP、ICE candidate、IP 地址、媒体或 TURN 凭据;首版仍是单进程、单实例控制面。 +- public-watch 只省略房间级 grant/password,不省略站点访问;房间码只定位,不提供隐私保证、目录或防转发承诺。现有每房 Viewer 上限、全局/未鉴权连接上限、房间上限和中央 egress/admission cap 仍适用,public-watch 不能创建无界服务器 fanout。 +- SQLite 只用于持久房间身份与最小授权材料:房间 ID、Host token 摘要、nullable Viewer grant 摘要和 nullable 48-byte Viewer-password salt+verifier。不得保存 site access 密码、房间观看密码、token/grant 明文、cookie、显示名、成员、`clientId`、`peerId`、SDP、ICE candidate、IP 地址、媒体或 TURN 凭据;首版仍是单进程、单实例控制面。 - 当前八名观看者和 1 至 16 配置范围是接入边界;目标默认 20 也只是通过指定矩阵后的接入边界,不承诺任意机器、档位、网络或跨洲路径都稳定承载。 - 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。 - 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。 @@ -92,17 +92,17 @@ ### 房间模式与生命周期 -- Given `HOST_ADMISSION_PASSWORD` 和 `ROOM_DATABASE_PATH` 都已配置,When 连续创建房间并重启应用,Then 房间码从 `1` 开始单调递增,Host ownership 可恢复,且 SQLite 的应用数据只包含房间 ID、Host token 摘要和 nullable Viewer grant 摘要。 +- Given `SITE_ACCESS_PASSWORD` 和 `ROOM_DATABASE_PATH` 都已配置,When 连续创建房间并重启应用,Then 房间码从 `1` 开始单调递增,Host ownership 可恢复,且 SQLite 的应用数据只包含房间 ID、Host token 摘要和 nullable Viewer grant 摘要。 - Given 持久房间正在分享,When 分享者明确停止或捕获轨结束,Then 房间和链接保持有效,分享端显示“已停止分享”,观看者显示“等待开始分享”;分享者重新发布后可继续在同一房间观看。 - Given 分享期间只有 host 信令短暂断开,When 现有 P2P 媒体仍健康,Then 客户端继续播放媒体并恢复信令,而不是错误显示“已停止分享”。 -- Given 应用以 production 模式启动但 `HOST_ADMISSION_PASSWORD` 缺失、`ROOM_DATABASE_PATH` 已配置但该密码为空,或原子迁移后的配置仍提供已删除的 `ACCESS_PASSWORD`,When 应用启动,Then 配置校验明确失败,而不是兼容旧 gate 或公开建房。 -- Given production Host admission secret 短于 8 bytes、长于 128 bytes 或含非可见 ASCII,When 新 release 启动,Then 必须 fail-fast。Given 同一来源连续提交错误 secret,When 超过 deployment `limit_req` 的 `5r/m`、`burst=5 nodelay` 边界,Then nginx 返回不含凭据的通用限频结果;允许请求的错误值仍使用常量时间比较和统一鉴权失败,limiter 只使用 nginx 内存且不新增 IP 持久化/日志字段。 -- Given `ROOM_DATABASE_PATH` 为空,When 创建房间,Then 无论是否启用 Host admission 都使用随机临时房间码并遵守 `ROOM_TTL_SECONDS`;停止分享本身不立即销毁房间。 +- Given 应用以 production 模式启动但 `SITE_ACCESS_PASSWORD` 缺失、`ROOM_DATABASE_PATH` 已配置但该密码为空,或原子迁移后的配置仍提供已删除的 `ACCESS_PASSWORD`,When 应用启动,Then 配置校验明确失败,而不是兼容旧 gate 或公开建房。 +- Given production site access secret 短于 8 bytes、长于 128 bytes 或含非可见 ASCII,When 新 release 启动,Then 必须 fail-fast。Given 同一来源连续提交错误 secret,When 超过 deployment `limit_req` 的 `5r/m`、`burst=5 nodelay` 边界,Then nginx 返回不含凭据的通用限频结果;允许请求的错误值仍使用常量时间比较和统一鉴权失败,limiter 只使用 nginx 内存且不新增 IP 持久化/日志字段。 +- Given `ROOM_DATABASE_PATH` 为空,When 创建房间,Then 无论是否启用 site access 都使用随机临时房间码并遵守 `ROOM_TTL_SECONDS`;停止分享本身不立即销毁房间。 - Given 新建私密房间,When 服务端返回邀请并由 Host/Viewer 客户端读取,Then grant 含 256-bit 随机量、只授权该房间 Viewer、只在该页面会话的 room-scoped `sessionStorage` 留存,并在首次读取后从地址栏移除;HTTP request-target、WebSocket URL、Referrer、cookie、`localStorage`、SQLite 明文、日志和错误均不包含 grant。Given 独立页面既无 fragment 也无该 room key,When 尝试进入私密房间,Then 必须要求完整邀请而不是退回 code-only。 -- Given Viewer 只有 Host admission cookie、另一个房间的 grant、缺失/过期 grant 且没有正确房间密码,或自行把 role 改为 Host,When 首条信令鉴权到达,Then 私密房间返回同一通用无效结果且不泄露房间存在性;不能建房、发布、跨房或绕过 Host token。 +- Given Viewer 只有 site access cookie、另一个房间的 grant、缺失/过期 grant 且没有正确房间密码,或自行把 role 改为 Host,When 首条信令鉴权到达,Then 私密房间返回同一通用无效结果且不泄露房间存在性;不能建房、发布、跨房或绕过 Host token。 - Given Host rotate/revoke 私密邀请或把 public-watch 改为私密,When 新摘要持久化成功,Then 服务端先提交新授权代次,再清除 Viewer grace/路由,命令 Viewer 关闭接收 PC/媒体并让每个 Host/relay/SFU upstream owner 关闭对应旧代次媒体边,最后关闭旧 Viewer socket;rotate 和 public-to-private 各返回一次新 grant,revoke 不返回 grant 且锁定 grant 路径,已配置的房间密码仍是独立的以后进入方式。Given 持久化失败,Then 旧摘要、连接和媒体保持不变。 -- Given 房间处于 public-watch,When 用户只输入房间码,Then 可在现有 Viewer/连接/egress 上限内观看,但仍不能创建房间、取得 Host 角色或枚举房间目录;Host 切回私密时签发新 grant 并撤销现有 code-only Viewer。 -- Given private room 配置了观看密码,When Viewer 仅以房间号访问,Then 未授权页面只显示中性密码表单;正确密码只鉴权当前 Viewer 会话,错误密码与不存在房间返回同一通用结果。Given Host 改密或移除,Then 旧密码的以后鉴权失败,现有 fragment grant 仍可直接进入;public-watch 纯房间码仍直接进入。 +- Given 房间处于 public-watch,When 已通过站点访问的用户输入房间码,Then 可在现有 Viewer/连接/egress 上限内观看;没有站点访问时返回中性拒绝。Host 切回私密时签发新 grant 并撤销现有 code-only Viewer。 +- Given private room 配置了观看密码,When Viewer 仅以房间号访问,Then 先显示中性站点访问验证,通过后才显示中性房间密码表单;正确密码只鉴权当前 Viewer 会话,错误密码与不存在房间返回同一通用结果。Given Host 改密或移除,Then 旧密码的以后鉴权失败,现有 fragment grant 仍可直接进入;public-watch 仍要求站点访问。 - Given schema v1 或 v2 SQLite 在备份后首次由新 release 打开,When 单事务升级到 v3,Then v1 先补 checked `viewer_grant_digest` 并 fail-closed 锁定旧行,v1/v2 都新增 `viewer_password_material BLOB NULL CHECK (viewer_password_material IS NULL OR length(viewer_password_material) = 48)`;旧 Host token 仍可回收房间,旧 Viewer 链接不能 fail-open。畸形非空值必须拒绝写入或读取时 fail closed;回滚旧 binary 必须恢复迁移前备份,不运行双 schema 代码。 - Given 部署切换了不兼容的当前协议,When 仍打开的旧页面连接新服务端,Then 单一 release/protocol mismatch 边界终止该 session、停止自动重连并明确提示刷新;不得通过旧 parser、双写或双协议继续假连接。 diff --git a/native/sender/README.md b/native/sender/README.md index dec62d3..92d8a16 100644 --- a/native/sender/README.md +++ b/native/sender/README.md @@ -10,7 +10,7 @@ server. directory. 2. Run `screener-sender.exe`. It opens a loopback-only UI in the default browser; current Chrome or Edge is required. -3. Enter the Screener server and Host admission password, choose VP8 (default) +3. Enter the Screener server and site access password, choose VP8 (default) or experimental H.264, then choose the screen or window to share. 4. Process audio is off by default. To enable it, select the exact target in the audio list and select the same target in the browser picker. diff --git a/native/sender/internal/app/server.go b/native/sender/internal/app/server.go index 485a276..63cde53 100644 --- a/native/sender/internal/app/server.go +++ b/native/sender/internal/app/server.go @@ -228,7 +228,7 @@ func (app *App) handleStart(response http.ResponseWriter, request *http.Request) session, room, err := remote.Start(startContext, remote.StartOptions{ BaseURL: baseURL, - HostAdmissionPassword: strings.TrimSpace(input.Password), + SiteAccessPassword: strings.TrimSpace(input.Password), Codec: codec, EnableAudio: audioTarget != nil, OnEvent: func(event remote.Event) { app.emit(generation, event) }, diff --git a/native/sender/internal/app/server_test.go b/native/sender/internal/app/server_test.go index 8f4544a..830625e 100644 --- a/native/sender/internal/app/server_test.go +++ b/native/sender/internal/app/server_test.go @@ -602,7 +602,7 @@ func newAppRemoteServer(t *testing.T, terminal chan<- []byte) *httptest.Server { t.Helper() mux := http.NewServeMux() server := httptest.NewServer(mux) - mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true}) }) mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { @@ -654,7 +654,7 @@ func newDelayedAppRemoteServer( t.Helper() mux := http.NewServeMux() server := httptest.NewServer(mux) - mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true}) }) mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { diff --git a/native/sender/internal/remote/session.go b/native/sender/internal/remote/session.go index 8738ed0..38b9376 100644 --- a/native/sender/internal/remote/session.go +++ b/native/sender/internal/remote/session.go @@ -28,7 +28,7 @@ const ( maxRemoteResponse = 64 << 10 remoteRequestTimeout = 10 * time.Second signalWriteTimeout = 5 * time.Second - hostAdmissionTTL = 12 * time.Hour + siteAccessTTL = 12 * time.Hour nativeHostClaimTTL = 5 * time.Minute privateViewerGrantTTL = 7 * 24 * time.Hour privateViewerPolicy = "private-link" @@ -53,7 +53,7 @@ type Event struct { type StartOptions struct { BaseURL *url.URL - HostAdmissionPassword string + SiteAccessPassword string Codec media.Codec EnableAudio bool OnEvent func(Event) @@ -106,7 +106,7 @@ type createRoomResponse struct { ExpiresAt json.RawMessage `json:"expiresAt"` } -type hostAdmissionStatus struct { +type siteAccessStatus struct { Required *bool `json:"required"` Authenticated *bool `json:"authenticated"` } @@ -146,7 +146,7 @@ func Start(parent context.Context, options StartOptions) (*Session, Room, error) return http.ErrUseLastResponse }, } - if err = authenticateHTTP(parent, httpClient, options.BaseURL, options.HostAdmissionPassword); err != nil { + if err = authenticateHTTP(parent, httpClient, options.BaseURL, options.SiteAccessPassword); err != nil { return nil, Room{}, err } created, err := createRoom(parent, httpClient, options.BaseURL) @@ -712,17 +712,17 @@ func (session *Session) close() { } func authenticateHTTP(ctx context.Context, client *http.Client, base *url.URL, password string) error { - endpoint := base.ResolveReference(&url.URL{Path: "/api/host-admission"}) + endpoint := base.ResolveReference(&url.URL{Path: "/api/site-access"}) request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) if err != nil { - return errors.New("prepare host admission request failed") + return errors.New("prepare site access request failed") } request.Header.Set("Origin", remoteOrigin(base)) request.Header.Set("Accept", "application/json") if password != "" { request.Header.Set("Authorization", "Bearer "+password) } - var status hostAdmissionStatus + var status siteAccessStatus response, err := doJSON(client, request, &status) if err != nil { return fmt.Errorf("authenticate with the Screener site: %w", err) @@ -736,7 +736,7 @@ func authenticateHTTP(ctx context.Context, client *http.Client, base *url.URL, p if !*status.Authenticated { return errors.New("the Screener site did not authenticate this sender") } - if err = validateHostAdmissionCookie(response, client.Jar, base, *status.Required); err != nil { + if err = validateSiteAccessCookie(response, client.Jar, base, *status.Required); err != nil { return fmt.Errorf("authenticate with the Screener site: %w", err) } return nil @@ -798,35 +798,35 @@ func remoteOrigin(base *url.URL) string { return (&url.URL{Scheme: base.Scheme, Host: base.Host}).String() } -func validateHostAdmissionCookie(response *http.Response, jar http.CookieJar, base *url.URL, required bool) error { +func validateSiteAccessCookie(response *http.Response, jar http.CookieJar, base *url.URL, required bool) error { setCookies := response.Header.Values("Set-Cookie") if !required { if len(setCookies) != 0 { - return errors.New("unexpected host admission cookie") + return errors.New("unexpected site access cookie") } return nil } if len(setCookies) != 1 { - return errors.New("host admission cookie is missing or ambiguous") + return errors.New("site access cookie is missing or ambiguous") } cookies := response.Cookies() if len(cookies) != 1 { - return errors.New("host admission cookie is invalid") + return errors.New("site access cookie is invalid") } cookie := cookies[0] - expectedName := "screener-host-admission" + expectedName := "screener-site-access" if base.Scheme == "https" { - expectedName = "__Host-screener-host-admission" + expectedName = "__Host-screener-site-access" } if cookie.Name != expectedName || !admissionCookiePattern.MatchString(cookie.Value) || - cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge != int(hostAdmissionTTL/time.Second) || + cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge != int(siteAccessTTL/time.Second) || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Secure != (base.Scheme == "https") || cookie.Quoted || cookie.Partitioned || cookie.RawExpires != "" || !cookie.Expires.IsZero() || len(cookie.Unparsed) != 0 { - return errors.New("host admission cookie is invalid") + return errors.New("site access cookie is invalid") } stored := jar.Cookies(base) if len(stored) != 1 || stored[0].Name != cookie.Name || stored[0].Value != cookie.Value { - return errors.New("host admission cookie was not retained") + return errors.New("site access cookie was not retained") } return nil } diff --git a/native/sender/internal/remote/session_test.go b/native/sender/internal/remote/session_test.go index fb951fb..d1fee0a 100644 --- a/native/sender/internal/remote/session_test.go +++ b/native/sender/internal/remote/session_test.go @@ -20,8 +20,8 @@ import ( "github.com/coder/websocket" ) -func TestStartUsesHostAdmissionCookieAndPrivateV2Room(t *testing.T) { - const password = "host-admission-password" +func TestStartUsesSiteAccessCookieAndPrivateV2Room(t *testing.T) { + const password = "site-access-password" harness := newRemoteHarnessWithPassword(t, []string{}, password) defer harness.Close() baseURL, err := url.Parse(harness.URL()) @@ -30,7 +30,7 @@ func TestStartUsesHostAdmissionCookieAndPrivateV2Room(t *testing.T) { } session, room, err := Start(context.Background(), StartOptions{ BaseURL: baseURL, - HostAdmissionPassword: password, + SiteAccessPassword: password, }) if err != nil { t.Fatal(err) @@ -40,13 +40,13 @@ func TestStartUsesHostAdmissionCookieAndPrivateV2Room(t *testing.T) { admission := <-harness.admission if admission.Method != http.MethodPost || admission.Authorization != "Bearer "+password || admission.Origin != harness.URL() || admission.Body != "" { - t.Fatalf("host admission request = %+v", admission) + t.Fatalf("site access request = %+v", admission) } creation := <-harness.roomCreation if creation.Method != http.MethodPost || creation.Authorization != "" || creation.Origin != harness.URL() || creation.ContentType != "application/json" || creation.Body != `{"viewerPolicy":"private-link","hostClaimTtlSeconds":300}` || - !strings.HasPrefix(creation.Cookie, "screener-host-admission=v1.") { + !strings.HasPrefix(creation.Cookie, "screener-site-access=v1.") { t.Fatalf("room creation request = %+v", creation) } if signalCookie := <-harness.signalCookie; signalCookie != creation.Cookie { @@ -65,7 +65,7 @@ func TestStartRetriesTheSameProvisionalRoomOnce(t *testing.T) { var authentications [][]byte mux := http.NewServeMux() var server *httptest.Server - mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) }) mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { @@ -128,7 +128,7 @@ func TestStartRetriesTheSameProvisionalRoomOnce(t *testing.T) { } } -func TestHostAdmissionFailuresAreBoundedAndDoNotLeakSecrets(t *testing.T) { +func TestSiteAccessFailuresAreBoundedAndDoNotLeakSecrets(t *testing.T) { const password = "never-report-this-password" const grant = "g1.1.1893456000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" tests := []struct { @@ -146,17 +146,17 @@ func TestHostAdmissionFailuresAreBoundedAndDoNotLeakSecrets(t *testing.T) { {name: "unknown-field", status: http.StatusOK, body: `{"required":false,"authenticated":true,"url":"https://private.test/path"}`}, { name: "quoted-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`, - setCookie: `screener-host-admission="v1.1893456000.` + strings.Repeat("a", 43) + `"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict`, + setCookie: `screener-site-access="v1.1893456000.` + strings.Repeat("a", 43) + `"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict`, }, { name: "partitioned-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`, - setCookie: `screener-host-admission=v1.1893456000.` + strings.Repeat("a", 43) + `; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict; Partitioned`, + setCookie: `screener-site-access=v1.1893456000.` + strings.Repeat("a", 43) + `; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict; Partitioned`, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.URL.Path != "/api/host-admission" { + if request.URL.Path != "/api/site-access" { http.NotFound(response, request) return } @@ -173,14 +173,14 @@ func TestHostAdmissionFailuresAreBoundedAndDoNotLeakSecrets(t *testing.T) { } _, _, startErr := Start(context.Background(), StartOptions{ BaseURL: baseURL, - HostAdmissionPassword: password, + SiteAccessPassword: password, }) if startErr == nil { - t.Fatal("invalid host admission response was accepted") + t.Fatal("invalid site access response was accepted") } reported := startErr.Error() if strings.Contains(reported, password) || strings.Contains(reported, grant) || strings.Contains(reported, server.URL) { - t.Fatalf("host admission error leaked untrusted data: %q", reported) + t.Fatalf("site access error leaked untrusted data: %q", reported) } }) } @@ -219,7 +219,7 @@ func TestCreateRoomFailuresAreStrictAndDoNotLeakGrant(t *testing.T) { t.Run(test.name, func(t *testing.T) { mux := http.NewServeMux() var server *httptest.Server - mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) }) mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { @@ -577,7 +577,7 @@ func newRemoteHarnessWithPassword(t *testing.T, roster []string, password string signalCookie: make(chan string, 1), } mux := http.NewServeMux() - mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { body, _ := io.ReadAll(request.Body) harness.admission <- capturedRequest{ Method: request.Method, @@ -594,7 +594,7 @@ func newRemoteHarnessWithPassword(t *testing.T, roster []string, password string writeHarnessJSON(response, map[string]string{"error": "secret must never be reflected"}) return } - response.Header().Set("Set-Cookie", "screener-host-admission=v1.1893456000."+strings.Repeat("a", 43)+"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict") + response.Header().Set("Set-Cookie", "screener-site-access=v1.1893456000."+strings.Repeat("a", 43)+"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict") writeHarnessJSON(response, map[string]any{"required": true, "authenticated": true}) }) mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { diff --git a/scripts/native-one-viewer-gate.ts b/scripts/native-one-viewer-gate.ts index 8515965..ba249ce 100644 --- a/scripts/native-one-viewer-gate.ts +++ b/scripts/native-one-viewer-gate.ts @@ -217,9 +217,9 @@ class ObservedRoomStore extends RoomStore { function observeNodeStages(server: ScreenerServer, ledger: SenderStartLedger): void { server.httpServer.prependListener("request", (request, response) => { const path = new URL(request.url ?? "/", "http://gate.invalid").pathname; - if (request.method === "POST" && path === "/api/host-admission") { + if (request.method === "POST" && path === "/api/site-access") { response.once("finish", () => { - if (response.statusCode === 200) ledger.record({ hostAdmissionAccepted: true }); + if (response.statusCode === 200) ledger.record({ siteAccessAccepted: true }); }); } else if (request.method === "POST" && path === "/api/rooms") { response.once("finish", () => { @@ -321,7 +321,7 @@ async function main(): Promise { LISTEN_HOST: "127.0.0.1", PUBLIC_BASE_URL: baseUrl, ALLOWED_ORIGINS: baseUrl, - HOST_ADMISSION_PASSWORD: accessKey, + SITE_ACCESS_PASSWORD: accessKey, ROOM_DATABASE_PATH: "", PEER_ASSISTED_MEDIA: "false", STUN_URLS: "", diff --git a/scripts/native-one-viewer-ledger.ts b/scripts/native-one-viewer-ledger.ts index c25b511..6157587 100644 --- a/scripts/native-one-viewer-ledger.ts +++ b/scripts/native-one-viewer-ledger.ts @@ -1,7 +1,7 @@ export interface SenderStartObservation { getDisplayMediaRequested: boolean; getDisplayMediaResolved: boolean; - hostAdmissionAccepted: boolean; + siteAccessAccepted: boolean; roomCreated: boolean; hostWssAuthenticated: boolean; bridgeConnected: boolean; @@ -32,7 +32,7 @@ type LedgerSink = (record: Readonly) => void; const initialObservation: SenderStartObservation = { getDisplayMediaRequested: false, getDisplayMediaResolved: false, - hostAdmissionAccepted: false, + siteAccessAccepted: false, roomCreated: false, hostWssAuthenticated: false, bridgeConnected: false, @@ -56,7 +56,7 @@ const initialObservation: SenderStartObservation = { const booleanKeys = [ "getDisplayMediaRequested", "getDisplayMediaResolved", - "hostAdmissionAccepted", + "siteAccessAccepted", "roomCreated", "hostWssAuthenticated", "bridgeConnected", diff --git a/src/client/App.tsx b/src/client/App.tsx index a577c8f..4239365 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -2,9 +2,9 @@ import { KeyRound, LoaderCircle } from "lucide-react"; import { useEffect, useState, type FormEvent } from "react"; import { ApiError, - authenticateHost, - getHostAdmission, - type HostAdmissionStatus, + authenticateSiteAccess, + getSiteAccess, + type SiteAccessStatus, } from "./lib/api"; import { readViewerRoute } from "./lib/session"; import { HostPage } from "./pages/HostPage"; @@ -21,7 +21,7 @@ type AccessState = | { kind: "required"; error: string | null } | { kind: "unavailable"; message: string }; -function stateFromStatus(status: HostAdmissionStatus): AccessState { +function stateFromStatus(status: SiteAccessStatus): AccessState { return !status.required || status.authenticated ? { kind: "ready" } : { kind: "required", error: null }; @@ -35,25 +35,33 @@ function readableError(error: unknown): string { export function App() { if (viewerRoute) { - return ; + return viewerRoute.viewerGrant ? ( + + ) : ( + + ); } if (isHostRoute) { - return ; + return ; } if (isJoinRoute) { - return ; + return ; } return ; } -function HostAdmissionGate({ surface }: { surface: "host" | "join" }) { +function SiteAccessGate({ + surface, +}: { + surface: "host" | "join" | "viewer"; +}) { const [access, setAccess] = useState({ kind: "checking" }); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); useEffect(() => { let active = true; - void getHostAdmission().then( + void getSiteAccess().then( (status) => active && setAccess(stateFromStatus(status)), (error: unknown) => active && @@ -67,7 +75,7 @@ function HostAdmissionGate({ surface }: { surface: "host" | "join" }) { async function retry(): Promise { setAccess({ kind: "checking" }); try { - setAccess(stateFromStatus(await getHostAdmission())); + setAccess(stateFromStatus(await getSiteAccess())); } catch (error) { setAccess({ kind: "unavailable", message: readableError(error) }); } @@ -84,7 +92,9 @@ function HostAdmissionGate({ surface }: { surface: "host" | "join" }) { setSubmitting(true); try { - setAccess(stateFromStatus(await authenticateHost(submittedPassword))); + setAccess( + stateFromStatus(await authenticateSiteAccess(submittedPassword)), + ); } catch (error) { setAccess({ kind: "required", @@ -99,6 +109,9 @@ function HostAdmissionGate({ surface }: { surface: "host" | "join" }) { } if (access.kind === "ready") { + if (surface === "viewer" && viewerRoute) { + return ; + } if (surface === "join") { return ; } diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index be4df14..8a03989 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -4,7 +4,7 @@ import { type ViewerAccessPolicy, } from "../../shared/protocol"; -export interface HostAdmissionStatus { +export interface SiteAccessStatus { required: boolean; authenticated: boolean; } @@ -46,7 +46,7 @@ async function responseBody(response: Response): Promise { } } -function parseHostAdmissionStatus(value: unknown): HostAdmissionStatus { +function parseSiteAccessStatus(value: unknown): SiteAccessStatus { if (!value || typeof value !== "object") { throw new ApiError("验证服务返回的数据格式不正确", 502); } @@ -63,8 +63,8 @@ function parseHostAdmissionStatus(value: unknown): HostAdmissionStatus { }; } -export async function getHostAdmission(): Promise { - const response = await fetch("/api/host-admission", { +export async function getSiteAccess(): Promise { + const response = await fetch("/api/site-access", { headers: { Accept: "application/json" }, }); const body = await responseBody(response); @@ -76,13 +76,13 @@ export async function getHostAdmission(): Promise { response.status, ); } - return parseHostAdmissionStatus(body); + return parseSiteAccessStatus(body); } -export async function authenticateHost( +export async function authenticateSiteAccess( password: string, -): Promise { - const response = await fetch("/api/host-admission", { +): Promise { + const response = await fetch("/api/site-access", { method: "POST", headers: { Accept: "application/json", @@ -98,7 +98,7 @@ export async function authenticateHost( response.status, ); } - return parseHostAdmissionStatus(body); + return parseSiteAccessStatus(body); } export async function createRoom( diff --git a/src/server/access-session.ts b/src/server/access-session.ts index 7e0e50d..e030de0 100644 --- a/src/server/access-session.ts +++ b/src/server/access-session.ts @@ -3,18 +3,18 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto"; const DEFAULT_SESSION_TTL_SECONDS = 12 * 60 * 60; const COOKIE_VERSION = "v1"; -export interface HostAdmissionOptions { +export interface SiteAccessOptions { password?: string; secure: boolean; now?: () => number; ttlSeconds?: number; } -export class HostAdmission { +export class SiteAccess { private readonly now: () => number; private readonly ttlSeconds: number; - constructor(private readonly options: HostAdmissionOptions) { + constructor(private readonly options: SiteAccessOptions) { this.now = options.now ?? Date.now; this.ttlSeconds = options.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS; if (!Number.isSafeInteger(this.ttlSeconds) || this.ttlSeconds <= 0) { @@ -76,8 +76,8 @@ export class HostAdmission { private get cookieName(): string { return this.options.secure - ? "__Host-screener-host-admission" - : "screener-host-admission"; + ? "__Host-screener-site-access" + : "screener-site-access"; } private sign(payload: string): string { diff --git a/src/server/app.ts b/src/server/app.ts index 918da28..8f1679f 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -8,7 +8,7 @@ import { createRoomRequestSchema, type CreateRoomResponse, } from "../shared/protocol.js"; -import { HostAdmission } from "./access-session.js"; +import { SiteAccess } from "./access-session.js"; import { loadConfig, type ServerConfig } from "./config.js"; import type { SfuTokenIssuer } from "./livekit-token.js"; import { RoomDatabase } from "./room-database.js"; @@ -27,7 +27,7 @@ export interface CreateServerOptions { cleanupIntervalMs?: number; maxSignalConnections?: number; maxUnauthenticatedSignalConnections?: number; - hostAdmissionTtlSeconds?: number; + siteAccessTtlSeconds?: number; sfuTokenIssuer?: SfuTokenIssuer; } @@ -71,13 +71,13 @@ export async function createScreenerServer( : undefined, now, }); - const hostAdmission = new HostAdmission({ - password: config.hostAdmissionPassword, + const siteAccess = new SiteAccess({ + password: config.siteAccessPassword, secure: config.nodeEnv === "production" && config.publicBaseUrl.protocol === "https:", now, - ttlSeconds: options.hostAdmissionTtlSeconds, + ttlSeconds: options.siteAccessTtlSeconds, }); const iceOptions = { stunUrls: config.stunUrls, @@ -91,7 +91,7 @@ export async function createScreenerServer( response, config, roomStore, - hostAdmission, + siteAccess, () => frontendHandler, ).catch((error: unknown) => { console.error("HTTP request failed", { @@ -115,8 +115,8 @@ export async function createScreenerServer( ...(config.selectedEdgeTurn ? { selectedEdgeTurn: config.selectedEdgeTurn } : {}), ice: iceOptions, allowedOrigins: config.allowedOrigins, - hostAdmissionAtUpgrade: (request) => - hostAdmission.isAuthenticated(request.headers.cookie), + siteAccessAtUpgrade: (request) => + siteAccess.isAuthenticated(request.headers.cookie), publicBaseUrl: config.publicBaseUrl, now, authenticationTimeoutMs: options.authenticationTimeoutMs, @@ -212,7 +212,7 @@ async function handleRequest( response: ServerResponse, config: ServerConfig, roomStore: RoomStore, - hostAdmission: HostAdmission, + siteAccess: SiteAccess, getFrontendHandler: () => FrontendHandler | undefined, ): Promise { const url = new URL(request.url ?? "/", config.publicBaseUrl); @@ -227,8 +227,8 @@ async function handleRequest( return; } - if (url.pathname === "/api/host-admission") { - handleHostAdmissionRequest(request, response, config, hostAdmission); + if (url.pathname === "/api/site-access") { + handleSiteAccessRequest(request, response, config, siteAccess); return; } @@ -252,7 +252,7 @@ async function handleRequest( if ( !isRoomCreationAuthorized( request, - hostAdmission, + siteAccess, ) ) { sendJson(response, 401, { error: "Unauthorized" }); @@ -315,17 +315,17 @@ async function handleRequest( sendJson(response, 404, { error: "Not found" }); } -function handleHostAdmissionRequest( +function handleSiteAccessRequest( request: IncomingMessage, response: ServerResponse, config: ServerConfig, - hostAdmission: HostAdmission, + siteAccess: SiteAccess, ): void { response.setHeader("Cache-Control", "no-store"); response.setHeader("X-Content-Type-Options", "nosniff"); if (request.method === "GET") { - sendJson(response, 200, hostAdmissionStatus(request, hostAdmission)); + sendJson(response, 200, siteAccessStatus(request, siteAccess)); return; } @@ -349,27 +349,27 @@ function handleHostAdmissionRequest( return; } - if (!hostAdmission.required) { + if (!siteAccess.required) { sendJson(response, 200, { required: false, authenticated: true }); return; } - if (!isBearerAuthorized(request, hostAdmission)) { + if (!isBearerAuthorized(request, siteAccess)) { response.setHeader("WWW-Authenticate", "Bearer"); sendJson(response, 401, { error: "Unauthorized" }); return; } - response.setHeader("Set-Cookie", hostAdmission.createCookie()!); + response.setHeader("Set-Cookie", siteAccess.createCookie()!); sendJson(response, 200, { required: true, authenticated: true }); } -function hostAdmissionStatus( +function siteAccessStatus( request: IncomingMessage, - hostAdmission: HostAdmission, + siteAccess: SiteAccess, ): { required: boolean; authenticated: boolean } { return { - required: hostAdmission.required, - authenticated: hostAdmission.isAuthenticated(request.headers.cookie), + required: siteAccess.required, + authenticated: siteAccess.isAuthenticated(request.headers.cookie), }; } @@ -400,24 +400,24 @@ function isStrictlyAllowedRequestOrigin( function isRoomCreationAuthorized( request: IncomingMessage, - hostAdmission: HostAdmission, + siteAccess: SiteAccess, ): boolean { - if (!hostAdmission.required) { + if (!siteAccess.required) { return true; } - return hostAdmission.isAuthenticated(request.headers.cookie); + return siteAccess.isAuthenticated(request.headers.cookie); } function isBearerAuthorized( request: IncomingMessage, - hostAdmission: HostAdmission, + siteAccess: SiteAccess, ): boolean { const authorization = request.headers.authorization; if (!authorization?.startsWith("Bearer ")) { return false; } const provided = authorization.slice("Bearer ".length); - return provided.length > 0 && hostAdmission.passwordMatches(provided); + return provided.length > 0 && siteAccess.passwordMatches(provided); } async function readJsonBody( diff --git a/src/server/config.ts b/src/server/config.ts index 88867b5..a892d08 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -7,8 +7,8 @@ import { export type RuntimeEnvironment = "development" | "test" | "production"; -const MIN_HOST_ADMISSION_PASSWORD_BYTES = 8; -const MAX_HOST_ADMISSION_PASSWORD_BYTES = 128; +const MIN_SITE_ACCESS_PASSWORD_BYTES = 8; +const MAX_SITE_ACCESS_PASSWORD_BYTES = 128; const MIN_LIVEKIT_API_SECRET_BYTES = 32; const MIN_SELECTED_EDGE_TURN_SECRET_BYTES = 32; const MAX_SELECTED_EDGE_TURN_SECRET_BYTES = 128; @@ -27,6 +27,7 @@ const REMOVED_ENVIRONMENT_VARIABLES = [ "PEER_ICE_TURN_SHARED_SECRET", "PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS", "PEER_ASSISTED_ROOM_IDS", + "HOST_ADMISSION_PASSWORD", ] as const; export interface LiveKitFallbackConfig { @@ -48,7 +49,7 @@ export interface ServerConfig { listenHost: string; publicBaseUrl: URL; allowedOrigins: ReadonlySet; - hostAdmissionPassword?: string; + siteAccessPassword?: string; roomDatabasePath?: string; roomTtlMs: number; maxRooms: number; @@ -269,7 +270,9 @@ export function loadConfig( for (const name of REMOVED_ENVIRONMENT_VARIABLES) { if (Object.prototype.hasOwnProperty.call(environment, name)) { throw new Error( - name === "PEER_ASSISTED_ROOM_IDS" + name === "HOST_ADMISSION_PASSWORD" + ? `${name} is no longer supported; use SITE_ACCESS_PASSWORD` + : name === "PEER_ASSISTED_ROOM_IDS" ? `${name} is no longer supported; peer-assisted media applies to every room when enabled` : `${name} is no longer supported; ordinary ICE accepts STUN_URLS only`, ); @@ -277,7 +280,7 @@ export function loadConfig( } if (Object.prototype.hasOwnProperty.call(environment, "ACCESS_PASSWORD")) { throw new Error( - "ACCESS_PASSWORD is no longer supported; use HOST_ADMISSION_PASSWORD", + "ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD", ); } @@ -312,10 +315,10 @@ export function loadConfig( throw new Error("PUBLIC_BASE_URL must use https in production"); } - const hostAdmissionPassword = - environment.HOST_ADMISSION_PASSWORD === "" + const siteAccessPassword = + environment.SITE_ACCESS_PASSWORD === "" ? undefined - : environment.HOST_ADMISSION_PASSWORD; + : environment.SITE_ACCESS_PASSWORD; const roomDatabasePath = environment.ROOM_DATABASE_PATH?.trim() || undefined; const stunUrls = parseStunUrlList(environment.STUN_URLS); @@ -343,33 +346,33 @@ export function loadConfig( ); } const configuredSecrets = [ - hostAdmissionPassword, + siteAccessPassword, livekitFallback?.apiKey, livekitFallback?.apiSecret, selectedEdgeTurn?.sharedSecret, ].filter((secret): secret is string => secret !== undefined); if (new Set(configuredSecrets).size !== configuredSecrets.length) { throw new Error( - "HOST_ADMISSION_PASSWORD, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, and SELECTED_EDGE_TURN_SHARED_SECRET must use independent values", + "SITE_ACCESS_PASSWORD, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, and SELECTED_EDGE_TURN_SHARED_SECRET must use independent values", ); } if ( - hostAdmissionPassword && - (!VISIBLE_ASCII_PATTERN.test(hostAdmissionPassword) || - Buffer.byteLength(hostAdmissionPassword) < - MIN_HOST_ADMISSION_PASSWORD_BYTES || - Buffer.byteLength(hostAdmissionPassword) > - MAX_HOST_ADMISSION_PASSWORD_BYTES) + siteAccessPassword && + (!VISIBLE_ASCII_PATTERN.test(siteAccessPassword) || + Buffer.byteLength(siteAccessPassword) < + MIN_SITE_ACCESS_PASSWORD_BYTES || + Buffer.byteLength(siteAccessPassword) > + MAX_SITE_ACCESS_PASSWORD_BYTES) ) { throw new Error( - "HOST_ADMISSION_PASSWORD must contain 8 to 128 visible ASCII bytes", + "SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes", ); } - if (nodeEnv === "production" && !hostAdmissionPassword) { - throw new Error("HOST_ADMISSION_PASSWORD is required in production"); + if (nodeEnv === "production" && !siteAccessPassword) { + throw new Error("SITE_ACCESS_PASSWORD is required in production"); } - if (roomDatabasePath && !hostAdmissionPassword) { - throw new Error("ROOM_DATABASE_PATH requires HOST_ADMISSION_PASSWORD"); + 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"); @@ -392,7 +395,7 @@ export function loadConfig( environment.ALLOWED_ORIGINS, publicBaseUrl.origin, ), - hostAdmissionPassword, + siteAccessPassword, roomDatabasePath, roomTtlMs: parsePositiveInteger(environment.ROOM_TTL_SECONDS, 14_400, "ROOM_TTL_SECONDS") * diff --git a/src/server/room-store.ts b/src/server/room-store.ts index 62c177b..a71bed4 100644 --- a/src/server/room-store.ts +++ b/src/server/room-store.ts @@ -312,6 +312,18 @@ export class RoomStore { return this.connectViewer(room, input); } + viewerGrantMayEnter(roomId: string, grant: string | undefined): boolean { + try { + const room = this.getAvailableRoom(roomId); + return room.viewerGrantDigest !== null && this.viewerMayEnter(room, grant); + } catch (error) { + if (error instanceof RoomStoreError) { + return false; + } + throw error; + } + } + async connectViewerWithPassword( input: ConnectViewerWithPasswordInput, mayConnect: () => boolean = () => true, diff --git a/src/server/signaling.ts b/src/server/signaling.ts index c872c5f..9c1ecf5 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -54,7 +54,7 @@ interface SocketState { sessionId: string; alive: boolean; authenticationTimer: NodeJS.Timeout; - hostAdmissionAuthenticated: boolean; + siteAccessAuthenticated: boolean; revoked?: boolean; authenticating?: boolean; viewerPasswordUpdatePending?: boolean; @@ -80,7 +80,7 @@ export interface SignalingOptions { selectedEdgeTurn?: SelectedEdgeTurnConfig; ice: IceConfigOptions; allowedOrigins: ReadonlySet; - hostAdmissionAtUpgrade: (request: IncomingMessage) => boolean; + siteAccessAtUpgrade: (request: IncomingMessage) => boolean; publicBaseUrl: URL; now?: () => number; authenticationTimeoutMs?: number; @@ -191,7 +191,7 @@ export class SignalingServer { }; options.server.on("upgrade", this.upgradeHandler); this.webSocketServer.on("connection", (socket, request) => - this.accept(socket, options.hostAdmissionAtUpgrade(request)), + this.accept(socket, options.siteAccessAtUpgrade(request)), ); this.heartbeatTimer = setInterval( @@ -227,7 +227,7 @@ export class SignalingServer { }); } - private accept(socket: WebSocket, hostAdmissionAuthenticated: boolean): void { + private accept(socket: WebSocket, siteAccessAuthenticated: boolean): void { if (!this.hasConnectionCapacity()) { socket.terminate(); return; @@ -244,7 +244,7 @@ export class SignalingServer { sessionId, alive: true, authenticationTimer, - hostAdmissionAuthenticated, + siteAccessAuthenticated, }; this.socketStates.set(socket, state); this.socketsBySessionId.set(sessionId, socket); @@ -346,8 +346,25 @@ export class SignalingServer { state: SocketState, message: Extract, ): Promise { - if (message.role === "host" && !state.hostAdmissionAuthenticated) { - this.sendError(socket, "AUTH_REQUIRED", "Host admission is required"); + if (message.role === "host" && !state.siteAccessAuthenticated) { + this.sendError(socket, "AUTH_REQUIRED", "Site access is required"); + socket.close(4003, "Authentication failed"); + return; + } + if ( + message.role === "viewer" && + !state.siteAccessAuthenticated && + (!message.viewerGrant || + !this.options.roomStore.viewerGrantMayEnter( + message.roomId, + message.viewerGrant, + )) + ) { + this.sendError( + socket, + "INVALID_TOKEN", + authenticationErrorMessage("INVALID_TOKEN"), + ); socket.close(4003, "Authentication failed"); return; } diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index dc0cb39..9bd0c39 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ApiError, - authenticateHost, + authenticateSiteAccess, createRoom, - getHostAdmission, + getSiteAccess, } from "../src/client/lib/api.ts"; import { readDisplayName, @@ -325,8 +325,8 @@ describe("client session identity", () => { }); }); -describe("Host admission API", () => { - it("checks and authenticates Host admission without a request body", async () => { +describe("site access API", () => { + it("checks and authenticates site access without a request body", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -343,17 +343,17 @@ describe("Host admission API", () => { ); vi.stubGlobal("fetch", fetchMock); - await expect(getHostAdmission()).resolves.toEqual({ + await expect(getSiteAccess()).resolves.toEqual({ required: true, authenticated: false, }); - await expect(authenticateHost(" instance-password ")).resolves.toEqual({ + await expect(authenticateSiteAccess(" instance-password ")).resolves.toEqual({ required: true, authenticated: true, }); expect(fetchMock.mock.calls[0]).toEqual([ - "/api/host-admission", + "/api/site-access", { headers: { Accept: "application/json" } }, ]); const post = fetchMock.mock.calls[1][1]; @@ -375,7 +375,7 @@ describe("Host admission API", () => { ), ); - await expect(authenticateHost("wrong-password")).rejects.toMatchObject({ + await expect(authenticateSiteAccess("wrong-password")).rejects.toMatchObject({ status: 401, message: "访问密码不正确,请重试", }); @@ -567,7 +567,7 @@ describe("client signaling recovery policy", () => { value: JSON.stringify({ type: "error", code: "AUTH_REQUIRED", - message: "Host admission is required", + message: "Site access is required", }), }, }); @@ -577,7 +577,7 @@ describe("client signaling recovery policy", () => { expect(onMessage).toHaveBeenCalledWith({ type: "error", code: "AUTH_REQUIRED", - message: "Host admission is required", + message: "Site access is required", }); expect(statuses.at(-1)).toBe("offline"); expect(sockets).toHaveLength(1); diff --git a/tests/native-one-viewer-ledger.test.ts b/tests/native-one-viewer-ledger.test.ts index e4f28ab..097d497 100644 --- a/tests/native-one-viewer-ledger.test.ts +++ b/tests/native-one-viewer-ledger.test.ts @@ -15,7 +15,7 @@ describe("native one-viewer sender-start ledger", () => { { getDisplayMediaRequested: true }, { getDisplayMediaResolved: true, - hostAdmissionAccepted: true, + siteAccessAccepted: true, roomCreated: true, }, { @@ -53,7 +53,7 @@ describe("native one-viewer sender-start ledger", () => { sequence: 3, getDisplayMediaRequested: true, getDisplayMediaResolved: true, - hostAdmissionAccepted: true, + siteAccessAccepted: true, roomCreated: true, hostWssAuthenticated: true, bridgeConnected: true, @@ -82,7 +82,7 @@ describe("native one-viewer sender-start ledger", () => { ledger.record({ getDisplayMediaRequested: true, getDisplayMediaResolved: true, - hostAdmissionAccepted: true, + siteAccessAccepted: true, roomCreated: true, hostWssAuthenticated: true, bridgeConnected: true, diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index 9cc55cc..5674242 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -176,7 +176,7 @@ describe("server configuration", () => { it.each([ { - HOST_ADMISSION_PASSWORD: "x".repeat(32), + SITE_ACCESS_PASSWORD: "x".repeat(32), PEER_ASSISTED_MEDIA: "true", LIVEKIT_URL: "wss://livekit.test", LIVEKIT_API_KEY: "test-key", @@ -247,14 +247,14 @@ describe("server configuration", () => { loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - HOST_ADMISSION_PASSWORD: "host-password-12", + SITE_ACCESS_PASSWORD: "host-password-12", }), ).toThrow("STUN is required in production"); const config = loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - HOST_ADMISSION_PASSWORD: "easy-key", + SITE_ACCESS_PASSWORD: "easy-key", STUN_URLS: "stun:stun.test:3478", }); expect(config.stunUrls).toEqual(["stun:stun.test:3478"]); @@ -264,7 +264,7 @@ describe("server configuration", () => { const config = loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - HOST_ADMISSION_PASSWORD: "host-password-12", + SITE_ACCESS_PASSWORD: "host-password-12", STUN_URLS: "stun:stun.test:3478", PEER_ASSISTED_MEDIA: "true", LIVEKIT_URL: "wss://livekit.test", @@ -347,30 +347,30 @@ describe("server configuration", () => { ).toThrow("STUN_URLS must contain at most 8 URLs"); }); - it("requires and accepts a bounded production Host admission password", () => { + it("requires and accepts a bounded production site access password", () => { expect(() => loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", STUN_URLS: "stun:stun.test:3478", }), - ).toThrow("HOST_ADMISSION_PASSWORD is required in production"); + ).toThrow("SITE_ACCESS_PASSWORD is required in production"); const config = loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - HOST_ADMISSION_PASSWORD: "easy-key", + SITE_ACCESS_PASSWORD: "easy-key", STUN_URLS: "stun:stun.test:3478", }); - expect(config.hostAdmissionPassword).toBe("easy-key"); + expect(config.siteAccessPassword).toBe("easy-key"); }); - it("requires Host admission protection for a persistent room database", () => { + it("requires site access protection for a persistent room database", () => { expect(() => loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" })).toThrow( - "ROOM_DATABASE_PATH requires HOST_ADMISSION_PASSWORD", + "ROOM_DATABASE_PATH requires SITE_ACCESS_PASSWORD", ); const config = loadConfig({ - HOST_ADMISSION_PASSWORD: "host-password-12", + SITE_ACCESS_PASSWORD: "host-password-12", ROOM_DATABASE_PATH: "rooms.sqlite", }); expect(config.roomDatabasePath).toBe("rooms.sqlite"); @@ -381,7 +381,7 @@ describe("server configuration", () => { loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - HOST_ADMISSION_PASSWORD: "host-password-12", + SITE_ACCESS_PASSWORD: "host-password-12", ROOM_DATABASE_PATH: ":memory:", STUN_URLS: "stun:stun.test:3478", }), @@ -389,12 +389,12 @@ describe("server configuration", () => { }); it.each(["x".repeat(7), "密码密码密码密码", "contains spaces", "x".repeat(129)])( - "rejects a Host admission password outside the visible ASCII boundary", - (hostAdmissionPassword) => { + "rejects a site access password outside the visible ASCII boundary", + (siteAccessPassword) => { expect(() => - loadConfig({ HOST_ADMISSION_PASSWORD: hostAdmissionPassword }), + loadConfig({ SITE_ACCESS_PASSWORD: siteAccessPassword }), ).toThrow( - "HOST_ADMISSION_PASSWORD must contain 8 to 128 visible ASCII bytes", + "SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes", ); }, ); @@ -403,7 +403,18 @@ describe("server configuration", () => { "rejects the removed ACCESS_PASSWORD configuration", (accessPassword) => { expect(() => loadConfig({ ACCESS_PASSWORD: accessPassword })).toThrow( - "ACCESS_PASSWORD is no longer supported; use HOST_ADMISSION_PASSWORD", + "ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD", + ); + }, + ); + + it.each([undefined, "", "legacy-password"])( + "rejects the removed HOST_ADMISSION_PASSWORD configuration", + (removedPassword) => { + expect(() => + loadConfig({ HOST_ADMISSION_PASSWORD: removedPassword }), + ).toThrow( + "HOST_ADMISSION_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD", ); }, ); diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index ce9daef..63b54e3 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -13,7 +13,7 @@ import { import type { ServerConfig } from "../src/server/config.ts"; const allowedOrigin = "http://allowed.test"; -const hostAdmissionPassword = "instance-access-password"; +const siteAccessPassword = "instance-access-password"; let runningServer: ScreenerServer | undefined; const temporaryDirectories: string[] = []; @@ -38,7 +38,7 @@ function testConfig(overrides: Partial = {}): ServerConfig { listenHost: "127.0.0.1", publicBaseUrl: new URL("https://share.example.test"), allowedOrigins: new Set([allowedOrigin]), - hostAdmissionPassword, + siteAccessPassword, roomTtlMs: 14_400_000, maxRooms: 10, maxViewersPerRoom: 8, @@ -52,7 +52,7 @@ async function start( config = testConfig(), options: Pick< CreateServerOptions, - "now" | "hostAdmissionTtlSeconds" | "sfuTokenIssuer" + "now" | "siteAccessTtlSeconds" | "sfuTokenIssuer" > = {}, ): Promise { runningServer = await createScreenerServer({ @@ -73,10 +73,10 @@ function cookiePair(response: Response): string { } async function login(baseUrl: string): Promise { - return fetch(`${baseUrl}/api/host-admission`, { + return fetch(`${baseUrl}/api/site-access`, { method: "POST", headers: { - Authorization: `Bearer ${hostAdmissionPassword}`, + Authorization: `Bearer ${siteAccessPassword}`, Origin: allowedOrigin, }, }); @@ -102,10 +102,10 @@ async function createRoom( }); } -describe("Host admission", () => { +describe("site access", () => { it("reports status and issues a stateless 12-hour cookie", async () => { const baseUrl = await start(); - const initial = await fetch(`${baseUrl}/api/host-admission`); + const initial = await fetch(`${baseUrl}/api/site-access`); expect(initial.status).toBe(200); expect(initial.headers.get("cache-control")).toBe("no-store"); @@ -114,7 +114,7 @@ describe("Host admission", () => { authenticated: false, }); - const denied = await fetch(`${baseUrl}/api/host-admission`, { + const denied = await fetch(`${baseUrl}/api/site-access`, { method: "POST", headers: { Authorization: "Bearer wrong-password", @@ -127,15 +127,15 @@ describe("Host admission", () => { const authenticated = await login(baseUrl); const setCookie = authenticated.headers.get("set-cookie") ?? ""; expect(authenticated.status).toBe(200); - expect(setCookie).toContain("screener-host-admission=v1."); + expect(setCookie).toContain("screener-site-access=v1."); expect(setCookie).toContain("Path=/"); expect(setCookie).toContain("Max-Age=43200"); expect(setCookie).toContain("HttpOnly"); expect(setCookie).toContain("SameSite=Strict"); expect(setCookie).not.toContain("Secure"); - expect(setCookie).not.toContain(hostAdmissionPassword); + expect(setCookie).not.toContain(siteAccessPassword); - const status = await fetch(`${baseUrl}/api/host-admission`, { + const status = await fetch(`${baseUrl}/api/site-access`, { headers: { Cookie: cookiePair(authenticated) }, }); expect(await status.json()).toEqual({ @@ -149,7 +149,7 @@ describe("Host admission", () => { const response = await login(baseUrl); const setCookie = response.headers.get("set-cookie") ?? ""; - expect(setCookie).toContain("__Host-screener-host-admission="); + expect(setCookie).toContain("__Host-screener-site-access="); expect(setCookie).toContain("Secure"); expect(setCookie).toContain("HttpOnly"); expect(setCookie).toContain("SameSite=Strict"); @@ -160,12 +160,12 @@ describe("Host admission", () => { it("requires an allowed Origin and an empty login body", async () => { const baseUrl = await start(); const authorization = { - Authorization: `Bearer ${hostAdmissionPassword}`, + Authorization: `Bearer ${siteAccessPassword}`, }; expect( ( - await fetch(`${baseUrl}/api/host-admission`, { + await fetch(`${baseUrl}/api/site-access`, { method: "POST", headers: authorization, }) @@ -173,7 +173,7 @@ describe("Host admission", () => { ).toBe(403); expect( ( - await fetch(`${baseUrl}/api/host-admission`, { + await fetch(`${baseUrl}/api/site-access`, { method: "POST", headers: { ...authorization, Origin: "https://foreign.test" }, }) @@ -181,7 +181,7 @@ describe("Host admission", () => { ).toBe(403); expect( ( - await fetch(`${baseUrl}/api/host-admission`, { + await fetch(`${baseUrl}/api/site-access`, { method: "POST", headers: { ...authorization, Origin: `${allowedOrigin}/path` }, }) @@ -189,7 +189,7 @@ describe("Host admission", () => { ).toBe(403); expect( ( - await fetch(`${baseUrl}/api/host-admission`, { + await fetch(`${baseUrl}/api/site-access`, { method: "POST", headers: { ...authorization, Origin: allowedOrigin }, body: "{}", @@ -202,18 +202,18 @@ describe("Host admission", () => { let now = 1_000; const baseUrl = await start(testConfig(), { now: () => now, - hostAdmissionTtlSeconds: 1, + siteAccessTtlSeconds: 1, }); const authenticated = await login(baseUrl); const cookie = cookiePair(authenticated); - const modified = await fetch(`${baseUrl}/api/host-admission`, { + const modified = await fetch(`${baseUrl}/api/site-access`, { headers: { Cookie: `${cookie}x` }, }); expect((await modified.json()).authenticated).toBe(false); now = 2_000; - const expired = await fetch(`${baseUrl}/api/host-admission`, { + const expired = await fetch(`${baseUrl}/api/site-access`, { headers: { Cookie: cookie }, }); expect(await expired.json()).toEqual({ @@ -224,15 +224,15 @@ describe("Host admission", () => { it("is immediately authenticated when the access password is empty", async () => { const baseUrl = await start( - testConfig({ hostAdmissionPassword: undefined }), + testConfig({ siteAccessPassword: undefined }), ); - const status = await fetch(`${baseUrl}/api/host-admission`); + const status = await fetch(`${baseUrl}/api/site-access`); expect(await status.json()).toEqual({ required: false, authenticated: true, }); - const post = await fetch(`${baseUrl}/api/host-admission`, { + const post = await fetch(`${baseUrl}/api/site-access`, { method: "POST", headers: { Origin: allowedOrigin }, }); @@ -251,7 +251,7 @@ describe("room HTTP API", () => { const bearerBypass = await fetch(`${baseUrl}/api/rooms`, { method: "POST", headers: { - Authorization: `Bearer ${hostAdmissionPassword}`, + Authorization: `Bearer ${siteAccessPassword}`, "Content-Type": "application/json", Origin: allowedOrigin, }, @@ -300,9 +300,9 @@ describe("room HTTP API", () => { ); }); - it("allows explicit public-watch creation without Host admission in local mode", async () => { + it("allows explicit public-watch creation without site access in local mode", async () => { const baseUrl = await start( - testConfig({ hostAdmissionPassword: undefined }), + testConfig({ siteAccessPassword: undefined }), ); const response = await createRoom(baseUrl, undefined, "public-watch"); expect(response.status).toBe(201); @@ -344,7 +344,7 @@ describe("room HTTP API", () => { it("rejects malformed room requests and foreign browser origins", async () => { const baseUrl = await start( - testConfig({ hostAdmissionPassword: undefined }), + testConfig({ siteAccessPassword: undefined }), ); const withBody = await fetch(`${baseUrl}/api/rooms`, { method: "POST", @@ -366,7 +366,7 @@ describe("room HTTP API", () => { it("returns service unavailable at the global room bound", async () => { const baseUrl = await start( - testConfig({ hostAdmissionPassword: undefined, maxRooms: 1 }), + testConfig({ siteAccessPassword: undefined, maxRooms: 1 }), ); const create = () => createRoom(baseUrl); diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 88ea801..cf2f401 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -162,7 +162,7 @@ async function startHarness( maxViewersPerRoom?: number; maxSignalConnections?: number; maxUnauthenticatedSignalConnections?: number; - hostAdmissionPassword?: string; + siteAccessPassword?: string; persistent?: boolean; provisionalHostClaimSeconds?: number; peerAssistedMedia?: boolean; @@ -171,7 +171,7 @@ async function startHarness( } = {}, ): Promise { const config = testConfig(); - config.hostAdmissionPassword = overrides.hostAdmissionPassword; + config.siteAccessPassword = overrides.siteAccessPassword; config.peerAssistedMedia = overrides.peerAssistedMedia ?? false; config.stunUrls = overrides.stunUrls ?? []; const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; @@ -328,7 +328,7 @@ async function startSfuHarness(options: { stunUrls: options.stunUrls ?? [], }, allowedOrigins: new Set([allowedOrigin]), - hostAdmissionAtUpgrade: () => true, + siteAccessAtUpgrade: () => true, publicBaseUrl: new URL("https://share.example.test"), now: options.now, authenticationTimeoutMs: 500, @@ -1240,9 +1240,9 @@ describe("WebSocket signaling", () => { await host.inbox.expectNone(30); }); - it("protects Host admission without gating authorized Viewers", async () => { - const hostAdmissionPassword = "protected-instance-password"; - const harness = await startHarness({ hostAdmissionPassword }); + 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); await expect( @@ -1264,10 +1264,10 @@ describe("WebSocket signaling", () => { code: "AUTH_REQUIRED", }); - const login = await fetch(`${harness.baseUrl}/api/host-admission`, { + const login = await fetch(`${harness.baseUrl}/api/site-access`, { method: "POST", headers: { - Authorization: `Bearer ${hostAdmissionPassword}`, + Authorization: `Bearer ${siteAccessPassword}`, Origin: allowedOrigin, }, }); @@ -1288,6 +1288,88 @@ describe("WebSocket signaling", () => { code: "INVALID_TOKEN", }); + const publicRoom = harness.roomStore.createRoom("public-watch"); + const publicWithoutSiteAccess = await openClient(harness.webSocketUrl); + publicWithoutSiteAccess.socket.send( + JSON.stringify({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId: publicRoom.roomId, + role: "viewer", + clientId: "public-viewer-without-site-access", + }), + ); + await expect( + publicWithoutSiteAccess.inbox.next("error"), + ).resolves.toMatchObject({ code: "INVALID_TOKEN" }); + + const publicWithForgedGrant = await openClient(harness.webSocketUrl); + publicWithForgedGrant.socket.send( + JSON.stringify({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId: publicRoom.roomId, + role: "viewer", + clientId: "public-viewer-with-forged-grant", + viewerGrant: `g1.${publicRoom.roomId}.1893456000.${"x".repeat(43)}`, + }), + ); + await expect( + publicWithForgedGrant.inbox.next("error"), + ).resolves.toMatchObject({ code: "INVALID_TOKEN" }); + + const publicWithSiteAccess = await openClient(harness.webSocketUrl, cookie); + await expect( + authenticate( + publicWithSiteAccess, + publicRoom, + "viewer", + "public-viewer-with-site-access", + ), + ).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, + "room-password", + "password-room-setup-session", + ); + + const passwordWithoutSiteAccess = await openClient(harness.webSocketUrl); + passwordWithoutSiteAccess.socket.send( + JSON.stringify({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId: passwordRoom.roomId, + role: "viewer", + clientId: "password-viewer-without-site-access", + viewerPassword: "room-password", + }), + ); + await expect( + passwordWithoutSiteAccess.inbox.next("error"), + ).resolves.toMatchObject({ code: "INVALID_TOKEN" }); + + const passwordWithSiteAccess = await openClient(harness.webSocketUrl, cookie); + await expect( + authenticate( + passwordWithSiteAccess, + passwordRoom, + "viewer", + "password-viewer-with-site-access", + 1, + undefined, + { viewerPassword: "room-password" }, + ), + ).resolves.toMatchObject({ role: "viewer" }); + const host = await openClient(harness.webSocketUrl, cookie); await expect( authenticate(host, harness.room, "host", "host-client-protected"),