diff --git a/docs/adr/0002-memory-resident-protected-rooms.md b/docs/adr/0002-memory-resident-protected-rooms.md index 4065e4c..fa1dc03 100644 --- a/docs/adr/0002-memory-resident-protected-rooms.md +++ b/docs/adr/0002-memory-resident-protected-rooms.md @@ -91,13 +91,16 @@ room-scoped `sessionStorage`, immediately clears the fragment with `history.replaceState`, and never places it in a cookie, query, log, error, or `localStorage`. -Code entry is a separate three-state policy: +Code entry is a separate two-state policy: - `open` is the default: after site access, the four-digit code admits a Viewer without a room password; -- `password`: after site access, the code also requires the room password; -- `disabled`: code-only admission is rejected and the grant remains the entry - path. +- `password`: after site access, the code also requires the room password. + +The Host UI presents these as a globe for public code entry and a lock for +password code entry. The password input appears only for the password state. +There is no code-entry-disabled state because it is easily mistaken for +disabling the whole room and is not required by either accepted entry path. The Host may rotate or revoke the grant without changing code-entry policy, and may change code-entry policy without changing a healthy media route. Strong @@ -154,8 +157,8 @@ the generic `SERVER_ERROR`. None of those paths is folded into - Default creation atomically produces `open` code entry with no room password and an independent 22-character token-bearing invitation. The grant remains valid for exactly the current room incarnation and has no independent expiry. - `open`, `password`, and `disabled` code entry are covered independently from - grant rotate/revoke. + `open` and `password` code entry are covered independently from grant + rotate/revoke. - Same-browser recreation reapplies the Host profile; another browser or cleared storage does not. No fingerprint or server user record participates. - Raw site passwords, Host tokens, Viewer grants, and room passwords remain out diff --git a/docs/project-memory.md b/docs/project-memory.md index 24e7435..20a76d8 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -8,7 +8,7 @@ Last updated: 2026-08-24 - A room supports one Host and up to `20` authenticated Viewers. Admission is independent of endpoint fanout and SFU capacity; the event-driven route controller must remain efficient at that bound without all-pairs probing. - Authenticated Hosts and shipped clients are trusted media participants in these private rooms; route authority, generation fencing, and server admission remain fail-closed. - Rooms use one bounded, process-memory model: a random free code from `1000..9999`, default 24-hour configurable dormant lease, and no database or cross-restart recovery. Active sharing never expires; stop/disconnect starts the lease, the exact Host token may resume before expiry, Viewer activity does not renew it, and expiry releases the code. Process restart invalidates every room, token, invitation, password verifier, and participant. -- Viewer grants and code entry are orthogonal. Each room has one random 128-bit/22-character grant digest bound to that exact in-memory incarnation; it has no independent expiry and ends on reclamation, restart, rotate, or revoke. Code entry is `open | password | disabled`, defaulting to `open`; an unallocated/reclaimed/expired code returns `ROOM_NOT_FOUND` without a password prompt, while other existing-room denials remain `ROOM_ACCESS_DENIED`. The Host browser retains its creation preferences and raw ownership token; the server keeps only current-process digests/verifiers and uses no fingerprint. Recreation reapplies preferences but creates a new code and credentials. +- Viewer grants and code entry are orthogonal. Each room has one random 128-bit/22-character grant digest bound to that exact in-memory incarnation; it has no independent expiry and ends on reclamation, restart, rotate, or revoke. Every new room creates that invitation and defaults code entry to public `open` with no password; the only other code-entry state is `password`. An unallocated/reclaimed/expired code returns `ROOM_NOT_FOUND` without a password prompt, while other existing-room denials remain `ROOM_ACCESS_DENIED`. The Host browser retains its creation preferences and raw ownership token; the server keeps only current-process digests/verifiers and uses no fingerprint. Recreation reapplies preferences but creates a new code and credentials. - Web is the current delivery target. The route release covers Browser Host, Viewer, and relay endpoints; packaged/native senders, capture helpers, shared-encode executables, and their acceptance runs are lowest-priority later work outside this milestone. - Keep routing automatic and media distributed. Direct/peer UDP is preferred; the dedicated LiveKit SFU/UDP path is the only application fallback and is not the default topology. Screener configures no TURN, ICE/TCP, media TCP, or TLS-relayed media path. - Every non-server endpoint uses one server-authoritative steady outbound media-copy capacity: default `2`, statically configurable as `1`, `2`, or `3`. A peer child or the Host publication consumes one slot; upstream receive is free, and SFU subscriber egress is accounted at the server. Browser role/UA/visibility does not create a separate tier, and clients cannot raise the deployment value. diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index 9ef75fc..62dc4b1 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -282,7 +282,7 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 ### 最小访问模型 -接受的 access boundary 使用 production 必配的 `SITE_ACCESS_PASSWORD` 无状态 cookie 保护建房、Host role 和所有 code-only Viewer 入口;合法未过期的 exact-room fragment grant 可直接观看。没有 grant 时,服务端先要求 site access,再检查 `open | password | disabled` code-only policy;房间码只定位,cookie 也不替代房间密码。WebSocket upgrade 仍只做 Origin/容量检查并记录 cookie 状态,首条 `authenticate` 完成上述 role/room 授权。该边界不引入账号、JWT、服务端 session Map 或逐人 ACL。 +接受的 access boundary 使用 production 必配的 `SITE_ACCESS_PASSWORD` 无状态 cookie 保护建房、Host role 和所有 code-only Viewer 入口;合法未过期的 exact-room fragment grant 可直接观看。没有 grant 时,服务端先要求 site access,再检查 `open | password` code-only policy;房间码只定位,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`。因此把 128-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;这是传输边界,不是“不会泄漏”的保证。 diff --git a/docs/status.md b/docs/status.md index c28e7c4..3fba949 100644 --- a/docs/status.md +++ b/docs/status.md @@ -29,10 +29,11 @@ This is the current execution index. Git history owns completed timelines; [veri ## Current Milestone -1. After explicit owner instruction, release the exact v12 source atomically and verify stale-wire rejection, health, invitation/access behavior, and unchanged direct/SFU operation; no deployment has occurred at this checkpoint. -2. Physically validate the corrected live screen-audio statistics and 64/128/256 kbps ceilings with known audible content across direct, browser-relay, and SFU paths. -3. Physically verify fixed-VP8/no-video-hint direct, browser-relay, and SFU paths from actual codec, capture, outbound, and decoded stats. -4. Finish representative ICE/STUN/SFU acceptance including mobile networks. Browser port prediction, NAT classification, TCP probing, fake page keepalive, and quality-driven reparenting remain outside the accepted model. +1. Implement the accepted two-state `open | password` room-code contract on strict v12: remove `disabled`, preserve default `open` plus the independently generated invitation, and present public/password entry with globe/lock controls and an on-demand password field. Current source and production still retain the older three-state implementation at this truth checkpoint. +2. After explicit owner instruction, release the exact v12 source atomically and verify stale-wire rejection, health, invitation/access behavior, and unchanged direct/SFU operation; no deployment has occurred at this checkpoint. +3. Physically validate the corrected live screen-audio statistics and 64/128/256 kbps ceilings with known audible content across direct, browser-relay, and SFU paths. +4. Physically verify fixed-VP8/no-video-hint direct, browser-relay, and SFU paths from actual codec, capture, outbound, and decoded stats. +5. Finish representative ICE/STUN/SFU acceptance including mobile networks. Browser port prediction, NAT classification, TCP probing, fake page keepalive, and quality-driven reparenting remain outside the accepted model. ## Active Boundaries diff --git a/docs/todo.md b/docs/todo.md index c8878f8..bfb1ca7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -6,10 +6,11 @@ Only items in **Now** are executable after their stated decision gate. A branch ## Now -1. **Deploy Browser v12 only after explicit owner instruction.** Current source implementation `064db16c1b6d25fd24b7057b7e2fca172002e3c4` already contains the strict v12 grant/access/UI and exact-audio-stat changes; production remains exact `c4962f5` on v11. A release must switch server and Browser assets atomically, reject stale v11 before room authority, run bounded health/postflight checks, and verify the shorter complete invitation URL, rotate/revoke, code-only missing-room result, direct media, and SFU fallback. This is an application-only release unless its scope is explicitly expanded. -2. **Physically validate live screen-audio statistics.** The source fix binds ordinary Host and SFU publisher audio stats to the exact current sender track, merges SFU video/audio reports, and resets the baseline when audio identity changes or disappears. After deployment, reproduce the reported `1 kbps` state with known audible content and switch the 64/128/256 kbps ceilings across direct, browser-relay, and SFU paths while checking sender/receiver counters, route continuity, audible result, and A/V synchronization. A real muted/idle track may legitimately use about 1 kbps; do not multiply the value or display the configured ceiling as observed bitrate. -3. **Physically validate fixed Browser VP8 with no video hint.** Production exact deployed application/runtime revision `c4962f54443ad5f98bc65861195a3d9c74a48996`, release `c4962f5`, runs strict v11, VP8-only direct/browser-relay offers, VP8 SFU publication with no backup codec, unset video `contentHint`, and no codec UI/state/wire. Confirm actual codec, capture, outbound, and decoded stats on direct, browser-relay, and SFU paths; automated gates and the owner SFU connectivity proof do not close the full codec gate. -4. **Finish real-network route acceptance.** Measure standard ICE/STUN direct paths, peer relay, SFU ingress/subscription, exact first-frame commit and rollback, relay-ingress repair with subtree retention, disconnect/capacity drain, Pause/Resume, and bounded explicit failure across representative IPv4/IPv6 NATs and mobile networks. Include an all-UDP-blocked case to establish the current failure boundary. Do not add port prediction, NAT classification, TCP probes, guessed candidates, route scores, or quality-driven reparenting. +1. **Implement the accepted two-state room-code contract.** Strict v12 must accept only `open | password`, default every new room to `open` without a room password, and independently generate its Viewer-grant invitation. Remove the current `disabled` state from protocol, storage, server authorization, client preferences, UI, and tests. Host UI uses globe/lock controls and shows the password field only when password entry is selected; the invitation surface remains independent. +2. **Deploy Browser v12 only after explicit owner instruction.** Current source implementation `064db16c1b6d25fd24b7057b7e2fca172002e3c4` already contains the strict v12 grant/access/UI and exact-audio-stat changes; production remains exact `c4962f5` on v11. A release must switch server and Browser assets atomically, reject stale v11 before room authority, run bounded health/postflight checks, and verify the shorter complete invitation URL, rotate/revoke, code-only missing-room result, direct media, and SFU fallback. This is an application-only release unless its scope is explicitly expanded. +3. **Physically validate live screen-audio statistics.** The source fix binds ordinary Host and SFU publisher audio stats to the exact current sender track, merges SFU video/audio reports, and resets the baseline when audio identity changes or disappears. After deployment, reproduce the reported `1 kbps` state with known audible content and switch the 64/128/256 kbps ceilings across direct, browser-relay, and SFU paths while checking sender/receiver counters, route continuity, audible result, and A/V synchronization. A real muted/idle track may legitimately use about 1 kbps; do not multiply the value or display the configured ceiling as observed bitrate. +4. **Physically validate fixed Browser VP8 with no video hint.** Production exact deployed application/runtime revision `c4962f54443ad5f98bc65861195a3d9c74a48996`, release `c4962f5`, runs strict v11, VP8-only direct/browser-relay offers, VP8 SFU publication with no backup codec, unset video `contentHint`, and no codec UI/state/wire. Confirm actual codec, capture, outbound, and decoded stats on direct, browser-relay, and SFU paths; automated gates and the owner SFU connectivity proof do not close the full codec gate. +5. **Finish real-network route acceptance.** Measure standard ICE/STUN direct paths, peer relay, SFU ingress/subscription, exact first-frame commit and rollback, relay-ingress repair with subtree retention, disconnect/capacity drain, Pause/Resume, and bounded explicit failure across representative IPv4/IPv6 NATs and mobile networks. Include an all-UDP-blocked case to establish the current failure boundary. Do not add port prediction, NAT classification, TCP probes, guessed candidates, route scores, or quality-driven reparenting. ## Accepted Later Roadmap diff --git a/docs/方案设计.md b/docs/方案设计.md index fe1dbfb..8ae49cf 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -72,11 +72,11 @@ tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试 `SITE_ACCESS_PASSWORD` 是简单的部署访问口令,不是 Viewer 密码或账号凭据。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 8 至 128 个可见 ASCII bytes(`0x21` 至 `0x7e`),并作为不复用 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,因此仍允许没有 site-access cookie 的合法 grant Viewer 完成握手;握手前严格校验 `/signal` 无 query、Origin allowlist、总连接数和未鉴权连接数,并把当次 cookie 校验结果保存到 socket state。五秒内的第一条 `authenticate` 完成授权:Host 必须同时拥有 upgrade 时有效的 site-access 状态和当前房间 `hostToken`;Viewer 必须拥有目标房间当前 digest 对应的 grant,或者先通过 site access 再按 `open | password | disabled` code-only policy 校验。site-access cookie 只允许进行 code-only 尝试,不能替代 grant/房间密码;Viewer grant 也不能用于 HTTP 建房、Host role、其他房间或其他操作。 +WebSocket upgrade 无法预知后续 role,因此仍允许没有 site-access cookie 的合法 grant Viewer 完成握手;握手前严格校验 `/signal` 无 query、Origin allowlist、总连接数和未鉴权连接数,并把当次 cookie 校验结果保存到 socket state。五秒内的第一条 `authenticate` 完成授权:Host 必须同时拥有 upgrade 时有效的 site-access 状态和当前房间 `hostToken`;Viewer 必须拥有目标房间当前 digest 对应的 grant,或者先通过 site access 再按 `open | password` code-only policy 校验。site-access cookie 只允许进行 code-only 尝试,不能替代 grant/房间密码;Viewer grant 也不能用于 HTTP 建房、Host role、其他房间或其他操作。 Viewer grant 与 code-only 入口是两个正交维度。每个房间用 `randomBytes(16).toString("base64url")` 生成 128-bit、22 字符 room-scoped opaque grant,服务端只在该 exact room incarnation 内保存完整 grant 的 SHA-256 摘要并用定长比较验证。grant 不编码版本、房间号或到期时间,也没有独立 expiry;它随房间存在,并在房间回收、进程重启、rotate 或 revoke 时失效。同号新房间生成新 digest,旧链接不能进入。Host 的主分享动作始终复制 token-bearing invitation;即时清退使用 rotate/revoke。 -code-only policy 使用单一 `open | password | disabled` 状态:`open` 是无房间密码的默认值;`password` 要求已通过 site access 的 Viewer 再输入该房间密码;`disabled` 中性拒绝 code-only 尝试。它们都不改变有效 grant 的 exact-room Viewer authority,也不建立目录、账号、ACL 或额外媒体容量。Host 可独立改变 code-only policy、设置/替换/移除密码,以及 rotate/revoke grant。 +code-only policy 使用单一 `open | password` 状态:`open` 是无房间密码的默认值;`password` 要求已通过 site access 的 Viewer 再输入该房间密码。两种状态都不改变有效 grant 的 exact-room Viewer authority,也不建立目录、账号、ACL 或额外媒体容量。Host 可独立改变 code-only policy、设置/替换/移除密码,以及 rotate/revoke grant。Host UI 只用地球与锁表达公开加入和密码加入;选择密码加入时才显示密码输入,不呈现会被误解为整个房间不可访问的关闭状态。 Host 的同源 `localStorage` creation profile 保存显示名、code-only policy 和可选房间密码,使进程重启或租约过期后的下一次显式分享可原子应用同一偏好。该密码只保存在 Host 浏览器;不保存在 Viewer 浏览器、URL/query/fragment、cookie、日志、错误或服务器持久存储。建房/更新请求通过 HTTPS/WSS 把明文短暂交给服务端,服务端使用 Node 标准异步 `scrypt` 固定 `N=16384,r=8,p=1`、16-byte 随机 salt 和 32-byte verifier,只在当前房间内存保存 48-byte material。进程级 gate 最多执行 2 个、等待 16 个派生,队满即有界失败;任务开始和提交前都复核当前 Host/Viewer session 与 room material。不存在、已回收或已过期房间使用 `ROOM_NOT_FOUND`;现存房间的未配置/错误密码和正确密码后的满房使用 `ROOM_ACCESS_DENIED`。 @@ -116,7 +116,7 @@ WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site- | server -> client | `authenticated` | 返回角色、peerId、可为空的房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 | | viewer -> server | `set-display-name` | 只更新该 Viewer 当前 authenticated socket state 的规范化名称,不写房间或数据库状态 | | server -> subscribed Web host | `viewer-presence` | 返回全部当前在线 Viewer 的权威 snapshot 与精确 active upstream;不触发媒体连接 | -| host -> server -> host | room-access update | 独立设置 `open/password/disabled`、设置/移除 password,或 rotate/revoke grant;raw grant 只在新建或 rotate 成功响应中出现一次,服务端不回显密码 | +| host -> server -> host | room-access update | 独立设置 `open/password`、设置/移除 password,或 rotate/revoke grant;raw grant 只在新建或 rotate 成功响应中出现一次,服务端不回显密码 | | server -> host | `peer-joined` / `peer-left` | 建立或关闭该观看者的独立连接 | | host -> viewer | `offer` / `candidate` | 带 `peerId` 与 `connectionId` 的定向信令 | | viewer -> host | `answer` / `candidate` | 只能回给本房间分享者 | @@ -128,7 +128,7 @@ WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site- Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。 -accepted 的唯一 wire 是 `screener-v12`,不做版本协商、旧 parser、双写或 translator。任何其他 Browser wire 及 executable sender 的首条消息在角色进入 room lookup 前得到同一个 fatal refresh 终态并停止自动重连。Client 与 server 共用 `SIGNAL_CLOSE_CODES` 定义会话替换、鉴权失败和 Viewer access revoke 的终态语义,避免双方对重连边界产生分叉;非法协议消息使用 WebSocket policy-violation close code。缺失、跨房、已 rotate 或 revoked 的当前 Viewer grant 使用同一个通用“邀请无效或已失效”结果。已通过 site access 的 well-formed code-only attempt 在没有 current room 时发 `ROOM_NOT_FOUND`;现存房间的 disabled policy、absent/wrong password、room full 与其他已知有界 admission refusal 继续发 `ROOM_ACCESS_DENIED`。两者都使用共享 authentication-failed close code。Grant 继续使用 `INVALID_TOKEN`,Host 鉴权保持独立,unexpected internal failure 使用不含 raw message 的 `SERVER_ERROR`。 +accepted 的唯一 wire 是 `screener-v12`,不做版本协商、旧 parser、双写或 translator。任何其他 Browser wire 及 executable sender 的首条消息在角色进入 room lookup 前得到同一个 fatal refresh 终态并停止自动重连。Client 与 server 共用 `SIGNAL_CLOSE_CODES` 定义会话替换、鉴权失败和 Viewer access revoke 的终态语义,避免双方对重连边界产生分叉;非法协议消息使用 WebSocket policy-violation close code。缺失、跨房、已 rotate 或 revoked 的当前 Viewer grant 使用同一个通用“邀请无效或已失效”结果。已通过 site access 的 well-formed code-only attempt 在没有 current room 时发 `ROOM_NOT_FOUND`;现存房间的 absent/wrong password、room full 与其他已知有界 admission refusal 继续发 `ROOM_ACCESS_DENIED`。两者都使用共享 authentication-failed close code。Grant 继续使用 `INVALID_TOKEN`,Host 鉴权保持独立,unexpected internal failure 使用不含 raw message 的 `SERVER_ERROR`。 ## WebRTC 协商 @@ -295,7 +295,7 @@ Host 与 relay Viewer 复用同一个纯 presentation reducer 保存远端 `View - 访问入口:未鉴权根路径、`/join` 和合法 code-only `/r/{code}` 显示“站点访问”与“站点口令”;malformed `/r/...` 直接显示房间号必须是 `1000..9999` 四位数字,其他未知路径只显示“无法访问”。有效 `/r/{code}#v={grant}` 直接进入;其余 Viewer 通过 site access 后以 code-only 方式进入。未分配、已回收或已过期的 well-formed code 显示“房间不存在或已过期”,不展开或提供密码输入;现存房间的 `ROOM_ACCESS_DENIED` 保留“输入房间密码”和使用 Host 邀请的动作。已有有效 site-access cookie 不重复显示整站验证;站点口令和房间密码不共享输入值或授权状态。 - 分享者根页面:顶部显示“{分享者显示名} 的屏幕”和房间状态,并在标题旁显示可单独复制的紧凑四位房间号;Viewer 同一位置显示权威 Host 名称,不再在标题下重复。主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;固定 action row 只能包含这两个按钮及分隔,disclosure 是紧随其后的 sibling,不得参与 action-row track sizing。前者直接开始现有分享流程,后者在当前舞台内展开共享的房间码表单,提交后才导航到 `/r/{code}`。按钮同步 `aria-expanded`/`aria-controls`,展开后输入获得焦点,DOM 与焦点顺序保持触发器后接表单;窄屏允许 disclosure 自己换行,不改变两个 action 的矩形或产生横向滚动。不增加首页、向导或拓扑选择。直播时舞台显示本地预览及独立全屏图标,底部保留停止、同步暂停音视频和质量档位。邀请控制使用紧凑 copy-first 布局:包含 22 字符 grant 的完整短 URL 保持可见,复制是主动作;更新和撤销使用有 tooltip/accessible name 的 familiar icons,房间号复制、`开放 | 密码 | 关闭` code-entry segmented control 与密码编辑保持职责分离。页面失焦仅暂停本地 video element,并在舞台提示“本地预览已暂停,分享仍在继续”;媒体暂停则禁用 capture stream 轨道,两者都保留现有连接。 - `/join`:保留为可直接访问的回退入口,并复用同一个四位房间码表单;一个纯 route parser 返回 `host | join | viewer | malformed-room | unknown`,只把精确 `/r/{1000..9999}` 当成 Viewer route。输入保留用户原文,`inputMode="numeric"` 只提示软键盘,提交时由共享 schema 拒绝而不是 `trim`、删除非数字或接受 Unicode 数字。通过 site access 后输入合法 code 并进入 `/r/{code}`,后续行为由 code-only admission 决定。 -- `/r/{code}` 观看者:首次解析合法 fragment grant 后立即清理地址栏并直达;仅有 code 时先通过 site access,再按 `open/password/disabled` policy 进入。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制;Host 主动暂停时复用视频舞台遮罩显示“分享者已暂停”,恢复后立即移除。视频舞台用单一派生 presentation reducer 合成 access、Host presence、route prepare/active、peer/SFU connection、remote track、首个 composited frame、autoplay rejection 和 bounded failure。支持时用 `requestVideoFrameCallback()` 确认第一帧已提交,旧浏览器回退到当前代 decoded-frame delta 与 video readiness;`playing`、track object 或 ICE `connected` 单独都不足以结束占位层。连接阶段占用固定布局且不显示虚假百分比,播放按钮只处理实际 autoplay rejection,失败重试只调用现有有界 controller 入口。 +- `/r/{code}` 观看者:首次解析合法 fragment grant 后立即清理地址栏并直达;仅有 code 时先通过 site access,再按 `open/password` policy 进入。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制;Host 主动暂停时复用视频舞台遮罩显示“分享者已暂停”,恢复后立即移除。视频舞台用单一派生 presentation reducer 合成 access、Host presence、route prepare/active、peer/SFU connection、remote track、首个 composited frame、autoplay rejection 和 bounded failure。支持时用 `requestVideoFrameCallback()` 确认第一帧已提交,旧浏览器回退到当前代 decoded-frame delta 与 video readiness;`playing`、track object 或 ICE `connected` 单独都不足以结束占位层。连接阶段占用固定布局且不显示虚假百分比,播放按钮只处理实际 autoplay rejection,失败重试只调用现有有界 controller 入口。 - 首帧观察绑定当前 assignment/connection/media-binding generation 以及适用的 publication generation;`srcObject` 或 route identity 变化立即使旧回调和旧 decoded baseline 失效,迟到回调不能把新路线标成已播放。 - 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。 @@ -333,12 +333,12 @@ Token-free SFU standby prewarm 保持可选且不创建 participant、publicatio 自动测试: - SiteAccess 的 production 必配、8 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、已移除 access key fail-fast 和无服务端 session 状态; -- RoomStore 的四位空闲码随机分配/释放、活动分享免过期、dormant lease/续租、Host token/Viewer grant 摘要验证、22 字符 128-bit grant 与 exact room incarnation 同寿、`open/password/disabled` 正交 policy、跨房/同号重建拒绝、rotate/revoke 强撤销、Viewer 上限和超额拒绝; +- RoomStore 的四位空闲码随机分配/释放、活动分享免过期、dormant lease/续租、Host token/Viewer grant 摘要验证、22 字符 128-bit grant 与 exact room incarnation 同寿、`open/password` 正交 policy、跨房/同号重建拒绝、rotate/revoke 强撤销、Viewer 上限和超额拒绝; - 进程重启后 room state 全空、旧 Host/grant/password fail closed、释放号码复用时旧 grant 仍失败;`ROOM_LEASE_SECONDS` 的默认/边界与 `MAX_ROOMS <= 9000` 校验,已删除的 `ROOM_DATABASE_PATH`/`ROOM_TTL_SECONDS` fail-fast; - STUN UDP URL 严格校验、生产 STUN 必需、LiveKit tuple 完整性/TTL/secret 独立性、当前 route/session/revision/connection generation guard,以及 ordinary peer、未授权房间和 Native 的 STUN-only isolation;LiveKit `sfu-config` 保持独立边界; - Accepted v12 contract 使用单一 `screener-v12` 信令 literal、严格 JSON schema、方向与房间授权校验、payload 上限和 client-mismatch 刷新终态;分享状态由 `shareGeneration`-fenced paused boolean 表达,missing room 使用 `ROOM_NOT_FOUND`,现存房间准入失败使用 `ROOM_ACCESS_DENIED`,Viewer route failure 为 typed terminal,Host-only bounded route snapshot 保留给自动验收但不进入产品 UI。`qualityProfileIdSchema` 保持三档,`qualityResolutionSchema` 包含 `480p`,`DEFAULT_QUALITY_SETTINGS` 为 1080p30,quality schema 严格拒绝 codec 字段; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;screen audio SDP helper 覆盖 CRLF、existing/missing fmtp、unknown key preservation、幂等、payload number、rejected/non-audio,以及 empty/no-Opus/ambiguous/parser 异常原对象 fallback,`ViewerPeer` 覆盖 initial/restart/rebuild 时同一结果的 local/signaling 一致性,SFU publisher 精确断言 high-quality stereo/forceStereo/DTX 且不覆盖 RED; -- HTTP/WS 集成:nginx per-source site-access `limit_req` 与错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured site access 与 Host token,Viewer 要么持 exact-room grant,要么先有 site access 再按 `open/password/disabled`。覆盖原子创建 profile、同浏览器重建偏好、fragment/sessionStorage/replaceState、无 site access 的 code-only 拒绝、unknown/reclaimed/expired room 使用 `ROOM_NOT_FOUND` 且不显示密码、现存 disabled/wrong-or-absent-password/full code-only 使用 `ROOM_ACCESS_DENIED`、grant 与 Host 的独立错误、raw grant/密码不进入 URL/日志/服务端持久状态,以及 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 再按 `open/password`。覆盖原子创建 profile、同浏览器重建偏好、fragment/sessionStorage/replaceState、无 site access 的 code-only 拒绝、unknown/reclaimed/expired room 使用 `ROOM_NOT_FOUND` 且不显示密码、现存 wrong-or-absent-password/full code-only 使用 `ROOM_ACCESS_DENIED`、grant 与 Host 的独立错误、raw grant/密码不进入 URL/日志/服务端持久状态,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; - 客户端策略与 stats 解析:会话替换不重连,ordinary ICE 与 LiveKit protocol 分离; - SFU client isolation:`SfuPublisher` 与 `SfuSubscriber` 的 `Room.connect` mock 分别精确断言 `autoSubscribe: false` 与 `rtcConfig: { iceServers: [] }`,ordinary `HostPeer`/`ViewerPeer` 回归继续断言部署 STUN,防止空列表遗漏 Viewer 或扩散到 peer edge; - route planner property tests:候选输入乱序仍产生相同 list,健康边 sticky,硬过滤拒绝 stale/cycle/effective-capacity/unadmitted parent,优先结果深度和剩余容量并稳定打破平局;失败候选清理后 cursor 前进且总 deadline 不重置; diff --git a/docs/需求理解.md b/docs/需求理解.md index 883b184..8fc8ac2 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -17,7 +17,7 @@ SFU 的 publication、subscription、server-resource accounting、临时 overlap ### P0 - 必须实现 - 首屏双入口:分享入口通过站点访问后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;持合法房间 grant 的观看者不需要知道站点访问密码。“加入房间”在当前舞台内就地展开房间码输入并提交,不先跳转到独立页面;直接访问 `/join` 仍提供同一输入能力。不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。 -- 简单房间:分享者从当前空闲池随机获得一个 `1000` 至 `9999` 的四位房间码,房间码只负责定位,不是秘密或永久身份。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作;Host 的主分享动作复制含独立 Viewer grant 的完整邀请链接。新房默认允许已通过站点访问的 Viewer 直接用房间号加入且不设房间密码;Host 可把 code-only 入口改为需要房间密码或完全关闭,不影响 grant 邀请。所有入口只授予该房间 Viewer,不能建房、发布、冒充 Host 或跨房。 +- 简单房间:分享者从当前空闲池随机获得一个 `1000` 至 `9999` 的四位房间码,房间码只负责定位,不是秘密或永久身份。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作;每个新房默认生成含独立 Viewer grant 的完整邀请链接,Host 的主分享动作复制该链接。房间号入口只有公开加入和密码加入两种状态;新房默认允许已通过站点访问的 Viewer 直接用房间号加入且不设房间密码,Host 可改为要求房间密码。邀请链接与房间号准入相互独立。所有入口只授予该房间 Viewer,不能建房、发布、冒充 Host 或跨房。 - 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。 - 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时同步暂停音视频和切换分享来源;暂停保留现有连接并禁用当前 capture stream 的全部媒体轨道。服务端按当前分享代次同步这一主动暂停状态,观看端在视频舞台显示“分享者已暂停”,不得从黑帧、track mute 或网络状态推断暂停;晚加入和信令重连使用权威快照,恢复、停止、新分享或会话替换清除旧提示。Host 本地预览必须直接使用 capture stream,支持独立全屏;页面失焦或隐藏时只暂停本地 video element,并明确提示“本地预览已暂停,分享仍在继续”,恢复焦点后继续预览,不得停止 capture、编码或上行。预览不得建立 viewer/媒体边或消耗 SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。 - 游戏声音:Windows Chrome/Edge 的初次分享和切换来源都默认请求可用的标签页、窗口或系统音频;浏览器、系统、所选来源或用户没有返回音轨时必须明确提示,而不是静默无声或阻止 video-only 分享。 @@ -34,8 +34,8 @@ SFU 的 publication、subscription、server-resource accounting、临时 overlap - 同一 operation 的一个总 deadline 只按实际存在的 `direct | sfu` 语义阶段等分,不配置每个 candidate 的 magic timeout。阶段内的硬失败继续推进 cursor;silent ICE 到达 direct 阶段边界时跳过剩余 direct tuple,必须把派生份额留给 SFU。20 名 Viewer 同时加入时,每名 child 的 operation deadline 不会被人数除分,但单一 room-serial operation 可能形成线性排队尾延迟;在接受另一种并发模型前,只测量 queue wait、candidate start、first decoded frame 与 final route 的 p50/p95/max,不用 NAT 猜测、TCP probe 或 magic timeout 绕过排队。 - 可观测性:按目标 RTP transport 与不透明连接/ICE 代次关联 selected path、`iceGatheringState`/ICE/connection state、事件推导的建立时长、generation-baselined pair-change delta,以及 RTT、RTP 丢包率、抖动、码率、帧率、编码耗时和受限原因。候选、route decision、队列时序与错误的 wire 和日志使用封闭 schema 和 code bucket,禁止 raw SDP、candidate、地址、URL、`errorText`、真实 peer/parent/session/connection identity 或网络标识。Host 与 Viewer 产品 UI 不提供连接诊断文件下载;本地折叠详情只显示当前页面直接观测到的连接事实。现有 Host-only bounded route snapshot 保留给自动验收,不连续推送、不进入产品 UI、也不形成 route authority。只有创建该 `RTCPeerConnection` 的本机可显示浏览器对 selected local/remote candidate 实际暴露的地址与端口;该值不上传、不持久化、不用于地理/NAT 推断,缺失或被隐私保护时显示未知。 - 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;Host 的 Viewer 卡片只把本机连接或当前接收证据称为“已连接”,其余状态保持中性的“线路分配中”或具体连接状态,不把 active assignment 当作连接成功。展开连接详情也只在当前媒体证据成立后显示媒体方式:direct peer使用绿色`P2P`,SFU使用黄色`SFU fallback`。尚未连接时不预告 P2P、SFU 或未知 ICE,只保留中性状态。Host/Peer 上下游仍只在独立的权威拓扑视图中展示。传输协议、候选路径、RTT、视频/音频 RTP 区间丢包率、实际编解码器、音频区间码率/jitter 和视频编码耗时等技术详情由一个明确的“显示连接详情”开关展开。远端 Viewer evidence 的详情 shell 在同一 viewer/parent/connection/route identity 内保持稳定;非空字段从各自最后一次实际观测起精确保留五秒,`connecting`/`disconnected` 只降为非 fresh,`failed`/`closed`、identity/session/share/route 变化或非单调 sequence 则清空/换代。非 fresh evidence 不得继续证明“已连接”或被其他 current-state 消费者使用。音频 codec MIME、RTP clock、channels 与 fmtp 只能来自当前 RTP 对象引用的 `RTCCodecStats`,缺失即未知;`opus/48000/2` 不得写成 48 kHz 音源或实际立体声。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。 -- 站点访问:生产部署必须配置独立的 `SITE_ACCESS_PASSWORD` 控制建房、Host 信令角色和所有 code-only Viewer 入口;只有本地开发/测试可显式关闭。非空值接受 8 至 128 个可见 ASCII bytes,不能复用 LiveKit、TLS 或其他部署 secret。`POST /api/site-access` 验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` site-access cookie。有效 room-scoped fragment grant 可绕过站点 gate 直接进入对应房间;其余 `/r/{code}` 和 `/join` 必须先持有效 site-access cookie,之后才检查房间是否存在与 `open | password | disabled` code-only policy。未分配、已回收或已过期的 code 返回 `ROOM_NOT_FOUND`;现存房间的 policy、密码与容量拒绝继续使用 `ROOM_ACCESS_DENIED`。建房 HTTP 只认该 cookie。已移除的旧环境变量、endpoint 和 cookie 不保留兼容。不引入账号、JWT、服务端 session Map 或通用 logout。 -- 房间级观看授权:新房间总是生成一个 128-bit 随机、22 字符 base64url、可 rotate/revoke 的 room-scoped Viewer grant;服务端内存只在该 exact room incarnation 保存完整 grant 的 SHA-256 摘要。grant 没有独立到期时间或内嵌 room/expiry 字段,与房间同寿,并在房间回收、进程重启、rotate 或 revoke 时失效;同号新房间使用新摘要,旧链接不能进入。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续。code-only 入口独立采用 `open | password | disabled`,默认 `open` 且无房间密码;所有 code-only 尝试先通过站点访问。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可独立 rotate/revoke grant 或改变 code-only policy;授权变化必须先提交新的内存代次,再清除 Viewer grace/路由、终止旧代次信令和媒体并断开受影响 Viewer。 +- 站点访问:生产部署必须配置独立的 `SITE_ACCESS_PASSWORD` 控制建房、Host 信令角色和所有 code-only Viewer 入口;只有本地开发/测试可显式关闭。非空值接受 8 至 128 个可见 ASCII bytes,不能复用 LiveKit、TLS 或其他部署 secret。`POST /api/site-access` 验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` site-access cookie。有效 room-scoped fragment grant 可绕过站点 gate 直接进入对应房间;其余 `/r/{code}` 和 `/join` 必须先持有效 site-access cookie,之后才检查房间是否存在与 `open | password` code-only policy。未分配、已回收或已过期的 code 返回 `ROOM_NOT_FOUND`;现存房间的密码与容量拒绝继续使用 `ROOM_ACCESS_DENIED`。建房 HTTP 只认该 cookie。已移除的旧环境变量、endpoint 和 cookie 不保留兼容。不引入账号、JWT、服务端 session Map 或通用 logout。 +- 房间级观看授权:新房间总是生成一个 128-bit 随机、22 字符 base64url、可 rotate/revoke 的 room-scoped Viewer grant;服务端内存只在该 exact room incarnation 保存完整 grant 的 SHA-256 摘要。grant 没有独立到期时间或内嵌 room/expiry 字段,与房间同寿,并在房间回收、进程重启、rotate 或 revoke 时失效;同号新房间使用新摘要,旧链接不能进入。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续。code-only 入口独立采用 `open | password`,默认 `open` 且无房间密码;所有 code-only 尝试先通过站点访问。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可独立 rotate/revoke grant 或改变 code-only policy;授权变化必须先提交新的内存代次,再清除 Viewer grace/路由、终止旧代次信令和媒体并断开受影响 Viewer。 - 易失房间状态:服务器只在一个有界内存 RoomStore 中保存房间码、租期、Host token/Viewer grant 摘要、code-only policy、可选 48-byte 房间密码校验材料、参与者和控制状态。`ROOM_LEASE_SECONDS` 默认 `86400`;Host 正在分享时不回收,停止分享或 Host 断开后开始 dormant lease,同一 Host token 在期限内重新分享即续租,Viewer 活动不续租,过期后释放号码。应用进程重启时全部房间、租约、邀请和密码状态直接失效;不使用 SQLite、`ROOM_DATABASE_PATH`、schema migration、账号、用户表或服务器 session 表。 - 本地 Host 偏好:房间所有权继续使用随机 Host token,不使用 IP、UA、硬件信息或浏览器指纹。Host 浏览器在同源 `localStorage` 保存 raw Host token,以及昵称、code-only policy 和可选房间密码组成的创建偏好;服务器只在当前进程内保存 token 摘要和密码 verifier。旧房间不存在时,下一次显式分享创建新房间并原子应用本地偏好,同时生成新的四位房间号、Host token 和 Viewer grant。换设备、换浏览器或清理站点数据即不恢复。 ### P1 - 重要功能 @@ -102,9 +102,9 @@ SFU 的 publication、subscription、server-resource accounting、临时 overlap - 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 新建房间,When 服务端返回邀请并由 Host/Viewer 客户端读取,Then grant 是 128-bit 随机量的 22 字符 base64url,只授权该 exact room incarnation 的 Viewer,并只在该页面会话的 room-scoped `sessionStorage` 留存且在首次读取后从地址栏移除;HTTP request-target、WebSocket URL、Referrer、cookie、`localStorage`、服务端持久存储、日志和错误均不包含 grant。Given 房间仍存在且未 rotate/revoke,Then grant 保持有效;Given 房间回收、进程重启、rotate/revoke 或同号新房间,Then 旧 grant 失效。 - Given 本地输入不是 `1000..9999` 的四位房间码,When 用户提交或直接访问 malformed `/r/...`,Then 客户端保留原输入并直接提示“四位数字”,不发起房间鉴权、不静默删除字符。Given 默认新房,When 已通过站点访问的 Viewer 只输入合法房间码,Then 无房间密码即可在现有 Viewer/连接/egress 上限内加入,同时 Host 的主分享动作仍复制独立的 token-bearing link。 -- Given 已通过 site access 的 well-formed code-only 尝试对应未分配、已回收或已过期房间,When `screener-v12` 首条信令鉴权到达,Then 服务端返回 `ROOM_NOT_FOUND`,UI 显示“房间不存在或已过期”且不提供密码重试。Given code 属于现存房间但 disabled policy、缺失/错误密码、满房或其他已知有界 admission refusal,Then 服务端继续返回 `ROOM_ACCESS_DENIED` 并使用共享 authentication-failed close code,UI 可提供现有可选密码重试和 Host 邀请提示。Given grant 无效,Then 继续使用通用 `INVALID_TOKEN`;Host 鉴权保持独立错误,unexpected internal failure 使用不含 raw message 的 `SERVER_ERROR`。Given Viewer 持另一个房间的 grant、缺失 grant 或自行把 role 改为 Host,Then 不能建房、发布、跨房或绕过 Host token。 +- Given 已通过 site access 的 well-formed code-only 尝试对应未分配、已回收或已过期房间,When `screener-v12` 首条信令鉴权到达,Then 服务端返回 `ROOM_NOT_FOUND`,UI 显示“房间不存在或已过期”且不提供密码重试。Given code 属于现存房间但缺失/错误密码、满房或其他已知有界 admission refusal,Then 服务端继续返回 `ROOM_ACCESS_DENIED` 并使用共享 authentication-failed close code,UI 可提供现有可选密码重试和 Host 邀请提示。Given grant 无效,Then 继续使用通用 `INVALID_TOKEN`;Host 鉴权保持独立错误,unexpected internal failure 使用不含 raw message 的 `SERVER_ERROR`。Given Viewer 持另一个房间的 grant、缺失 grant 或自行把 role 改为 Host,Then 不能建房、发布、跨房或绕过 Host token。 - Given Host rotate/revoke Viewer grant,When 新内存摘要和 authorization generation 提交,Then 服务端清除 Viewer grace/路由,命令 Viewer 关闭接收 PC/媒体并让每个 Host/relay/SFU upstream owner 关闭对应旧代次媒体边,最后关闭旧 Viewer socket;rotate 返回一次新 grant,revoke 锁定 grant 路径,code-only policy 保持独立。 -- Given Host 同源本地偏好保存了 `open | password | disabled` 和可选房间密码,When 服务器重启后该 Host 下一次显式开始分享,Then 客户端清除失效 ownership、创建新四位房间并原子重放偏好;新的 Host token 和 grant 替换旧值。Given 换浏览器、换设备或清理站点数据,Then 不恢复这些偏好或所有权。 +- Given Host 同源本地偏好保存了 `open | password` 和可选房间密码,When 服务器重启后该 Host 下一次显式开始分享,Then 客户端清除失效 ownership、创建新四位房间并原子重放偏好;新的 Host token 和 grant 替换旧值。Given 换浏览器、换设备或清理站点数据,Then 不恢复这些偏好或所有权。 - Given 应用进程重启,When 旧 Host、Viewer 或 code-only 链接再次访问,Then 所有旧房间、租约、摘要、密码 verifier 和参与者均已丢弃并 fail closed;运行时不读取数据库、迁移旧 schema 或恢复旧号码。 - Given 部署切换了不兼容的当前协议,When 仍打开的旧页面连接新服务端,Then 单一 release/protocol mismatch 边界终止该 session、停止自动重连并明确提示刷新;不得通过旧 parser、双写或双协议继续假连接。