feat(access): scope viewer invitations to rooms (#56)
This commit is contained in:
1 parent
27e715f038
commit
de967fbc61
33 files changed
+2258
-407
No files matched your search
+5
-5
@@ -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
|
||||
# Optional whole-site gate. A non-empty value must contain 1-128 visible ASCII
|
||||
# characters (0x21-0x7e). When set, every browser signs in before sharing or watching.
|
||||
ACCESS_PASSWORD=
|
||||
# Optional SQLite file for persistent protected rooms. Requires ACCESS_PASSWORD.
|
||||
# Production-required Host admission secret. Local development/tests may leave it
|
||||
# empty. A non-empty value must contain 16-128 visible ASCII bytes (0x21-0x7e).
|
||||
HOST_ADMISSION_PASSWORD=
|
||||
# Optional SQLite file for persistent rooms. Requires HOST_ADMISSION_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.
|
||||
@@ -36,7 +36,7 @@ MAX_SFU_ROOTS_PER_ROOM=2
|
||||
STUN_URLS=
|
||||
|
||||
# Minimal production example (use independent untracked secrets):
|
||||
# ACCESS_PASSWORD=replace-with-a-strong-passphrase
|
||||
# HOST_ADMISSION_PASSWORD=replace-with-an-independent-32-byte-secret
|
||||
# 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
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
- Prefer the smallest proven extension point; do not add speculative frameworks, hooks, or abstractions without a current consumer.
|
||||
- Add automated tests in proportion to behavioral risk. Block the current phase on P0 security, privacy, irreversible data, authorization, generation, bounded-resource, and rollback failures, plus P1 failures of the milestone's core user path. Record or cover P2/P3 rare environments, diagnostic detail, and minor UI edges cheaply without turning them into canary blockers unless they can cause P0 harm. Use high-repeat race runs only for a reproduced concurrency failure. For realtime media changes, also document the manual network and browser matrix used.
|
||||
- Batch validation by risk and phase: run narrow checks while iterating, and run expensive full suites, browser matrices, endurance tests, or deployment checks only at an acceptance boundary or when a relevant change invalidates prior evidence. Documentation-only or unrelated changes do not invalidate media-path evidence.
|
||||
- A dedicated deployment subagent owns each staged production deployment and verification while root/mainline continues; root supplies the exact merged SHA, artifact hash, rollback target, and acceptance list, and the deployer must not expand scope.
|
||||
- Use comments for non-obvious rationale, invariants, protocol constraints, and workarounds; do not narrate obvious code.
|
||||
- Use WebRTC statistics and reproducible measurements for latency, bitrate, candidate type, packet loss, encode time, and quality limitations. Do not claim performance from assumptions alone.
|
||||
- Keep repository scripts and hooks cross-platform, deterministic, fast, and runnable in CI. A hook must call a tracked script rather than hide project logic in machine-local configuration.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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; friends can open `/r/{code}` directly or enter the code at `/join`, with no separate viewer token. 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 password-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. 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.
|
||||
|
||||
This is not yet a production release. The currently deployed old release verified public TURN/UDP and TURN/TCP relay paths, but the repository candidate now uses self-hosted STUN-only ordinary ICE plus a separate LiveKit SFU/UDP domain. Real cross-network Screener media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix.
|
||||
This is not yet a production release. Production runs the bounded SFU failure-stage diagnostic from `9610032`; the repository uses self-hosted STUN-only ordinary ICE plus a separate LiveKit SFU/UDP domain. 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.
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -15,7 +15,7 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`ACCESS_PASSWORD` is optional. Leaving it empty makes the site public; setting it gates both hosting and viewing behind one site-wide password. Internet deployments intended to stay private should set it, because in public mode the numeric room code is the only viewing capability and does not provide strong privacy by itself.
|
||||
Production requires an independent 16-128 byte visible-ASCII `HOST_ADMISSION_PASSWORD`. It authorizes room creation and Host role only; 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.
|
||||
|
||||
Open `http://localhost:8787`. The server listens on `0.0.0.0` by default so a
|
||||
phone on the same LAN can load `http://<computer-lan-ip>:8787`. To generate an
|
||||
@@ -26,15 +26,15 @@ must remain on `localhost` or HTTPS because screen capture requires a secure
|
||||
context. The local default has no STUN, so cross-network use still requires
|
||||
HTTPS and the production ICE configuration documented separately.
|
||||
|
||||
The current production release still requires STUN plus separate TURN/UDP and
|
||||
TURN/TCP URLs; keep that old release and configuration intact for rollback. The
|
||||
repository candidate deliberately replaces that contract: ordinary authenticated
|
||||
ICE contains only self-hosted STUN, LiveKit separately serves normally one or two
|
||||
SFU/UDP roots, and exhausted paths fail clearly after bounded recovery. Run it on
|
||||
an isolated instance for exact-room acceptance rather than retaining a same-process
|
||||
old-release compatibility branch. Optional generation-bound TURN for a selected exceptional
|
||||
edge is not implemented and requires a separate evidence-backed change. HTTPS/WSS
|
||||
continues to use TLS/TCP independently.
|
||||
Before deploying this access migration, take the stopped v1 SQLite backup and
|
||||
rehearse its restore as documented in [Deployment](./docs/deployment.md). After
|
||||
deployment, `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
|
||||
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
|
||||
implemented; HTTPS/WSS remains TLS/TCP independently.
|
||||
|
||||
Run the complete automated validation with:
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ map $http_upgrade $screener_connection_upgrade {
|
||||
'' close;
|
||||
}
|
||||
|
||||
limit_req_zone $binary_remote_addr zone=screener_host_admission:10m rate=5r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
@@ -82,6 +84,17 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location = /api/host-admission {
|
||||
limit_req zone=screener_host_admission burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0002: Persistent Rooms And Scoped Viewer Access
|
||||
|
||||
- Status: Accepted design; runtime migration pending
|
||||
- Status: Accepted; runtime candidate implemented, deployment pending
|
||||
- Date: 2026-08-19
|
||||
|
||||
## Context
|
||||
@@ -11,7 +11,8 @@ 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 current runtime uses one optional `ACCESS_PASSWORD`, one stateless cookie,
|
||||
The superseded runtime and current production deployment use one optional
|
||||
`ACCESS_PASSWORD`, one stateless cookie,
|
||||
and the same WebSocket upgrade gate for both roles. A Viewer then authenticates
|
||||
with only a numeric room code. This is simple but conflates deployment admission
|
||||
with room privacy: sharing the site password grants broader access than a Viewer
|
||||
@@ -190,3 +191,14 @@ SQLite or participates in authorization, routing, or quality decisions.
|
||||
- A production-copy v1 `STRICT` database migrates transactionally to checked,
|
||||
locked-private v2; malformed non-null values fail closed, rollback restores its
|
||||
backup, and v1 tabs terminate without compatibility code.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
The 2026-08-20 repository candidate implements the single-version v2 runtime,
|
||||
same-row SQLite migration, private/public room policy, fragment consumption,
|
||||
Host admission, and commit-first generation teardown. Focused automated tests
|
||||
cover protocol bounds, exact-room grants, persistent-write failure, active
|
||||
peer/SFU edge retirement, old-generation socket rejection, and v1 migration
|
||||
rollback. The tracked nginx limiter and backup/restore procedure are present.
|
||||
Production ingress loading, browser storage/request/log leak inspection, a
|
||||
production-copy migration rehearsal, and deployment remain acceptance work.
|
||||
+63
-39
@@ -1,6 +1,6 @@
|
||||
# Minimal Deployment
|
||||
|
||||
Last verified against upstream documentation: 2026-08-19.
|
||||
Last verified against upstream documentation: 2026-08-20.
|
||||
|
||||
This section documents the repository's UDP-only deployment candidate: one
|
||||
Node.js process provides the built Web client, room API, and WebSocket signaling
|
||||
@@ -113,7 +113,7 @@ LISTEN_HOST=127.0.0.1
|
||||
PORT=8787
|
||||
PUBLIC_BASE_URL=https://share.example.com
|
||||
ALLOWED_ORIGINS=https://share.example.com
|
||||
ACCESS_PASSWORD=<WHOLE_SITE_PASSWORD>
|
||||
HOST_ADMISSION_PASSWORD=<INDEPENDENT_16_TO_128_BYTE_SECRET>
|
||||
ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite
|
||||
ROOM_TTL_SECONDS=14400
|
||||
MAX_ROOMS=1000
|
||||
@@ -147,15 +147,14 @@ than enabling every room. There is no browser control, percentage rollout, or
|
||||
all-room fail-open.
|
||||
|
||||
`ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`.
|
||||
`ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. A
|
||||
non-empty value must contain 1 through 128 visible ASCII characters (`0x21`
|
||||
through `0x7e`). For an Internet deployment intended to stay private, use an
|
||||
independent value that is not reused elsewhere.
|
||||
`ROOM_DATABASE_PATH` is optional but requires `ACCESS_PASSWORD`; startup fails
|
||||
if a database path is supplied without the whole-site gate. The baseline above
|
||||
enables persistent protected rooms. Omit `ROOM_DATABASE_PATH` to keep random
|
||||
temporary rooms, and omit both values for public mode. `ROOM_TTL_SECONDS` applies
|
||||
only to temporary rooms.
|
||||
`HOST_ADMISSION_PASSWORD` is required in production and must contain 16 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`.
|
||||
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
|
||||
limit, not evidence that the publisher can sustain that many streams.
|
||||
|
||||
@@ -164,7 +163,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
|
||||
`ACCESS_PASSWORD`. `MAX_SFU_ROOTS_PER_ROOM` defaults to
|
||||
`HOST_ADMISSION_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.
|
||||
@@ -182,37 +181,48 @@ does not restore the removed TURN wire. After acceptance, retire or replace the
|
||||
temporary exact-room gate in a separate coherent change; never clear the value
|
||||
to trigger an implicit all-room rollout.
|
||||
|
||||
When `ACCESS_PASSWORD` is configured, both host and viewer routes first show the
|
||||
same login gate. Only `POST /api/session` accepts the password in an
|
||||
`Authorization: Bearer` header, then returns a 12-hour stateless HMAC-SHA256
|
||||
cookie with `HttpOnly`, `SameSite=Strict`, `Path=/`, a
|
||||
bounded `Max-Age`, and, under production HTTPS, `Secure` plus an `__Host-` name.
|
||||
The cookie contains no account or server-side session identifier. There is no
|
||||
account database, JWT, session map, or logout endpoint; expiry or clearing site
|
||||
cookies ends access. The optional room database is unrelated to access sessions.
|
||||
Only `POST /api/host-admission` accepts the Host admission 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
|
||||
`__Host-` name. The cookie contains no account or server-side session ID. There
|
||||
is no account database, JWT, session map, or logout endpoint.
|
||||
|
||||
`POST /api/rooms` and the `/signal` WebSocket upgrade use that cookie when the
|
||||
gate is enabled. The room API does not accept a direct Bearer credential as an
|
||||
alternate creation path. Viewers open `/r/{code}` or enter only the numeric code
|
||||
at `/join`; there is no viewer token or URL fragment, while the host token remains
|
||||
internal to the host page.
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
lets a Viewer create a room or authenticate as Host.
|
||||
|
||||
Room allocation has two deliberately small policies:
|
||||
|
||||
- With `ROOM_DATABASE_PATH`, SQLite allocates positive decimal IDs starting at
|
||||
`1`. These protected rooms and links do not expire. An explicit stop or capture
|
||||
`1`. These rooms do not expire, while each private Viewer invitation expires
|
||||
after seven days unless rotated earlier. An explicit stop or capture
|
||||
track ending stops the current publication and leaves viewers waiting; it does
|
||||
not delete the room. A brief signaling disconnect does not stop otherwise
|
||||
healthy P2P media.
|
||||
- Without `ROOM_DATABASE_PATH`, rooms use random numeric IDs and expire according
|
||||
to `ROOM_TTL_SECONDS`. Stopping sharing does not immediately delete them. Public
|
||||
mode always uses this policy, so anonymous clients cannot simply walk a
|
||||
sequential namespace.
|
||||
to `ROOM_TTL_SECONDS`; a private grant cannot outlive its room. Stopping sharing
|
||||
does not immediately delete it. Public-watch remains available but explicit.
|
||||
|
||||
The built-in `node:sqlite` database stores only each room ID (the table rowid)
|
||||
and SHA-256 host-token digest. It must not contain plaintext tokens, passwords,
|
||||
access cookies, SDP, ICE candidates, IP addresses, viewer state, TURN
|
||||
credentials, or media. Protect and back up the file as service state. The tracked
|
||||
The built-in `node:sqlite` schema v2 stores only each room ID, a SHA-256 Host
|
||||
token digest, and one nullable SHA-256 Viewer-grant digest in the same `STRICT`
|
||||
row. `NULL` means public-watch; a checked 32-byte BLOB means private-link. It
|
||||
must not contain plaintext tokens or grants, passwords, cookies, names,
|
||||
participants, SDP, ICE candidates, IP addresses, TURN credentials, or media.
|
||||
Protect and back up the file as service state. The tracked
|
||||
systemd unit creates `/var/lib/screener` with `StateDirectory=screener` and mode
|
||||
`0700`; the production path above is writable despite `ProtectSystem=strict`.
|
||||
The database is designed for one application process, not shared storage across
|
||||
@@ -225,14 +235,27 @@ ownership to the service account and mode `0600`, run SQLite
|
||||
use SQLite's [backup API](https://www.sqlite.org/backup.html) or `VACUUM INTO`;
|
||||
do not copy only the live main file while it may have an active journal.
|
||||
|
||||
The access release changes schema v1 to v2 and has no dual-schema runtime. For
|
||||
an existing deployment, stop the service and take a named, immutable v1 copy
|
||||
before installing or starting the new binary. Confirm the stopped source reports
|
||||
`PRAGMA user_version = 1`, retain the backup outside the release directory, and
|
||||
record its checksum. On first v2 startup, one `BEGIN IMMEDIATE` transaction adds
|
||||
the checked nullable Viewer digest, writes a different fresh locked-private
|
||||
digest to every old room, and advances `user_version` to `2`. Old code-only
|
||||
Viewer links intentionally stop working; each existing Host must use rotate once
|
||||
to produce a private invitation.
|
||||
|
||||
After startup, verify `PRAGMA integrity_check`, `user_version = 2`, service
|
||||
health, Host reclaim, rotation, and a new Viewer join before removing the
|
||||
maintenance boundary. If rollback is required, stop the v2 service, preserve the
|
||||
v2 database separately for diagnosis, restore the exact v1 backup with service
|
||||
ownership and mode `0600`, verify integrity and `user_version = 1`, and only then
|
||||
start the old binary. Never point the old binary at the migrated v2 file.
|
||||
|
||||
Enabling persistence does not migrate rooms that existed only in memory. The
|
||||
deployment restart invalidates those temporary links; the first subsequently
|
||||
created persistent room receives ID `1`.
|
||||
|
||||
With no `ACCESS_PASSWORD`, the random room code is the sole viewing capability
|
||||
and does not provide strong privacy, so public mode is suitable only when public
|
||||
access is acceptable or another trusted access layer exists.
|
||||
|
||||
Production startup requires one to eight syntactically valid `stun:` URLs
|
||||
before the server listens. This validates shape only; it does not prove DNS,
|
||||
firewall, NAT mappings, or external STUN reachability. The current application
|
||||
@@ -282,8 +305,9 @@ the first certificate is installed. This hook reloads only the Web ingress.
|
||||
|
||||
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; in
|
||||
public mode a current code is itself the only viewing capability.
|
||||
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.
|
||||
|
||||
For a process-level liveness probe, send `GET /healthz`. A running process
|
||||
returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other
|
||||
|
||||
+10
-9
@@ -28,25 +28,26 @@ Last updated: 2026-08-20
|
||||
- ADR-0005 accepts SFU roots as the primary central fallback after direct/peer UDP. The candidate has no TURN consumer or credential wire; any future exceptional-edge grant is a complete separate change. PR #12 is superseded.
|
||||
- Keep the controller exact-room only: room `1` is the STUN/SFU smoke. Preserve sticky progressing P2P, mobile leaves and break-before-make. Recovery spends one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries; active SFU gets one fresh grant before Peer failback.
|
||||
- Local reparenting is a later candidate: start with unassigned relay admission rescue; do not block current work.
|
||||
- Flagship media is UDP; HTTPS/WSS stays TLS/TCP. Production `d6c8aa0` uses STUN-only ordinary ICE and separate LiveKit SFU/UDP; old coturn TURN and `769de201f7cc` remain rollback-only.
|
||||
- Flagship media is UDP; HTTPS/WSS stays TLS/TCP; the old release remains rollback-only.
|
||||
- Treat settings as ceilings and degradation as unclassified. Use correlated Host A+B/Viewer C and one-variable evidence; never force AV1, infer by UA, or create a composite score.
|
||||
- Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`.
|
||||
- Candidate UI has local Viewer volume/mute, copyable room codes, and a favicon. Names/roster, endpoint details, and RTP loss remain pending; no wire, media, or routing effects.
|
||||
- Do not add scene detection, dynamic FPS or forced AV1 without stats/hardware evidence. Codec gates require actual negotiation, encode cost, game FPS, CPU/GPU and sender count.
|
||||
- After the current media acceptance boundary, migrate access atomically under amended ADR-0002: `HOST_ADMISSION_PASSWORD`, `screener-v2`, default private fragment grants, optional public-watch, rotate/revoke, and one nullable SQLite digest column. Do not mix username/roster runtime into that PR.
|
||||
- Do not add scene detection, dynamic-FPS control, or forced AV1 without negotiation, encode, game, CPU/GPU, and sender evidence.
|
||||
- ADR-0002 access passed independent review/full check and awaits PR/CI before native work: `HOST_ADMISSION_PASSWORD`, `screener-v2`, private grants/public-watch, rotate/revoke, and one nullable digest. No username/roster runtime.
|
||||
- Deferred architecture audit: `docs/maintenance.md`.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn.
|
||||
- Production `d6c8aa0` at `https://share.bonfire.icu` keeps the current Web UI and enables peer/SFU routing only for persistent room `1`; unlisted rooms remain ordinary P2P.
|
||||
- Production `9610032fc5f55a946853f3687c6ffd15f89a2105` deployed at 2026-08-20 02:26 +08 to `https://share.bonfire.icu` and enables peer/SFU routing only for persistent room `1`; unlisted rooms remain ordinary P2P. Its bounded SFU failure-stage diagnostic is active, while the old release is retained only for rollback.
|
||||
- Ordinary ICE is process-wide STUN-only. Room `1` may use one LiveKit publication feeding at most two roots while retaining peer descendants; real 1/2-root media and game quality remain unverified.
|
||||
- Current production/repository runtime still uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie for both roles, internal Host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map.
|
||||
- Current SQLite still stores only room ID and Host-token digest; room `1` survived deployment. The accepted Viewer-grant digest/schema v2 design is documentation-only and not deployed.
|
||||
- Production uses nginx, Node.js 24.19.0, coturn 4.17.2 for STUN/rollback, and pinned LiveKit 1.13.5 on UDP 7882 with TCP fallback disabled. Public TCP 7880/7881 is blocked; LiveKit has a 192/256 MiB cgroup boundary and no automatic restart.
|
||||
- Old TURN parser/signer/refresh/UI/SNI artifacts are absent from `d6c8aa0`; the unchanged coturn relay and old release are operational rollback, not advertised candidate media.
|
||||
- ADR-0005 is configured with exact `PEER_ASSISTED_ROOM_IDS=1`; unlisted rooms use ordinary P2P. `screener-v1` remains the deployed signaling literal until the separately reviewed access migration.
|
||||
- Production still uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie for both roles, internal Host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map. Its SQLite database stores only the room ID and Host-token digest; room `1` survived deployment.
|
||||
- Production uses nginx, Node.js 24.19.0, coturn 4.17.2 for STUN/rollback, and pinned LiveKit 1.13.5 on UDP 7882 with TCP fallback disabled. Screener and LiveKit report `NRestarts=0`; local/public health return 200, the deployed asset is `index-DbGEy9hr.js`, and `/rtc`, `/rtc/validate`, and `/rtc/v1` return 404. Public TCP 7880/7881 is blocked; LiveKit has a 192/256 MiB cgroup boundary and no automatic restart.
|
||||
- Old TURN parser/signer/refresh/UI/SNI artifacts are absent; coturn and the old release are rollback-only, not candidate media.
|
||||
- ADR-0005 is configured with exact `PEER_ASSISTED_ROOM_IDS=1`; unlisted rooms use ordinary P2P. `screener-v1` remains the deployed signaling literal until this separately reviewed access migration is deployed.
|
||||
- Production logs show two root participants and two short Host participants with no service restart or retained track. Their timing is consistent with, but does not directly prove, the code's one fresh-grant retry and Peer failback. The current candidate exposes only the Host-local current-revision failure stage (`connect|source|video-publish|sender-config|audio-publish|transport`) and uploads no raw error or endpoint data. Browser relays still re-encode; native sharing remains outside product code.
|
||||
- The repository access candidate requires `HOST_ADMISSION_PASSWORD` in production for creation/Host role only; private Viewer admission is room-scoped, with no accounts/JWT/session map. SQLite v2 stores one nullable Viewer-grant digest beside the Host digest. Its v1 migration locks every prior room private and requires a stopped v1 backup before deployment.
|
||||
- Candidate signaling is only `screener-v2`; an incompatible first message receives one refresh outcome before room lookup. It has no old parser or translator. Candidate media remains STUN-only ordinary ICE plus separate LiveKit UDP; it does not change the deployed room-1 smoke until the atomic access release is deployed.
|
||||
- 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.
|
||||
- Native drafts remain experiments: one WebCodecs object is not hardware proof; stock GCC+RTX is no-go.
|
||||
- Chrome 151 synthetic topology/quality-control runs kept fanout 2/1 and all viewers decoding; one relay close recovered in 5.32 seconds. This is control evidence only.
|
||||
|
||||
@@ -228,7 +228,7 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热
|
||||
|
||||
### 最小访问模型
|
||||
|
||||
当前 runtime 的 `ACCESS_PASSWORD`、同一 Host/Viewer cookie gate 和 code-only Viewer 是已部署事实,不再是接受的目标。目标把“谁可以建立/发布房间”和“谁可以看这一间房”分开:production 必配的 `HOST_ADMISSION_PASSWORD` 无状态 cookie 只允许建房及尝试 Host role,房间 Host token 仍独立验证;默认 private-link 使用一个 room-scoped Viewer bearer grant,public-watch 才接受 code-only Viewer。它们不需要账号、JWT、服务端 session Map、逐人 ACL 或人类房间密码。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。
|
||||
旧 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。它们不需要账号、JWT、服务端 session Map、逐人 ACL 或人类房间密码。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。
|
||||
|
||||
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;这是传输边界,不是“不会泄漏”的保证。
|
||||
|
||||
|
||||
+12
-7
@@ -4,7 +4,9 @@ Last updated: 2026-08-20
|
||||
|
||||
## Phase
|
||||
|
||||
`https://share.bonfire.icu` runs `d6c8aa06dbd` as a shared-IP production smoke.
|
||||
`https://share.bonfire.icu` runs `9610032fc5f55a946853f3687c6ffd15f89a2105`
|
||||
since 2026-08-20 02:26 +08 as a shared-IP production smoke. It includes the
|
||||
bounded SFU failure-stage diagnostic.
|
||||
Ordinary ICE is STUN-only process-wide; persistent room `1` alone enables the
|
||||
automatic peer/SFU-UDP controller with at most two SFU roots. The old
|
||||
`769de201f7cc` release and coturn relay remain an isolated rollback path.
|
||||
@@ -14,8 +16,9 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic.
|
||||
## Current Snapshot
|
||||
|
||||
- Capture precedes room creation; live source/quality changes preserve peers and picture pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options.
|
||||
- Current runtime still uses optional `ACCESS_PASSWORD` and one stateless 12-hour HMAC HttpOnly Strict cookie for both roles; Host auth stays internal and signaling role-bound.
|
||||
- Current SQLite still stores only room ID and Host-token digest. Amended ADR-0002 accepts a later atomic `HOST_ADMISSION_PASSWORD`/`screener-v2` migration with default room-scoped private Viewer grants, explicit public-watch, rotate/revoke, and one nullable digest column; no runtime is implemented.
|
||||
- Production still uses optional `ACCESS_PASSWORD` and one stateless 12-hour HMAC HttpOnly Strict cookie for both roles; Host auth stays internal and signaling role-bound. Its SQLite database stores only the room ID and Host-token digest.
|
||||
- This repository candidate atomically replaces that access contract with production-required `HOST_ADMISSION_PASSWORD` for creation/Host role, `screener-v2`, default private fragment grants, explicit public-watch, and generation-bound rotate/revoke. Independent review and the 357-test full check pass; it is ready for PR/CI but remains unmerged and undeployed.
|
||||
- Candidate SQLite v2 keeps Host and nullable Viewer-grant digests in one checked `STRICT` row; transactional v1 migration locks each old room private without minting a raw grant. Deployment requires a stopped v1 backup and restore rehearsal.
|
||||
- PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP.
|
||||
- Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky.
|
||||
- Room `1` can publish exactly `HIGH+LOW` with Dynacast/backup codec off and subscriber `HIGH` ceilings; it has no retained real media frame.
|
||||
@@ -23,7 +26,7 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic.
|
||||
|
||||
## Verified Evidence
|
||||
|
||||
- `d6c8aa0` passed typecheck, 330 tests and both builds, then an atomic production switch with SQLite integrity/room-1 preservation. Node, nginx, coturn and pinned LiveKit 1.13.5 are active with zero restarts; health is 200, retired `/rtc*` is 404, and public TCP 7880/7881 is blocked. Rollback is ready.
|
||||
- `9610032` deployed without changing environment, SQLite, nginx, nftables, LiveKit, or coturn. Screener and LiveKit have `NRestarts=0`; local/public health are 200, `index-DbGEy9hr.js` is active, `/rtc`, `/rtc/validate`, and `/rtc/v1` are 404, and public TCP 7880/7881 is blocked. The preceding release remains rollback-only.
|
||||
- On the 960 MiB host, idle LiveKit peaked near 16 MiB with no cgroup high/max/OOM event under `MemoryHigh=192M`, `MemoryMax=256M`, swap disabled and restart disabled. This is containment/idle evidence, not media capacity.
|
||||
- Chrome 151 synthetic `1/3/5/8` and 720p30 quality-change runs kept 2/1 fanout and decoding; slowest first frame was 1.05 seconds and one relay close recovered in 5.32 seconds. This is control evidence only.
|
||||
- A mobile-network room-1 run produced two root participants for about 4.6 seconds and two short Host participants (about 0.46/0.27 seconds), all client-requested leaves, with zero service restarts and no retained track. This is consistent with the code's one fresh-grant retry and Peer failback, but logs cannot prove those transitions. The next candidate shows only the local failure stage.
|
||||
@@ -31,6 +34,7 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic.
|
||||
- SVC is `no-go-web-svc-cross-path-hardware-contract`: no direct/peer selection, cross-PC shared encode, or portable hardware proof; pinned screen share is `L1T3`. No browser run was warranted.
|
||||
- HTTPS/WSS, access and room/WebSocket auth, renewal, public STUN, and authenticated TURN/UDP/TCP relay-only paths pass; TURN/TLS is off.
|
||||
- Draft #16/#18/#22/#23/#25/#28 passed isolated experiments only. ADR-0006 reached host setup/one encoder output, then timed out before viewer 1 decoded/rendered.
|
||||
- Access focused tests pass protocol/config/HTTP/storage/RoomStore/SQLite/signaling, including commit-first teardown, persistence-failure continuity, relay/SFU retirement, grant bounds, and v1 migration rollback.
|
||||
|
||||
## Unverified Boundaries
|
||||
|
||||
@@ -41,8 +45,9 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic.
|
||||
- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it.
|
||||
- Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats.
|
||||
- Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges.
|
||||
- Room-1 BWE is unverified: test zero-child leaves, then root impact. Use Linux `tc` or public canary, never CDP; Web P2P/SVC shortcuts remain no-go.
|
||||
- Scoped Viewer access runtime is under final review and is not deployed. Human passwords/accounts stay out.
|
||||
- PR #49/#50 BWE is unverified/default-off: test zero-child leaves, then root impact. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go.
|
||||
- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
|
||||
- Scoped Viewer access is implemented but unmerged/undeployed. Remaining deployment gates are nginx load/rate limit, browser storage/request/log leak inspection, and production-copy v1 backup/migration/restore rehearsal. Accounts stay out.
|
||||
|
||||
## Next Milestone
|
||||
|
||||
@@ -53,7 +58,7 @@ The recovery target uses one ICE restart, one same-parent rebuild, one alternate
|
||||
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. In parallel, finish the atomic ADR-0002 access PR and native staged
|
||||
evacuation. In parallel, merge this atomic ADR-0002 access PR after CI and then begin the native staged
|
||||
gate; username/roster stays separate. Audio A/B/C may run without displacing P0.
|
||||
TURN remains a future selected-edge change, not this canary.
|
||||
|
||||
|
||||
+1
-1
@@ -315,7 +315,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
|
||||
|
||||
自动测试:
|
||||
|
||||
- HostAdmissionSession 的 production 必配、16 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态;
|
||||
- HostAdmission 的 production 必配、16 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态;
|
||||
- RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝;
|
||||
- SQLite 房间库从 `1` 自增、重启恢复、v1 -> v2 单事务 locked-private 迁移、nullable 32-byte CHECK、畸形值 fail-closed、rotate/revoke 写失败原子性和数据最小化;数据库路径无 Host admission 时启动失败;
|
||||
- STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界;
|
||||
|
||||
@@ -2169,7 +2169,6 @@ export async function main(): Promise<number> {
|
||||
LISTEN_HOST: "127.0.0.1",
|
||||
PUBLIC_BASE_URL: baseUrl,
|
||||
ALLOWED_ORIGINS: baseUrl,
|
||||
ACCESS_PASSWORD: "",
|
||||
ROOM_DATABASE_PATH: "",
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
PEER_ASSISTED_ROOM_IDS: "1",
|
||||
|
||||
+20
-23
@@ -2,9 +2,9 @@ import { KeyRound, LoaderCircle } from "lucide-react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
authenticate,
|
||||
getSession,
|
||||
type SessionStatus,
|
||||
authenticateHost,
|
||||
getHostAdmission,
|
||||
type HostAdmissionStatus,
|
||||
} 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: SessionStatus): AccessState {
|
||||
function stateFromStatus(status: HostAdmissionStatus): AccessState {
|
||||
return !status.required || status.authenticated
|
||||
? { kind: "ready" }
|
||||
: { kind: "required", error: null };
|
||||
@@ -34,13 +34,23 @@ function readableError(error: unknown): string {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
if (viewerRoute) {
|
||||
return <ViewerPage {...viewerRoute} />;
|
||||
}
|
||||
if (isJoinRoute || !isHostRoute) {
|
||||
return <JoinPage />;
|
||||
}
|
||||
return <HostAdmissionGate />;
|
||||
}
|
||||
|
||||
function HostAdmissionGate() {
|
||||
const [access, setAccess] = useState<AccessState>({ kind: "checking" });
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getSession().then(
|
||||
void getHostAdmission().then(
|
||||
(status) => active && setAccess(stateFromStatus(status)),
|
||||
(error: unknown) =>
|
||||
active &&
|
||||
@@ -54,7 +64,7 @@ export function App() {
|
||||
async function retry(): Promise<void> {
|
||||
setAccess({ kind: "checking" });
|
||||
try {
|
||||
setAccess(stateFromStatus(await getSession()));
|
||||
setAccess(stateFromStatus(await getHostAdmission()));
|
||||
} catch (error) {
|
||||
setAccess({ kind: "unavailable", message: readableError(error) });
|
||||
}
|
||||
@@ -71,7 +81,7 @@ export function App() {
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
setAccess(stateFromStatus(await authenticate(submittedPassword)));
|
||||
setAccess(stateFromStatus(await authenticateHost(submittedPassword)));
|
||||
} catch (error) {
|
||||
setAccess({
|
||||
kind: "required",
|
||||
@@ -86,19 +96,6 @@ export function App() {
|
||||
}
|
||||
|
||||
if (access.kind === "ready") {
|
||||
if (viewerRoute) {
|
||||
return (
|
||||
<ViewerPage
|
||||
{...viewerRoute}
|
||||
onAuthorizationRequired={() =>
|
||||
setAccess({ kind: "required", error: "验证已失效,请重新登录" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isJoinRoute || !isHostRoute) {
|
||||
return <JoinPage />;
|
||||
}
|
||||
return (
|
||||
<HostPage
|
||||
onAuthorizationRequired={() =>
|
||||
@@ -133,11 +130,11 @@ export function App() {
|
||||
) : (
|
||||
<form className="access-panel" onSubmit={(event) => void submit(event)}>
|
||||
<div>
|
||||
<h1>访问验证</h1>
|
||||
<p className="section-meta">请输入此站点的访问密码</p>
|
||||
<h1>分享权限</h1>
|
||||
<p className="section-meta">请输入分享准入密码</p>
|
||||
</div>
|
||||
<label className="token-field">
|
||||
<span>访问密码</span>
|
||||
<span>分享准入密码</span>
|
||||
<span className="input-with-icon">
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
<input
|
||||
|
||||
+18
-11
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
createRoomResponseSchema,
|
||||
type CreateRoomResponse,
|
||||
type ViewerAccessPolicy,
|
||||
} from "../../shared/protocol";
|
||||
|
||||
export interface SessionStatus {
|
||||
export interface HostAdmissionStatus {
|
||||
required: boolean;
|
||||
authenticated: boolean;
|
||||
}
|
||||
@@ -45,7 +46,7 @@ async function responseBody(response: Response): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionStatus(value: unknown): SessionStatus {
|
||||
function parseHostAdmissionStatus(value: unknown): HostAdmissionStatus {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new ApiError("验证服务返回的数据格式不正确", 502);
|
||||
}
|
||||
@@ -62,8 +63,8 @@ function parseSessionStatus(value: unknown): SessionStatus {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionStatus> {
|
||||
const response = await fetch("/api/session", {
|
||||
export async function getHostAdmission(): Promise<HostAdmissionStatus> {
|
||||
const response = await fetch("/api/host-admission", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
@@ -75,13 +76,13 @@ export async function getSession(): Promise<SessionStatus> {
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return parseSessionStatus(body);
|
||||
return parseHostAdmissionStatus(body);
|
||||
}
|
||||
|
||||
export async function authenticate(
|
||||
export async function authenticateHost(
|
||||
password: string,
|
||||
): Promise<SessionStatus> {
|
||||
const response = await fetch("/api/session", {
|
||||
): Promise<HostAdmissionStatus> {
|
||||
const response = await fetch("/api/host-admission", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
@@ -97,13 +98,19 @@ export async function authenticate(
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return parseSessionStatus(body);
|
||||
return parseHostAdmissionStatus(body);
|
||||
}
|
||||
|
||||
export async function createRoom(): Promise<CreateRoomResponse> {
|
||||
export async function createRoom(
|
||||
viewerPolicy: ViewerAccessPolicy,
|
||||
): Promise<CreateRoomResponse> {
|
||||
const response = await fetch("/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ viewerPolicy }),
|
||||
});
|
||||
|
||||
const body = await responseBody(response);
|
||||
|
||||
+173
-8
@@ -1,15 +1,34 @@
|
||||
import {
|
||||
createRoomResponseSchema,
|
||||
roomCodeSchema,
|
||||
viewerGrantSchema,
|
||||
type CreateRoomResponse,
|
||||
type ViewerAccessPolicy,
|
||||
} from "../../shared/protocol";
|
||||
import { createOpaqueId } from "./opaque-id";
|
||||
|
||||
const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
||||
const HOST_ROOM_STORAGE_KEY = "screener:host-room:v1";
|
||||
const hostRoomStorageSchema = createRoomResponseSchema.pick({
|
||||
roomId: true,
|
||||
hostToken: true,
|
||||
inviteUrl: true,
|
||||
expiresAt: true,
|
||||
});
|
||||
|
||||
export type HostRoomIdentity = Pick<
|
||||
CreateRoomResponse,
|
||||
"roomId" | "hostToken" | "expiresAt"
|
||||
> & { canonicalUrl: string };
|
||||
|
||||
export interface HostRoomState extends HostRoomIdentity {
|
||||
viewerPolicy: ViewerAccessPolicy | null;
|
||||
inviteUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ViewerRoute {
|
||||
roomId: string;
|
||||
viewerGrant?: string;
|
||||
}
|
||||
|
||||
export function isValidRoomId(value: string): boolean {
|
||||
@@ -17,7 +36,7 @@ export function isValidRoomId(value: string): boolean {
|
||||
}
|
||||
|
||||
export function isHostRoomExpired(
|
||||
room: CreateRoomResponse,
|
||||
room: HostRoomIdentity,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (room.expiresAt === null) {
|
||||
@@ -35,7 +54,7 @@ export function clearHostRoom(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function readHostRoom(now = Date.now()): CreateRoomResponse | null {
|
||||
export function readHostRoom(now = Date.now()): HostRoomIdentity | null {
|
||||
let stored: string | null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(HOST_ROOM_STORAGE_KEY);
|
||||
@@ -47,26 +66,57 @@ export function readHostRoom(now = Date.now()): CreateRoomResponse | null {
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = createRoomResponseSchema.safeParse(JSON.parse(stored));
|
||||
if (!parsed.success || isHostRoomExpired(parsed.data, now)) {
|
||||
const parsed = hostRoomStorageSchema.safeParse(JSON.parse(stored));
|
||||
if (!parsed.success) {
|
||||
clearHostRoom();
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
const room: HostRoomIdentity = {
|
||||
roomId: parsed.data.roomId,
|
||||
hostToken: parsed.data.hostToken,
|
||||
expiresAt: parsed.data.expiresAt,
|
||||
canonicalUrl: canonicalViewerUrl(parsed.data.inviteUrl),
|
||||
};
|
||||
if (isHostRoomExpired(room, now)) {
|
||||
clearHostRoom();
|
||||
return null;
|
||||
}
|
||||
return room;
|
||||
} catch {
|
||||
clearHostRoom();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHostRoom(room: CreateRoomResponse): void {
|
||||
export function writeHostRoom(
|
||||
room: HostRoomIdentity | CreateRoomResponse,
|
||||
): void {
|
||||
try {
|
||||
window.localStorage.setItem(HOST_ROOM_STORAGE_KEY, JSON.stringify(room));
|
||||
const canonicalUrl =
|
||||
"canonicalUrl" in room
|
||||
? room.canonicalUrl
|
||||
: canonicalViewerUrl(room.inviteUrl);
|
||||
window.localStorage.setItem(
|
||||
HOST_ROOM_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
roomId: room.roomId,
|
||||
hostToken: room.hostToken,
|
||||
expiresAt: room.expiresAt,
|
||||
inviteUrl: canonicalUrl,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// A storage failure must not prevent the current sharing session.
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalViewerUrl(inviteUrl: string): string {
|
||||
const parsed = new URL(inviteUrl);
|
||||
parsed.hash = "";
|
||||
parsed.search = "";
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function readSessionValue(key: string): string | null {
|
||||
try {
|
||||
return window.sessionStorage.getItem(key);
|
||||
@@ -89,7 +139,122 @@ export function readViewerRoute(): ViewerRoute | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { roomId: match[1] };
|
||||
const roomId = match[1];
|
||||
const fragment = window.location.hash;
|
||||
if (fragment) {
|
||||
const fragmentMatch = fragment.match(/^#v=(.+)$/);
|
||||
const viewerGrant = fragmentMatch?.[1];
|
||||
const validGrant =
|
||||
viewerGrant && isViewerGrantForRoom(viewerGrant, roomId)
|
||||
? viewerGrant
|
||||
: null;
|
||||
if (validGrant) {
|
||||
writeSessionValue(viewerGrantStorageKey(roomId), validGrant);
|
||||
} else {
|
||||
clearViewerGrant(roomId);
|
||||
}
|
||||
window.history.replaceState(window.history.state, "", `/r/${roomId}`);
|
||||
return validGrant ? { roomId, viewerGrant: validGrant } : { roomId };
|
||||
}
|
||||
|
||||
const storedGrant = readSessionValue(viewerGrantStorageKey(roomId));
|
||||
if (storedGrant && isViewerGrantForRoom(storedGrant, roomId)) {
|
||||
return { roomId, viewerGrant: storedGrant };
|
||||
}
|
||||
if (storedGrant) {
|
||||
clearViewerGrant(roomId);
|
||||
}
|
||||
return { roomId };
|
||||
}
|
||||
|
||||
export function replaceViewerInvite(
|
||||
roomId: string,
|
||||
inviteUrl: string | null,
|
||||
): void {
|
||||
if (inviteUrl === null) {
|
||||
clearViewerGrant(roomId);
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(inviteUrl);
|
||||
} catch {
|
||||
clearViewerGrant(roomId);
|
||||
return;
|
||||
}
|
||||
const match = parsed.hash.match(/^#v=(.+)$/);
|
||||
const viewerGrant = match?.[1];
|
||||
if (viewerGrant && isViewerGrantForRoom(viewerGrant, roomId)) {
|
||||
writeSessionValue(viewerGrantStorageKey(roomId), viewerGrant);
|
||||
return;
|
||||
}
|
||||
clearViewerGrant(roomId);
|
||||
}
|
||||
|
||||
export function readViewerGrant(roomId: string): string | null {
|
||||
const value = readSessionValue(viewerGrantStorageKey(roomId));
|
||||
if (value && isViewerGrantForRoom(value, roomId)) {
|
||||
return value;
|
||||
}
|
||||
if (value) {
|
||||
clearViewerGrant(roomId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mergeAuthenticatedHostRoom(
|
||||
current: HostRoomState | null,
|
||||
activeRoomId: string,
|
||||
roomExpiresAt: string | null,
|
||||
viewerPolicy: ViewerAccessPolicy,
|
||||
): HostRoomState | null {
|
||||
if (!current || current.roomId !== activeRoomId) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let inviteUrl = current.inviteUrl;
|
||||
if (viewerPolicy === "public-watch") {
|
||||
inviteUrl = current.canonicalUrl;
|
||||
} else if (current.viewerPolicy === null) {
|
||||
const viewerGrant = readViewerGrant(current.roomId);
|
||||
if (viewerGrant) {
|
||||
const restoredInvite = new URL(current.canonicalUrl);
|
||||
restoredInvite.hash = `v=${viewerGrant}`;
|
||||
inviteUrl = restoredInvite.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
expiresAt: roomExpiresAt,
|
||||
viewerPolicy,
|
||||
inviteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearViewerGrant(roomId: string): void {
|
||||
try {
|
||||
window.sessionStorage.removeItem(viewerGrantStorageKey(roomId));
|
||||
} catch {
|
||||
// Restricted storage does not change the current in-memory authorization.
|
||||
}
|
||||
}
|
||||
|
||||
function viewerGrantStorageKey(roomId: string): string {
|
||||
return `screener:viewer-grant:${roomId}`;
|
||||
}
|
||||
|
||||
function isViewerGrantForRoom(value: string, roomId: string): boolean {
|
||||
if (!viewerGrantSchema.safeParse(value).success) {
|
||||
return false;
|
||||
}
|
||||
const [, grantRoomId, expiresAtText] = value.split(".", 4);
|
||||
const expiresAtSeconds = Number(expiresAtText);
|
||||
return (
|
||||
grantRoomId === roomId &&
|
||||
Number.isSafeInteger(expiresAtSeconds) &&
|
||||
expiresAtSeconds > Math.floor(Date.now() / 1_000)
|
||||
);
|
||||
}
|
||||
|
||||
export function getStableClientId(role: "host" | "viewer", roomId: string): string {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import type { SignalConnectionState } from "../types";
|
||||
import { getSession } from "./api";
|
||||
|
||||
type WithoutProtocolEnvelope<T> = T extends {
|
||||
type: string;
|
||||
@@ -33,12 +32,15 @@ const FATAL_SIGNAL_ERRORS = new Set([
|
||||
]);
|
||||
const SESSION_REPLACED_CLOSE_CODE = 4001;
|
||||
const INVALID_MESSAGE_CLOSE_CODE = 1008;
|
||||
const VIEWER_ACCESS_REVOKED_CLOSE_CODE = 4004;
|
||||
const PROTOCOL_REFRESH_MESSAGE = "页面版本已更新,请刷新后重试";
|
||||
const TERMINAL_SEND_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function shouldReconnectSignaling(code: number): boolean {
|
||||
return (
|
||||
code !== SESSION_REPLACED_CLOSE_CODE && code !== INVALID_MESSAGE_CLOSE_CODE
|
||||
code !== SESSION_REPLACED_CLOSE_CODE &&
|
||||
code !== INVALID_MESSAGE_CLOSE_CODE &&
|
||||
code !== VIEWER_ACCESS_REVOKED_CLOSE_CODE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +59,6 @@ export class SignalingClient {
|
||||
private authenticationTimer: number | null = null;
|
||||
private terminalTimer: number | null = null;
|
||||
private terminalMessage: ClientMessage | null = null;
|
||||
private accessCheck: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly identity: SignalingIdentity,
|
||||
@@ -164,6 +165,9 @@ export class SignalingClient {
|
||||
if (message.type === "error" && FATAL_SIGNAL_ERRORS.has(message.code)) {
|
||||
this.stop();
|
||||
this.events.onMessage(message);
|
||||
if (message.code === "AUTH_REQUIRED" && this.identity.role === "host") {
|
||||
this.events.onAccessRequired();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,9 +197,6 @@ export class SignalingClient {
|
||||
this.clearAuthenticationTimer();
|
||||
if (!this.stopped && shouldReconnectSignaling(event.code)) {
|
||||
this.scheduleReconnect();
|
||||
if (event.code === 1006) {
|
||||
this.checkAccess();
|
||||
}
|
||||
} else if (!this.stopped) {
|
||||
this.stopped = true;
|
||||
this.clearTimers();
|
||||
@@ -231,30 +232,6 @@ export class SignalingClient {
|
||||
this.events.onTerminated(PROTOCOL_REFRESH_MESSAGE);
|
||||
}
|
||||
|
||||
private checkAccess(): void {
|
||||
if (this.accessCheck) {
|
||||
return;
|
||||
}
|
||||
this.accessCheck = getSession()
|
||||
.then((status) => {
|
||||
if (
|
||||
!this.stopped &&
|
||||
!this.authenticated &&
|
||||
status.required &&
|
||||
!status.authenticated
|
||||
) {
|
||||
this.stop();
|
||||
this.events.onAccessRequired();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Network errors keep the ordinary WebSocket reconnect loop active.
|
||||
})
|
||||
.finally(() => {
|
||||
this.accessCheck = null;
|
||||
});
|
||||
}
|
||||
|
||||
private clearAuthenticationTimer(): void {
|
||||
if (this.authenticationTimer !== null) {
|
||||
window.clearTimeout(this.authenticationTimer);
|
||||
|
||||
@@ -4,6 +4,7 @@ const AUTHORITY_MESSAGE_TYPES = new Set<ServerMessage["type"]>([
|
||||
"authenticated",
|
||||
"host-status",
|
||||
"sharing-stopped",
|
||||
"viewer-access-revoked",
|
||||
"room-closed",
|
||||
"error",
|
||||
]);
|
||||
|
||||
+183
-25
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Globe2,
|
||||
Hash,
|
||||
LockKeyhole,
|
||||
MonitorUp,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
type CreateRoomResponse,
|
||||
type IceConfig,
|
||||
type ServerMessage,
|
||||
type ViewerAccessPolicy,
|
||||
} from "../../shared/protocol";
|
||||
import { AppHeader } from "../components/AppHeader";
|
||||
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
|
||||
@@ -32,9 +35,14 @@ import { ApiError, createRoom } from "../lib/api";
|
||||
import { createOpaqueId } from "../lib/opaque-id";
|
||||
import {
|
||||
clearHostRoom,
|
||||
clearViewerGrant,
|
||||
getStableClientId,
|
||||
type HostRoomIdentity,
|
||||
type HostRoomState,
|
||||
isHostRoomExpired,
|
||||
mergeAuthenticatedHostRoom,
|
||||
readHostRoom,
|
||||
replaceViewerInvite,
|
||||
writeHostRoom,
|
||||
} from "../lib/session";
|
||||
import { SignalingClient } from "../lib/signaling";
|
||||
@@ -98,7 +106,7 @@ function readableError(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : "启动分享失败";
|
||||
}
|
||||
|
||||
function closeAbandonedRoom(room: CreateRoomResponse): void {
|
||||
function closeAbandonedRoom(room: HostRoomIdentity): void {
|
||||
try {
|
||||
const signal = new SignalingClient(
|
||||
{
|
||||
@@ -122,6 +130,31 @@ function closeAbandonedRoom(room: CreateRoomResponse): void {
|
||||
}
|
||||
}
|
||||
|
||||
function hostRoomFromStored(room: HostRoomIdentity | null): HostRoomState | null {
|
||||
return room
|
||||
? {
|
||||
...room,
|
||||
viewerPolicy: null,
|
||||
inviteUrl: null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function hostRoomFromCreated(room: CreateRoomResponse): HostRoomState {
|
||||
replaceViewerInvite(room.roomId, room.inviteUrl);
|
||||
const canonicalUrl = new URL(room.inviteUrl);
|
||||
canonicalUrl.hash = "";
|
||||
canonicalUrl.search = "";
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
hostToken: room.hostToken,
|
||||
expiresAt: room.expiresAt,
|
||||
canonicalUrl: canonicalUrl.toString(),
|
||||
viewerPolicy: room.viewerPolicy,
|
||||
inviteUrl: room.inviteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
interface HostPageProps {
|
||||
onAuthorizationRequired?: () => void;
|
||||
}
|
||||
@@ -139,7 +172,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
useState<SignalConnectionState>("offline");
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [details, setDetails] = useState<CaptureDetails | null>(null);
|
||||
const [room, setRoom] = useState<CreateRoomResponse | null>(readHostRoom);
|
||||
const [room, setRoom] = useState<HostRoomState | null>(() =>
|
||||
hostRoomFromStored(readHostRoom()),
|
||||
);
|
||||
const [newRoomViewerPolicy, setNewRoomViewerPolicy] =
|
||||
useState<ViewerAccessPolicy>("private-link");
|
||||
const [viewerAccessUpdating, setViewerAccessUpdating] = useState(false);
|
||||
const [maxViewers, setMaxViewers] = useState<number | null>(null);
|
||||
const [peerSnapshots, setPeerSnapshots] = useState<Map<string, PeerSnapshot>>(
|
||||
() => new Map(),
|
||||
@@ -704,16 +742,32 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSignalMessage(message: ServerMessage, generation: number): void {
|
||||
function handleSignalMessage(
|
||||
message: ServerMessage,
|
||||
generation: number,
|
||||
activeRoomId: string,
|
||||
): void {
|
||||
if (!isCurrentGeneration(generation)) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "authenticated" && message.role === "host") {
|
||||
setViewerAccessUpdating(false);
|
||||
clearAllViewerQualityEvidence();
|
||||
setSfuStandbyUrl(
|
||||
"sfuStandbyUrl" in message ? message.sfuStandbyUrl : null,
|
||||
);
|
||||
setMaxViewers(message.maxViewers);
|
||||
if (message.viewerPolicy === "public-watch") {
|
||||
clearViewerGrant(activeRoomId);
|
||||
}
|
||||
setRoom((current) =>
|
||||
mergeAuthenticatedHostRoom(
|
||||
current,
|
||||
activeRoomId,
|
||||
message.roomExpiresAt,
|
||||
message.viewerPolicy,
|
||||
),
|
||||
);
|
||||
if (
|
||||
"mediaMode" in message &&
|
||||
message.mediaMode === "peer-assisted"
|
||||
@@ -745,6 +799,27 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "viewer-access-updated") {
|
||||
setViewerAccessUpdating(false);
|
||||
replaceViewerInvite(activeRoomId, message.inviteUrl);
|
||||
setRoom((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
viewerPolicy: message.viewerPolicy,
|
||||
inviteUrl: message.inviteUrl,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setNotice(
|
||||
message.viewerPolicy === "public-watch"
|
||||
? "已允许仅凭房间号观看"
|
||||
: message.inviteUrl
|
||||
? "已生成新的私密邀请,旧邀请已失效"
|
||||
: "已撤销当前私密邀请",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.type === "route-update") {
|
||||
if (peerAssistedRef.current) {
|
||||
if (
|
||||
@@ -827,6 +902,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
setViewerAccessUpdating(false);
|
||||
if (["INVALID_TOKEN", "ROOM_EXPIRED"].includes(message.code)) {
|
||||
forgetRoom();
|
||||
endSharing("房间已失效,再次点击将创建新房", false);
|
||||
@@ -887,7 +963,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setDetails(captureDetails(captured));
|
||||
watchCaptureEnd(captured, generation);
|
||||
|
||||
let createdRoom: CreateRoomResponse | null = null;
|
||||
let createdRoom: HostRoomState | null = null;
|
||||
let claimedRoom = false;
|
||||
try {
|
||||
let reusableRoom = room;
|
||||
@@ -895,9 +971,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
forgetRoom();
|
||||
reusableRoom = null;
|
||||
}
|
||||
createdRoom = reusableRoom ?? readHostRoom();
|
||||
createdRoom = reusableRoom ?? hostRoomFromStored(readHostRoom());
|
||||
if (!createdRoom) {
|
||||
createdRoom = await createRoom();
|
||||
const response = await createRoom(newRoomViewerPolicy);
|
||||
createdRoom = hostRoomFromCreated(response);
|
||||
if (!isCurrentGeneration(generation)) {
|
||||
captured.getTracks().forEach((track) => track.stop());
|
||||
closeAbandonedRoom(createdRoom);
|
||||
@@ -953,19 +1030,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "authenticated" && message.role === "host") {
|
||||
const authenticatedRoom = {
|
||||
...activeRoom,
|
||||
expiresAt: message.roomExpiresAt,
|
||||
};
|
||||
iceConfigRef.current = message.iceConfig;
|
||||
peersRef.current.forEach((peer) =>
|
||||
peer.updateIceConfig(message.iceConfig),
|
||||
);
|
||||
writeHostRoom(authenticatedRoom);
|
||||
setRoom(authenticatedRoom);
|
||||
writeHostRoom({
|
||||
...activeRoom,
|
||||
expiresAt: message.roomExpiresAt,
|
||||
});
|
||||
setPhase("live");
|
||||
}
|
||||
handleSignalMessage(message, generation);
|
||||
handleSignalMessage(message, generation, activeRoom.roomId);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1159,6 +1234,21 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function changeViewerAccess(
|
||||
action: "public-watch" | "rotate" | "revoke",
|
||||
): void {
|
||||
if (
|
||||
viewerAccessUpdating ||
|
||||
phase !== "live" ||
|
||||
!signalRef.current?.send({ type: "set-viewer-access", action })
|
||||
) {
|
||||
setNotice("开始分享并连接后才能修改观看权限");
|
||||
return;
|
||||
}
|
||||
setViewerAccessUpdating(true);
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
const expirationText = room
|
||||
? room.expiresAt
|
||||
? `${new Intl.DateTimeFormat("zh-CN", {
|
||||
@@ -1324,6 +1414,23 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
|
||||
<div className="setup-controls">
|
||||
<div className="quality-controls">
|
||||
{!room && (
|
||||
<label className="viewer-policy-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRoomViewerPolicy === "public-watch"}
|
||||
disabled={phase === "starting"}
|
||||
onChange={(event) =>
|
||||
setNewRoomViewerPolicy(
|
||||
event.target.checked
|
||||
? "public-watch"
|
||||
: "private-link",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>允许仅凭房间号观看</span>
|
||||
</label>
|
||||
)}
|
||||
<fieldset className="control-group">
|
||||
<legend>推荐画质</legend>
|
||||
<div className="segmented-control">
|
||||
@@ -1471,21 +1578,72 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<div className="invite-bar">
|
||||
<div className="invite-copy">
|
||||
<span className="field-label">
|
||||
房间 {room.roomId} · {expirationText}
|
||||
房间 {room.roomId} · {expirationText} ·{" "}
|
||||
{room.viewerPolicy === "public-watch"
|
||||
? "公开观看"
|
||||
: "私密链接"}
|
||||
</span>
|
||||
<span className="invite-url" title={room.inviteUrl}>
|
||||
{room.inviteUrl}
|
||||
<span className="invite-url" title={room.inviteUrl ?? undefined}>
|
||||
{room.inviteUrl ?? "当前没有有效邀请,请轮换生成新链接"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="复制邀请链接"
|
||||
aria-label="复制邀请链接"
|
||||
onClick={() => void copyInvite()}
|
||||
>
|
||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||
</button>
|
||||
<div className="invite-actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="复制邀请链接"
|
||||
aria-label="复制邀请链接"
|
||||
disabled={!room.inviteUrl}
|
||||
onClick={() => void copyInvite()}
|
||||
>
|
||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||
</button>
|
||||
{room.viewerPolicy === "public-watch" ? (
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="设为私密并生成新邀请"
|
||||
aria-label="设为私密并生成新邀请"
|
||||
disabled={viewerAccessUpdating || phase !== "live"}
|
||||
onClick={() => changeViewerAccess("rotate")}
|
||||
>
|
||||
<LockKeyhole size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="轮换私密邀请"
|
||||
aria-label="轮换私密邀请"
|
||||
disabled={viewerAccessUpdating || phase !== "live"}
|
||||
onClick={() => changeViewerAccess("rotate")}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="撤销当前邀请"
|
||||
aria-label="撤销当前邀请"
|
||||
disabled={viewerAccessUpdating || phase !== "live"}
|
||||
onClick={() => changeViewerAccess("revoke")}
|
||||
>
|
||||
<LockKeyhole size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="允许仅凭房间号观看"
|
||||
aria-label="允许仅凭房间号观看"
|
||||
disabled={viewerAccessUpdating || phase !== "live"}
|
||||
onClick={() => changeViewerAccess("public-watch")}
|
||||
>
|
||||
<Globe2 size={18} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
WarningBanner,
|
||||
} from "../components/StatusBadge";
|
||||
import { StatsGrid } from "../components/StatsGrid";
|
||||
import { getStableClientId } from "../lib/session";
|
||||
import { clearViewerGrant, getStableClientId } from "../lib/session";
|
||||
import { SignalingClient } from "../lib/signaling";
|
||||
import type { QualitySettings } from "../media/quality";
|
||||
import { relayCapacityMessageForBrowser } from "../media/relay-capability";
|
||||
@@ -58,7 +58,7 @@ import { ViewerRelay } from "../webrtc/viewer-relay";
|
||||
|
||||
interface ViewerPageProps {
|
||||
roomId: string;
|
||||
onAuthorizationRequired: () => void;
|
||||
viewerGrant?: string;
|
||||
}
|
||||
|
||||
type ViewerQualityEvidence = Extract<
|
||||
@@ -66,7 +66,7 @@ type ViewerQualityEvidence = Extract<
|
||||
{ type: "viewer-quality-evidence" }
|
||||
>;
|
||||
|
||||
export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) {
|
||||
export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const [signalStatus, setSignalStatus] =
|
||||
useState<SignalConnectionState>("offline");
|
||||
const [statusText, setStatusText] = useState("正在连接");
|
||||
@@ -103,6 +103,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
let peerAssisted = false;
|
||||
let currentPeerId: string | null = null;
|
||||
let currentRouteRevision = 0;
|
||||
let viewerAuthorizationGeneration: string | null = null;
|
||||
let currentQualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS;
|
||||
let currentAssignment: MediaAssignment = {
|
||||
parentPeerId: null,
|
||||
@@ -133,6 +134,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
roomId,
|
||||
role: "viewer",
|
||||
clientId: getStableClientId("viewer", roomId),
|
||||
...(viewerGrant ? { viewerGrant } : {}),
|
||||
},
|
||||
{
|
||||
onStatus: (status) => {
|
||||
@@ -154,7 +156,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
if (active) {
|
||||
messageAuthority.invalidate();
|
||||
setSfuStandbyUrl(null);
|
||||
onAuthorizationRequired();
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText("邀请无效或已失效");
|
||||
}
|
||||
},
|
||||
onMessage: (message) => {
|
||||
@@ -461,6 +465,8 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
if (message.type === "authenticated") {
|
||||
clearRelayChildEvidence();
|
||||
currentPeerId = message.peerId;
|
||||
viewerAuthorizationGeneration =
|
||||
message.viewerAuthorizationGeneration;
|
||||
setSfuStandbyUrl(
|
||||
"sfuStandbyUrl" in message ? message.sfuStandbyUrl : null,
|
||||
);
|
||||
@@ -634,6 +640,22 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
setStatusText("等待开始分享");
|
||||
return;
|
||||
}
|
||||
if (message.type === "viewer-access-revoked") {
|
||||
if (
|
||||
viewerAuthorizationGeneration !==
|
||||
message.viewerAuthorizationGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
viewerAuthorizationGeneration = null;
|
||||
clearViewerGrant(roomId);
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText("邀请已失效,请向分享者获取新链接");
|
||||
signal.stop();
|
||||
return;
|
||||
}
|
||||
if (message.type === "room-closed") {
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
@@ -660,6 +682,11 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
}
|
||||
if (message.code === "INVALID_TOKEN") {
|
||||
clearViewerGrant(roomId);
|
||||
setStatusText("邀请无效或已失效");
|
||||
return;
|
||||
}
|
||||
setStatusText(message.message);
|
||||
}
|
||||
}
|
||||
@@ -685,7 +712,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
viewerRelay?.dispose();
|
||||
viewerRelay = null;
|
||||
};
|
||||
}, [onAuthorizationRequired, roomId]);
|
||||
}, [roomId, viewerGrant]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
@@ -441,6 +441,23 @@ h3 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-policy-toggle {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.viewer-policy-toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--green-strong);
|
||||
}
|
||||
|
||||
.advanced-quality {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -702,6 +719,12 @@ h3 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.invite-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-copy .field-label {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
@@ -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 AccessSessionOptions {
|
||||
export interface HostAdmissionOptions {
|
||||
password?: string;
|
||||
secure: boolean;
|
||||
now?: () => number;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export class AccessSession {
|
||||
export class HostAdmission {
|
||||
private readonly now: () => number;
|
||||
private readonly ttlSeconds: number;
|
||||
|
||||
constructor(private readonly options: AccessSessionOptions) {
|
||||
constructor(private readonly options: HostAdmissionOptions) {
|
||||
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 AccessSession {
|
||||
|
||||
private get cookieName(): string {
|
||||
return this.options.secure
|
||||
? "__Host-screener-session"
|
||||
: "screener-session";
|
||||
? "__Host-screener-host-admission"
|
||||
: "screener-host-admission";
|
||||
}
|
||||
|
||||
private sign(payload: string): string {
|
||||
|
||||
+82
-53
@@ -4,8 +4,11 @@ import { fileURLToPath } from "node:url";
|
||||
import sirv from "sirv";
|
||||
import type { ViteDevServer } from "vite";
|
||||
|
||||
import type { CreateRoomResponse } from "../shared/protocol.js";
|
||||
import { AccessSession } from "./access-session.js";
|
||||
import {
|
||||
createRoomRequestSchema,
|
||||
type CreateRoomResponse,
|
||||
} from "../shared/protocol.js";
|
||||
import { HostAdmission } from "./access-session.js";
|
||||
import { loadConfig, type ServerConfig } from "./config.js";
|
||||
import type { SfuTokenIssuer } from "./livekit-token.js";
|
||||
import { RoomDatabase } from "./room-database.js";
|
||||
@@ -24,7 +27,7 @@ export interface CreateServerOptions {
|
||||
cleanupIntervalMs?: number;
|
||||
maxSignalConnections?: number;
|
||||
maxUnauthenticatedSignalConnections?: number;
|
||||
accessSessionTtlSeconds?: number;
|
||||
hostAdmissionTtlSeconds?: number;
|
||||
sfuTokenIssuer?: SfuTokenIssuer;
|
||||
}
|
||||
|
||||
@@ -68,13 +71,13 @@ export async function createScreenerServer(
|
||||
: undefined,
|
||||
now,
|
||||
});
|
||||
const accessSession = new AccessSession({
|
||||
password: config.accessPassword,
|
||||
const hostAdmission = new HostAdmission({
|
||||
password: config.hostAdmissionPassword,
|
||||
secure:
|
||||
config.nodeEnv === "production" &&
|
||||
config.publicBaseUrl.protocol === "https:",
|
||||
now,
|
||||
ttlSeconds: options.accessSessionTtlSeconds,
|
||||
ttlSeconds: options.hostAdmissionTtlSeconds,
|
||||
});
|
||||
const iceOptions = {
|
||||
stunUrls: config.stunUrls,
|
||||
@@ -88,7 +91,7 @@ export async function createScreenerServer(
|
||||
response,
|
||||
config,
|
||||
roomStore,
|
||||
accessSession,
|
||||
hostAdmission,
|
||||
() => frontendHandler,
|
||||
).catch((error: unknown) => {
|
||||
console.error("HTTP request failed", {
|
||||
@@ -112,8 +115,9 @@ export async function createScreenerServer(
|
||||
...(sfuFallback ? { sfuFallback } : {}),
|
||||
ice: iceOptions,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
authorizeUpgrade: (request) =>
|
||||
accessSession.isAuthenticated(request.headers.cookie),
|
||||
hostAdmissionAtUpgrade: (request) =>
|
||||
hostAdmission.isAuthenticated(request.headers.cookie),
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
now,
|
||||
authenticationTimeoutMs: options.authenticationTimeoutMs,
|
||||
viewerDisconnectGraceMs: options.viewerDisconnectGraceMs,
|
||||
@@ -208,7 +212,7 @@ async function handleRequest(
|
||||
response: ServerResponse,
|
||||
config: ServerConfig,
|
||||
roomStore: RoomStore,
|
||||
accessSession: AccessSession,
|
||||
hostAdmission: HostAdmission,
|
||||
getFrontendHandler: () => FrontendHandler | undefined,
|
||||
): Promise<void> {
|
||||
const url = new URL(request.url ?? "/", config.publicBaseUrl);
|
||||
@@ -223,8 +227,8 @@ async function handleRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/session") {
|
||||
handleSessionRequest(request, response, config, accessSession);
|
||||
if (url.pathname === "/api/host-admission") {
|
||||
handleHostAdmissionRequest(request, response, config, hostAdmission);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,35 +240,51 @@ async function handleRequest(
|
||||
sendJson(response, 405, { error: "Method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (!isAllowedRequestOrigin(request.headers.origin, config.allowedOrigins)) {
|
||||
if (
|
||||
!isStrictlyAllowedRequestOrigin(
|
||||
request.headers.origin,
|
||||
config.allowedOrigins,
|
||||
)
|
||||
) {
|
||||
sendJson(response, 403, { error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
if (hasRequestBody(request)) {
|
||||
sendJson(response, 400, { error: "Request body is not accepted" });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isRoomCreationAuthorized(
|
||||
request,
|
||||
accessSession,
|
||||
isStrictlyAllowedRequestOrigin(
|
||||
request.headers.origin,
|
||||
config.allowedOrigins,
|
||||
),
|
||||
hostAdmission,
|
||||
)
|
||||
) {
|
||||
sendJson(response, 401, { error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedRequest;
|
||||
try {
|
||||
const room = roomStore.createRoom();
|
||||
parsedRequest = createRoomRequestSchema.safeParse(
|
||||
await readJsonBody(request, 1_024),
|
||||
);
|
||||
} catch {
|
||||
sendJson(response, 400, { error: "Invalid room request" });
|
||||
return;
|
||||
}
|
||||
if (!parsedRequest.success) {
|
||||
sendJson(response, 400, { error: "Invalid room request" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const room = roomStore.createRoom(parsedRequest.data.viewerPolicy);
|
||||
const inviteUrl = new URL(`/r/${room.roomId}`, config.publicBaseUrl);
|
||||
if (room.viewerGrant) {
|
||||
inviteUrl.hash = `v=${room.viewerGrant}`;
|
||||
}
|
||||
const responseBody: CreateRoomResponse = {
|
||||
roomId: room.roomId,
|
||||
hostToken: room.hostToken,
|
||||
inviteUrl: inviteUrl.toString(),
|
||||
viewerPolicy: room.viewerPolicy,
|
||||
viewerGrantExpiresAt: room.viewerGrantExpiresAt,
|
||||
expiresAt: room.expiresAt,
|
||||
};
|
||||
sendJson(response, 201, responseBody);
|
||||
@@ -292,17 +312,17 @@ async function handleRequest(
|
||||
sendJson(response, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
function handleSessionRequest(
|
||||
function handleHostAdmissionRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
config: ServerConfig,
|
||||
accessSession: AccessSession,
|
||||
hostAdmission: HostAdmission,
|
||||
): void {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
if (request.method === "GET") {
|
||||
sendJson(response, 200, sessionStatus(request, accessSession));
|
||||
sendJson(response, 200, hostAdmissionStatus(request, hostAdmission));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -326,27 +346,27 @@ function handleSessionRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessSession.required) {
|
||||
if (!hostAdmission.required) {
|
||||
sendJson(response, 200, { required: false, authenticated: true });
|
||||
return;
|
||||
}
|
||||
if (!isBearerAuthorized(request, accessSession)) {
|
||||
if (!isBearerAuthorized(request, hostAdmission)) {
|
||||
response.setHeader("WWW-Authenticate", "Bearer");
|
||||
sendJson(response, 401, { error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader("Set-Cookie", accessSession.createCookie()!);
|
||||
response.setHeader("Set-Cookie", hostAdmission.createCookie()!);
|
||||
sendJson(response, 200, { required: true, authenticated: true });
|
||||
}
|
||||
|
||||
function sessionStatus(
|
||||
function hostAdmissionStatus(
|
||||
request: IncomingMessage,
|
||||
accessSession: AccessSession,
|
||||
hostAdmission: HostAdmission,
|
||||
): { required: boolean; authenticated: boolean } {
|
||||
return {
|
||||
required: accessSession.required,
|
||||
authenticated: accessSession.isAuthenticated(request.headers.cookie),
|
||||
required: hostAdmission.required,
|
||||
authenticated: hostAdmission.isAuthenticated(request.headers.cookie),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -360,20 +380,6 @@ function hasRequestBody(request: IncomingMessage): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedRequestOrigin(
|
||||
origin: string | undefined,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return allowedOrigins.has(new URL(origin).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isStrictlyAllowedRequestOrigin(
|
||||
origin: string | undefined,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
@@ -391,25 +397,48 @@ function isStrictlyAllowedRequestOrigin(
|
||||
|
||||
function isRoomCreationAuthorized(
|
||||
request: IncomingMessage,
|
||||
accessSession: AccessSession,
|
||||
hasAllowedOrigin: boolean,
|
||||
hostAdmission: HostAdmission,
|
||||
): boolean {
|
||||
if (!accessSession.required) {
|
||||
if (!hostAdmission.required) {
|
||||
return true;
|
||||
}
|
||||
return hasAllowedOrigin && accessSession.isAuthenticated(request.headers.cookie);
|
||||
return hostAdmission.isAuthenticated(request.headers.cookie);
|
||||
}
|
||||
|
||||
function isBearerAuthorized(
|
||||
request: IncomingMessage,
|
||||
accessSession: AccessSession,
|
||||
hostAdmission: HostAdmission,
|
||||
): boolean {
|
||||
const authorization = request.headers.authorization;
|
||||
if (!authorization?.startsWith("Bearer ")) {
|
||||
return false;
|
||||
}
|
||||
const provided = authorization.slice("Bearer ".length);
|
||||
return provided.length > 0 && accessSession.passwordMatches(provided);
|
||||
return provided.length > 0 && hostAdmission.passwordMatches(provided);
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
request: IncomingMessage,
|
||||
maximumBytes: number,
|
||||
): Promise<unknown> {
|
||||
const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim();
|
||||
if (contentType !== "application/json") {
|
||||
throw new Error("Request content type must be application/json");
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += buffer.byteLength;
|
||||
if (bytes > maximumBytes) {
|
||||
throw new Error("Request body is too large");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
if (bytes === 0) {
|
||||
throw new Error("Request body is required");
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
||||
|
||||
+27
-12
@@ -7,7 +7,8 @@ import {
|
||||
|
||||
export type RuntimeEnvironment = "development" | "test" | "production";
|
||||
|
||||
const MAX_ACCESS_PASSWORD_BYTES = 128;
|
||||
const MIN_HOST_ADMISSION_PASSWORD_BYTES = 16;
|
||||
const MAX_HOST_ADMISSION_PASSWORD_BYTES = 128;
|
||||
const MIN_LIVEKIT_API_SECRET_BYTES = 32;
|
||||
const MAX_PEER_ASSISTED_VIEWERS = 8;
|
||||
const DEFAULT_MAX_VIEWERS_PER_ROOM = 8;
|
||||
@@ -33,7 +34,7 @@ export interface ServerConfig {
|
||||
listenHost: string;
|
||||
publicBaseUrl: URL;
|
||||
allowedOrigins: ReadonlySet<string>;
|
||||
accessPassword?: string;
|
||||
hostAdmissionPassword?: string;
|
||||
roomDatabasePath?: string;
|
||||
roomTtlMs: number;
|
||||
maxRooms: number;
|
||||
@@ -235,6 +236,11 @@ export function loadConfig(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(environment, "ACCESS_PASSWORD")) {
|
||||
throw new Error(
|
||||
"ACCESS_PASSWORD is no longer supported; use HOST_ADMISSION_PASSWORD",
|
||||
);
|
||||
}
|
||||
|
||||
const nodeEnv = parseEnvironment(environment.NODE_ENV);
|
||||
const port = parsePositiveInteger(environment.PORT, 8787, "PORT");
|
||||
@@ -267,7 +273,10 @@ export function loadConfig(
|
||||
throw new Error("PUBLIC_BASE_URL must use https in production");
|
||||
}
|
||||
|
||||
const accessPassword = environment.ACCESS_PASSWORD?.trim() || undefined;
|
||||
const hostAdmissionPassword =
|
||||
environment.HOST_ADMISSION_PASSWORD === ""
|
||||
? undefined
|
||||
: environment.HOST_ADMISSION_PASSWORD;
|
||||
const roomDatabasePath =
|
||||
environment.ROOM_DATABASE_PATH?.trim() || undefined;
|
||||
const stunUrls = parseStunUrlList(environment.STUN_URLS);
|
||||
@@ -302,26 +311,32 @@ export function loadConfig(
|
||||
throw new Error("LiveKit fallback requires PEER_ASSISTED_MEDIA=true");
|
||||
}
|
||||
const configuredSecrets = [
|
||||
accessPassword,
|
||||
hostAdmissionPassword,
|
||||
livekitFallback?.apiKey,
|
||||
livekitFallback?.apiSecret,
|
||||
].filter((secret): secret is string => secret !== undefined);
|
||||
if (new Set(configuredSecrets).size !== configuredSecrets.length) {
|
||||
throw new Error(
|
||||
"ACCESS_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values",
|
||||
"HOST_ADMISSION_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values",
|
||||
);
|
||||
}
|
||||
if (
|
||||
accessPassword &&
|
||||
(!VISIBLE_ASCII_PATTERN.test(accessPassword) ||
|
||||
Buffer.byteLength(accessPassword) > MAX_ACCESS_PASSWORD_BYTES)
|
||||
hostAdmissionPassword &&
|
||||
(!VISIBLE_ASCII_PATTERN.test(hostAdmissionPassword) ||
|
||||
Buffer.byteLength(hostAdmissionPassword) <
|
||||
MIN_HOST_ADMISSION_PASSWORD_BYTES ||
|
||||
Buffer.byteLength(hostAdmissionPassword) >
|
||||
MAX_HOST_ADMISSION_PASSWORD_BYTES)
|
||||
) {
|
||||
throw new Error(
|
||||
"ACCESS_PASSWORD must contain at most 128 visible ASCII characters",
|
||||
"HOST_ADMISSION_PASSWORD must contain 16 to 128 visible ASCII bytes",
|
||||
);
|
||||
}
|
||||
if (roomDatabasePath && !accessPassword) {
|
||||
throw new Error("ROOM_DATABASE_PATH requires ACCESS_PASSWORD");
|
||||
if (nodeEnv === "production" && !hostAdmissionPassword) {
|
||||
throw new Error("HOST_ADMISSION_PASSWORD is required in production");
|
||||
}
|
||||
if (roomDatabasePath && !hostAdmissionPassword) {
|
||||
throw new Error("ROOM_DATABASE_PATH requires HOST_ADMISSION_PASSWORD");
|
||||
}
|
||||
if (nodeEnv === "production" && roomDatabasePath === ":memory:") {
|
||||
throw new Error("ROOM_DATABASE_PATH must be file-backed in production");
|
||||
@@ -344,7 +359,7 @@ export function loadConfig(
|
||||
environment.ALLOWED_ORIGINS,
|
||||
publicBaseUrl.origin,
|
||||
),
|
||||
accessPassword,
|
||||
hostAdmissionPassword,
|
||||
roomDatabasePath,
|
||||
roomTtlMs:
|
||||
parsePositiveInteger(environment.ROOM_TTL_SECONDS, 14_400, "ROOM_TTL_SECONDS") *
|
||||
|
||||
+127
-20
@@ -1,17 +1,21 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SCHEMA_VERSION = 2;
|
||||
const MAX_ROOM_ID = 999_999_999_999;
|
||||
|
||||
export interface StoredRoom {
|
||||
roomId: string;
|
||||
hostTokenDigest: Buffer;
|
||||
viewerGrantDigest: Buffer | null;
|
||||
}
|
||||
|
||||
export class RoomDatabase {
|
||||
private readonly database: DatabaseSync;
|
||||
private readonly random: (size: number) => Buffer;
|
||||
|
||||
constructor(path: string) {
|
||||
constructor(path: string, random: (size: number) => Buffer = randomBytes) {
|
||||
this.random = random;
|
||||
this.database = new DatabaseSync(path, { timeout: 5_000 });
|
||||
try {
|
||||
this.initialize();
|
||||
@@ -24,7 +28,7 @@ export class RoomDatabase {
|
||||
loadRooms(): StoredRoom[] {
|
||||
const rows = this.database
|
||||
.prepare(
|
||||
"SELECT id, host_token_digest FROM rooms ORDER BY id",
|
||||
"SELECT id, host_token_digest, viewer_grant_digest FROM rooms ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
|
||||
@@ -39,31 +43,71 @@ export class RoomDatabase {
|
||||
return {
|
||||
roomId: roomId.toString(),
|
||||
hostTokenDigest: Buffer.from(row.host_token_digest),
|
||||
viewerGrantDigest: readViewerGrantDigest(row.viewer_grant_digest),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
createRoom(hostTokenDigest: Buffer): StoredRoom {
|
||||
if (hostTokenDigest.byteLength !== 32) {
|
||||
throw new Error("Host token digest must contain 32 bytes");
|
||||
}
|
||||
createRoom(
|
||||
hostTokenDigest: Buffer,
|
||||
viewerGrantDigestForRoom: (roomId: string) => Buffer | null,
|
||||
): StoredRoom {
|
||||
assertDigest(hostTokenDigest, "Host token");
|
||||
|
||||
const result = this.database
|
||||
.prepare("INSERT INTO rooms (host_token_digest) VALUES (?)")
|
||||
.run(hostTokenDigest);
|
||||
const roomId = readSafeInteger(result.lastInsertRowid, "created room id");
|
||||
return {
|
||||
roomId: roomId.toString(),
|
||||
hostTokenDigest: Buffer.from(hostTokenDigest),
|
||||
};
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = this.database
|
||||
.prepare(
|
||||
"INSERT INTO rooms (host_token_digest, viewer_grant_digest) VALUES (?, NULL)",
|
||||
)
|
||||
.run(hostTokenDigest);
|
||||
const roomId = readSafeInteger(result.lastInsertRowid, "created room id");
|
||||
const roomIdText = roomId.toString();
|
||||
const viewerGrantDigest = viewerGrantDigestForRoom(roomIdText);
|
||||
if (viewerGrantDigest !== null) {
|
||||
assertDigest(viewerGrantDigest, "Viewer grant");
|
||||
const updated = this.database
|
||||
.prepare("UPDATE rooms SET viewer_grant_digest = ? WHERE id = ?")
|
||||
.run(viewerGrantDigest, roomId).changes;
|
||||
if (updated !== 1) {
|
||||
throw new Error("Created room could not be updated");
|
||||
}
|
||||
}
|
||||
this.database.exec("COMMIT");
|
||||
return {
|
||||
roomId: roomIdText,
|
||||
hostTokenDigest: Buffer.from(hostTokenDigest),
|
||||
viewerGrantDigest:
|
||||
viewerGrantDigest === null ? null : Buffer.from(viewerGrantDigest),
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.database.isTransaction) {
|
||||
this.database.exec("ROLLBACK");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
updateViewerGrantDigest(
|
||||
roomId: string,
|
||||
viewerGrantDigest: Buffer | null,
|
||||
): void {
|
||||
const numericRoomId = parseRoomId(roomId);
|
||||
if (viewerGrantDigest !== null) {
|
||||
assertDigest(viewerGrantDigest, "Viewer grant");
|
||||
}
|
||||
const changes = this.database
|
||||
.prepare("UPDATE rooms SET viewer_grant_digest = ? WHERE id = ?")
|
||||
.run(viewerGrantDigest, numericRoomId).changes;
|
||||
if (changes !== 1) {
|
||||
throw new Error("Persistent room is missing from the room database");
|
||||
}
|
||||
}
|
||||
|
||||
deleteRoom(roomId: string): boolean {
|
||||
if (!/^[1-9]\d{0,11}$/.test(roomId)) {
|
||||
throw new Error("Room id must be a positive integer with at most 12 digits");
|
||||
}
|
||||
const numericRoomId = parseRoomId(roomId);
|
||||
return (
|
||||
this.database.prepare("DELETE FROM rooms WHERE id = ?").run(Number(roomId))
|
||||
this.database.prepare("DELETE FROM rooms WHERE id = ?").run(numericRoomId)
|
||||
.changes === 1
|
||||
);
|
||||
}
|
||||
@@ -80,6 +124,10 @@ export class RoomDatabase {
|
||||
if (version === SCHEMA_VERSION) {
|
||||
return;
|
||||
}
|
||||
if (version === 1) {
|
||||
this.migrateV1();
|
||||
return;
|
||||
}
|
||||
if (version !== 0) {
|
||||
throw new Error(`Unsupported room database schema version ${version}`);
|
||||
}
|
||||
@@ -91,7 +139,12 @@ export class RoomDatabase {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
CHECK (id BETWEEN 1 AND ${MAX_ROOM_ID}),
|
||||
host_token_digest BLOB NOT NULL
|
||||
CHECK (length(host_token_digest) = 32)
|
||||
CHECK (length(host_token_digest) = 32),
|
||||
viewer_grant_digest BLOB NULL
|
||||
CHECK (
|
||||
viewer_grant_digest IS NULL OR
|
||||
length(viewer_grant_digest) = 32
|
||||
)
|
||||
) STRICT;
|
||||
PRAGMA user_version = ${SCHEMA_VERSION};
|
||||
COMMIT;
|
||||
@@ -103,6 +156,60 @@ export class RoomDatabase {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private migrateV1(): void {
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
this.database.exec(`
|
||||
ALTER TABLE rooms ADD COLUMN viewer_grant_digest BLOB NULL
|
||||
CHECK (
|
||||
viewer_grant_digest IS NULL OR
|
||||
length(viewer_grant_digest) = 32
|
||||
);
|
||||
`);
|
||||
const roomIds = this.database.prepare("SELECT id FROM rooms ORDER BY id").all();
|
||||
const lockRoom = this.database.prepare(
|
||||
"UPDATE rooms SET viewer_grant_digest = ? WHERE id = ?",
|
||||
);
|
||||
for (const row of roomIds) {
|
||||
const roomId = readSafeInteger(row.id, "room id");
|
||||
const lockedDigest = this.random(32);
|
||||
assertDigest(lockedDigest, "Migration random source");
|
||||
if (lockRoom.run(lockedDigest, roomId).changes !== 1) {
|
||||
throw new Error("Room database migration lost a room row");
|
||||
}
|
||||
}
|
||||
this.database.exec(`PRAGMA user_version = ${SCHEMA_VERSION}; COMMIT;`);
|
||||
} catch (error) {
|
||||
if (this.database.isTransaction) {
|
||||
this.database.exec("ROLLBACK");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertDigest(value: Buffer, name: string): void {
|
||||
if (value.byteLength !== 32) {
|
||||
throw new Error(`${name} digest must contain 32 bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function readViewerGrantDigest(value: unknown): Buffer | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!(value instanceof Uint8Array) || value.byteLength !== 32) {
|
||||
throw new Error("Room database contains an invalid viewer grant digest");
|
||||
}
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
function parseRoomId(roomId: string): number {
|
||||
if (!/^[1-9]\d{0,11}$/.test(roomId)) {
|
||||
throw new Error("Room id must be a positive integer with at most 12 digits");
|
||||
}
|
||||
return Number(roomId);
|
||||
}
|
||||
|
||||
function readSafeInteger(value: unknown, name: string): number {
|
||||
|
||||
+206
-3
@@ -3,7 +3,9 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
ROOM_CODE_LENGTH,
|
||||
viewerGrantSchema,
|
||||
type Role,
|
||||
type ViewerAccessPolicy,
|
||||
} from "../shared/protocol.js";
|
||||
import { RoomDatabase } from "./room-database.js";
|
||||
|
||||
@@ -14,6 +16,8 @@ export type RoomStoreErrorCode =
|
||||
| "HOST_ALREADY_CONNECTED"
|
||||
| "ROOM_LIMIT";
|
||||
|
||||
const PERSISTENT_VIEWER_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
export class RoomStoreError extends Error {
|
||||
constructor(public readonly code: RoomStoreErrorCode) {
|
||||
super(code);
|
||||
@@ -30,6 +34,8 @@ interface Participant {
|
||||
interface Room {
|
||||
roomId: string;
|
||||
hostTokenDigest: Buffer;
|
||||
viewerGrantDigest: Buffer | null;
|
||||
viewerAuthorizationGeneration: string;
|
||||
expiresAtMs: number | null;
|
||||
host?: Participant;
|
||||
viewers: Map<string, Participant>;
|
||||
@@ -38,6 +44,9 @@ interface Room {
|
||||
export interface CreatedRoom {
|
||||
roomId: string;
|
||||
hostToken: string;
|
||||
viewerPolicy: ViewerAccessPolicy;
|
||||
viewerGrant: string | null;
|
||||
viewerGrantExpiresAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
@@ -52,6 +61,7 @@ export type ConnectParticipantInput =
|
||||
| {
|
||||
roomId: string;
|
||||
role: "viewer";
|
||||
viewerGrant?: string;
|
||||
clientId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
@@ -64,6 +74,8 @@ export interface ConnectedParticipant {
|
||||
hostOnline: boolean;
|
||||
replacedSessionId?: string;
|
||||
viewerPeerIds: readonly string[];
|
||||
viewerPolicy: ViewerAccessPolicy;
|
||||
viewerAuthorizationGeneration: string;
|
||||
}
|
||||
|
||||
export interface DisconnectedParticipant {
|
||||
@@ -82,6 +94,20 @@ export interface ClosedRoom {
|
||||
sessionIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface RevokedViewer {
|
||||
peerId: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface ViewerAccessUpdate {
|
||||
viewerPolicy: ViewerAccessPolicy;
|
||||
viewerGrant: string | null;
|
||||
viewerGrantExpiresAt: string | null;
|
||||
viewerAuthorizationGeneration: string;
|
||||
previousViewerAuthorizationGeneration: string;
|
||||
revokedViewers: readonly RevokedViewer[];
|
||||
}
|
||||
|
||||
export interface RoomStoreOptions {
|
||||
ttlMs: number;
|
||||
maxRooms: number;
|
||||
@@ -125,6 +151,11 @@ export class RoomStore {
|
||||
this.rooms.set(storedRoom.roomId, {
|
||||
roomId: storedRoom.roomId,
|
||||
hostTokenDigest: Buffer.from(storedRoom.hostTokenDigest),
|
||||
viewerGrantDigest:
|
||||
storedRoom.viewerGrantDigest === null
|
||||
? null
|
||||
: Buffer.from(storedRoom.viewerGrantDigest),
|
||||
viewerAuthorizationGeneration: this.newAuthorizationGeneration(),
|
||||
expiresAtMs: null,
|
||||
viewers: new Map(),
|
||||
});
|
||||
@@ -135,21 +166,49 @@ export class RoomStore {
|
||||
}
|
||||
}
|
||||
|
||||
createRoom(): CreatedRoom {
|
||||
createRoom(viewerPolicy: ViewerAccessPolicy = "private-link"): CreatedRoom {
|
||||
if (this.rooms.size >= this.options.maxRooms) {
|
||||
throw new RoomStoreError("ROOM_LIMIT");
|
||||
}
|
||||
|
||||
const hostToken = this.random(32).toString("base64url");
|
||||
const hostTokenDigest = digest(hostToken);
|
||||
const viewerAuthorizationGeneration = this.newAuthorizationGeneration();
|
||||
const createdAtMs = this.now();
|
||||
const storedRoom = this.options.database?.createRoom(hostTokenDigest);
|
||||
let viewerGrant: string | null = null;
|
||||
let viewerGrantExpiresAtMs: number | null = null;
|
||||
const storedRoom = this.options.database?.createRoom(
|
||||
hostTokenDigest,
|
||||
(roomId) => {
|
||||
if (viewerPolicy === "public-watch") {
|
||||
return null;
|
||||
}
|
||||
const persistentGrantExpiresAtMs = toEpochSecondsMs(
|
||||
createdAtMs + PERSISTENT_VIEWER_GRANT_TTL_MS,
|
||||
);
|
||||
viewerGrantExpiresAtMs = persistentGrantExpiresAtMs;
|
||||
viewerGrant = this.createViewerGrant(roomId, persistentGrantExpiresAtMs);
|
||||
return digest(viewerGrant);
|
||||
},
|
||||
);
|
||||
const roomId = storedRoom?.roomId ?? this.uniqueRoomCode();
|
||||
const expiresAtMs = storedRoom ? null : createdAtMs + this.options.ttlMs;
|
||||
if (!storedRoom && viewerPolicy === "private-link") {
|
||||
const temporaryGrantExpiresAtMs = toEpochSecondsMs(
|
||||
createdAtMs + this.options.ttlMs,
|
||||
);
|
||||
viewerGrantExpiresAtMs = temporaryGrantExpiresAtMs;
|
||||
viewerGrant = this.createViewerGrant(roomId, temporaryGrantExpiresAtMs);
|
||||
}
|
||||
const viewerGrantDigest =
|
||||
storedRoom?.viewerGrantDigest ??
|
||||
(viewerGrant === null ? null : digest(viewerGrant));
|
||||
|
||||
this.rooms.set(roomId, {
|
||||
roomId,
|
||||
hostTokenDigest,
|
||||
viewerGrantDigest,
|
||||
viewerAuthorizationGeneration,
|
||||
expiresAtMs,
|
||||
viewers: new Map(),
|
||||
});
|
||||
@@ -157,12 +216,23 @@ export class RoomStore {
|
||||
return {
|
||||
roomId,
|
||||
hostToken,
|
||||
viewerPolicy,
|
||||
viewerGrant,
|
||||
viewerGrantExpiresAt: formatExpiresAt(viewerGrantExpiresAtMs),
|
||||
expiresAt: formatExpiresAt(expiresAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
connectParticipant(input: ConnectParticipantInput): ConnectedParticipant {
|
||||
const room = this.getAvailableRoom(input.roomId);
|
||||
let room: Room;
|
||||
try {
|
||||
room = this.getAvailableRoom(input.roomId);
|
||||
} catch (error) {
|
||||
if (input.role === "viewer" && error instanceof RoomStoreError) {
|
||||
throw new RoomStoreError("INVALID_TOKEN");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (input.role === "host" && !verifyDigest(input.token, room.hostTokenDigest)) {
|
||||
throw new RoomStoreError("INVALID_TOKEN");
|
||||
}
|
||||
@@ -170,9 +240,69 @@ export class RoomStore {
|
||||
if (input.role === "host") {
|
||||
return this.connectHost(room, input);
|
||||
}
|
||||
if (!this.viewerMayEnter(room, input.viewerGrant)) {
|
||||
throw new RoomStoreError("INVALID_TOKEN");
|
||||
}
|
||||
return this.connectViewer(room, input);
|
||||
}
|
||||
|
||||
setViewerAccess(
|
||||
roomId: string,
|
||||
action: "public-watch" | "rotate" | "revoke",
|
||||
): ViewerAccessUpdate {
|
||||
const room = this.getAvailableRoom(roomId);
|
||||
const previousViewerAuthorizationGeneration =
|
||||
room.viewerAuthorizationGeneration;
|
||||
|
||||
if (action === "public-watch") {
|
||||
this.options.database?.updateViewerGrantDigest(roomId, null);
|
||||
room.viewerGrantDigest = null;
|
||||
return {
|
||||
viewerPolicy: "public-watch",
|
||||
viewerGrant: null,
|
||||
viewerGrantExpiresAt: null,
|
||||
viewerAuthorizationGeneration: room.viewerAuthorizationGeneration,
|
||||
previousViewerAuthorizationGeneration,
|
||||
revokedViewers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const viewerGrantExpiresAtMs =
|
||||
action === "rotate"
|
||||
? toEpochSecondsMs(
|
||||
room.expiresAtMs ?? this.now() + PERSISTENT_VIEWER_GRANT_TTL_MS,
|
||||
)
|
||||
: null;
|
||||
const viewerGrant =
|
||||
viewerGrantExpiresAtMs === null
|
||||
? null
|
||||
: this.createViewerGrant(room.roomId, viewerGrantExpiresAtMs);
|
||||
const nextDigest =
|
||||
viewerGrant === null ? this.randomDigest() : digest(viewerGrant);
|
||||
const nextViewerAuthorizationGeneration =
|
||||
this.newAuthorizationGeneration();
|
||||
|
||||
// Persistence is the commit point. No in-memory authorization or topology
|
||||
// state changes before this write succeeds.
|
||||
this.options.database?.updateViewerGrantDigest(roomId, nextDigest);
|
||||
|
||||
const revokedViewers = [...room.viewers.values()].map((viewer) => ({
|
||||
peerId: viewer.peerId,
|
||||
...(viewer.sessionId ? { sessionId: viewer.sessionId } : {}),
|
||||
}));
|
||||
room.viewerGrantDigest = nextDigest;
|
||||
room.viewerAuthorizationGeneration = nextViewerAuthorizationGeneration;
|
||||
room.viewers.clear();
|
||||
return {
|
||||
viewerPolicy: "private-link",
|
||||
viewerGrant,
|
||||
viewerGrantExpiresAt: formatExpiresAt(viewerGrantExpiresAtMs),
|
||||
viewerAuthorizationGeneration: room.viewerAuthorizationGeneration,
|
||||
previousViewerAuthorizationGeneration,
|
||||
revokedViewers,
|
||||
};
|
||||
}
|
||||
|
||||
disconnectParticipant(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
@@ -302,6 +432,8 @@ export class RoomStore {
|
||||
replacedSessionId === input.sessionId ? undefined : replacedSessionId,
|
||||
// Disconnected viewers remain room members until their grace period ends.
|
||||
viewerPeerIds: [...room.viewers.values()].map((viewer) => viewer.peerId),
|
||||
viewerPolicy: viewerPolicy(room),
|
||||
viewerAuthorizationGeneration: room.viewerAuthorizationGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,6 +465,8 @@ export class RoomStore {
|
||||
replacedSessionId:
|
||||
replacedSessionId === input.sessionId ? undefined : replacedSessionId,
|
||||
viewerPeerIds: [],
|
||||
viewerPolicy: viewerPolicy(room),
|
||||
viewerAuthorizationGeneration: room.viewerAuthorizationGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -385,6 +519,47 @@ export class RoomStore {
|
||||
}
|
||||
throw new Error("Unable to allocate a unique identifier");
|
||||
}
|
||||
|
||||
private viewerMayEnter(room: Room, grant: string | undefined): boolean {
|
||||
if (room.viewerGrantDigest === null) {
|
||||
return true;
|
||||
}
|
||||
if (!grant || !viewerGrantSchema.safeParse(grant).success) {
|
||||
return false;
|
||||
}
|
||||
const parsed = parseViewerGrant(grant);
|
||||
return Boolean(
|
||||
parsed &&
|
||||
parsed.roomId === room.roomId &&
|
||||
parsed.expiresAtMs > this.now() &&
|
||||
verifyDigest(grant, room.viewerGrantDigest),
|
||||
);
|
||||
}
|
||||
|
||||
private createViewerGrant(roomId: string, expiresAtMs: number): string {
|
||||
const expiresAtSeconds = Math.floor(expiresAtMs / 1_000);
|
||||
const secret = this.random(32);
|
||||
if (secret.byteLength !== 32) {
|
||||
throw new Error("Viewer grant random source must return 32 bytes");
|
||||
}
|
||||
return `g1.${roomId}.${expiresAtSeconds}.${secret.toString("base64url")}`;
|
||||
}
|
||||
|
||||
private randomDigest(): Buffer {
|
||||
const value = this.random(32);
|
||||
if (value.byteLength !== 32) {
|
||||
throw new Error("Viewer lock random source must return 32 bytes");
|
||||
}
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
private newAuthorizationGeneration(): string {
|
||||
const value = this.random(16);
|
||||
if (value.byteLength !== 16) {
|
||||
throw new Error("Authorization generation random source must return 16 bytes");
|
||||
}
|
||||
return value.toString("base64url");
|
||||
}
|
||||
}
|
||||
|
||||
function isRoomCode(value: string): boolean {
|
||||
@@ -398,6 +573,10 @@ function formatExpiresAt(expiresAtMs: number | null): string | null {
|
||||
return expiresAtMs === null ? null : new Date(expiresAtMs).toISOString();
|
||||
}
|
||||
|
||||
function toEpochSecondsMs(value: number): number {
|
||||
return Math.floor(value / 1_000) * 1_000;
|
||||
}
|
||||
|
||||
function findViewerByPeerId(room: Room, peerId: string): Participant | undefined {
|
||||
return [...room.viewers.values()].find((viewer) => viewer.peerId === peerId);
|
||||
}
|
||||
@@ -419,3 +598,27 @@ function digest(value: string): Buffer {
|
||||
function verifyDigest(value: string, expectedDigest: Buffer): boolean {
|
||||
return timingSafeEqual(digest(value), expectedDigest);
|
||||
}
|
||||
|
||||
function viewerPolicy(room: Room): ViewerAccessPolicy {
|
||||
return room.viewerGrantDigest === null ? "public-watch" : "private-link";
|
||||
}
|
||||
|
||||
function parseViewerGrant(
|
||||
value: string,
|
||||
): { roomId: string; expiresAtMs: number } | null {
|
||||
const [version, roomId, expiresAtText, secret, ...extra] = value.split(".");
|
||||
if (
|
||||
version !== "g1" ||
|
||||
extra.length > 0 ||
|
||||
!isRoomCode(roomId ?? "") ||
|
||||
!/^[1-9]\d{0,12}$/.test(expiresAtText ?? "") ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(secret ?? "")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expiresAtSeconds = Number(expiresAtText);
|
||||
if (!Number.isSafeInteger(expiresAtSeconds)) {
|
||||
return null;
|
||||
}
|
||||
return { roomId: roomId!, expiresAtMs: expiresAtSeconds * 1_000 };
|
||||
}
|
||||
+136
-7
@@ -45,6 +45,8 @@ interface SocketState {
|
||||
sessionId: string;
|
||||
alive: boolean;
|
||||
authenticationTimer: NodeJS.Timeout;
|
||||
hostAdmissionAuthenticated: boolean;
|
||||
revoked?: boolean;
|
||||
authenticated?: AuthenticatedSession;
|
||||
}
|
||||
|
||||
@@ -66,7 +68,8 @@ export interface SignalingOptions {
|
||||
sfuFallback?: SfuFallbackOptions;
|
||||
ice: IceConfigOptions;
|
||||
allowedOrigins: ReadonlySet<string>;
|
||||
authorizeUpgrade: (request: IncomingMessage) => boolean;
|
||||
hostAdmissionAtUpgrade: (request: IncomingMessage) => boolean;
|
||||
publicBaseUrl: URL;
|
||||
now?: () => number;
|
||||
authenticationTimeoutMs?: number;
|
||||
viewerDisconnectGraceMs?: number;
|
||||
@@ -167,10 +170,6 @@ export class SignalingServer {
|
||||
rejectUpgrade(socket, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
if (!options.authorizeUpgrade(request)) {
|
||||
rejectUpgrade(socket, 401, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
if (!this.hasConnectionCapacity()) {
|
||||
rejectUpgrade(socket, 503, "Service Unavailable");
|
||||
return;
|
||||
@@ -183,7 +182,9 @@ export class SignalingServer {
|
||||
});
|
||||
};
|
||||
options.server.on("upgrade", this.upgradeHandler);
|
||||
this.webSocketServer.on("connection", (socket) => this.accept(socket));
|
||||
this.webSocketServer.on("connection", (socket, request) =>
|
||||
this.accept(socket, options.hostAdmissionAtUpgrade(request)),
|
||||
);
|
||||
|
||||
this.heartbeatTimer = setInterval(
|
||||
() => this.heartbeat(),
|
||||
@@ -218,7 +219,7 @@ export class SignalingServer {
|
||||
});
|
||||
}
|
||||
|
||||
private accept(socket: WebSocket): void {
|
||||
private accept(socket: WebSocket, hostAdmissionAuthenticated: boolean): void {
|
||||
if (!this.hasConnectionCapacity()) {
|
||||
socket.terminate();
|
||||
return;
|
||||
@@ -235,6 +236,7 @@ export class SignalingServer {
|
||||
sessionId,
|
||||
alive: true,
|
||||
authenticationTimer,
|
||||
hostAdmissionAuthenticated,
|
||||
};
|
||||
this.socketStates.set(socket, state);
|
||||
this.socketsBySessionId.set(sessionId, socket);
|
||||
@@ -261,6 +263,9 @@ export class SignalingServer {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (state.revoked) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message: ClientMessage;
|
||||
try {
|
||||
@@ -320,6 +325,12 @@ export class SignalingServer {
|
||||
state: SocketState,
|
||||
message: Extract<ClientMessage, { type: "authenticate" }>,
|
||||
): void {
|
||||
if (message.role === "host" && !state.hostAdmissionAuthenticated) {
|
||||
this.sendError(socket, "AUTH_REQUIRED", "Host admission is required");
|
||||
socket.close(4003, "Authentication failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let participant;
|
||||
try {
|
||||
participant = this.options.roomStore.connectParticipant(
|
||||
@@ -334,6 +345,9 @@ export class SignalingServer {
|
||||
: {
|
||||
roomId: message.roomId,
|
||||
role: "viewer",
|
||||
...(message.viewerGrant
|
||||
? { viewerGrant: message.viewerGrant }
|
||||
: {}),
|
||||
clientId: message.clientId,
|
||||
sessionId: state.sessionId,
|
||||
},
|
||||
@@ -423,6 +437,9 @@ export class SignalingServer {
|
||||
connectionId,
|
||||
viewerPeerIds: [...participant.viewerPeerIds],
|
||||
iceConfig: this.iceConfig(),
|
||||
viewerPolicy: participant.viewerPolicy,
|
||||
viewerAuthorizationGeneration:
|
||||
participant.viewerAuthorizationGeneration,
|
||||
} satisfies Extract<ServerMessage, { type: "authenticated" }>;
|
||||
if (hybridState) {
|
||||
this.send(socket, {
|
||||
@@ -602,6 +619,17 @@ export class SignalingServer {
|
||||
case "viewer-quality-evidence":
|
||||
this.handleViewerQualityEvidence(socket, authenticated, message);
|
||||
return;
|
||||
case "set-viewer-access":
|
||||
if (authenticated.role !== "host") {
|
||||
this.sendError(
|
||||
socket,
|
||||
"FORBIDDEN",
|
||||
"Only the host may change Viewer access",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.updateViewerAccess(socket, authenticated, message.action);
|
||||
return;
|
||||
case "stop-sharing":
|
||||
if (authenticated.role !== "host") {
|
||||
this.sendError(socket, "FORBIDDEN", "Only the host may stop sharing");
|
||||
@@ -643,6 +671,78 @@ export class SignalingServer {
|
||||
}
|
||||
}
|
||||
|
||||
private updateViewerAccess(
|
||||
hostSocket: WebSocket,
|
||||
authenticated: AuthenticatedSession,
|
||||
action: "public-watch" | "rotate" | "revoke",
|
||||
): void {
|
||||
let update;
|
||||
try {
|
||||
update = this.options.roomStore.setViewerAccess(
|
||||
authenticated.roomId,
|
||||
action,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Viewer access update failed", error);
|
||||
this.sendError(hostSocket, "SERVER_ERROR", "Viewer access could not be updated");
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.revokedViewers.length > 0) {
|
||||
this.clearRoomGraceTimers(authenticated.roomId);
|
||||
this.clearRoomConnectionIds(authenticated.roomId);
|
||||
|
||||
for (const viewer of update.revokedViewers) {
|
||||
if (!viewer.sessionId) {
|
||||
continue;
|
||||
}
|
||||
this.sendToSession(viewer.sessionId, {
|
||||
type: "viewer-access-revoked",
|
||||
viewerAuthorizationGeneration:
|
||||
update.previousViewerAuthorizationGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isPeerAssistedRoom(authenticated.roomId)) {
|
||||
for (const viewer of update.revokedViewers) {
|
||||
this.hybridMediaRouter!.removeViewer(
|
||||
authenticated.roomId,
|
||||
viewer.peerId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const host = this.options.roomStore.getConnectedHost(authenticated.roomId);
|
||||
if (host) {
|
||||
for (const viewer of update.revokedViewers) {
|
||||
this.sendToSession(host.sessionId, {
|
||||
type: "peer-left",
|
||||
peerId: viewer.peerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewer of update.revokedViewers) {
|
||||
if (!viewer.sessionId) {
|
||||
continue;
|
||||
}
|
||||
this.closeRevokedViewerSession(viewer.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const inviteUrl =
|
||||
action === "revoke"
|
||||
? null
|
||||
: this.viewerInviteUrl(authenticated.roomId, update.viewerGrant);
|
||||
this.send(hostSocket, {
|
||||
type: "viewer-access-updated",
|
||||
viewerPolicy: update.viewerPolicy,
|
||||
viewerAuthorizationGeneration: update.viewerAuthorizationGeneration,
|
||||
inviteUrl,
|
||||
viewerGrantExpiresAt: update.viewerGrantExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
private handleViewerQualityEvidence(
|
||||
socket: WebSocket,
|
||||
source: AuthenticatedSession,
|
||||
@@ -906,6 +1006,9 @@ export class SignalingServer {
|
||||
if (this.socketsBySessionId.get(state.sessionId) === socket) {
|
||||
this.socketsBySessionId.delete(state.sessionId);
|
||||
}
|
||||
if (state.revoked) {
|
||||
return;
|
||||
}
|
||||
if (!state.authenticated) {
|
||||
this.unauthenticatedConnections -= 1;
|
||||
return;
|
||||
@@ -1077,6 +1180,14 @@ export class SignalingServer {
|
||||
return createIceConfig(this.options.ice);
|
||||
}
|
||||
|
||||
private viewerInviteUrl(roomId: string, viewerGrant: string | null): string {
|
||||
const inviteUrl = new URL(`/r/${roomId}`, this.options.publicBaseUrl);
|
||||
if (viewerGrant) {
|
||||
inviteUrl.hash = `v=${viewerGrant}`;
|
||||
}
|
||||
return inviteUrl.toString();
|
||||
}
|
||||
|
||||
private connectedPeer(roomId: string, peerId: string) {
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
if (host?.peerId === peerId) {
|
||||
@@ -1092,6 +1203,24 @@ export class SignalingServer {
|
||||
}
|
||||
}
|
||||
|
||||
private closeRevokedViewerSession(sessionId: string): void {
|
||||
const socket = this.socketsBySessionId.get(sessionId);
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
const state = this.socketStates.get(socket);
|
||||
if (state?.sessionId === sessionId) {
|
||||
clearTimeout(state.authenticationTimer);
|
||||
state.revoked = true;
|
||||
}
|
||||
if (this.socketsBySessionId.get(sessionId) === socket) {
|
||||
this.socketsBySessionId.delete(sessionId);
|
||||
}
|
||||
// Deauthorize before close so queued callbacks from the old generation
|
||||
// cannot route signaling after the persistent authorization commit.
|
||||
socket.close(4004, "Viewer access revoked");
|
||||
}
|
||||
|
||||
private sendEncodedToSession(sessionId: string, encoded: string): boolean {
|
||||
const socket = this.socketsBySessionId.get(sessionId);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
|
||||
+46
-1
@@ -4,7 +4,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
|
||||
|
||||
export const MAX_VIEWERS_PER_ROOM_LIMIT = 16;
|
||||
export const MAX_SIGNAL_BYTES = 64 * 1024;
|
||||
export const SIGNALING_PROTOCOL = "screener-v1";
|
||||
export const SIGNALING_PROTOCOL = "screener-v2";
|
||||
export const ROOM_CODE_LENGTH = 12;
|
||||
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
|
||||
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
|
||||
@@ -25,6 +25,18 @@ const tokenSchema = z
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
export const viewerGrantSchema = z
|
||||
.string()
|
||||
.min(50)
|
||||
.max(96)
|
||||
.regex(/^g1\.[1-9]\d{0,11}\.[1-9]\d{0,12}\.[A-Za-z0-9_-]{43}$/);
|
||||
|
||||
export const viewerAccessPolicySchema = z.enum([
|
||||
"private-link",
|
||||
"public-watch",
|
||||
]);
|
||||
export type ViewerAccessPolicy = z.infer<typeof viewerAccessPolicySchema>;
|
||||
|
||||
const liveKitWebSocketUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
@@ -332,6 +344,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
|
||||
roomId: roomCodeSchema,
|
||||
role: z.literal("viewer"),
|
||||
clientId: opaqueIdSchema,
|
||||
viewerGrant: viewerGrantSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
@@ -387,6 +400,12 @@ export const clientMessageSchema = z.union([
|
||||
})
|
||||
.strict(),
|
||||
viewerQualityEvidenceMessageSchema,
|
||||
z
|
||||
.object({
|
||||
type: z.literal("set-viewer-access"),
|
||||
action: z.enum(["public-watch", "rotate", "revoke"]),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("stop-sharing"),
|
||||
@@ -420,6 +439,8 @@ const authenticatedMessageShape = {
|
||||
connectionId: opaqueIdSchema.nullable(),
|
||||
viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS_PER_ROOM_LIMIT),
|
||||
iceConfig: iceConfigSchema,
|
||||
viewerPolicy: viewerAccessPolicySchema,
|
||||
viewerAuthorizationGeneration: opaqueIdSchema,
|
||||
};
|
||||
|
||||
const authenticatedMessageSchema = z.union([
|
||||
@@ -512,6 +533,21 @@ export const serverMessageSchema = z.union([
|
||||
online: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("viewer-access-updated"),
|
||||
viewerPolicy: viewerAccessPolicySchema,
|
||||
viewerAuthorizationGeneration: opaqueIdSchema,
|
||||
inviteUrl: z.string().url().max(2048).nullable(),
|
||||
viewerGrantExpiresAt: z.string().datetime().nullable(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("viewer-access-revoked"),
|
||||
viewerAuthorizationGeneration: opaqueIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ type: z.literal("sharing-stopped") }).strict(),
|
||||
z
|
||||
.object({
|
||||
@@ -534,11 +570,20 @@ export const createRoomResponseSchema = z
|
||||
roomId: roomCodeSchema,
|
||||
hostToken: tokenSchema,
|
||||
inviteUrl: z.string().url().max(2048),
|
||||
viewerPolicy: viewerAccessPolicySchema,
|
||||
viewerGrantExpiresAt: z.string().datetime().nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
})
|
||||
.strict();
|
||||
export type CreateRoomResponse = z.infer<typeof createRoomResponseSchema>;
|
||||
|
||||
export const createRoomRequestSchema = z
|
||||
.object({
|
||||
viewerPolicy: viewerAccessPolicySchema,
|
||||
})
|
||||
.strict();
|
||||
export type CreateRoomRequest = z.infer<typeof createRoomRequestSchema>;
|
||||
|
||||
export function decodeClientMessage(value: string): ClientMessage {
|
||||
return clientMessageSchema.parse(JSON.parse(value));
|
||||
}
|
||||
|
||||
+220
-37
@@ -2,15 +2,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
authenticate,
|
||||
authenticateHost,
|
||||
createRoom,
|
||||
getSession,
|
||||
getHostAdmission,
|
||||
} from "../src/client/lib/api.ts";
|
||||
import {
|
||||
clearHostRoom,
|
||||
getStableClientId,
|
||||
readHostRoom,
|
||||
isValidRoomId,
|
||||
mergeAuthenticatedHostRoom,
|
||||
readHostRoom,
|
||||
readViewerGrant,
|
||||
readViewerRoute,
|
||||
replaceViewerInvite,
|
||||
writeHostRoom,
|
||||
} from "../src/client/lib/session.ts";
|
||||
import {
|
||||
@@ -58,17 +62,61 @@ describe("client session identity", () => {
|
||||
const room = {
|
||||
roomId: "7",
|
||||
hostToken: "a".repeat(32),
|
||||
inviteUrl: "https://share.test/r/7",
|
||||
inviteUrl: `https://share.test/r/7#v=g1.7.1893456000.${"b".repeat(43)}`,
|
||||
viewerPolicy: "private-link" as const,
|
||||
viewerGrantExpiresAt: "2026-08-25T00:00:00.000Z",
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
writeHostRoom(room);
|
||||
|
||||
expect(readHostRoom()).toEqual(room);
|
||||
expect(readHostRoom()).toEqual({
|
||||
roomId: "7",
|
||||
hostToken: "a".repeat(32),
|
||||
canonicalUrl: "https://share.test/r/7",
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(values.get("screener:host-room:v1")).toBe(
|
||||
JSON.stringify({
|
||||
roomId: "7",
|
||||
hostToken: "a".repeat(32),
|
||||
expiresAt: null,
|
||||
inviteUrl: "https://share.test/r/7",
|
||||
}),
|
||||
);
|
||||
expect([...values.values()].join(" ")).not.toContain("g1.7.");
|
||||
clearHostRoom();
|
||||
expect(readHostRoom()).toBeNull();
|
||||
});
|
||||
|
||||
it("reclaims a deployed Host ownership record through the stable shape", () => {
|
||||
const values = new Map<string, string>([
|
||||
[
|
||||
"screener:host-room:v1",
|
||||
JSON.stringify({
|
||||
roomId: "9",
|
||||
hostToken: "h".repeat(32),
|
||||
inviteUrl: "https://share.test/r/9",
|
||||
expiresAt: null,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
vi.stubGlobal("window", {
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
expect(readHostRoom()).toEqual({
|
||||
roomId: "9",
|
||||
hostToken: "h".repeat(32),
|
||||
canonicalUrl: "https://share.test/r/9",
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards expired or malformed host room records", () => {
|
||||
const values = new Map<string, string>();
|
||||
const removeItem = vi.fn((key: string) => values.delete(key));
|
||||
@@ -82,7 +130,7 @@ describe("client session identity", () => {
|
||||
const expired = {
|
||||
roomId: "12",
|
||||
hostToken: "b".repeat(32),
|
||||
inviteUrl: "https://share.test/r/12",
|
||||
canonicalUrl: "https://share.test/r/12",
|
||||
expiresAt: "2026-08-18T00:00:00.000Z",
|
||||
};
|
||||
|
||||
@@ -94,10 +142,136 @@ describe("client session identity", () => {
|
||||
expect(readHostRoom()).toBeNull();
|
||||
expect(removeItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("consumes a Viewer grant fragment once into room-scoped session storage", () => {
|
||||
const values = new Map<string, string>();
|
||||
const replaceState = vi.fn();
|
||||
const grant = `g1.7.1893456000.${"c".repeat(43)}`;
|
||||
vi.stubGlobal("window", {
|
||||
location: new URL(`https://share.test/r/7#v=${grant}`),
|
||||
history: { state: { navigation: 1 }, replaceState },
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
expect(readViewerRoute()).toEqual({ roomId: "7", viewerGrant: grant });
|
||||
expect(values.get("screener:viewer-grant:7")).toBe(grant);
|
||||
expect(replaceState).toHaveBeenCalledWith(
|
||||
{ navigation: 1 },
|
||||
"",
|
||||
"/r/7",
|
||||
);
|
||||
expect(readViewerGrant("8")).toBeNull();
|
||||
});
|
||||
|
||||
it("fails malformed Viewer fragments closed without leaking across rooms", () => {
|
||||
const values = new Map<string, string>();
|
||||
const oldGrant = `g1.7.1893456000.${"d".repeat(43)}`;
|
||||
values.set("screener:viewer-grant:7", oldGrant);
|
||||
vi.stubGlobal("window", {
|
||||
location: new URL("https://share.test/r/7#v=malformed"),
|
||||
history: { state: null, replaceState: vi.fn() },
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
expect(readViewerRoute()).toEqual({ roomId: "7" });
|
||||
expect(readViewerGrant("7")).toBeNull();
|
||||
replaceViewerInvite(
|
||||
"8",
|
||||
`https://share.test/r/8#v=g1.7.1893456000.${"e".repeat(43)}`,
|
||||
);
|
||||
expect(readViewerGrant("7")).toBeNull();
|
||||
expect(readViewerGrant("8")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists a rotated Viewer invitation across reload and clears it on revoke", () => {
|
||||
const values = new Map<string, string>();
|
||||
vi.stubGlobal("window", {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
});
|
||||
const firstGrant = `g1.7.1893456000.${"g".repeat(43)}`;
|
||||
const rotatedGrant = `g1.7.1893456000.${"h".repeat(43)}`;
|
||||
|
||||
replaceViewerInvite("7", `https://share.test/r/7#v=${firstGrant}`);
|
||||
replaceViewerInvite("7", `https://share.test/r/7#v=${rotatedGrant}`);
|
||||
|
||||
expect(readViewerGrant("7")).toBe(rotatedGrant);
|
||||
expect(readViewerGrant("7")).toBe(rotatedGrant);
|
||||
|
||||
replaceViewerInvite("7", null);
|
||||
expect(readViewerGrant("7")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the latest rotated or revoked invite through Host re-authentication", () => {
|
||||
const values = new Map<string, string>();
|
||||
vi.stubGlobal("window", {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
});
|
||||
const oldInvite = `https://share.test/r/7#v=g1.7.1893456000.${"i".repeat(43)}`;
|
||||
const rotatedInvite = `https://share.test/r/7#v=g1.7.1893456000.${"j".repeat(43)}`;
|
||||
replaceViewerInvite("7", oldInvite);
|
||||
const activeRoom = {
|
||||
roomId: "7",
|
||||
hostToken: "h".repeat(32),
|
||||
canonicalUrl: "https://share.test/r/7",
|
||||
expiresAt: null,
|
||||
viewerPolicy: "private-link" as const,
|
||||
inviteUrl: oldInvite,
|
||||
};
|
||||
|
||||
expect(
|
||||
mergeAuthenticatedHostRoom(
|
||||
{ ...activeRoom, inviteUrl: rotatedInvite },
|
||||
activeRoom.roomId,
|
||||
null,
|
||||
"private-link",
|
||||
)?.inviteUrl,
|
||||
).toBe(rotatedInvite);
|
||||
expect(
|
||||
mergeAuthenticatedHostRoom(
|
||||
{ ...activeRoom, inviteUrl: null },
|
||||
activeRoom.roomId,
|
||||
null,
|
||||
"private-link",
|
||||
)?.inviteUrl,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("removes an expired Viewer grant from room-scoped session storage", () => {
|
||||
const values = new Map<string, string>([
|
||||
["screener:viewer-grant:7", `g1.7.1.${"f".repeat(43)}`],
|
||||
]);
|
||||
const removeItem = vi.fn((key: string) => values.delete(key));
|
||||
vi.stubGlobal("window", {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem,
|
||||
},
|
||||
});
|
||||
|
||||
expect(readViewerGrant("7")).toBeNull();
|
||||
expect(removeItem).toHaveBeenCalledWith("screener:viewer-grant:7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("site access API", () => {
|
||||
it("checks and authenticates a browser session without a request body", async () => {
|
||||
describe("Host admission API", () => {
|
||||
it("checks and authenticates Host admission without a request body", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
@@ -114,17 +288,17 @@ describe("site access API", () => {
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getSession()).resolves.toEqual({
|
||||
await expect(getHostAdmission()).resolves.toEqual({
|
||||
required: true,
|
||||
authenticated: false,
|
||||
});
|
||||
await expect(authenticate(" instance-password ")).resolves.toEqual({
|
||||
await expect(authenticateHost(" instance-password ")).resolves.toEqual({
|
||||
required: true,
|
||||
authenticated: true,
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
"/api/session",
|
||||
"/api/host-admission",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
]);
|
||||
const post = fetchMock.mock.calls[1][1];
|
||||
@@ -146,7 +320,7 @@ describe("site access API", () => {
|
||||
),
|
||||
);
|
||||
|
||||
await expect(authenticate("wrong-password")).rejects.toMatchObject({
|
||||
await expect(authenticateHost("wrong-password")).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: "访问密码不正确,请重试",
|
||||
});
|
||||
@@ -161,17 +335,22 @@ describe("site access API", () => {
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(createRoom()).rejects.toBeInstanceOf(ApiError);
|
||||
await expect(createRoom("private-link")).rejects.toBeInstanceOf(ApiError);
|
||||
|
||||
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
|
||||
expect(headers.has("Authorization")).toBe(false);
|
||||
expect(fetchMock.mock.calls[0][1]?.body).toBe(
|
||||
JSON.stringify({ viewerPolicy: "private-link" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a persistent room without an expiry", async () => {
|
||||
const room = {
|
||||
roomId: "42",
|
||||
hostToken: "c".repeat(32),
|
||||
inviteUrl: "https://share.test/r/42",
|
||||
inviteUrl: `https://share.test/r/42#v=g1.42.1893456000.${"f".repeat(43)}`,
|
||||
viewerPolicy: "private-link",
|
||||
viewerGrantExpiresAt: "2026-08-25T00:00:00.000Z",
|
||||
expiresAt: null,
|
||||
};
|
||||
vi.stubGlobal(
|
||||
@@ -184,7 +363,7 @@ describe("site access API", () => {
|
||||
),
|
||||
);
|
||||
|
||||
await expect(createRoom()).resolves.toEqual(room);
|
||||
await expect(createRoom("private-link")).resolves.toEqual(room);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +382,7 @@ describe("room codes", () => {
|
||||
describe("client signaling recovery policy", () => {
|
||||
it("does not reconnect a session that another tab replaced", () => {
|
||||
expect(shouldReconnectSignaling(4001)).toBe(false);
|
||||
expect(shouldReconnectSignaling(4004)).toBe(false);
|
||||
expect(shouldReconnectSignaling(1008)).toBe(false);
|
||||
expect(shouldReconnectSignaling(1006)).toBe(true);
|
||||
});
|
||||
@@ -225,7 +405,7 @@ describe("client signaling recovery policy", () => {
|
||||
iceConfig: { iceServers: [] },
|
||||
}),
|
||||
])(
|
||||
"terminates once when an old server uses an incompatible protocol",
|
||||
"terminates once when a server payload is incompatible with v2",
|
||||
(payload) => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
class FakeWebSocket extends EventTarget {
|
||||
@@ -266,7 +446,7 @@ describe("client signaling recovery policy", () => {
|
||||
JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])),
|
||||
).toMatchObject({
|
||||
type: "authenticate",
|
||||
protocol: "screener-v1",
|
||||
protocol: "screener-v2",
|
||||
});
|
||||
const message = new Event("message");
|
||||
Object.defineProperty(message, "data", { value: payload });
|
||||
@@ -287,11 +467,11 @@ describe("client signaling recovery policy", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("returns to the access gate after an unauthorized upgrade", async () => {
|
||||
it("returns only a Host to the admission gate after AUTH_REQUIRED", () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
class FakeWebSocket extends EventTarget {
|
||||
static readonly CLOSING = 2;
|
||||
readyState = 0;
|
||||
readyState = 1;
|
||||
readonly send = vi.fn();
|
||||
readonly close = vi.fn();
|
||||
|
||||
@@ -300,13 +480,6 @@ describe("client signaling recovery policy", () => {
|
||||
sockets.push(this);
|
||||
}
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ required: true, authenticated: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("window", {
|
||||
location: new URL("https://share.test/r/123456789012"),
|
||||
@@ -315,14 +488,16 @@ describe("client signaling recovery policy", () => {
|
||||
});
|
||||
const onAccessRequired = vi.fn();
|
||||
const statuses: string[] = [];
|
||||
const onMessage = vi.fn();
|
||||
const signal = new SignalingClient(
|
||||
{
|
||||
roomId: "123456789012",
|
||||
role: "viewer",
|
||||
clientId: "viewer-client",
|
||||
role: "host",
|
||||
token: "h".repeat(43),
|
||||
clientId: "host-client",
|
||||
},
|
||||
{
|
||||
onMessage: () => undefined,
|
||||
onMessage,
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onTerminated: () => undefined,
|
||||
onAccessRequired,
|
||||
@@ -330,16 +505,24 @@ describe("client signaling recovery policy", () => {
|
||||
);
|
||||
|
||||
signal.start();
|
||||
const closeEvent = new Event("close");
|
||||
Object.defineProperties(closeEvent, {
|
||||
code: { value: 1006 },
|
||||
reason: { value: "" },
|
||||
sockets[0]!.dispatchEvent(new Event("open"));
|
||||
const message = new Event("message");
|
||||
Object.defineProperties(message, {
|
||||
data: {
|
||||
value: JSON.stringify({
|
||||
type: "error",
|
||||
code: "AUTH_REQUIRED",
|
||||
message: "Host admission is required",
|
||||
}),
|
||||
},
|
||||
});
|
||||
sockets[0]!.dispatchEvent(closeEvent);
|
||||
sockets[0]!.dispatchEvent(message);
|
||||
|
||||
await vi.waitFor(() => expect(onAccessRequired).toHaveBeenCalledOnce());
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/session", {
|
||||
headers: { Accept: "application/json" },
|
||||
expect(onAccessRequired).toHaveBeenCalledOnce();
|
||||
expect(onMessage).toHaveBeenCalledWith({
|
||||
type: "error",
|
||||
code: "AUTH_REQUIRED",
|
||||
message: "Host admission is required",
|
||||
});
|
||||
expect(statuses.at(-1)).toBe("offline");
|
||||
expect(sockets).toHaveLength(1);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
const token = "a".repeat(43);
|
||||
const roomId = "123456789012";
|
||||
const viewerGrant = `g1.${roomId}.1787076000.${"b".repeat(43)}`;
|
||||
const qualitySettings = {
|
||||
resolution: "1080p",
|
||||
maxFramerate: 60,
|
||||
@@ -68,6 +69,18 @@ describe("client signaling protocol", () => {
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ type: "authenticate", role: "viewer" });
|
||||
expect(
|
||||
decodeClientMessage(
|
||||
JSON.stringify({
|
||||
type: "authenticate",
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId,
|
||||
role: "viewer",
|
||||
viewerGrant,
|
||||
clientId: "client_12345678",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ viewerGrant });
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
@@ -87,6 +100,40 @@ describe("client signaling protocol", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only canonical bounded Viewer grants and access actions", () => {
|
||||
for (const malformedGrant of [
|
||||
`g0.${roomId}.1787076000.${"b".repeat(43)}`,
|
||||
`g1.${roomId}.0.${"b".repeat(43)}`,
|
||||
`g1.${roomId}.1787076000.${"b".repeat(42)}`,
|
||||
`${viewerGrant}.extra`,
|
||||
]) {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: malformedGrant,
|
||||
clientId: "client_12345678",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
}
|
||||
for (const action of ["public-watch", "rotate", "revoke"]) {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "set-viewer-access",
|
||||
action,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "set-viewer-access",
|
||||
action: "private-link",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown fields and malformed tokens", () => {
|
||||
const result = clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
@@ -456,6 +503,8 @@ describe("server signaling protocol", () => {
|
||||
hostOnline: true,
|
||||
connectionId: null,
|
||||
viewerPeerIds,
|
||||
viewerPolicy: "private-link",
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
iceConfig: {
|
||||
iceServers: [],
|
||||
},
|
||||
|
||||
+364
-1
@@ -1,8 +1,9 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../src/shared/protocol.ts";
|
||||
import { RoomDatabase } from "../src/server/room-database.ts";
|
||||
@@ -21,6 +22,13 @@ function expectRoomError(action: () => unknown, code: RoomStoreError["code"]): v
|
||||
}
|
||||
}
|
||||
|
||||
function viewerGrant(room: { viewerGrant: string | null }): string {
|
||||
if (!room.viewerGrant) {
|
||||
throw new Error("Expected a private room Viewer grant");
|
||||
}
|
||||
return room.viewerGrant;
|
||||
}
|
||||
|
||||
describe("RoomStore", () => {
|
||||
it("creates a four-hour room and authenticates both roles", () => {
|
||||
const now = Date.UTC(2026, 7, 18, 12);
|
||||
@@ -39,6 +47,7 @@ describe("RoomStore", () => {
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "viewer-client-1",
|
||||
sessionId: "viewer-session-1",
|
||||
}),
|
||||
@@ -89,12 +98,14 @@ describe("RoomStore", () => {
|
||||
const first = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "stable-viewer-client",
|
||||
sessionId: "viewer-session-old",
|
||||
});
|
||||
const replacement = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "stable-viewer-client",
|
||||
sessionId: "viewer-session-new",
|
||||
});
|
||||
@@ -120,6 +131,7 @@ describe("RoomStore", () => {
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: `viewer-client-${number}`,
|
||||
sessionId: `viewer-session-${number}`,
|
||||
}),
|
||||
@@ -130,6 +142,7 @@ describe("RoomStore", () => {
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "viewer-client-3",
|
||||
sessionId: "viewer-session-3",
|
||||
}),
|
||||
@@ -142,6 +155,7 @@ describe("RoomStore", () => {
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "viewer-client-3",
|
||||
sessionId: "viewer-session-3",
|
||||
}),
|
||||
@@ -152,6 +166,7 @@ describe("RoomStore", () => {
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "viewer-client-3",
|
||||
sessionId: "viewer-session-3",
|
||||
}).role,
|
||||
@@ -248,6 +263,9 @@ describe("RoomStore", () => {
|
||||
expect(readFileSync(databasePath).includes(Buffer.from(first.hostToken))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
readFileSync(databasePath).includes(Buffer.from(viewerGrant(first))),
|
||||
).toBe(false);
|
||||
|
||||
store = new RoomStore({
|
||||
ttlMs: 100,
|
||||
@@ -260,6 +278,7 @@ describe("RoomStore", () => {
|
||||
store.connectParticipant({
|
||||
roomId: first.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(first),
|
||||
clientId: "viewer-after-restart",
|
||||
sessionId: "viewer-session-after-restart",
|
||||
}),
|
||||
@@ -281,6 +300,7 @@ describe("RoomStore", () => {
|
||||
store!.connectParticipant({
|
||||
roomId: first.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(first),
|
||||
clientId: "viewer-after-abandon",
|
||||
sessionId: "viewer-session-after-abandon",
|
||||
}),
|
||||
@@ -293,6 +313,331 @@ describe("RoomStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fails private Viewer access closed and scopes grants to one room", () => {
|
||||
const now = Date.UTC(2026, 7, 18, 12);
|
||||
const store = new RoomStore({
|
||||
ttlMs: 14_400_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 3,
|
||||
now: () => now,
|
||||
});
|
||||
const first = store.createRoom();
|
||||
const second = store.createRoom();
|
||||
|
||||
for (const candidate of [undefined, "not-a-grant", viewerGrant(second)]) {
|
||||
expectRoomError(
|
||||
() =>
|
||||
store.connectParticipant({
|
||||
roomId: first.roomId,
|
||||
role: "viewer",
|
||||
...(candidate ? { viewerGrant: candidate } : {}),
|
||||
clientId: `viewer-${candidate ?? "missing"}`,
|
||||
sessionId: `session-${candidate ?? "missing"}`,
|
||||
}),
|
||||
"INVALID_TOKEN",
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
store.connectParticipant({
|
||||
roomId: first.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(first),
|
||||
clientId: "authorized-viewer",
|
||||
sessionId: "authorized-session",
|
||||
}),
|
||||
).toMatchObject({ viewerPolicy: "private-link" });
|
||||
});
|
||||
|
||||
it("allows grant-free viewing only for an explicitly public room", () => {
|
||||
const store = new RoomStore({
|
||||
ttlMs: 10_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 3,
|
||||
});
|
||||
const room = store.createRoom("public-watch");
|
||||
|
||||
expect(room).toMatchObject({
|
||||
viewerPolicy: "public-watch",
|
||||
viewerGrant: null,
|
||||
viewerGrantExpiresAt: null,
|
||||
});
|
||||
expect(
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
clientId: "public-viewer",
|
||||
sessionId: "public-session",
|
||||
}),
|
||||
).toMatchObject({ viewerPolicy: "public-watch" });
|
||||
});
|
||||
|
||||
it("treats public-to-private as a strong Viewer authorization rotation", () => {
|
||||
const store = new RoomStore({
|
||||
ttlMs: 10_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 3,
|
||||
});
|
||||
const room = store.createRoom("public-watch");
|
||||
const connected = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
clientId: "public-viewer",
|
||||
sessionId: "public-session",
|
||||
});
|
||||
|
||||
const update = store.setViewerAccess(room.roomId, "rotate");
|
||||
|
||||
expect(update).toMatchObject({
|
||||
viewerPolicy: "private-link",
|
||||
revokedViewers: [
|
||||
{ peerId: connected.peerId, sessionId: "public-session" },
|
||||
],
|
||||
});
|
||||
expect(update.viewerGrant).toBeTruthy();
|
||||
expect(update.viewerAuthorizationGeneration).not.toBe(
|
||||
connected.viewerAuthorizationGeneration,
|
||||
);
|
||||
expectRoomError(
|
||||
() =>
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
clientId: "grant-free-after-rotation",
|
||||
sessionId: "grant-free-session",
|
||||
}),
|
||||
"INVALID_TOKEN",
|
||||
);
|
||||
expect(
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(update),
|
||||
clientId: "private-viewer",
|
||||
sessionId: "private-session",
|
||||
}),
|
||||
).toMatchObject({
|
||||
viewerPolicy: "private-link",
|
||||
viewerAuthorizationGeneration: update.viewerAuthorizationGeneration,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps old authorization and connected Viewers when persistence fails", () => {
|
||||
const database = new RoomDatabase(":memory:");
|
||||
const store = new RoomStore({
|
||||
ttlMs: 10_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 3,
|
||||
database,
|
||||
});
|
||||
const room = store.createRoom();
|
||||
const connected = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "existing-viewer",
|
||||
sessionId: "existing-session",
|
||||
});
|
||||
vi.spyOn(database, "updateViewerGrantDigest").mockImplementation(() => {
|
||||
throw new Error("disk write failed");
|
||||
});
|
||||
|
||||
expect(() => store.setViewerAccess(room.roomId, "rotate")).toThrow(
|
||||
"disk write failed",
|
||||
);
|
||||
expect(store.getConnectedViewer(room.roomId, connected.peerId)).toEqual({
|
||||
peerId: connected.peerId,
|
||||
sessionId: "existing-session",
|
||||
});
|
||||
expect(
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "new-viewer",
|
||||
sessionId: "new-session",
|
||||
}),
|
||||
).toMatchObject({
|
||||
viewerAuthorizationGeneration: connected.viewerAuthorizationGeneration,
|
||||
});
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("does not persist a room when its authorization generation cannot be created", () => {
|
||||
const database = new RoomDatabase(":memory:");
|
||||
let randomCall = 0;
|
||||
const store = new RoomStore({
|
||||
ttlMs: 10_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 3,
|
||||
database,
|
||||
random: (size) => {
|
||||
randomCall += 1;
|
||||
return Buffer.alloc(randomCall === 2 ? size - 1 : size, randomCall);
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => store.createRoom()).toThrow(
|
||||
"Authorization generation random source must return 16 bytes",
|
||||
);
|
||||
expect(database.loadRooms()).toEqual([]);
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("keeps persisted authorization and Viewers when generation creation fails", () => {
|
||||
const database = new RoomDatabase(":memory:");
|
||||
let randomValue = 0;
|
||||
let failGeneration = false;
|
||||
const store = new RoomStore({
|
||||
ttlMs: 10_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 3,
|
||||
database,
|
||||
random: (size) => {
|
||||
randomValue += 1;
|
||||
return Buffer.alloc(
|
||||
failGeneration && size === 16 ? size - 1 : size,
|
||||
randomValue,
|
||||
);
|
||||
},
|
||||
});
|
||||
const room = store.createRoom();
|
||||
const connected = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "existing-viewer",
|
||||
sessionId: "existing-session",
|
||||
});
|
||||
const persistedDigest = database.loadRooms()[0].viewerGrantDigest;
|
||||
|
||||
failGeneration = true;
|
||||
expect(() => store.setViewerAccess(room.roomId, "rotate")).toThrow(
|
||||
"Authorization generation random source must return 16 bytes",
|
||||
);
|
||||
failGeneration = false;
|
||||
|
||||
expect(database.loadRooms()[0].viewerGrantDigest).toEqual(persistedDigest);
|
||||
expect(store.getConnectedViewer(room.roomId, connected.peerId)).toEqual({
|
||||
peerId: connected.peerId,
|
||||
sessionId: "existing-session",
|
||||
});
|
||||
expect(
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "new-viewer",
|
||||
sessionId: "new-session",
|
||||
}),
|
||||
).toMatchObject({
|
||||
viewerAuthorizationGeneration: connected.viewerAuthorizationGeneration,
|
||||
});
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("commits a strong rotation before invalidating old Viewer authority", () => {
|
||||
const store = new RoomStore({
|
||||
ttlMs: 10_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 3,
|
||||
});
|
||||
const room = store.createRoom();
|
||||
const connected = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "old-viewer",
|
||||
sessionId: "old-session",
|
||||
});
|
||||
|
||||
const update = store.setViewerAccess(room.roomId, "rotate");
|
||||
expect(update.viewerAuthorizationGeneration).not.toBe(
|
||||
connected.viewerAuthorizationGeneration,
|
||||
);
|
||||
expect(update.revokedViewers).toEqual([
|
||||
{ peerId: connected.peerId, sessionId: "old-session" },
|
||||
]);
|
||||
expect(store.getConnectedViewer(room.roomId, connected.peerId)).toBeUndefined();
|
||||
expectRoomError(
|
||||
() =>
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(room),
|
||||
clientId: "old-grant-viewer",
|
||||
sessionId: "old-grant-session",
|
||||
}),
|
||||
"INVALID_TOKEN",
|
||||
);
|
||||
expect(
|
||||
store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: viewerGrant(update),
|
||||
clientId: "new-grant-viewer",
|
||||
sessionId: "new-grant-session",
|
||||
}),
|
||||
).toMatchObject({
|
||||
viewerAuthorizationGeneration: update.viewerAuthorizationGeneration,
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates every v1 room to a distinct locked private digest", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "screener-room-migration-"));
|
||||
const databasePath = join(directory, "rooms.sqlite");
|
||||
try {
|
||||
createV1Database(databasePath, 2);
|
||||
let randomIndex = 0;
|
||||
const database = new RoomDatabase(databasePath, (size) =>
|
||||
Buffer.alloc(size, ++randomIndex),
|
||||
);
|
||||
|
||||
const rooms = database.loadRooms();
|
||||
expect(rooms).toHaveLength(2);
|
||||
expect(rooms.every((room) => room.viewerGrantDigest?.byteLength === 32)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(rooms[0].viewerGrantDigest).not.toEqual(rooms[1].viewerGrantDigest);
|
||||
database.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls a failed v1 migration back to the untouched v1 schema", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "screener-room-rollback-"));
|
||||
const databasePath = join(directory, "rooms.sqlite");
|
||||
try {
|
||||
createV1Database(databasePath, 2);
|
||||
let calls = 0;
|
||||
expect(
|
||||
() =>
|
||||
new RoomDatabase(databasePath, (size) => {
|
||||
calls += 1;
|
||||
return Buffer.alloc(calls === 2 ? size - 1 : size, calls);
|
||||
}),
|
||||
).toThrow("Migration random source digest must contain 32 bytes");
|
||||
|
||||
const inspection = new DatabaseSync(databasePath);
|
||||
expect(inspection.prepare("PRAGMA user_version").get()).toEqual({
|
||||
user_version: 1,
|
||||
});
|
||||
expect(
|
||||
inspection
|
||||
.prepare("PRAGMA table_info(rooms)")
|
||||
.all()
|
||||
.map((column) => column.name),
|
||||
).toEqual(["id", "host_token_digest"]);
|
||||
expect(inspection.prepare("SELECT COUNT(*) AS count FROM rooms").get()).toEqual(
|
||||
{ count: 2 },
|
||||
);
|
||||
inspection.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([0, 1.5, MAX_VIEWERS_PER_ROOM_LIMIT + 1])(
|
||||
"rejects invalid per-room viewer limit %s",
|
||||
(maxViewersPerRoom) => {
|
||||
@@ -309,3 +654,21 @@ describe("RoomStore", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function createV1Database(path: string, roomCount: number): void {
|
||||
const database = new DatabaseSync(path);
|
||||
database.exec(`
|
||||
CREATE TABLE rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_token_digest BLOB NOT NULL CHECK (length(host_token_digest) = 32)
|
||||
) STRICT;
|
||||
PRAGMA user_version = 1;
|
||||
`);
|
||||
const insert = database.prepare(
|
||||
"INSERT INTO rooms (host_token_digest) VALUES (?)",
|
||||
);
|
||||
for (let index = 0; index < roomCount; index += 1) {
|
||||
insert.run(Buffer.alloc(32, index + 1));
|
||||
}
|
||||
database.close();
|
||||
}
|
||||
+37
-13
@@ -135,7 +135,7 @@ describe("server configuration", () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
ACCESS_PASSWORD: "x".repeat(32),
|
||||
HOST_ADMISSION_PASSWORD: "x".repeat(32),
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
PEER_ASSISTED_ROOM_IDS: "1",
|
||||
LIVEKIT_URL: "wss://livekit.test",
|
||||
@@ -151,7 +151,7 @@ describe("server configuration", () => {
|
||||
},
|
||||
])("rejects reused infrastructure secrets", (environment) => {
|
||||
expect(() => loadConfig(environment)).toThrow(
|
||||
"ACCESS_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values",
|
||||
"HOST_ADMISSION_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -240,12 +240,17 @@ describe("server configuration", () => {
|
||||
|
||||
it("requires STUN but not TURN in production", () => {
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test" }),
|
||||
loadConfig({
|
||||
NODE_ENV: "production",
|
||||
PUBLIC_BASE_URL: "https://share.test",
|
||||
HOST_ADMISSION_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: "host-password-12",
|
||||
STUN_URLS: "stun:stun.test:3478",
|
||||
});
|
||||
expect(config.stunUrls).toEqual(["stun:stun.test:3478"]);
|
||||
@@ -255,6 +260,7 @@ describe("server configuration", () => {
|
||||
const config = loadConfig({
|
||||
NODE_ENV: "production",
|
||||
PUBLIC_BASE_URL: "https://share.test",
|
||||
HOST_ADMISSION_PASSWORD: "host-password-12",
|
||||
STUN_URLS: "stun:stun.test:3478",
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
PEER_ASSISTED_ROOM_IDS: "1",
|
||||
@@ -335,23 +341,30 @@ describe("server configuration", () => {
|
||||
).toThrow("STUN_URLS must contain at most 8 URLs");
|
||||
});
|
||||
|
||||
it("accepts a short production access password", () => {
|
||||
it("requires and accepts a bounded production Host admission 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");
|
||||
const config = loadConfig({
|
||||
NODE_ENV: "production",
|
||||
PUBLIC_BASE_URL: "https://share.test",
|
||||
ACCESS_PASSWORD: "short-pass1",
|
||||
HOST_ADMISSION_PASSWORD: "host-password-12",
|
||||
STUN_URLS: "stun:stun.test:3478",
|
||||
});
|
||||
expect(config.accessPassword).toBe("short-pass1");
|
||||
expect(config.hostAdmissionPassword).toBe("host-password-12");
|
||||
});
|
||||
|
||||
it("requires access protection for a persistent room database", () => {
|
||||
it("requires Host admission protection for a persistent room database", () => {
|
||||
expect(() => loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" })).toThrow(
|
||||
"ROOM_DATABASE_PATH requires ACCESS_PASSWORD",
|
||||
"ROOM_DATABASE_PATH requires HOST_ADMISSION_PASSWORD",
|
||||
);
|
||||
|
||||
const config = loadConfig({
|
||||
ACCESS_PASSWORD: "x",
|
||||
HOST_ADMISSION_PASSWORD: "host-password-12",
|
||||
ROOM_DATABASE_PATH: "rooms.sqlite",
|
||||
});
|
||||
expect(config.roomDatabasePath).toBe("rooms.sqlite");
|
||||
@@ -362,18 +375,29 @@ describe("server configuration", () => {
|
||||
loadConfig({
|
||||
NODE_ENV: "production",
|
||||
PUBLIC_BASE_URL: "https://share.test",
|
||||
ACCESS_PASSWORD: "x",
|
||||
HOST_ADMISSION_PASSWORD: "host-password-12",
|
||||
ROOM_DATABASE_PATH: ":memory:",
|
||||
STUN_URLS: "stun:stun.test:3478",
|
||||
}),
|
||||
).toThrow("ROOM_DATABASE_PATH must be file-backed in production");
|
||||
});
|
||||
|
||||
it.each(["密码密码密码密码", "contains spaces", "x".repeat(129)])(
|
||||
"rejects an access password that cannot be carried safely in a header",
|
||||
it.each(["x".repeat(15), "密码密码密码密码", "contains spaces", "x".repeat(129)])(
|
||||
"rejects a Host admission password outside the visible ASCII boundary",
|
||||
(hostAdmissionPassword) => {
|
||||
expect(() =>
|
||||
loadConfig({ HOST_ADMISSION_PASSWORD: hostAdmissionPassword }),
|
||||
).toThrow(
|
||||
"HOST_ADMISSION_PASSWORD must contain 16 to 128 visible ASCII bytes",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, "", "legacy-password"])(
|
||||
"rejects the removed ACCESS_PASSWORD configuration",
|
||||
(accessPassword) => {
|
||||
expect(() => loadConfig({ ACCESS_PASSWORD: accessPassword })).toThrow(
|
||||
"ACCESS_PASSWORD must contain at most 128 visible ASCII characters",
|
||||
"ACCESS_PASSWORD is no longer supported; use HOST_ADMISSION_PASSWORD",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+79
-62
@@ -13,7 +13,7 @@ import {
|
||||
import type { ServerConfig } from "../src/server/config.ts";
|
||||
|
||||
const allowedOrigin = "http://allowed.test";
|
||||
const accessPassword = "instance-access-password";
|
||||
const hostAdmissionPassword = "instance-access-password";
|
||||
let runningServer: ScreenerServer | undefined;
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
@@ -38,7 +38,7 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
||||
listenHost: "127.0.0.1",
|
||||
publicBaseUrl: new URL("https://share.example.test"),
|
||||
allowedOrigins: new Set([allowedOrigin]),
|
||||
accessPassword,
|
||||
hostAdmissionPassword,
|
||||
roomTtlMs: 14_400_000,
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 8,
|
||||
@@ -52,7 +52,7 @@ async function start(
|
||||
config = testConfig(),
|
||||
options: Pick<
|
||||
CreateServerOptions,
|
||||
"now" | "accessSessionTtlSeconds" | "sfuTokenIssuer"
|
||||
"now" | "hostAdmissionTtlSeconds" | "sfuTokenIssuer"
|
||||
> = {},
|
||||
): Promise<string> {
|
||||
runningServer = await createScreenerServer({
|
||||
@@ -73,19 +73,35 @@ function cookiePair(response: Response): string {
|
||||
}
|
||||
|
||||
async function login(baseUrl: string): Promise<Response> {
|
||||
return fetch(`${baseUrl}/api/session`, {
|
||||
return fetch(`${baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessPassword}`,
|
||||
Authorization: `Bearer ${hostAdmissionPassword}`,
|
||||
Origin: allowedOrigin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("site access session", () => {
|
||||
async function createRoom(
|
||||
baseUrl: string,
|
||||
cookie?: string,
|
||||
viewerPolicy: "private-link" | "public-watch" = "private-link",
|
||||
): Promise<Response> {
|
||||
return fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: allowedOrigin,
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify({ viewerPolicy }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Host admission", () => {
|
||||
it("reports status and issues a stateless 12-hour cookie", async () => {
|
||||
const baseUrl = await start();
|
||||
const initial = await fetch(`${baseUrl}/api/session`);
|
||||
const initial = await fetch(`${baseUrl}/api/host-admission`);
|
||||
|
||||
expect(initial.status).toBe(200);
|
||||
expect(initial.headers.get("cache-control")).toBe("no-store");
|
||||
@@ -94,7 +110,7 @@ describe("site access session", () => {
|
||||
authenticated: false,
|
||||
});
|
||||
|
||||
const denied = await fetch(`${baseUrl}/api/session`, {
|
||||
const denied = await fetch(`${baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer wrong-password",
|
||||
@@ -107,15 +123,15 @@ describe("site access session", () => {
|
||||
const authenticated = await login(baseUrl);
|
||||
const setCookie = authenticated.headers.get("set-cookie") ?? "";
|
||||
expect(authenticated.status).toBe(200);
|
||||
expect(setCookie).toContain("screener-session=v1.");
|
||||
expect(setCookie).toContain("screener-host-admission=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(accessPassword);
|
||||
expect(setCookie).not.toContain(hostAdmissionPassword);
|
||||
|
||||
const status = await fetch(`${baseUrl}/api/session`, {
|
||||
const status = await fetch(`${baseUrl}/api/host-admission`, {
|
||||
headers: { Cookie: cookiePair(authenticated) },
|
||||
});
|
||||
expect(await status.json()).toEqual({
|
||||
@@ -129,7 +145,7 @@ describe("site access session", () => {
|
||||
const response = await login(baseUrl);
|
||||
const setCookie = response.headers.get("set-cookie") ?? "";
|
||||
|
||||
expect(setCookie).toContain("__Host-screener-session=");
|
||||
expect(setCookie).toContain("__Host-screener-host-admission=");
|
||||
expect(setCookie).toContain("Secure");
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
expect(setCookie).toContain("SameSite=Strict");
|
||||
@@ -139,11 +155,13 @@ describe("site access session", () => {
|
||||
|
||||
it("requires an allowed Origin and an empty login body", async () => {
|
||||
const baseUrl = await start();
|
||||
const authorization = { Authorization: `Bearer ${accessPassword}` };
|
||||
const authorization = {
|
||||
Authorization: `Bearer ${hostAdmissionPassword}`,
|
||||
};
|
||||
|
||||
expect(
|
||||
(
|
||||
await fetch(`${baseUrl}/api/session`, {
|
||||
await fetch(`${baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: authorization,
|
||||
})
|
||||
@@ -151,7 +169,7 @@ describe("site access session", () => {
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await fetch(`${baseUrl}/api/session`, {
|
||||
await fetch(`${baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: { ...authorization, Origin: "https://foreign.test" },
|
||||
})
|
||||
@@ -159,7 +177,7 @@ describe("site access session", () => {
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await fetch(`${baseUrl}/api/session`, {
|
||||
await fetch(`${baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: { ...authorization, Origin: `${allowedOrigin}/path` },
|
||||
})
|
||||
@@ -167,7 +185,7 @@ describe("site access session", () => {
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await fetch(`${baseUrl}/api/session`, {
|
||||
await fetch(`${baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: { ...authorization, Origin: allowedOrigin },
|
||||
body: "{}",
|
||||
@@ -180,18 +198,18 @@ describe("site access session", () => {
|
||||
let now = 1_000;
|
||||
const baseUrl = await start(testConfig(), {
|
||||
now: () => now,
|
||||
accessSessionTtlSeconds: 1,
|
||||
hostAdmissionTtlSeconds: 1,
|
||||
});
|
||||
const authenticated = await login(baseUrl);
|
||||
const cookie = cookiePair(authenticated);
|
||||
|
||||
const modified = await fetch(`${baseUrl}/api/session`, {
|
||||
const modified = await fetch(`${baseUrl}/api/host-admission`, {
|
||||
headers: { Cookie: `${cookie}x` },
|
||||
});
|
||||
expect((await modified.json()).authenticated).toBe(false);
|
||||
|
||||
now = 2_000;
|
||||
const expired = await fetch(`${baseUrl}/api/session`, {
|
||||
const expired = await fetch(`${baseUrl}/api/host-admission`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
expect(await expired.json()).toEqual({
|
||||
@@ -201,14 +219,16 @@ describe("site access session", () => {
|
||||
});
|
||||
|
||||
it("is immediately authenticated when the access password is empty", async () => {
|
||||
const baseUrl = await start(testConfig({ accessPassword: undefined }));
|
||||
const status = await fetch(`${baseUrl}/api/session`);
|
||||
const baseUrl = await start(
|
||||
testConfig({ hostAdmissionPassword: undefined }),
|
||||
);
|
||||
const status = await fetch(`${baseUrl}/api/host-admission`);
|
||||
|
||||
expect(await status.json()).toEqual({
|
||||
required: false,
|
||||
authenticated: true,
|
||||
});
|
||||
const post = await fetch(`${baseUrl}/api/session`, {
|
||||
const post = await fetch(`${baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: { Origin: allowedOrigin },
|
||||
});
|
||||
@@ -220,33 +240,26 @@ describe("site access session", () => {
|
||||
describe("room HTTP API", () => {
|
||||
it("requires a logged-in browser when access protection is enabled", async () => {
|
||||
const baseUrl = await start();
|
||||
const anonymous = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { Origin: allowedOrigin },
|
||||
});
|
||||
const anonymous = await createRoom(baseUrl);
|
||||
expect(anonymous.status).toBe(401);
|
||||
expect(anonymous.headers.get("www-authenticate")).toBeNull();
|
||||
|
||||
const bearerBypass = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessPassword}`,
|
||||
Authorization: `Bearer ${hostAdmissionPassword}`,
|
||||
"Content-Type": "application/json",
|
||||
Origin: allowedOrigin,
|
||||
},
|
||||
body: JSON.stringify({ viewerPolicy: "private-link" }),
|
||||
});
|
||||
expect(bearerBypass.status).toBe(401);
|
||||
});
|
||||
|
||||
it("creates a numeric room and token-free invite for an authenticated browser", async () => {
|
||||
it("creates a private room with a fragment-only Viewer grant", async () => {
|
||||
const baseUrl = await start();
|
||||
const authenticated = await login(baseUrl);
|
||||
const response = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: cookiePair(authenticated),
|
||||
Origin: allowedOrigin,
|
||||
},
|
||||
});
|
||||
const response = await createRoom(baseUrl, cookiePair(authenticated));
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
@@ -255,18 +268,27 @@ describe("room HTTP API", () => {
|
||||
expect(body.roomId).toMatch(/^\d{12}$/);
|
||||
expect(invite.origin).toBe("https://share.example.test");
|
||||
expect(invite.pathname).toBe(`/r/${body.roomId}`);
|
||||
expect(invite.hash).toBe("");
|
||||
expect(invite.hash).toMatch(
|
||||
new RegExp(`^#v=g1\\.${body.roomId}\\.\\d+\\.[A-Za-z0-9_-]{43}$`),
|
||||
);
|
||||
expect(invite.search).toBe("");
|
||||
expect(body.viewerPolicy).toBe("private-link");
|
||||
expect(body.viewerGrantExpiresAt).toBeTruthy();
|
||||
expect("iceConfig" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("allows room creation without a session in public mode", async () => {
|
||||
const baseUrl = await start(testConfig({ accessPassword: undefined }));
|
||||
const response = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { Origin: allowedOrigin },
|
||||
});
|
||||
it("allows explicit public-watch creation without Host admission in local mode", async () => {
|
||||
const baseUrl = await start(
|
||||
testConfig({ hostAdmissionPassword: undefined }),
|
||||
);
|
||||
const response = await createRoom(baseUrl, undefined, "public-watch");
|
||||
expect(response.status).toBe(201);
|
||||
const body = createRoomResponseSchema.parse(await response.json());
|
||||
expect(body).toMatchObject({
|
||||
viewerPolicy: "public-watch",
|
||||
viewerGrantExpiresAt: null,
|
||||
});
|
||||
expect(new URL(body.inviteUrl).hash).toBe("");
|
||||
});
|
||||
|
||||
it("persists sequential protected rooms across server restarts", async () => {
|
||||
@@ -274,11 +296,7 @@ describe("room HTTP API", () => {
|
||||
let baseUrl = await start(config);
|
||||
const authenticated = await login(baseUrl);
|
||||
const cookie = cookiePair(authenticated);
|
||||
const create = () =>
|
||||
fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookie, Origin: allowedOrigin },
|
||||
});
|
||||
const create = () => createRoom(baseUrl, cookie);
|
||||
|
||||
const responses = await Promise.all([create(), create()]);
|
||||
const rooms = await Promise.all(
|
||||
@@ -294,41 +312,40 @@ describe("room HTTP API", () => {
|
||||
await runningServer?.close();
|
||||
runningServer = undefined;
|
||||
baseUrl = await start(config);
|
||||
const third = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookie, Origin: allowedOrigin },
|
||||
});
|
||||
const third = await createRoom(baseUrl, cookie);
|
||||
expect(createRoomResponseSchema.parse(await third.json())).toMatchObject({
|
||||
roomId: "3",
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects request bodies and foreign browser origins", async () => {
|
||||
const baseUrl = await start(testConfig({ accessPassword: undefined }));
|
||||
it("rejects malformed room requests and foreign browser origins", async () => {
|
||||
const baseUrl = await start(
|
||||
testConfig({ hostAdmissionPassword: undefined }),
|
||||
);
|
||||
const withBody = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { Origin: allowedOrigin },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: allowedOrigin,
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
expect(withBody.status).toBe(400);
|
||||
|
||||
const foreign = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { Origin: "https://foreign.test" },
|
||||
headers: { "Content-Type": "application/json", Origin: "https://foreign.test" },
|
||||
body: JSON.stringify({ viewerPolicy: "private-link" }),
|
||||
});
|
||||
expect(foreign.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns service unavailable at the global room bound", async () => {
|
||||
const baseUrl = await start(
|
||||
testConfig({ accessPassword: undefined, maxRooms: 1 }),
|
||||
testConfig({ hostAdmissionPassword: undefined, maxRooms: 1 }),
|
||||
);
|
||||
const create = () =>
|
||||
fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { Origin: allowedOrigin },
|
||||
});
|
||||
const create = () => createRoom(baseUrl);
|
||||
|
||||
expect((await create()).status).toBe(201);
|
||||
expect((await create()).status).toBe(503);
|
||||
|
||||
+282
-14
@@ -1,7 +1,7 @@
|
||||
import { connect } from "node:net";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import {
|
||||
@@ -158,7 +158,7 @@ async function startHarness(
|
||||
maxViewersPerRoom?: number;
|
||||
maxSignalConnections?: number;
|
||||
maxUnauthenticatedSignalConnections?: number;
|
||||
accessPassword?: string;
|
||||
hostAdmissionPassword?: string;
|
||||
persistent?: boolean;
|
||||
peerAssistedMedia?: boolean;
|
||||
stunUrls?: readonly string[];
|
||||
@@ -166,7 +166,7 @@ async function startHarness(
|
||||
} = {},
|
||||
): Promise<SignalHarness> {
|
||||
const config = testConfig();
|
||||
config.accessPassword = overrides.accessPassword;
|
||||
config.hostAdmissionPassword = overrides.hostAdmissionPassword;
|
||||
config.peerAssistedMedia = overrides.peerAssistedMedia ?? false;
|
||||
config.stunUrls = overrides.stunUrls ?? [];
|
||||
const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8;
|
||||
@@ -270,7 +270,8 @@ async function startSfuHarness(options: {
|
||||
stunUrls: options.stunUrls ?? [],
|
||||
},
|
||||
allowedOrigins: new Set([allowedOrigin]),
|
||||
authorizeUpgrade: () => true,
|
||||
hostAdmissionAtUpgrade: () => true,
|
||||
publicBaseUrl: new URL("https://share.example.test"),
|
||||
authenticationTimeoutMs: 500,
|
||||
viewerDisconnectGraceMs: options.viewerDisconnectGraceMs ?? 50,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -399,6 +400,7 @@ async function authenticate(
|
||||
roomId: room.roomId,
|
||||
role,
|
||||
clientId,
|
||||
...(room.viewerGrant ? { viewerGrant: room.viewerGrant } : {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -650,26 +652,289 @@ async function closeClient(client: TestClient): Promise<void> {
|
||||
}
|
||||
|
||||
describe("WebSocket signaling", () => {
|
||||
it("requires the global access session when protection is enabled", async () => {
|
||||
const accessPassword = "protected-instance-password";
|
||||
const harness = await startHarness({ accessPassword });
|
||||
it("protects Host admission without gating authorized Viewers", async () => {
|
||||
const hostAdmissionPassword = "protected-instance-password";
|
||||
const harness = await startHarness({ hostAdmissionPassword });
|
||||
|
||||
expect(await rejectedUpgradeStatus(harness.webSocketUrl)).toBe(401);
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
await expect(
|
||||
authenticate(viewer, harness.room, "viewer", "viewer-client-protected"),
|
||||
).resolves.toMatchObject({ role: "viewer" });
|
||||
|
||||
const login = await fetch(`${harness.baseUrl}/api/session`, {
|
||||
const unauthorizedHost = await openClient(harness.webSocketUrl);
|
||||
unauthorizedHost.socket.send(
|
||||
JSON.stringify({
|
||||
type: "authenticate",
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId: harness.room.roomId,
|
||||
role: "host",
|
||||
token: harness.room.hostToken,
|
||||
clientId: "host-client-without-admission",
|
||||
}),
|
||||
);
|
||||
await expect(unauthorizedHost.inbox.next("error")).resolves.toMatchObject({
|
||||
code: "AUTH_REQUIRED",
|
||||
});
|
||||
|
||||
const login = await fetch(`${harness.baseUrl}/api/host-admission`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessPassword}`,
|
||||
Authorization: `Bearer ${hostAdmissionPassword}`,
|
||||
Origin: allowedOrigin,
|
||||
},
|
||||
});
|
||||
const cookie = login.headers.get("set-cookie")?.split(";", 1)[0];
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
const viewer = await openClient(harness.webSocketUrl, cookie);
|
||||
const viewerWithHostCookie = await openClient(harness.webSocketUrl, cookie);
|
||||
viewerWithHostCookie.socket.send(
|
||||
JSON.stringify({
|
||||
type: "authenticate",
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId: harness.room.roomId,
|
||||
role: "viewer",
|
||||
clientId: "viewer-with-host-cookie",
|
||||
}),
|
||||
);
|
||||
await expect(viewerWithHostCookie.inbox.next("error")).resolves.toMatchObject({
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
|
||||
const host = await openClient(harness.webSocketUrl, cookie);
|
||||
await expect(
|
||||
authenticate(viewer, harness.room, "viewer", "viewer-client-protected"),
|
||||
).resolves.toMatchObject({ role: "viewer" });
|
||||
authenticate(host, harness.room, "host", "host-client-protected"),
|
||||
).resolves.toMatchObject({ role: "host" });
|
||||
});
|
||||
|
||||
it("leaves established authorization and media untouched when access persistence fails", async () => {
|
||||
const harness = await startHarness({ persistent: true });
|
||||
const host = await openClient(harness.webSocketUrl);
|
||||
const hostAuth = await authenticate(
|
||||
host,
|
||||
harness.room,
|
||||
"host",
|
||||
"persistence-failure-host",
|
||||
);
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
const viewerAuth = await authenticate(
|
||||
viewer,
|
||||
harness.room,
|
||||
"viewer",
|
||||
"persistence-failure-viewer",
|
||||
);
|
||||
await host.inbox.next("peer-joined");
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "signal",
|
||||
targetPeerId: viewerAuth.peerId,
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "persistence-failure-edge",
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await viewer.inbox.next("signal");
|
||||
|
||||
const update = vi
|
||||
.spyOn(harness.roomStore, "setViewerAccess")
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("simulated database write failure");
|
||||
});
|
||||
host.socket.send(
|
||||
JSON.stringify({ type: "set-viewer-access", action: "rotate" }),
|
||||
);
|
||||
expect(await host.inbox.next("error")).toMatchObject({ code: "SERVER_ERROR" });
|
||||
await expect(
|
||||
viewer.inbox.next("viewer-access-revoked", 30),
|
||||
).rejects.toThrow("Timed out");
|
||||
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "signal",
|
||||
targetPeerId: viewerAuth.peerId,
|
||||
payload: {
|
||||
kind: "candidate",
|
||||
connectionId: "persistence-failure-edge",
|
||||
candidate: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await viewer.inbox.next("signal")).toMatchObject({
|
||||
fromPeerId: hostAuth.peerId,
|
||||
payload: { connectionId: "persistence-failure-edge" },
|
||||
});
|
||||
update.mockRestore();
|
||||
|
||||
const secondViewer = await openClient(harness.webSocketUrl);
|
||||
expect(
|
||||
await authenticate(
|
||||
secondViewer,
|
||||
harness.room,
|
||||
"viewer",
|
||||
"persistence-failure-new-viewer",
|
||||
),
|
||||
).toMatchObject({
|
||||
viewerAuthorizationGeneration:
|
||||
viewerAuth.viewerAuthorizationGeneration,
|
||||
});
|
||||
});
|
||||
|
||||
it("strongly rotates and revokes every Viewer generation and media edge", async () => {
|
||||
const harness = await startSfuHarness({
|
||||
tokenIssuer: {
|
||||
async issueToken(request) {
|
||||
return `token-${request.peerId}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
const peers = await exhaustDeepViewerPeerRoutes(
|
||||
harness.webSocketUrl,
|
||||
harness.room,
|
||||
"access-generation",
|
||||
);
|
||||
const hostPrepare = await nextPreparedRoute(peers.host);
|
||||
const branchPrepare = await nextPreparedRoute(peers.secondRoot);
|
||||
const failedPrepare = await nextPreparedRoute(peers.failedViewer);
|
||||
await peers.host.inbox.next("sfu-config");
|
||||
await peers.secondRoot.inbox.next("sfu-config");
|
||||
await peers.failedViewer.inbox.next("sfu-config");
|
||||
for (const [client, revision] of [
|
||||
[peers.host, hostPrepare.revision],
|
||||
[peers.secondRoot, branchPrepare.revision],
|
||||
[peers.failedViewer, failedPrepare.revision],
|
||||
] as const) {
|
||||
client.socket.send(
|
||||
JSON.stringify({ type: "route-ready", revision, phase: "prepare" }),
|
||||
);
|
||||
}
|
||||
const hostActive = await nextActiveRouteAfter(
|
||||
peers.host,
|
||||
hostPrepare.revision - 1,
|
||||
);
|
||||
expect(hostActive.assignment.sfuPublicationGeneration).toBeTruthy();
|
||||
|
||||
const revokedViewers = [
|
||||
peers.firstRoot,
|
||||
peers.secondRoot,
|
||||
peers.failedViewer,
|
||||
];
|
||||
const closeCodes = revokedViewers.map(
|
||||
(viewer) =>
|
||||
new Promise<number>((resolve) =>
|
||||
viewer.socket.once("close", (code) => resolve(code)),
|
||||
),
|
||||
);
|
||||
for (const viewer of revokedViewers) {
|
||||
viewer.socket.on("message", (data) => {
|
||||
if (
|
||||
decodeServerMessage(data.toString()).type === "viewer-access-revoked" &&
|
||||
viewer.socket.readyState === WebSocket.OPEN
|
||||
) {
|
||||
viewer.socket.send(
|
||||
JSON.stringify({
|
||||
type: "signal",
|
||||
targetPeerId: peers.hostAuth.peerId,
|
||||
payload: {
|
||||
kind: "candidate",
|
||||
connectionId: "stale-revoked-generation",
|
||||
candidate: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
peers.host.socket.send(
|
||||
JSON.stringify({ type: "set-viewer-access", action: "rotate" }),
|
||||
);
|
||||
for (const viewer of revokedViewers) {
|
||||
expect(await viewer.inbox.next("viewer-access-revoked")).toEqual({
|
||||
type: "viewer-access-revoked",
|
||||
viewerAuthorizationGeneration:
|
||||
peers.firstRootAuth.viewerAuthorizationGeneration,
|
||||
});
|
||||
}
|
||||
expect(await Promise.all(closeCodes)).toEqual([4004, 4004, 4004]);
|
||||
expect(harness.roomStore.getConnectedViewers(harness.room.roomId)).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
let finalHostRoute = hostActive;
|
||||
for (let change = 0; change < 6; change += 1) {
|
||||
if (
|
||||
finalHostRoute.assignment.childPeerIds.length === 0 &&
|
||||
finalHostRoute.assignment.sfuPublicationGeneration === null
|
||||
) {
|
||||
break;
|
||||
}
|
||||
finalHostRoute = await nextActiveRouteAfter(
|
||||
peers.host,
|
||||
finalHostRoute.revision,
|
||||
);
|
||||
}
|
||||
expect(finalHostRoute.assignment).toMatchObject({
|
||||
childPeerIds: [],
|
||||
sfuPublicationGeneration: null,
|
||||
});
|
||||
await expect(peers.host.inbox.next("signal", 30)).rejects.toThrow(
|
||||
"Timed out",
|
||||
);
|
||||
|
||||
const rotated = await peers.host.inbox.next("viewer-access-updated");
|
||||
expect(rotated.viewerPolicy).toBe("private-link");
|
||||
expect(rotated.viewerAuthorizationGeneration).not.toBe(
|
||||
peers.firstRootAuth.viewerAuthorizationGeneration,
|
||||
);
|
||||
const inviteUrl = new URL(rotated.inviteUrl!);
|
||||
const rotatedGrant = new URLSearchParams(inviteUrl.hash.slice(1)).get("v");
|
||||
expect(rotatedGrant).toBeTruthy();
|
||||
|
||||
const oldGrantViewer = await openClient(harness.webSocketUrl);
|
||||
oldGrantViewer.socket.send(
|
||||
JSON.stringify({
|
||||
type: "authenticate",
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId: harness.room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: harness.room.viewerGrant,
|
||||
clientId: "old-generation-viewer",
|
||||
}),
|
||||
);
|
||||
expect(await oldGrantViewer.inbox.next("error")).toMatchObject({
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
|
||||
const rotatedRoom = {
|
||||
...harness.room,
|
||||
viewerGrant: rotatedGrant,
|
||||
} satisfies CreatedRoom;
|
||||
const currentViewer = await openClient(harness.webSocketUrl);
|
||||
const currentAuth = await authenticate(
|
||||
currentViewer,
|
||||
rotatedRoom,
|
||||
"viewer",
|
||||
"current-generation-viewer",
|
||||
);
|
||||
expect(currentAuth.viewerAuthorizationGeneration).toBe(
|
||||
rotated.viewerAuthorizationGeneration,
|
||||
);
|
||||
|
||||
const currentClosed = new Promise<number>((resolve) =>
|
||||
currentViewer.socket.once("close", (code) => resolve(code)),
|
||||
);
|
||||
peers.host.socket.send(
|
||||
JSON.stringify({ type: "set-viewer-access", action: "revoke" }),
|
||||
);
|
||||
expect(await currentViewer.inbox.next("viewer-access-revoked")).toMatchObject({
|
||||
viewerAuthorizationGeneration: rotated.viewerAuthorizationGeneration,
|
||||
});
|
||||
expect(await currentClosed).toBe(4004);
|
||||
expect(await peers.host.inbox.next("viewer-access-updated")).toMatchObject({
|
||||
viewerPolicy: "private-link",
|
||||
inviteUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets viewers arrive first and replays their snapshot when the host joins", async () => {
|
||||
@@ -955,7 +1220,9 @@ describe("WebSocket signaling", () => {
|
||||
"role",
|
||||
"roomExpiresAt",
|
||||
"type",
|
||||
"viewerAuthorizationGeneration",
|
||||
"viewerPeerIds",
|
||||
"viewerPolicy",
|
||||
].sort(),
|
||||
);
|
||||
expect(ordinaryHostAuth.iceConfig).toEqual(hybridHostAuth.iceConfig);
|
||||
@@ -3538,6 +3805,7 @@ describe("WebSocket signaling", () => {
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId: harness.room.roomId,
|
||||
role: "viewer",
|
||||
viewerGrant: harness.room.viewerGrant,
|
||||
clientId: `viewer-client-${maxViewersPerRoom + 1}`,
|
||||
}),
|
||||
);
|
||||
@@ -3622,7 +3890,7 @@ describe("WebSocket signaling", () => {
|
||||
oldClient.socket.send(
|
||||
JSON.stringify({
|
||||
type: "authenticate",
|
||||
roomId: harness.room.roomId,
|
||||
roomId: "999999999999",
|
||||
role: "viewer",
|
||||
clientId: "old-client",
|
||||
}),
|
||||
|
||||
@@ -29,4 +29,13 @@ describe("ViewerMessageAuthority", () => {
|
||||
authority.invalidate();
|
||||
expect(authority.owns(second)).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates an awaited continuation when Viewer access is revoked", () => {
|
||||
const authority = new ViewerMessageAuthority();
|
||||
const authenticated = authority.tokenFor(message("authenticated"));
|
||||
|
||||
authority.tokenFor(message("viewer-access-revoked"));
|
||||
|
||||
expect(authority.owns(authenticated)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user