fix(rooms): bound active room preference
This commit is contained in:
1 parent
d52f1b950b
commit
e0e7c30978
6 files changed
+50
-26
No files matched your search
@@ -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
|
||||
|
||||
+1
-1
@@ -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 必须把链接称为访问凭据。
|
||||
|
||||
|
||||
+1
-1
@@ -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、防火墙、端口、容量和适用的恢复预检。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<QualitySettings>(
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
@@ -323,6 +325,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const retiringStreamRef = useRef<MediaStream | null>(null);
|
||||
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
||||
const sfuStandbyPrewarmerRef = useRef<SfuStandbyPrewarmer | null>(null);
|
||||
const preferredRoomRenewalTimerRef = useRef<number | null>(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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user