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" });
}
};
}