import type { IncomingMessage, ServerResponse } from 'http'; import { PORTS } from '../utils/config'; /** * Allowed CORS origins — only the Electron renderer (Vite dev or production) * and the local opencode runtime are permitted to make cross-origin requests. */ const ALLOWED_ORIGINS = new Set([ `http://127.0.0.1:${PORTS.NIANCODE_DEV}`, `http://localhost:${PORTS.NIANCODE_DEV}`, `http://127.0.0.1:${PORTS.OPENCODE_RUNTIME}`, `http://localhost:${PORTS.OPENCODE_RUNTIME}`, ]); export async function parseJsonBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const raw = Buffer.concat(chunks).toString('utf8').trim(); if (!raw) { return {} as T; } return JSON.parse(raw) as T; } /** * Validate that mutation requests (POST/PUT/PATCH/DELETE) carry a JSON Content-Type. * This prevents "simple request" CSRF where the browser skips the preflight * when Content-Type is text/plain or application/x-www-form-urlencoded. */ export function requireJsonContentType(req: IncomingMessage): boolean { if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') { return true; } // Requests without a body (content-length 0 or absent) are safe — CSRF // "simple request" attacks rely on sending a crafted body. const contentLength = req.headers['content-length']; if (contentLength === '0' || contentLength === undefined) { return true; } const ct = req.headers['content-type'] || ''; return ct.includes('application/json'); } export function setCorsHeaders(res: ServerResponse, origin?: string): void { // Only reflect the Origin header back if it is in the allow-list. // Omitting the header for unknown origins causes the browser to block // the response — this is the intended behavior for untrusted callers. if (origin && ALLOWED_ORIGINS.has(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); } res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-NianCode-Access-Token'); } export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void { res.statusCode = statusCode; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(payload)); } export function sendNoContent(res: ServerResponse): void { res.statusCode = 204; res.end(); } export function sendText(res: ServerResponse, statusCode: number, text: string): void { res.statusCode = statusCode; res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end(text); } export function flushStreamingHeaders(res: ServerResponse): void { res.socket?.setNoDelay(true); res.flushHeaders?.(); } function waitForResponseDrain(res: ServerResponse): Promise { if (res.destroyed || res.writableEnded) { return Promise.resolve(false); } return new Promise((resolve, reject) => { const cleanup = () => { res.off('drain', onDrain); res.off('close', onClose); res.off('error', onError); }; const onDrain = () => { cleanup(); resolve(true); }; const onClose = () => { cleanup(); resolve(false); }; const onError = (error: Error) => { cleanup(); reject(error); }; res.once('drain', onDrain); res.once('close', onClose); res.once('error', onError); }); } export async function writeStreamingChunk( res: ServerResponse, chunk: string | Uint8Array, ): Promise { if (res.destroyed || res.writableEnded) { return false; } if (res.write(chunk)) { return true; } return await waitForResponseDrain(res); }