docs(access): design room-scoped viewer grants (#48)

docs(access): design room-scoped viewer grants
This commit is contained in:
AAA authored and GitHub committed 2026-08-19 22:56:09 +08:00
commit f15019ca53
7 files changed
+244 -145

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@
- [Browser screen-audio quality](./research/browser-screen-audio-quality.md): capture/source compatibility, standard readback limits, Opus negotiation boundaries, and the retained no-control decision.
- [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): optional SQLite persistence, protected sequential room IDs, and room lifecycle.
- [ADR-0002](./adr/0002-persistent-protected-rooms.md): Host admission, 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, with the current default-off controller and 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.
+174 -99
View File
@@ -1,117 +1,192 @@
# ADR-0002: Persistent Protected Rooms
# ADR-0002: Persistent Rooms And Scoped Viewer Access
- Status: Accepted
- Date: 2026-08-18
- Status: Accepted design; runtime migration pending
- Date: 2026-08-19
## Context
The trusted-friends workflow benefits from short room numbers, reusable links,
and a room that remains available while its broadcaster is offline. The previous
ephemeral policy used random room codes and invalidated a room after its TTL.
Making short sequential IDs public would create a trivial enumeration surface,
while adding accounts, per-room passwords, invitations, or a general database
would exceed the current product need.
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 media topology is independent of this decision. Rooms still use the
P2P-first WebRTC design in ADR-0001, with TURN only for viewer pairs that cannot
connect directly.
The current runtime uses one optional `ACCESS_PASSWORD`, one stateless cookie,
and the same WebSocket upgrade gate for both roles. A Viewer then authenticates
with only a numeric room code. This is simple but conflates deployment admission
with room privacy: sharing the site password grants broader access than a Viewer
needs, while disabling it makes a random room code the only weak capability.
Persistent sequential IDs make that problem more visible.
Accounts, a user database, per-person ACLs, human room passwords, and a server
session table would exceed the product need. A single room-scoped bearer
capability provides the intended delegation with less user and operational
state. Media topology remains independent and is governed by ADR-0001/0005.
## Decision
Keep persistence an explicit, protected deployment option:
Separate Host admission from Viewer authorization in one atomic release:
- `ACCESS_PASSWORD` remains the single whole-site gate. An unset or empty value
means public mode; a non-empty value must contain 1 through 128 visible ASCII
characters (`0x21` through `0x7e`).
- `ROOM_DATABASE_PATH` is optional and requires `ACCESS_PASSWORD`. Configuring a
database path without the password is a startup error.
- When both values are configured, the service creates persistent protected
rooms. SQLite allocates positive decimal room IDs with
`INTEGER PRIMARY KEY AUTOINCREMENT`, beginning at `1`. These rooms and their
`/r/{roomId}` links do not expire.
- Without `ROOM_DATABASE_PATH`, rooms remain temporary and use random numeric IDs
plus `ROOM_TTL_SECONDS`. Public mode is therefore always random and temporary;
a password-only deployment is protected but still temporary.
- An explicit `stop-sharing` or capture track ending ends only the current
publication. The room remains joinable and viewers enter a waiting state. A
brief host signaling disconnect does not stop healthy P2P media. A temporary
room is removed only when its TTL expires; a persistent room is not
automatically deleted.
- A canceled request may abandon a newly allocated room only before the host
client claims it. SQLite `AUTOINCREMENT` ensures that an abandoned numeric ID
is never reassigned. Normal stop and reload paths never abandon the room.
- The built-in `node:sqlite` database stores only the room ID (the table rowid)
and SHA-256 host-token digest. It never stores the host token in plaintext, the
site password, access cookies, participants, SDP, ICE candidates, IP addresses,
TURN credentials, or media.
- The host browser retains the plaintext room response in same-origin
`localStorage` so it can reclaim publication rights after a reload. Clearing
site data or moving to another device loses that local ownership token; the
server cannot recover it from the digest.
- Persistence remains a single-process, single-file facility. Do not add an ORM,
Redis, an account service, or multi-node coordination without a measured need.
- The systemd example uses `StateDirectory=screener`; its protected-room database
path is `/var/lib/screener/rooms.sqlite`.
- Replace `ACCESS_PASSWORD` with `HOST_ADMISSION_PASSWORD`. Production startup
requires it; local development and tests may explicitly disable it. It accepts
16 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.
- 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
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`
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,
total-capacity, and unauthenticated-capacity checks. The server records whether
the upgrade carried a valid Host-admission 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.
- 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.
This decision supersedes only the fixed room-code and room-lifetime details in
ADR-0001. It does not change that ADR's media topology.
Every room has one of two Viewer policies:
- `private-link` is the default. The server creates a grant containing a
256-bit random value and bounded expiry, stores only SHA-256 of the canonical
full grant, and accepts it only for the Viewer role in that exact room. A
temporary-room grant cannot outlive the room; a persistent-room grant lasts
seven days unless rotated earlier. Expiry rejects later authentication and
reconnects; it does not schedule a mid-session disconnect. Rotate/revoke is
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
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.
Private invitations use `/r/{roomId}#v={grant}`. RFC 3986 separates a fragment
before dereference, and the WebSocket API rejects fragment-bearing URLs, so the
client never appends it to HTTP or `/signal`. On first load the client strictly
parses the grant into room-scoped `sessionStorage` and immediately calls
`history.replaceState` with canonical `/r/{roomId}`. Refresh and in-page
reconnect use that page session's value; an independent page without a fragment
or room key fails closed. The raw grant is never put in `localStorage`, a
cookie, query, Referrer, log, or error. Host creation and
rotation keep the returned raw grant only in memory and room-scoped
`sessionStorage` while constructing a copyable link. The persistent Host-room
object keeps the room ID, canonical URL, Host token, and room expiry, not the
Viewer grant.
The fragment and production `Referrer-Policy: no-referrer` prevent ordinary
request/referrer logging, but do not make the invitation harmless. The original
message, a browser extension, a screenshot, or deliberate forwarding can still
disclose it. The UI treats it as an access credential and provides Host
rotation/revoke.
Rotation, explicit revoke, and public-to-private changes are strong revocation
operations. The server first commits the new digest and authorization generation.
Only after a successful commit does it clear Viewer grace, connection
generations, and route state, tell Viewers to close receive PCs/media, tell every
Host/relay/SFU upstream owner to close the old-generation send edge, and close
every current Viewer socket. Closing signaling alone is insufficient because a
healthy P2P edge can keep playing without it. Rotation and public-to-private each
return one new grant. Revoke stores a fresh random locked-private digest for
which no grant was generated and returns no grant until a later rotation.
The old grant or code-only session then cannot reconnect. A persistence failure
leaves the old digest, existing sockets, and topology unchanged. Switching from
private to public only broadens future Viewer admission and does not move
healthy media.
## Persistence And Migration
`ROOM_DATABASE_PATH` remains optional and now requires
`HOST_ADMISSION_PASSWORD`. Without it, rooms use random numeric IDs and
`ROOM_TTL_SECONDS`. With it, SQLite allocates persistent decimal IDs using
`INTEGER PRIMARY KEY AUTOINCREMENT`.
Schema v2 keeps one `rooms` table and one additional field:
```text
id INTEGER PRIMARY KEY AUTOINCREMENT
host_token_digest BLOB NOT NULL
CHECK (length(host_token_digest) = 32)
viewer_grant_digest BLOB NULL
CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32)
```
`NULL` means public-watch; 32 bytes mean private-link. SQLite stores no raw
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.
Schema v1 migrates inside one `BEGIN IMMEDIATE` transaction. It adds the nullable
checked `BLOB` column, assigns every existing row a fresh random 32-byte
locked-private digest for which no grant is generated, and advances
`user_version`. Existing Host
tokens still reclaim their rooms, after which the Host rotates once to obtain a
new invitation. Old code-only links fail closed. Deployment takes a v1 backup before migration; a
rollback to the old binary stops the service and restores that backup rather
than retaining dual-schema runtime code.
Display names and roster presentation remain a separate change: browser
`localStorage` plus current-room memory only, displayed with at least six
characters of room-scoped `peerId` and extended on collision. Neither enters
SQLite or participates in authorization, routing, or quality decisions.
## Consequences
Positive:
- A protected instance gets memorable room numbers and links that survive a
stopped share or application restart.
- The persistent state is deliberately small and uses the Node.js runtime's
built-in SQLite implementation, so deployment gains no additional service.
- Public deployments retain random temporary identifiers rather than exposing a
predictable room sequence.
Negative:
- Anyone who knows the whole-site password can enumerate persistent room IDs.
This is accepted for one trusted friend group; the password is not per-room
authorization.
- The SQLite file becomes operational state that must have restricted
permissions and be included in backups when room continuity matters.
- Deleting or losing the database loses room ownership mappings. A SHA-256
digest cannot recover a lost plaintext host token.
- `MAX_ROOMS` bounds new room creation against the in-process room count.
Persistent rooms have no automatic retention policy, so an operator must
deliberately handle future cleanup if the small-instance assumption stops
holding.
- If SQLite commits a new room but the HTTP response never reaches the host,
its plaintext host token is unrecoverable and the row remains until an
operator cleans it up. `MAX_ROOMS` bounds this failure mode; adding an
idempotency or administration subsystem is deferred until it is observed.
- The first rollout cannot migrate rooms that existed only in process memory;
those temporary links end with the deployment restart.
- This design does not provide horizontal scaling or failover across multiple
application processes.
- In Node.js 24.19.0, `node:sqlite` has Stability 1.2 (release candidate), not
stable status. It is accepted here to avoid a native addon and hand-written
JSON transaction/recovery logic; follow its status and rerun persistence tests
with each Node LTS update. See the
[official Node.js SQLite documentation](https://nodejs.org/download/release/v24.19.0/docs/api/sqlite.html).
- Friends can watch a private room without learning the password that grants
room-creation and Host capability.
- One digest scopes leaked Viewer authority to one room and one role; rotation
provides a clear recovery operation.
- Public watching remains explicit, and persistent state grows by one nullable
digest column rather than a new subsystem.
- A capability link is a bearer credential. Anyone who obtains it can join
before expiry and remain until disconnect or room-wide rotation/revoke;
targeted per-person revocation is unavailable.
- Clearing the fragment sacrifices bookmark/copy-from-address-bar behavior.
Closing the only Host tab loses its raw copy; the Host must rotate to produce
another link, although room ownership remains recoverable from its Host token.
- Rotation/revoke deliberately disconnects all Viewers and rebuilds their routes.
- SQLite v2 is not readable by the old runtime, so rollback requires the
pre-migration backup.
- Public-watch consumes bounded Viewer/media capacity and offers no privacy.
## Rejected Alternatives
- Sequential IDs in public mode: easy to enumerate and unnecessary.
- Persisting every deployment automatically: changes the simple public/local
default and creates filesystem state where none is required.
- Accounts, per-room passwords, invite tokens, or a general authorization
database: additional concepts without a current trusted-friends requirement.
- Storing plaintext host tokens: unnecessary credential exposure.
- An external database, ORM, Redis, or distributed room allocator: operational
complexity without a present multi-node consumer.
- Deleting a room whenever sharing stops: breaks reusable links and turns a
temporary media action into an unrelated room-lifecycle action.
- One password for Host and Viewer: over-grants every invited Viewer and couples
deployment abuse control to room privacy.
- Human per-room passwords: adds input, password handling, and recovery without
improving the link-sharing workflow over a high-entropy capability.
- Query/path grant: request targets are commonly logged; RFC 6750 likewise warns
against bearer tokens in URI queries. A fragment plus first WS message keeps
it out of ordinary HTTP and proxy request logs.
- Viewer cookie or `localStorage`: broadens retention and ambient authority.
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.
- Keeping v1 compatibility: risks ambiguous old-tab behavior and leaves a
second security model in product code; Git history and database backup own
rollback.
## Revisit Triggers
## Acceptance Gates
- Multiple application processes or regions must allocate and serve the same
room namespace.
- Different friend groups need isolation beyond one deployment-wide password.
- Persistent room count makes deliberate archival or deletion necessary.
- Product requirements need revocable invitations, accounts, or auditable room
ownership.
- The auth matrix proves production Host admission, Viewer-without-Host-cookie,
16-byte minimum/independent secret, ingress rate limit, exact-room/role grant
scope, non-enumerating failures, and public-watch caps.
- 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
grace/route cleanup, old receive/send media teardown, one-time grant semantics,
old reconnect rejection, locked revoke, and no change after persistence failure.
- A production-copy v1 `STRICT` database migrates transactionally to checked,
locked-private v2; malformed non-null values fail closed, rollback restores its
backup, and v1 tabs terminate without compatibility code.
+4 -3
View File
@@ -11,6 +11,7 @@ Last updated: 2026-08-19
- Non-server nodes have at most two downstream edges; browser relays stay at one until resource gates pass. SFU normally feeds one or two roots that retain peer descendants; separately capped server edges may serve exceptional viewers that cannot attach behind a healthy root.
- 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.
- Flagship native media encodes each active representation once and reuses it across direct, peer, and SFU transports; per-connection packetization, pacing, encryption, feedback, and upload remain independent. Healthy rooms have one shared `HIGH`; verified weak paths may add one shared on-demand `LOW`, never per-viewer encoders. Standard Web P2P simulcast and pinned LiveKit 1.13.5/JS 2.22.0 Dynacast are static no-go paths for that exact selection policy; SVC is next and has no software fallback.
- Separate abuse control from watching: production Host admission protects room creation/Host role, while a default private capability grants Viewer access to one room; public-watch is explicit. Keep raw grants out of localStorage/cookies/query/logs/SQLite; retain no account, ACL, user, or session table.
- 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.
@@ -29,7 +30,7 @@ Last updated: 2026-08-19
- Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`.
- Candidate UI has local Viewer volume/mute, copyable room codes, and a favicon. Names/roster, endpoint details, and RTP loss remain pending; no wire, media, or routing effects.
- Do not add scene detection, dynamic-FPS control, or forced AV1 until stats and target hardware prove the need. Codec acceptance requires actual negotiation, power-efficient candidate evidence, interval encode cost, game FPS, CPU/GPU and sender count; Discord's native capture/hardware tuning is comparison evidence, not proof of server re-encoding or a reusable preset.
- Keep room policy deployment-driven: public/password-only rooms are random and temporary; password plus SQLite enables sequential persistent rooms and reusable stopped links.
- After the current media acceptance boundary, migrate access atomically under amended ADR-0002: `HOST_ADMISSION_PASSWORD`, `screener-v2`, default private fragment grants, optional public-watch, rotate/revoke, and one nullable SQLite digest column. Do not mix username/roster runtime into that PR.
- Deferred architecture audit: `docs/maintenance.md`.
## Current Implementation
@@ -37,8 +38,8 @@ Last updated: 2026-08-19
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn.
- Production `769de201f7cc` at `https://share.bonfire.icu` has equal entry actions, default-closed details, bounded clarity-first quality, live source/quality changes, pause, and sender warnings.
- Production still uses one host `RTCPeerConnection` per viewer because peer assistance and LiveKit are unconfigured; above two viewers it still exceeds the target host-edge budget. Its real game-capture quality/pause cycle remains unverified.
- Access uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map.
- With `ROOM_DATABASE_PATH` and the password, SQLite stores room ID and host-token digest; links persist and room `1` survived deployment. Without it, rooms are temporary.
- Current production/repository runtime still uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie for both roles, internal Host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map.
- Current SQLite still stores only room ID and Host-token digest; room `1` survived deployment. The accepted Viewer-grant digest/schema v2 design is documentation-only and not deployed.
- Production uses nginx, Node.js 24.19.0, and authenticated coturn 4.17.2 at `turn.bonfire.icu:3478`; public STUN, TURN/UDP, TURN/TCP, and relay-only traffic pass. TURN/TLS is off.
- Candidate migration is process-wide: ordinary ICE is STUN-only; coturn uses `stun-only`/`no-tcp`/`no-tls` without deprecated `no-dtls`; LiveKit 1.13.5 explicitly disables TCP fallback, uses deployment STUN and separate UDP participant ICE. Old TURN parser/signer/refresh/UI/SNI artifacts are gone; old production is isolated rollback.
- The default-off ADR-0005 controller has no production peer/SFU configuration. Enabling it requires non-empty exact `PEER_ASSISTED_ROOM_IDS`; missing/blank fails startup and unlisted rooms use current ordinary P2P. `screener-v1` is the single signaling literal; mismatch terminates once with a refresh prompt.
+9 -4
View File
@@ -205,11 +205,15 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热
### 最小访问模型
当前需求只有一个部署级密码,不需要账号数据库、JWT、服务端 session Map、逐人邀请或 logout。`ACCESS_PASSWORD` 为空时网站公开;非空值接受 1 至 128 个可见 ASCII 字符,避免把 Fetch 无法可靠编码的 Unicode 原文放进 Authorization header。配置时,host 和 viewer 都先通过同一登录 gate。成功登录得到 12 小时的无状态 HMAC-SHA256 cookie,使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`,HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。建房 HTTP 和 WebSocket upgrade 都只认 cookie,不允许直接给建房 API 传 Bearer 绕过 gate。异常 WebSocket 关闭会检查一次 access session;确认 cookie 失效时回到 gate,检查本身失败时继续正常网络重连。HMAC 与定长密码摘要比较使用 Node.js `crypto.createHmac()` 和 `crypto.timingSafeEqual()`;cookie 属性遵循 RFC6265bis 的语义。
当前 runtime 的 `ACCESS_PASSWORD`、同一 Host/Viewer cookie gate 和 code-only Viewer 是已部署事实,不再是接受的目标。目标把“谁可以建立/发布房间”和“谁可以看这一间房”分开:production 必配的 `HOST_ADMISSION_PASSWORD` 无状态 cookie 只允许建房及尝试 Host role,房间 Host token 仍独立验证;默认 private-link 使用一个 room-scoped Viewer bearer grant,public-watch 才接受 code-only Viewer。它们不需要账号、JWT、服务端 session Map、逐人 ACL 或人类房间密码。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。
房间始终使用纯数字 code。默认临时模式生成随机 code 并设置 TTL;只有同时配置 `ACCESS_PASSWORD` 与 `ROOM_DATABASE_PATH` 时,内置 `node:sqlite` 才从 `1` 开始分配不设过期时间的持久 code。数据库只保存作为 rowid 的房间 ID 与 host token 摘要,路径在无全站密码时会被启动校验拒绝。观看链接是 `/r/{code}`,也可在 `/join` 只输入 code;两者都没有 viewer token 或 fragment。256-bit host token 只用于房间级 host 信令鉴权,服务端仅持有其 SHA-256 摘要。在密码模式中,code 不能绕过全站 gate;在公开模式中,随机 code 是唯一观看 capability,仍不构成强隐私保证,面向互联网的私密实例应配置全站密码。
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;这是传输边界,不是“不会泄漏”的保证。
来源(访问于 2026-08-18):[Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/)、[Node.js Crypto](https://nodejs.org/api/crypto.html#cryptocreatehmacalgorithm-key-options) 与 [`crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b)。
W3C TAG 的 capability URL 指南指出 URL 仍会出现在地址栏、历史、扩展、同步服务、截图和转发路径中,建议高熵、到期与可撤销。目标因此使用 Node.js `randomBytes(32)` 的加密强随机量、完整 grant 的 SHA-256 摘要、有限期限,以及 Host rotate 或 locked revoke。浏览器首次严格解析后只写 room-scoped `sessionStorage`,立即 `history.replaceState` 到 canonical URL;刷新和页面内 WSS reconnect 可复用。HTML 标准明确新 auxiliary browsing context 可以复制同源 opener 的 session storage;这是已持有 bearer 的本地浏览器上下文转交边界,不是服务器扩大 room/role 权限,因此不增加导航状态机。独立且无 fragment/room key 的访问仍 fail closed。raw grant 不进入 `localStorage`、cookie、query、Referrer、SQLite、应用/代理日志或错误。OWASP 日志指南也明确把 access token、session identifier 和密码列为通常不应直接记录的数据。Fragment 降低服务端泄漏面,但 possession 仍等于该房间 Viewer 权限。
持久化只在现有 `rooms` row 增加 nullable `viewer_grant_digest`;`NULL` 表示 public-watch,非空值以 `CHECK` 约束为 32-byte BLOB。SQLite `STRICT` table 只接受规定的类型名,因此不能声明 `BLOB(32)`;括号长度也不是 SQLite 的长度约束。官方迁移指南支持在 transaction 中完成 schema/data 变更,目标用一个 `BEGIN IMMEDIATE` 给每个 schema v1 旧行写入未生成对应 grant 的 fresh random locked-private digest,再更新 `user_version`。这是项目设计推论,不是 SQLite 自动提供的权限语义。上线前备份 v1;旧 binary 回滚恢复备份,不在 runtime 保留双 schema。
来源(访问于 2026-08-19):[RFC 3986 section 3.5](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.5)、[WHATWG WebSockets](https://websockets.spec.whatwg.org/#the-websocket-interface)、[RFC 6455](https://www.rfc-editor.org/rfc/rfc6455.html)、[RFC 6750 section 2.3](https://www.rfc-editor.org/rfc/rfc6750.html#section-2.3)、[W3C Referrer Policy](https://www.w3.org/TR/referrer-policy/)、[W3C TAG Capability URLs](https://www.w3.org/TR/capability-urls/)、[HTML Web Storage](https://html.spec.whatwg.org/multipage/webstorage.html)、[Web Cryptography Level 2](https://www.w3.org/TR/WebCryptoAPI/)、[Node.js Crypto](https://nodejs.org/api/crypto.html)、[SQLite STRICT Tables](https://www.sqlite.org/stricttables.html)、[SQLite ALTER TABLE](https://www.sqlite.org/lang_altertable.html) 与 [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)。
WebRTC 媒体本身使用 DTLS-SRTP 加密;但 direct P2P 仍会让这组可信好友看到彼此网络地址,若需要隐藏 IP,必须允许强制 TURN,这会增加服务器带宽。
@@ -251,7 +255,8 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件
- WebRTC 使用 DTLS-SRTP。TURN 只能看到加密后的媒体包,但仍能看到地址、房间时序和流量元数据。
- P2P 会让房间内双方得知网络地址。熟人首版可以接受,陌生人房间不能默认接受。
- TURN 必须使用短期凭据、速率限制、每用户/房间配额和出口告警,不能提供匿名公共 relay。
- 面向互联网的私密部署应启用全站密码。公开模式的随机房间码只是轻量 capability,不应宣传为强私密邀请;可枚举的持久房间只允许与全站密码搭配,host token 始终只用于发布权限。
- Host admission password 只控制建房/Host role,不能作为私密观看凭据;private-link 依赖 room-scoped grant,public-watch 的 room code 则明确不提供隐私。两者都不改变媒体 fanout/egress 上限。
- raw Viewer grant 与 Host token 等同访问凭据:只保存摘要,禁止日志/遥测/错误/Referrer/SQLite 明文。显示名、room-scoped peer 后缀和 IP 诊断均不参与授权。
- 如果未来使用 SFU 且要求服务器看不到内容,再评估 SFrame/WebRTC Encoded Transform 和群组密钥管理。
## 参考代码优先级
+6 -2
View File
@@ -11,8 +11,8 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN.
## Current Snapshot
- Capture precedes room creation; live source/quality changes preserve peers and picture pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options.
- Optional `ACCESS_PASSWORD` uses a stateless 12-hour HMAC HttpOnly Strict cookie; host auth stays internal and signaling role-bound.
- Without `ROOM_DATABASE_PATH`, rooms are temporary. With it and the password, sequential links persist after stop; SQLite stores only room ID and host-token digest.
- Current runtime still uses optional `ACCESS_PASSWORD` and one stateless 12-hour HMAC HttpOnly Strict cookie for both roles; Host auth stays internal and signaling role-bound.
- Current SQLite still stores only room ID and Host-token digest. Amended ADR-0002 accepts a later atomic `HOST_ADMISSION_PASSWORD`/`screener-v2` migration with default room-scoped private Viewer grants, explicit public-watch, rotate/revoke, and one nullable digest column; no runtime is implemented.
- PR #44 merged the default-off exact-room candidate code: ordinary ICE is process-wide STUN-only, LiveKit SFU/UDP feeds at most two roots, browser relays stay at one child, the host stays at two, and exhaustion fails boundedly. Old production/coturn remains rollback; HTTPS/WSS stays TLS/TCP.
- Fallback prewarm is token-free and dormant without configuration. Mobile/iPad viewers are leaves and healthy edges stay sticky.
- Production has memory-only video settings and manual readback. Automatic `HIGH`/`FALLBACK` plus one shared on-demand `LOW` is accepted but not deployed; local A+B and authenticated P2P Viewer C are read-only evidence. Browser audio is request/presence-only.
@@ -38,6 +38,7 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN.
- Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges.
- Web P2P simulcast and pinned LiveKit 1.13.5/JS 2.22.0 Dynacast are static no-go: no receiver RID-selection/shared-encoder contract, and `HIGH` cumulatively enables `LOW`. No Chrome/resource-release claim follows; current SFU publishing is non-simulcast/Dynacast-off. SVC is next.
- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
- Scoped Viewer access is designed but unimplemented. Tests owe role bounds, fragment clearing/no-leak, expiry/cross-room/rotate/revoke, v1 tab termination, and SQLite locked migration/restore. Human passwords/accounts stay out.
## Next Milestone
@@ -55,6 +56,9 @@ owns two-state policy. An isolated exact room later gates peer/SFU UDP and
bounded failure before replacing the old release. Selected-edge TURN, if later
justified, is a separate complete change rather than part of this canary.
Candidate UI has volume, room-code copy and favicon; names/roster/diagnostics remain.
After the current media acceptance boundary, implement amended ADR-0002 as one
atomic access PR; do not combine it with username/roster runtime or preserve the
old config, cookie, protocol, or SQLite parser.
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
+31 -24
View File
@@ -11,7 +11,7 @@
本阶段必须形成完整的最小纵向链路:
- 分享者主动选择屏幕、窗口或标签页,并请求可用音频;
- 创建数字房间,并用可选全站密码统一保护分享和观看入口;受保护部署可选择最小 SQLite 持久化,使房间和链接在停止分享及进程重启后仍有效;
- 创建数字房间;production Host admission 密码严格控制建房/发布,默认 room-scoped capability link 让 Viewer 免站点密码观看,部署可选择最小 SQLite 持久化;
- 每名观看者拥有独立的 `RTCPeerConnection`;
- 当前仓库候选要求 STUN,普通 peer ICE 只发 STUN servers;SFU candidate 只开放 LiveKit ICE/UDP;
- 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据;
@@ -27,7 +27,7 @@
| Web 客户端 | React 19、TypeScript、Vite 8 | 同一套响应式界面覆盖分享者与桌面/移动观看者;官方模板成熟 |
| 媒体 | 浏览器原生 WebRTC 与 Screen Capture API | 不引入 PeerJS/simple-peer,保留对 ICE、sender 参数和 stats 的直接控制 |
| 应用服务 | Node.js 24 LTS、TypeScript、原生 HTTP、`ws` | 服务只负责小流量控制面;前后端可共享协议类型和校验 |
| 可选房间持久化 | Node.js 内置 `node:sqlite` | 单文件、无额外依赖;只保存恢复持久房间所需的 ID 与 token 摘要 |
| 可选房间持久化 | Node.js 内置 `node:sqlite` | 单文件、无额外依赖;只保存房间 ID、Host token 摘要与 nullable Viewer grant 摘要 |
| 运行时校验 | Zod | WebSocket 和 HTTP 输入不可信,需要与 TypeScript 类型一致的运行时边界 |
| 静态文件 | `sirv` | 生产环境由同一 Node 进程提供前端构建结果,避免引入完整 Web 框架 |
| 地址发现 | coturn 4.17.2 或更新补丁版本的 `stun-only` 模式 | 自托管 UDP STUN,不启用 relay、credential 或媒体 TCP |
@@ -66,19 +66,23 @@ tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试
## 房间与访问控制
`ACCESS_PASSWORD` 是可选的全站开关。为空时网站公开;非空值必须包含 1 至 128 个可见 ASCII 字符(`0x21` 至 `0x7e`)。配置时,分享者访问根页面、观看者访问 `/join` 或 `/r/{code}` 都先进入同一个登录 gate。`POST /api/session` 校验密码后签发 12 小时的无状态 cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map,也不提供 logout 流程。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/` 与有限 `Max-Age`;HTTPS 生产环境再使用 `Secure` 和 `__Host-` 前缀。WebSocket 异常断开后会用 `/api/session` 检查一次当前 cookie;仅在确认失效时停止重连并返回 gate,网络故障仍走原重连流程。
`HOST_ADMISSION_PASSWORD` 是分享准入,不是 Viewer 密码。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 16 至 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。
建房 `POST /api/rooms` 和 WebSocket upgrade 在启用密码时都只验证该 cookie。密码只以 `Authorization: Bearer` 提交给登录接口,建房 API 不接受相同的 Bearer 旁路。密码模式下 HTTP 建房还要求精确 Origin;公开模式也会拒绝不在 allowlist 中的已提供 Origin。WebSocket 在握手完成前同时校验 Origin、容量和 access cookie。
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。
`ROOM_DATABASE_PATH` 是可选 SQLite 文件路径,且配置它时必须同时配置 `ACCESS_PASSWORD`,否则启动失败。由此形成三个明确配置:
新房间默认 `private-link`,也允许 Host 显式选择 `public-watch`:
- `ACCESS_PASSWORD` 为空、数据库路径为空:公开临时模式,生成随机数字 `roomId`,使用 `ROOM_TTL_SECONDS` 过期,随机性降低匿名枚举风险;
- 只配置 `ACCESS_PASSWORD`:受保护临时模式,房间仍使用随机 ID 和 TTL;
- 同时配置 `ACCESS_PASSWORD` 与 `ROOM_DATABASE_PATH`:受保护持久模式,SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` 从 `1` 开始分配十进制 `roomId`,房间与邀请链接不设过期时间。
- `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 上限保护,不提供房间目录。
- 人类可输入的逐房间密码、逐人邀请、账号和通用授权服务不做。Capability link 已满足可信朋友的低摩擦观看;需要隔离单个观看者时再以真实需求评估多 grant,而不是先建 ACL。
所有模式都为新房间生成 256-bit 随机 `hostToken`,只把 SHA-256 摘要留在服务端。host 浏览器把建房响应保存在同源 `localStorage`,用于刷新后重新取得发布权;清除站点数据或换设备不会从摘要恢复 token。持久模式的 SQLite 表只有作为 rowid 的房间 ID 与 32-byte host token 摘要两个应用字段;不保存全站密码、token 明文、access cookie、SDP、ICE candidate、IP 地址、观看者状态、媒体或 TURN 凭据。`node:sqlite` 由单个 Node 进程直接使用,不增加 ORM、迁移框架、Redis 或多节点同步。生产 systemd 示例通过 `StateDirectory=screener` 提供 `/var/lib/screener`,当前实例使用 `/var/lib/screener/rooms.sqlite`。
私密邀请使用 `/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 必须把链接称为访问凭据。
观看者没有独立 token:邀请为 `/r/{roomId}`,也可访问 `/join` 并只输入房间码。在受保护模式中,两种入口都必须先通过全站 gate。自增 ID 可被已登录用户枚举,这是“一个全站密码保护一组可信朋友”的已接受边界;它绝不能用于公开模式。账号、逐人邀请、逐人撤销、逐房间密码和持久 access session 不在当前需求内。
`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 v2 在同一个 `rooms` row 上只有 `id`、32-byte `host_token_digest` 和 nullable 32-byte `viewer_grant_digest`:`NULL` 表示 public-watch,32 bytes 表示 private-link;没有用户、成员或 session 表。
schema v1 -> v2 使用单个 `BEGIN IMMEDIATE` 事务新增 `viewer_grant_digest BLOB NULL CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32)`,为每个旧行写入由 Node `randomBytes(32)` 产生、未生成对应 grant 的 locked digest,再更新 `user_version`。这既满足当前 `STRICT` table 的合法类型名,也让 SQLite 真实约束长度;读取畸形非空值时仍 fail closed。旧 Host token 能认领房间,但必须 rotate 才能取得新私密邀请;旧 code-only Viewer 不能 fail-open。部署前备份 v1 数据库;由于新 release 不读写双 schema,回滚旧 binary 必须停服并恢复该备份。临时内存房间随部署重启结束。
rotate、revoke 或从 public-watch 切回 private-link 都是强撤销:服务端先原子提交新 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,房间保持私密锁定直到下一次 rotate。数据库写失败时,内存摘要、现有 Viewer、媒体和拓扑保持不变。切到 public-watch 只放宽以后 Viewer 的 code-only 鉴权,不需要迁移健康媒体。服务端永不返回现有 grant 明文;丢失 Host 标签页中的 grant 时只能 rotate。
房间规则:
@@ -90,18 +94,19 @@ tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试
客户端用 `sessionStorage` 保存随机的 128-bit `clientId`,作为当前标签页内稳定的参与者 key。同一 role/clientId 的新 WebSocket 会原子替换旧 socket;观看者断线后保留五秒 grace,期间不发送 `peer-left` 且 `peerId` 不变,重新鉴权时重发幂等的 `peer-joined`,超时才释放名额。host 鉴权或重连后,`authenticated` 携带包含 grace 成员的权威 viewer ID 列表供客户端删除已离开的 peer,服务端再重放当前在线观看者的 `peer-joined`,因此观看者可以先于 host 打开邀请,host 离线期间的离开也不会遗留幽灵连接。
显示名是独立的本地偏好:一个同源 `localStorage` 值供 Host 与 Viewer 共用,未设置时使用角色默认标签。客户端鉴权时发送经同一严格 schema 校验的纯文本名称,服务端只在当前内存会话中保存;每次加入、离开或会话替换后向房间内所有参与者广播不超过一名 Host 加配置观看上限的完整权威 roster,而不增加增量事件状态机。房间标题取当前 Host 显示名;名称允许重复、只按文本渲染,并且不替代 role、host token、peer/session ID 或任何授权判断。SQLite schema 保持不变。
显示名是独立的本地偏好:一个同源 `localStorage` 值供 Host 与 Viewer 共用,未设置时使用角色默认标签。客户端鉴权时发送经同一严格 schema 校验的纯文本名称,服务端只在当前在线房间内存中保存;每次加入、离开或会话替换后广播不超过一名 Host 加配置观看上限的完整权威 roster。展示标签使用名称加当前房间服务端 `peerId` 的至少六字符短后缀,房内碰撞时延长;不暴露 `clientId`,也不伪称全局 UUID。名称、短后缀和 roster 都不替代 role、Host token、Viewer grant、peer/session ID 或任何授权/路由判断,SQLite 不保存它们。该 UI/roster 实现保持为独立后续 PR,不混入授权迁移。
## 信令协议
WebSocket 使用同源 `/signal`。启用全站密码时,upgrade 必须先携带有效 access cookie;连接后五秒内第一条消息仍必须是房间级 `authenticate`。host 消息携带内部 host token,viewer 只携带房间码、role 与 clientId,不存在 viewer token,也不把凭据放 query string。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 WebSocket upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket;这只是 PoC 的资源边界,不是容量承诺。
WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 Host-admission cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v2` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 Host admission;Viewer 消息携带 room、role、clientId 和 private-link 时的 Viewer grant。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。
主要消息:
| 方向 | 消息 | 作用 |
| --- | --- | --- |
| client -> server | `authenticate` | host 以 room、role、内部 host token 和 clientId 建立会话;viewer 只使用 room、role 和 clientId |
| client -> server | `authenticate` | Host 以 upgrade 时的 admission 状态加 room-scoped Host token 建立会话;Viewer 以 public room code 或 private Viewer grant 建立仅观看会话 |
| server -> client | `authenticated` | 返回角色、peerId、可为空的房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 |
| host -> server -> host | `set-viewer-access` / `viewer-access-updated` | Host 切换 public/private、rotate 或 revoke;raw grant 只在新建、rotate 或 public-to-private 成功响应中出现一次 |
| server -> host | `peer-joined` / `peer-left` | 建立或关闭该观看者的独立连接 |
| host -> viewer | `offer` / `candidate` | 带 `peerId` 与 `connectionId` 的定向信令 |
| viewer -> host | `answer` / `candidate` | 只能回给本房间分享者 |
@@ -110,7 +115,7 @@ WebSocket 使用同源 `/signal`。启用全站密码时,upgrade 必须先携
Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。
不兼容迁移使用一个精确的当前 release/protocol fingerprint,而不是版本协商。旧页面连接新服务端时收到专用 fatal mismatch,停止自动重连并提示刷新;服务端终止旧 session。客户端和服务端不解析旧 wire、不双写、不维护多版本 handler,部署切换完成后删除旧 schema 与测试。
该授权迁移把唯一 literal 从 `screener-v1` 原子改为 `screener-v2`,不做版本协商。旧页面的首条消息在任何角色进入 room lookup 前得到同一个 fatal refresh 终态、停止自动重连;服务端不解析 v1、不双写、不维护 translator。缺失、过期、跨房、已 rotate 或 revoked 的 v2 Viewer grant 则使用同一个通用“邀请无效或已失效”结果,不区分房间不存在与凭据错误。
## WebRTC 协商
@@ -250,13 +255,13 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。
- 全站 gate:仅在配置 `ACCESS_PASSWORD` 且 cookie 无效时显示一个密码输入窗口;验证后返回原目标路径。
- Host admission:只在用户进入分享/建房流程且 `HOST_ADMISSION_PASSWORD` cookie 无效时显示密码输入;`/join` 与 `/r/{code}` 不显示或泄露该密码。
- 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。
- `/join`:只输入正整数形式的数字房间码,成功后进入 `/r/{code}`。
- `/r/{code}` 观看者:主区域最大化远端视频;标题旁显示可复制的紧凑房间号,顶部保留连接状态;提供本地音量、静音、全屏和重试控制,不要求额外邀请码或 viewer token。
- `/join`:只输入正整数房间码并进入 `/r/{code}`,仅 public-watch 会通过;private-link 缺失 grant 时统一提示使用完整邀请链接。
- `/r/{code}` 观看者:主区域最大化远端视频;private-link 首次解析 fragment grant 后立即清理地址栏,public-watch 只需 code;标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。
- 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。
两端默认显示紧凑的在线 roster;Host 与 Viewer 均可修改本机显示名,更新只影响之后由服务端广播的纯文本 roster。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见,不能被该开关隐藏。分享者默认观看人数必须来自房间 roster,而不是只统计当前由 host 直连的媒体 child。
两端后续默认显示紧凑在线 roster;Host 与 Viewer 均可修改本机显示名,展示为名称加 room-scoped `peerId` 短后缀。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见。用户名/roster 是独立 PR,不进入 access credential 或 SQLite。
用户可见的非直播状态只使用“已停止分享”(分享端已结束当前发布)、“等待开始分享”(观看端等待发布)、“正在连接”与“正在恢复连接”。`room-closed` 显示“房间已关闭”或“房间已过期”,不得误写成“分享已结束”。房间寿命、媒体代次、换父和恢复等内部状态不得作为额外产品文案暴露。
@@ -267,7 +272,8 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
## 安全与故障边界
- 生产仅允许 HTTPS/WSS,并校验 WebSocket `Origin` allowlist。
- 全站密码以定长摘要配合常量时间比较;access cookie 由服务端 HMAC 签名且不含账号或可查询的 session ID。host token 只保存摘要并以常量时间验证;认证和建房响应设置 `Cache-Control: no-store`。
- Host admission 密码以定长摘要配合常量时间比较;Host-admission cookie 由服务端 HMAC 签名且不含账号或可查询 session ID。Host token 与 Viewer grant 只保存 SHA-256 摘要并以定长比较验证;Host admission 与建房 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 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。
- 自托管 coturn 必须启用 `stun-only` 及 `no-tcp`/`no-tls`,不得配置 relay port range、TURN auth secret、证书或匿名 TURN;coturn 4.17.2 已弃用 `no-dtls`,tracked 配置不再使用它。未来 selected-edge TURN 由独立设计取代这份 STUN-only 配置,而不是在当前 wire 上加回全房 credential。
@@ -277,13 +283,13 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
自动测试:
- AccessSession 的密码开关、12 小时签名 cookie、过期、篡改、cookie 属性和无服务端 session 状态;
- RoomStore 的随机临时房间、TTL、host token 摘要验证、停止分享等待、当前默认八人/1 至 16 边界、目标 20 人 gate 和超额拒绝;
- SQLite 房间库从 `1` 自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败;
- HostAdmissionSession 的 production 必配、16 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态;
- RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝;
- SQLite 房间库从 `1` 自增、重启恢复、v1 -> v2 单事务 locked-private 迁移、nullable 32-byte CHECK、畸形值 fail-closed、rotate/revoke 写失败原子性和数据最小化;数据库路径无 Host admission 时启动失败;
- STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界;
- 单一 `screener-v1` 信令 literal、旧页面/新服务或新页面/旧服务 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制;
- 单一 `screener-v2` 信令 literal、v1 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制;
- 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;
- HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和停止分享后的等待状态;
- 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,grant 不能建房/Host/跨房。覆盖 private fragment/sessionStorage/replaceState、独立无 grant 访问、不含 query/Referrer/log、public code-only、public-to-private 一次性 grant,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝;
- 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知;
- TypeScript 类型检查、Vite production build 和仓库 hygiene。
@@ -307,6 +313,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
4. 完成连接恢复和 stats 面板;随后以 STUN-only snapshot 替换旧全房 TURN credential/refresh wire。
5. 完成 automatic peer/SFU controller 与 exact-room canary 边界。
6. 用有界网络矩阵验收 direct/peer UDP、SFU/UDP 和明确失败;selected-edge 兼容 transport 只有出现真实缺口后才另立完整 PR。
7. 在媒体主线阶段验收后,用一个原子 PR 迁移 Host admission、`screener-v2`、room-scoped Viewer grant 和 SQLite v2;同一部署切换客户端/服务端/环境和数据库备份,随后删除 v1 配置、wire 与测试。用户名/roster 保持独立。
任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。
+19 -12
View File
@@ -12,13 +12,13 @@
### P0 - 必须实现
- [ ] 首屏双入口:通过全站验证后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;加入房间不能只藏在页头或次要链接中。点击后直接进入现有分享或房间码加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。
- [ ] 简单房间:分享者获得纯数字房间码和 `/r/{code}` 链接;观看者也可在 `/join` 只输入房间码加入,链接不携带额外观看 token 或 fragment。Host 与 Viewer 标题附近都以紧凑、显眼的形式显示房间号并提供只复制房间号的动作,Host 原有复制完整邀请链接动作继续保留。启用持久受保护房间时,房间码从 `1` 开始自增;其他模式继续使用随机临时房间码防止枚举。
- [ ] 首屏双入口:分享入口通过 Host admission 后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;观看者不需要知道 Host admission 密码。点击后直接进入现有分享或房间加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。
- [ ] 简单房间:分享者获得纯数字房间码。房间默认是私密链接模式,邀请使用 `/r/{code}#v={grant}`;拥有该链接只可作为 Viewer 进入这一间房,不能建房、发布、冒充 Host 或跨房。Host 可显式选择 public-watch,使 `/r/{code}` 和 `/join` 的纯房间码可直接观看;不实现房间密码或账号。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作,Host 保留复制完整邀请链接;持久房间码从 `1` 开始自增,其他模式继续使用随机临时房间码。
- [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。
- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。Host 本地预览必须直接使用 capture stream,不得为预览建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。
- [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。
- [ ] 观看端本地音量:播放控制栏提供 `0%` 至 `100%` 音量条和静音按钮,只改变当前浏览器的媒体元素,不修改 Host 音轨、编码、房间状态或其他观看者。切流、换父和 ICE 恢复保留当前页面内的值;刷新后不持久化。静音保留上次非零音量,拖动到非零值自动取消静音。
- [ ] 本地显示名与房间成员:用户可设置一个保存在当前浏览器 `localStorage` 的显示名;信令只在当前在线会话内保存和广播经严格长度/字符校验的纯文本标签,不写 SQLite。Host 页面显示“{分享者显示名} 的房间”,Host 与 Viewer 都看到包含分享者和当前观看者的权威 roster。显示名不是账号、授权身份或可信唯一标识,重复名称允许且不得参与路由或质量决策。
- [ ] 本地显示名与房间成员:用户可设置一个保存在当前浏览器 `localStorage` 的显示名;信令只在当前在线会话内保存和广播经严格长度/字符校验的纯文本标签,不写 SQLite。Host 页面显示“{分享者显示名} 的房间”,Host 与 Viewer 都看到包含分享者和当前观看者的权威 roster;展示身份使用“名称 + 当前房间 `peerId` 的短后缀”,短后缀碰撞时延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。
- [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。
- [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。当前 browser relay 因逐跳重编码仍保守限制为一个 child,只有资源矩阵通过后才可升到二。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
@@ -29,8 +29,9 @@
- [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。
- [ ] 可观测性:按目标 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、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。
- [ ] 访问控制:部署者可配置一个全站 `ACCESS_PASSWORD`;为空时网站公开,非空值必须为 1 至 128 个可见 ASCII 字符。配置时分享者和观看者都必须先登录。登录成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` cookie,建房 HTTP 和 WebSocket 都只使用该 cookie,不提供建房 Bearer 旁路;不引入账号、JWT、服务端 session Map 或 logout。
- [ ] 持久受保护房间:`ROOM_DATABASE_PATH` 可选且必须与 `ACCESS_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID 与 host token 摘要;房间及链接不设过期时间,停止后分享端显示“已停止分享”,观看页显示“等待开始分享”。密码模式不配置数据库时仍使用临时房间;公开模式始终使用随机临时房间。
- [ ] Host admission:生产部署必须配置独立的 `HOST_ADMISSION_PASSWORD` 严格控制建房和 Host 信令角色;只有本地开发/测试可显式关闭。非空值必须为 16 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。建房 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。不引入账号、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 的 locked-private digest,直到下一次 rotate 才产生新邀请。新摘要成功提交后必须清除 Viewer grace/路由、终止旧授权代次的信令和媒体并断开全部旧 Viewer,数据库写失败则不得改变连接。public-watch 是显式、可逆的 room policy,只放宽观看,不放宽建房或发布。
- [ ] 最小持久化:`ROOM_DATABASE_PATH` 可选且必须与 `HOST_ADMISSION_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID、Host token 摘要和同一 `rooms` 行上的 nullable Viewer grant 摘要;`NULL` 表示 public-watch,非空值必须由数据库 `CHECK` 约束为 32-byte BLOB,不新增用户、成员或 session 表。持久房间停止后仍显示等待状态;私密邀请按期限或 Host rotate 更新。旧 schema 行在原子迁移时必须 fail-closed 为没有可用明文 grant 的锁定私密状态,Host rotate 后才能生成新邀请。
### P1 - 重要功能
@@ -62,7 +63,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;P2P/TURN 边在 WebRTC 端点间使用 DTLS-SRTP,普通 `turn:` over TCP 不额外套 TLS,但不移除该媒体层加密。普通 SFU 是逐跳 DTLS-SRTP,只有另行实现并验收应用层 E2EE 才能对 SFU operator 保持媒体端到端机密。TURN 使用短期凭据、配额和速率限制;仓库不得包含真实密钥。
- 安全:应用控制面全程 HTTPS/WSS;Host admission、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 历史承担旧实现追溯。
@@ -71,8 +72,8 @@
## 边界与约束
- 首版不包含远程键鼠控制、公开/大规模直播、录制、社区系统或完整语音聊天;大规模分享直接使用现有直播服务。
- 公开模式中,随机数字房间码是唯一观看 capability,不提供强私密保证;面向互联网且要求私密的部署必须配置全站密码。可枚举的自增房间码只允许出现在已经由全站密码保护的持久模式中。
- SQLite 只用于持久房间身份,不得保存全站密码、host token 明文、access cookie、SDP、ICE candidate、IP 地址、观看者状态、媒体或 TURN 凭据;首版仍是单进程、单实例控制面。
- public-watch 中房间码本身就是有意公开的观看入口,不提供隐私保证、目录或防转发承诺;随机/自增房间码均不得被描述为私密邀请。现有每房 Viewer 上限、全局/未鉴权连接上限、房间上限和中央 egress/admission cap 仍适用,public-watch 不能创建无界服务器 fanout。
- SQLite 只用于持久房间身份与最小授权摘要:房间 ID、Host token 摘要、nullable Viewer grant 摘要。不得保存 Host admission 密码、token/grant 明文、cookie、显示名、成员、`clientId`、`peerId`、SDP、ICE candidate、IP 地址、媒体或 TURN 凭据;首版仍是单进程、单实例控制面。
- 当前八名观看者和 1 至 16 配置范围是接入边界;目标默认 20 也只是通过指定矩阵后的接入边界,不承诺任意机器、档位、网络或跨洲路径都稳定承载。
- 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。
- 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。
@@ -83,11 +84,17 @@
### 房间模式与生命周期
- Given `ACCESS_PASSWORD` 和 `ROOM_DATABASE_PATH` 都已配置,When 连续创建房间并重启应用,Then 房间码从 `1` 开始单调递增,已创建链接仍可加入,且 SQLite 的应用数据只包含房间 ID 与 host token 摘要。
- Given `HOST_ADMISSION_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 `ROOM_DATABASE_PATH` 已配置但 `ACCESS_PASSWORD` 为空,When 应用启动,Then 配置校验明确失败,不能在公开网站暴露可枚举的持久房间。
- Given `ROOM_DATABASE_PATH` 为空,When 创建房间,Then 无论是否启用全站密码都使用随机临时房间码并遵守 `ROOM_TTL_SECONDS`;停止分享本身不立即销毁房间。
- Given 应用以 production 模式启动但 `HOST_ADMISSION_PASSWORD` 缺失、`ROOM_DATABASE_PATH` 已配置但该密码为空,或原子迁移后的配置仍提供已删除的 `ACCESS_PASSWORD`,When 应用启动,Then 配置校验明确失败,而不是兼容旧 gate 或公开建房。
- Given production Host admission secret 短于 16 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 新建私密房间,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 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 且保持 locked-private,旧页面停止自动重连。Given 持久化失败,Then 旧摘要、连接和媒体保持不变。
- Given 房间处于 public-watch,When 用户只输入房间码,Then 可在现有 Viewer/连接/egress 上限内观看,但仍不能创建房间、取得 Host 角色或枚举房间目录;Host 切回私密时签发新 grant 并撤销现有 code-only Viewer。
- Given schema v1 SQLite 在备份后首次由新 release 打开,When 单事务升级到 v2,Then 只新增 `viewer_grant_digest BLOB NULL CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32)`,旧行被置为未生成对应 grant 的锁定私密摘要;旧 Host token 仍可回收房间并 rotate,旧 Viewer 链接不能 fail-open。畸形非空值必须拒绝写入或读取时 fail closed;回滚旧 binary 必须恢复迁移前备份,不运行双 schema 代码。
- Given 部署切换了不兼容的当前协议,When 仍打开的旧页面连接新服务端,Then 单一 release/protocol mismatch 边界终止该 session、停止自动重连并明确提示刷新;不得通过旧 parser、双写或双协议继续假连接。
### P2P、SFU 与可选 TURN 兜底
@@ -104,7 +111,7 @@
- Given 观看者只有受支持的手机浏览器,When 打开有效邀请链接并执行浏览器要求的播放手势,Then 无需安装应用即可进入房间、看到首帧并听到可用音频。
- Given 观看者调节音量或静音,When 媒体切流、换父或恢复连接,Then 当前页面的本地播放音量保持不变,其他观看者和发送端不受影响;刷新页面后恢复默认音量,不产生信令、持久化或媒体重协商。
- Given 用户在本机设置显示名,When 以 Host 或 Viewer 加入房间,Then 所有在线参与者看到同一份服务端权威 roster 和对应房间标题;刷新后本机名称仍在,清除站点数据后恢复未命名默认值,数据库中不存在显示名或成员记录。
- Given 用户在本机设置显示名,When 以 Host 或 Viewer 加入房间,Then 所有在线参与者看到同一份服务端权威 roster、对应房间标题和“名称 + room-scoped peer 后缀”;刷新后本机名称仍在,清除站点数据后恢复未命名默认值,数据库中不存在显示名或成员记录,展示身份不参与授权或路由。
- Given 手机从 Wi-Fi 切换到蜂窝网络或页面从后台恢复,When 原候选对失效,Then 客户端执行 ICE restart 或重建连接,并显示恢复进度而不是永久黑屏。
### 一对多与带宽保护