import { HttpError } from "../errors"; import { agentUpdate } from "./room-agents"; import { canWrite, resolveEditorToken } from "./session"; import { CLOSE_DOWNGRADED, CLOSE_REAUTH, CLOSE_REVOKED } from "~/lib/editor-protocol"; import { awarenessFrame, base64, decodeAwareness, encodeAwareness, removalFrame, unbase64 } from "./room-awareness"; const MAX_CACHED_AWARENESS = 1200; const MAX_CLIENT_ID = 2 ** 32; /** Per-socket state; it survives hibernation, so it must stay small (attachments are capped at 2 KiB). */ export type Attachment = { token: string; userId: string; name: string; kind: "human" | "agent"; writable: boolean; checkedAt: number; expiresAt: number; aw?: string; awClient?: number; awClock?: number; }; export type RoomJoin = Omit & { repoId: string; branch: string; head: string; }; export type Participant = { id: string; name: string; kind: "human" | "agent"; path: string | null }; type AgentRow = Parameters[0]; /** * Relays a cursor update with identity fields overwritten by the authenticated user, so nobody can * impersonate another participant. The last update is cached on the socket to replay to newcomers. */ export function sanitizeAwareness( attachment: Attachment, update: Uint8Array, ): { frame: Uint8Array; attachment: Attachment } | null { let entries: ReturnType; try { entries = decodeAwareness(update); } catch { return null; } if (entries.length !== 1) return null; const [entry] = entries; // A socket owns exactly one Yjs client id; it can't move, hide or impersonate anyone else's cursor. if (entry.clientId >= MAX_CLIENT_ID) return null; if (attachment.awClient !== undefined && entry.clientId !== attachment.awClient) return null; if (entry.state && typeof entry.state === "object") { const state = entry.state as Record; const user = typeof state.user === "object" && state.user ? (state.user as Record) : {}; state.user = { ...user, name: attachment.name, id: attachment.userId, kind: attachment.kind }; } const sanitized = encodeAwareness([entry]); const cached = sanitized.byteLength <= MAX_CACHED_AWARENESS && entry.state !== null; return { frame: awarenessFrame(sanitized), attachment: { ...attachment, aw: cached ? base64(sanitized) : undefined, awClient: entry.clientId, awClock: entry.clock, }, }; } export function presenceFrames(attachments: Array, agents: AgentRow[]): Uint8Array[] { const frames: Uint8Array[] = []; for (const attachment of attachments) if (attachment?.aw) frames.push(awarenessFrame(unbase64(attachment.aw))); for (const agent of agents) frames.push(awarenessFrame(agentUpdate(agent))); return frames; } export function removalFor(attachment: Attachment | null): Uint8Array | null { if (attachment?.awClient === undefined) return null; return removalFrame(attachment.awClient, attachment.awClock ?? 0); } export function participants(attachments: Array, agents: AgentRow[]): Participant[] { const out = new Map(); for (const attachment of attachments) { if (!attachment) continue; let path: string | null = null; if (attachment.aw) { const state = decodeAwareness(unbase64(attachment.aw))[0]?.state as { path?: unknown } | null; path = typeof state?.path === "string" ? state.path : null; } out.set(`${attachment.userId}:${path}`, { id: attachment.userId, name: attachment.name, kind: attachment.kind, path, }); } for (const agent of agents) { const name = agent.name.slice(agent.name.indexOf(":") + 1); out.set(`agent:${agent.name}`, { id: `agent:${agent.name}`, name, kind: "agent", path: agent.path }); } return [...out.values()]; } /** * Re-resolves a socket's editor token (live permission, PAT scope, sign-outs) and closes it when it no * longer applies: 4408 to refresh an expired token, 4403 when write access was lost, 4401 when revoked. */ export async function reauthorizeSocket( socket: WebSocket, attachment: Attachment, repoId: string | null, branch: string | null, ): Promise { if (attachment.expiresAt <= Date.now()) { socket.close(CLOSE_REAUTH, "Session token expired"); return null; } try { const session = await resolveEditorToken(attachment.token); if (session.repo.id !== repoId || session.revision.name !== branch) { socket.close(CLOSE_REVOKED, "Session no longer matches this room"); return null; } if (attachment.writable && !canWrite(session)) { socket.close(CLOSE_DOWNGRADED, "Write access was removed"); return null; } const next = { ...attachment, checkedAt: Date.now() }; socket.serializeAttachment(next); return next; } catch (error) { socket.close(CLOSE_REVOKED, error instanceof HttpError ? error.message.slice(0, 100) : "Access revoked"); return null; } }