export class HttpError extends Error { constructor(readonly status: number, message: string, readonly retryAfter?: number) { super(message); this.name = "HttpError"; } } export function securityLog(event: string, details: { actorId?: string; targetId?: string; status?: number } = {}) { console.info(JSON.stringify({ type: "security", event, ...details, at: new Date().toISOString() })); } export function errorResponse(cause: unknown): Response { const error = cause instanceof HttpError ? cause : new HttpError(503, "service-unavailable"); if (!(cause instanceof HttpError)) securityLog("dependency-unavailable", { status: 503 }); return Response.json({ error: error.message }, { status: error.status, headers: { "Cache-Control": "no-store", ...(error.retryAfter === undefined ? {} : { "Retry-After": String(error.retryAfter) }), }, }); } /** Mutations must come from the configured application, never the request Host. */ export function requireSameOrigin(request: Request) { const configured = process.env.BETTER_AUTH_URL; if (!configured) throw new HttpError(503, "origin-not-configured"); const origin = request.headers.get("origin"); if (origin !== new URL(configured).origin || request.headers.get("sec-fetch-site") === "cross-site") { throw new HttpError(403, "cross-origin-request"); } } /** Count actual streamed bytes as well as checking the untrusted Content-Length. */ export function boundedBody(request: Request, maximum: number): Response { const length = request.headers.get("content-length"); if (length && (!/^\d+$/u.test(length) || Number(length) > maximum)) { throw new HttpError(413, "body-too-large"); } let bytes = 0; const stream = request.body?.pipeThrough(new TransformStream({ transform(chunk, controller) { bytes += chunk.byteLength; if (bytes > maximum) throw new HttpError(413, "body-too-large"); controller.enqueue(chunk); }, })); return new Response(stream ?? null, { headers: request.headers }); } export async function readJson(request: Request, maximum = 16 * 1024): Promise { if (request.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") { throw new HttpError(415, "expected-json"); } try { return await boundedBody(request, maximum).json(); } catch (cause) { if (cause instanceof HttpError) throw cause; throw new HttpError(400, "invalid-json"); } } let uploads = 0; /** Acquire before buffering multipart bodies or decoding images. */ export async function withUploadSlot(task: () => Promise): Promise { if (uploads >= 2) throw new HttpError(429, "uploads-busy", 5); uploads += 1; try { return await task(); } finally { uploads -= 1; } }