import type { IncomingMessage, ServerResponse } from "node:http"; import { timingSafeEqual } from "node:crypto"; import { z } from "zod"; import { ChatError, type CoChat } from "./co-chat.js"; import type { InspectorOAuthAuth } from "./oauth-auth.js"; export interface CoChatWeb { chat: CoChat; origin: string; javascript: string; stylesheet: string; } const headers = { "cache-control": "no-store", "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY", "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", }; const html = `Co ยท Stream
`; function send(response: ServerResponse, status: number, body: unknown, type = "application/json; charset=utf-8"): void { response.writeHead(status, { ...headers, "content-type": type }); response.end(type.startsWith("application/json") ? JSON.stringify(body) : body); } function unique(request: IncomingMessage, name: string): string | undefined { let count = 0; for (let i = 0; i < request.rawHeaders.length; i += 2) if (request.rawHeaders[i]?.toLowerCase() === name) count++; const value = request.headers[name]; return count === 1 && typeof value === "string" ? value : undefined; } function equal(left: string, right: string): boolean { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b); } async function body(request: IncomingMessage): Promise { if (unique(request, "content-type") !== "application/json") throw new ChatError(415, "json-required"); let bytes = 0; const chunks: Buffer[] = []; for await (const chunk of request) { const buffer = Buffer.from(chunk as Uint8Array); bytes += buffer.length; if (bytes > 32_768) throw new ChatError(413, "request-too-large"); chunks.push(buffer); } try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new ChatError(400, "invalid-request"); } } /** OAuth is checked here independently; Basic and inspector capabilities cannot enter. */ export function createCoChatHandler(web: CoChatWeb | undefined, oauth: InspectorOAuthAuth | undefined) { let readWindow = 0; let reads = 0; let writes = 0; return async (request: IncomingMessage, response: ServerResponse, url: URL): Promise => { try { const session = await oauth?.authenticate(request.headers.cookie); if (!session) { request.resume(); send(response, 401, { error: "sign-in-required" }); return; } if (!web) { request.resume(); send(response, 503, { error: "chat-not-configured" }); return; } if (Date.now() - readWindow > 60_000) { readWindow = Date.now(); reads = 0; writes = 0; } const method = request.method; if (method !== "GET" && method !== "HEAD" && method !== "POST") throw new ChatError(405, "method-not-allowed"); if (++reads > 240) throw new ChatError(429, "rate-limited"); // OAuth redirects and links can be cross-site top-level navigations. // Admit only the inert authenticated shell, never API reads or writes. const shellNavigation = method === "GET" && (url.pathname === "/chat/" || url.pathname === "/chat") && !url.search && unique(request, "sec-fetch-mode") === "navigate" && unique(request, "sec-fetch-dest") === "document"; if (request.headers["sec-fetch-site"] === "cross-site" && !shellNavigation) throw new ChatError(403, "same-origin-required"); if (method === "POST") { if (++writes > 30) throw new ChatError(429, "rate-limited"); if (unique(request, "origin") !== web.origin || !equal(unique(request, "x-co-csrf") ?? "", session.csrfToken)) throw new ChatError(403, "request-not-verified"); } const path = url.pathname; const asset = path === "/chat/" || path === "/chat" || path === "/chat/app.js" || path === "/chat/app.css"; if (asset) { if (method === "POST" || url.search) throw new ChatError(405, "method-not-allowed"); request.resume(); const type = path.endsWith(".js") ? "text/javascript; charset=utf-8" : path.endsWith(".css") ? "text/css; charset=utf-8" : "text/html; charset=utf-8"; send(response, 200, method === "HEAD" ? "" : path.endsWith(".js") ? web.javascript : path.endsWith(".css") ? web.stylesheet : html, type); return; } if (method === "HEAD") throw new ChatError(405, "method-not-allowed"); if (path === "/chat/api/session" && method === "GET" && !url.search) { request.resume(); send(response, 200, { csrfToken: session.csrfToken, sharedMemory: true, permissionMode: "unrestricted" }); return; } if (path === "/chat/api/conversations" && !url.search) { if (method === "GET") { request.resume(); send(response, 200, { conversations: web.chat.list() }); return; } const input = z.object({ requestId: z.string().uuid() }).strict().parse(await body(request)); send(response, 201, await web.chat.create(input.requestId)); return; } const match = /^\/chat\/api\/conversations\/([a-f0-9-]{36})(?:\/(send|stop|rename))?$/.exec(path); if (!match) throw new ChatError(404, "not-found"); const id = z.string().uuid().parse(match[1]); const action = match[2]; if (!action && method === "GET") { const fields = [...url.searchParams.keys()]; if (fields.length > 1 || fields.some((key) => key !== "before")) throw new ChatError(400, "invalid-request"); const before = url.searchParams.get("before") ?? undefined; if (before && !/^[A-Za-z0-9_-]{1,100}$/.test(before)) throw new ChatError(400, "invalid-cursor"); request.resume(); send(response, 200, await web.chat.snapshot(id, before)); return; } if (method !== "POST" || url.search) throw new ChatError(405, "method-not-allowed"); const input = await body(request); if (action === "send") { send(response, 202, await web.chat.send(id, input)); return; } if (action === "stop") { z.object({}).strict().parse(input); send(response, 202, await web.chat.stop(id)); return; } if (action === "rename") { const data = z.object({ title: z.string().trim().min(1).max(100) }).strict().parse(input); send(response, 200, await web.chat.rename(id, data.title)); return; } throw new ChatError(404, "not-found"); } catch (error) { request.resume(); if (response.headersSent) { response.destroy(); return; } send(response, error instanceof ChatError ? error.status : error instanceof z.ZodError ? 400 : 503, { error: error instanceof ChatError ? error.code : error instanceof z.ZodError ? "invalid-request" : "chat-unavailable" }); } }; }