/** * Room key parsing (TASK-0091 R1/R2). * * Room keys are plain strings carried on the upgrade query * (`?room=page:abc`) — `page:{pageId}` / `database:{databaseId}` / * `comment:{pageId}` / `user:{userId}` (doc §1 + task header note 4). * * The key carries NO workspace: the server resolves * `resourceId → workspaceId` server-side (injectable resolver, note 14) * and checks the token's `workspaces[]` claim against it — the client * never supplies the workspace. */ export type RoomKind = "page" | "database" | "comment" | "user"; /** A parsed room key. */ export interface ParsedRoom { readonly kind: RoomKind; /** pageId / databaseId / pageId / userId — per kind. */ readonly id: string; } const ROOM_KINDS: readonly RoomKind[] = ["page", "database", "comment", "user"]; /** * Parse a room key. `null` when malformed (unknown kind, empty id, extra * segments, leading/trailing whitespace, empty string) — the handshake * closes `4000 bad_room` for a malformed key (R1). */ export function parseRoomKey(room: string): ParsedRoom | null { if (typeof room !== "string" || room.length === 0) return null; const idx = room.indexOf(":"); if (idx <= 0) return null; const kind = room.slice(0, idx); const id = room.slice(idx + 1); if (!ROOM_KINDS.includes(kind as RoomKind)) return null; if (id.length === 0 || id.includes(":")) return null; if (id !== id.trim()) return null; return { kind: kind as RoomKind, id }; } /** The room key for a parsed room (round-trips `parseRoomKey`). */ export function roomKeyFor(kind: RoomKind, id: string): string { return `${kind}:${id}`; } /** * The resource a room syncs (doc rooms) — `user:` control rooms have no * doc resource (note 16 / header note 4: pure fan-out). */ export function roomResource( room: ParsedRoom, ): { readonly kind: "page" | "database"; readonly id: string } | null { if (room.kind === "user") return null; // `comment:{pageId}` rooms fan out over the page; they carry no doc. if (room.kind === "comment") return null; return { kind: room.kind, id: room.id }; }