diff --git a/docs/adr/0002-memory-resident-protected-rooms.md b/docs/adr/0002-memory-resident-protected-rooms.md index 2d206a4..66671e1 100644 --- a/docs/adr/0002-memory-resident-protected-rooms.md +++ b/docs/adr/0002-memory-resident-protected-rooms.md @@ -68,9 +68,9 @@ current room. Clearing local data or changing browser/device loses ownership. The Host browser also keeps one local creation profile containing its display name, code-entry policy, and optional room password, plus an independent -non-secret preferred room code. It has no deadline while the Host is sharing; -normal stop starts a local deadline equal to that room's configured lease -duration (24 hours by default). When old ownership no longer works, the next explicit +non-secret preferred room code with a deadline equal to that room's configured +lease duration (24 hours by default). Host sharing renews it every half lease; +normal stop renews it once more. When old ownership no longer works, the next explicit share requests the still-current preferred code and atomically reapplies the creation profile. A free code may therefore be reused, but the result always has a new Host token, Viewer grant, password material, lease, and room incarnation; @@ -142,9 +142,9 @@ the generic `SERVER_ERROR`. None of those paths is folded into invitations. Active media already disconnects at that boundary; the additional cost is a new room code and invitation. Same-browser Host preferences are reapplied automatically on the next explicit share. -- A room unused beyond the lease is released. The local code preference likewise - starts its configured lease countdown on normal stop; active sharing does not - count down. +- A room unused beyond the lease is released. The local code preference is + renewed while sharing and once on normal stop, then expires after the same + configured lease duration. - Recycled code-only bookmarks may eventually identify a different room. A stale grant remains unusable because the new room has a different digest. - Multi-process room coordination, seamless restart, and horizontal scaling are diff --git a/docs/方案设计.md b/docs/方案设计.md index 5cee8f5..00324a9 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -80,7 +80,7 @@ code-only policy 使用单一 `open | private` 状态:`open` 是无房间密 房间准入管理统一使用同源 `POST /api/rooms/{roomId}/access`:site-access cookie 与 `Authorization: Bearer {hostToken}` 共同授权一个受 schema 约束的 policy、password、rotate 或 revoke 动作。该控制面在分享活动和休眠期间语义相同,不建立 Host participant/WebSocket/media route,不改变 `sharingActive` 或 dormant lease;rotate/revoke 的现有强撤销副作用保持不变。 -Host 的同源 `localStorage` creation profile 保存显示名、code-only policy 和可选房间密码,使进程重启或租约过期后的下一次显式分享可原子应用同一偏好。建房响应同时返回实际 `roomLeaseSeconds`;Host ownership 保存该值,独立的 preferred-room 记录保存上次四位号码和 nullable 截止时间。Host 鉴权分享时截止时间为 `null`,正常停播时写为 `now + roomLeaseSeconds`;清除失效 ownership 不清除仍有效的号码偏好,也不建立刷新 timer。该密码只保存在 Host 浏览器;不保存在 Viewer 浏览器、URL/query/fragment、cookie、日志、错误或服务器持久存储。建房/更新请求通过 HTTPS 把明文短暂交给服务端,服务端使用 Node 标准异步 `scrypt` 固定 `N=16384,r=8,p=1`、16-byte 随机 salt 和 32-byte verifier,只在当前房间内存保存 48-byte material。进程级 gate 最多执行 2 个、等待 16 个派生,队满即有界失败;任务开始和提交前都复核当前 Host authority 与 room material。不存在、已回收或已过期房间使用 `ROOM_NOT_FOUND`;现存房间的未配置/错误密码和正确密码后的满房使用 `ROOM_ACCESS_DENIED`。 +Host 的同源 `localStorage` creation profile 保存显示名、code-only policy 和可选房间密码,使进程重启或租约过期后的下一次显式分享可原子应用同一偏好。建房响应同时返回实际 `roomLeaseSeconds`;Host ownership 保存该值,独立的 preferred-room 记录保存上次四位号码和 `preferenceExpiresAt`。Host 鉴权分享时立即写 `now + roomLeaseSeconds`,并以半个租期为 interval 低频续写;正常停播再写一次,资源释放和页面卸载停止 interval。清除失效 ownership 不清除仍有效的号码偏好。该密码只保存在 Host 浏览器;不保存在 Viewer 浏览器、URL/query/fragment、cookie、日志、错误或服务器持久存储。建房/更新请求通过 HTTPS 把明文短暂交给服务端,服务端使用 Node 标准异步 `scrypt` 固定 `N=16384,r=8,p=1`、16-byte 随机 salt 和 32-byte verifier,只在当前房间内存保存 48-byte material。进程级 gate 最多执行 2 个、等待 16 个派生,队满即有界失败;任务开始和提交前都复核当前 Host authority 与 room material。不存在、已回收或已过期房间使用 `ROOM_NOT_FOUND`;现存房间的未配置/错误密码和正确密码后的满房使用 `ROOM_ACCESS_DENIED`。 邀请使用 `/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 重连继续使用该页面会话的值。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 必须把链接称为访问凭据。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 2f9b668..3e1a3ea 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -37,7 +37,7 @@ SFU 的 publication、subscription、server-resource accounting、临时 overlap - 站点访问:生产部署必须配置独立的 `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 | private` 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 | private`,默认 `open` 且无房间密码;`private` 可不设密码并拒绝全部 code-only 尝试,设置密码后只接受匹配值。所有 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 和可选房间密码组成的创建偏好;另以无秘密记录保存上次分配的四位号码。Host 分享中该偏好不倒计时,正常停播后按该房间实际 `ROOM_LEASE_SECONDS` 开始截止时间(默认 24 小时)。服务器只在当前进程内保存 token 摘要和密码 verifier。旧 ownership 失效后的下一次显式分享仅在偏好仍有效时请求原号码;服务端只在号码空闲时分配,否则随机分配。两种结果都创建全新 room incarnation、Host token 和 Viewer grant,不恢复旧租约、密码材料、成员或路由。换设备、换浏览器或清理站点数据即不恢复。 +- 本地 Host 偏好:房间所有权继续使用随机 Host token,不使用 IP、UA、硬件信息或浏览器指纹。Host 浏览器在同源 `localStorage` 保存 raw Host token,以及昵称、code-only policy 和可选房间密码组成的创建偏好;另以无秘密记录保存上次分配的四位号码和按该房间实际 `ROOM_LEASE_SECONDS` 计算的截止时间(默认 24 小时)。Host 分享中每半个租期续写一次,正常停播再续写一次;页面关闭或崩溃后不再续写,最多保留一个配置租期。服务器只在当前进程内保存 token 摘要和密码 verifier。旧 ownership 失效后的下一次显式分享仅在偏好仍有效时请求原号码;服务端只在号码空闲时分配,否则随机分配。两种结果都创建全新 room incarnation、Host token 和 Viewer grant,不恢复旧租约、密码材料、成员或路由。换设备、换浏览器或清理站点数据即不恢复。 ### P1 - 重要功能 - 公网服务器一键部署包:在用户自有的公网服务器上一键部署当前 Web/信令应用、易失房间状态和所需 STUN/SFU/反向代理组件,复用唯一配置、secret、路由、健康检查和恢复合同。普通 app-only 更新先验证新的不可变 artifact 再原子切换,切换前 application release 只保证保留到有限 health/postflight 完成;之后没有保留合同,也不作为维护中的备份。只有实际改动基础设施、配置、secret、持久或不可逆状态时,才为被触及表面定义恢复并预检。它不是托管服务,不能内置真实凭据或跳过域名、TLS、防火墙、端口、容量和适用的恢复预检。 diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index f56c9d7..75275cb 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -21,7 +21,7 @@ const hostRoomStorageSchema = createRoomResponseSchema.pick({ const hostRoomPreferenceSchema = z .object({ roomId: roomCodeSchema, - preferenceExpiresAt: z.string().datetime().nullable(), + preferenceExpiresAt: z.string().datetime(), }) .strict(); @@ -133,8 +133,7 @@ export function readPreferredRoomId(nowMs = Date.now()): string | null { const parsed = hostRoomPreferenceSchema.safeParse(JSON.parse(stored)); if ( !parsed.success || - (parsed.data.preferenceExpiresAt !== null && - Date.parse(parsed.data.preferenceExpiresAt) <= nowMs) + Date.parse(parsed.data.preferenceExpiresAt) <= nowMs ) { clearPreferredRoom(); return null; @@ -148,19 +147,18 @@ export function readPreferredRoomId(nowMs = Date.now()): string | null { export function writePreferredRoom( roomId: string, - roomLeaseSeconds: number | null, + roomLeaseSeconds: number, nowMs = Date.now(), ): void { if ( !roomCodeSchema.safeParse(roomId).success || - (roomLeaseSeconds !== null && - (!Number.isSafeInteger(roomLeaseSeconds) || roomLeaseSeconds <= 0)) + !Number.isSafeInteger(roomLeaseSeconds) || + roomLeaseSeconds <= 0 ) { return; } - const expiresAtMs = - roomLeaseSeconds === null ? null : nowMs + roomLeaseSeconds * 1_000; - if (expiresAtMs !== null && !Number.isFinite(expiresAtMs)) { + const expiresAtMs = nowMs + roomLeaseSeconds * 1_000; + if (!Number.isFinite(expiresAtMs)) { return; } try { @@ -168,8 +166,7 @@ export function writePreferredRoom( HOST_ROOM_PREFERENCE_STORAGE_KEY, JSON.stringify({ roomId, - preferenceExpiresAt: - expiresAtMs === null ? null : new Date(expiresAtMs).toISOString(), + preferenceExpiresAt: new Date(expiresAtMs).toISOString(), }), ); } catch { diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 5e9ddc1..54c1103 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -234,6 +234,8 @@ interface HostPageProps { onAuthorizationRequired?: () => void; } +const MAX_BROWSER_TIMER_DELAY_MS = 2_147_000_000; + export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [qualitySettings, setQualitySettings] = useState( DEFAULT_QUALITY_SETTINGS, @@ -323,6 +325,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const retiringStreamRef = useRef(null); const hostSfuRouteRef = useRef(null); const sfuStandbyPrewarmerRef = useRef(null); + const preferredRoomRenewalTimerRef = useRef(null); const mediaViewers = useMemo( () => Array.from(peerSnapshots.values()), @@ -395,6 +398,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { sourceSwitchRef.current = null; qualityChangeRef.current = null; pendingQualityChangeRef.current = null; + stopPreferredRoomRenewal(); signalRef.current?.stop(); peersRef.current.forEach((peer) => peer.dispose()); peersRef.current.clear(); @@ -428,6 +432,34 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ); } + function stopPreferredRoomRenewal(): void { + if (preferredRoomRenewalTimerRef.current !== null) { + window.clearInterval(preferredRoomRenewalTimerRef.current); + preferredRoomRenewalTimerRef.current = null; + } + } + + function startPreferredRoomRenewal(activeRoom: HostRoomState): void { + stopPreferredRoomRenewal(); + const renew = () => + writePreferredRoom( + activeRoom.roomId, + activeRoom.roomLeaseSeconds, + ); + renew(); + const intervalMs = Math.max( + 1_000, + Math.min( + Math.floor((activeRoom.roomLeaseSeconds * 1_000) / 2), + MAX_BROWSER_TIMER_DELAY_MS, + ), + ); + preferredRoomRenewalTimerRef.current = window.setInterval( + renew, + intervalMs, + ); + } + function ensureHostSfuRoute(generation: number): HostSfuRoute { const existing = hostSfuRouteRef.current; if (existing) { @@ -486,6 +518,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } function disposeResources(notifyServer: boolean): void { + stopPreferredRoomRenewal(); sourceSwitchRef.current = null; qualityChangeRef.current = null; pendingQualityChangeRef.current = null; @@ -1525,10 +1558,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ...activeRoom, expiresAt: message.roomExpiresAt, }); - writePreferredRoom( - activeRoom.roomId, - null, - ); + startPreferredRoomRenewal(activeRoom); setPhase("live"); } handleSignalMessage( diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 9b785b9..c651fbe 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -485,9 +485,6 @@ describe("client session identity", () => { }, }); - writePreferredRoom("4321", null, 1_000); - expect(readPreferredRoomId(Number.MAX_SAFE_INTEGER)).toBe("4321"); - writePreferredRoom("4321", 90, 1_000); clearHostRoom();