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