import { z } from "zod"; import { badRequest, forbidden, HttpError, unauthorized } from "../errors"; import { isSafeTreePath } from "../git/writer"; import { limitBrowse, limitWrite } from "../limits"; import { json } from "./http"; import { editorRoomStub } from "./room-client"; import type { RoomJoin } from "./room"; import { canWrite, EDITOR_TOKEN_PREFIX, resolveEditorToken } from "./session"; import type { EditorSession } from "./session"; import { EDITOR_WS_PROTOCOL } from "~/lib/editor-protocol"; const MAX_EDITS = 200; const agentName = z .string() .trim() .min(1) .max(60) .regex(/^[\w .@-]+$/, "Agent names may use letters, digits, spaces and . _ @ -"); function requireBranch(session: EditorSession): void { if (session.revision.kind !== "branch") throw badRequest("Live editing needs a branch"); } function roomTarget(session: EditorSession) { return { repoId: session.repo.id, branch: session.revision.name, head: session.revision.sha }; } /** * WebSocket upgrade for the co-editing room. Browsers can't set headers on sockets, so the editor token * travels as a second subprotocol next to `gitfor-editor.v1`; it is never put in the URL (logs, history). */ export async function handleRoomSocket(request: Request, url: URL): Promise { const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((part) => part.trim()); if (!protocols.includes(EDITOR_WS_PROTOCOL)) throw badRequest(`Use the ${EDITOR_WS_PROTOCOL} subprotocol`); const token = protocols.find((part) => part.startsWith(`${EDITOR_TOKEN_PREFIX}_`)); if (!token) throw unauthorized("An editor session token is required"); const session = await resolveEditorToken(token); await limitBrowse(session.viewer, request); requireBranch(session); const agent = url.searchParams.get("agent"); const parsedAgent = agent === null ? null : agentName.parse(agent); const join: RoomJoin = { ...roomTarget(session), token, userId: session.viewer.id, name: parsedAgent ? `${parsedAgent} (agent for ${session.viewer.name})` : (session.viewer.displayName ?? session.viewer.name), kind: parsedAgent ? "agent" : "human", writable: canWrite(session), expiresAt: session.expiresAt, }; const headers = new Headers(request.headers); headers.set("x-editor-join", JSON.stringify(join)); return editorRoomStub(session.repo.id, session.revision.name).fetch(new Request(request, { headers })); } const editInput = z.object({ path: z.string().min(1).max(4096).refine(isSafeTreePath, "Invalid path"), agent: agentName, baseVersion: z.number().int().nonnegative().optional(), edits: z .array( z.object({ from: z.number().int().nonnegative(), to: z.number().int().nonnegative(), insert: z.string().max(1024 * 1024), }), ) .min(1) .max(MAX_EDITS), }); async function readJsonBody(request: Request, schema: z.ZodType): Promise { let raw: unknown; try { raw = await request.json(); } catch { throw badRequest("Request body must be valid JSON"); } const parsed = schema.safeParse(raw); if (!parsed.success) throw new HttpError(422, parsed.error.issues.map((issue) => issue.message).join("; ")); return parsed.data; } /** * HTTP join path for agents that don't speak Yjs: read a file's live text and version, apply ranged * edits against a version (409 when stale), see pending changes, and leave. Same token and checks as sockets. */ export async function handleRoomRoute( request: Request, url: URL, action: string, session: EditorSession, ): Promise { if (!action.startsWith("room/")) return null; requireBranch(session); const room = editorRoomStub(session.repo.id, session.revision.name); const target = roomTarget(session); if (request.method === "GET" && action === "room/file") { const path = url.searchParams.get("path"); if (!path) throw badRequest("path is required"); return json(await room.snapshot(target.repoId, target.branch, target.head, path)); } if (request.method === "GET" && action === "room/status") { return json(await room.pending(target.repoId, target.branch, target.head)); } if (request.method === "POST" && action === "room/edit") { if (!canWrite(session)) throw forbidden("This editor session is read-only"); await limitWrite(session.viewer); const data = await readJsonBody(request, editInput); const result = await room.applyText( { ...target, path: data.path }, data.edits, { agent: data.agent, user: session.viewer.name, userId: session.viewer.id }, data.baseVersion, ); return json(result, result.ok ? 200 : 409); } if (request.method === "POST" && action === "room/leave") { const data = await readJsonBody(request, z.object({ agent: agentName })); await room.leave(session.viewer.id, data.agent); return json({ ok: true }); } return null; }