import type { AppContext } from "@/worker/hono"; import { HttpError } from "@/worker/http"; interface ErrorSpec { status: number; code: string; message: string; } export const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0; export const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isNonEmptyString); // Accept `application/json`, `application/json; charset=utf-8`, and the // `+json` family (RFC 6839). Anything else for a tessera-owned mutating // route is treated as a 400 invalid_content_type so a `text/plain` body // containing JSON cannot slip through readJsonBody's body parser. const isJsonContentType = (raw: string | undefined | null): boolean => { if (!raw) return false; const value = raw.split(";")[0]?.trim().toLowerCase(); if (!value) return false; return value === "application/json" || value.endsWith("+json"); }; const requireJsonContentType = (c: AppContext): void => { if (!isJsonContentType(c.req.header("content-type"))) { throw new HttpError(400, "invalid_content_type", "Request body must be JSON (Content-Type: application/json)."); } }; const hasBody = (c: AppContext): boolean => { const contentLength = c.req.header("content-length"); if (contentLength !== undefined) return Number.parseInt(contentLength, 10) > 0; return Boolean(c.req.header("content-type")); }; export const readJsonBody = async (c: AppContext, message = "JSON body required."): Promise => { requireJsonContentType(c); try { return (await c.req.json()) as T; } catch { throw new HttpError(400, "invalid_body", message); } }; export const readOptionalJsonBody = async (c: AppContext, fallback: T): Promise => { if (!hasBody(c)) return fallback; requireJsonContentType(c); try { return (await c.req.json()) as T; } catch { return fallback; } }; export const requiredParam = (c: AppContext, name: string, error: ErrorSpec): string => { const value = c.req.param(name); if (!value) { throw new HttpError(error.status, error.code, error.message); } return value; }; // Cloudflare-fronted production always sets CF-Connecting-IP. Failing // closed when it is missing keeps every public Turnstile / rate-limited // flow from collapsing into a shared `unknown` bucket and removes the // X-Forwarded-For fallback which is forgeable everywhere it is exposed. export const remoteIp = (c: AppContext): string => { const ip = c.req.header("CF-Connecting-IP")?.trim(); if (!ip) { throw new HttpError(400, "missing_client_ip", "Client IP could not be determined."); } return ip; };