type CorsMode = "public" | "integration" | "webhook"; type JsonBodyOptions = { maxBytes: number; errorMessage?: string; }; type RateLimitOptions = { scope: string; key: string; maxCount: number; windowSeconds: number; blockSeconds?: number; }; type RateLimitResult = { allowed: boolean; currentCount: number; limitCount: number; blockedUntil: string | null; windowStart: string; }; type IntegrationAuthOptions = { rawBody: string; secretEnvNames?: string[]; tokenEnvNames?: string[]; signatureHeader?: string; timestampHeader?: string; requestIdHeader?: string; allowedClockSkewSeconds?: number; }; const DEFAULT_LOCAL_ORIGINS = [ "http://localhost:5173", "http://localhost:4173", "http://127.0.0.1:5173", "http://127.0.0.1:4173", ]; const normalizeOrigin = (value: string) => value.replace(/\/$/, ""); const splitList = (value: string | null | undefined) => (value || "") .split(",") .map((item) => normalizeOrigin(item.trim())) .filter(Boolean); const getRequestOrigin = (request: Request) => { const origin = request.headers.get("origin"); if (origin) { return normalizeOrigin(origin); } const referer = request.headers.get("referer"); if (!referer) { return ""; } try { return normalizeOrigin(new URL(referer).origin); } catch { return ""; } }; const readEnv = (name: string) => { try { if (typeof Deno === "undefined") { return ""; } return Deno.env.get(name) || ""; } catch { return ""; } }; const isLocalhostOrigin = (origin: string) => /:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); const resolveAllowedOrigins = (mode: CorsMode) => { const publicOrigins = [ ...splitList(readEnv("APP_ALLOWED_ORIGINS")), ...splitList(readEnv("PUBLIC_APP_URL")), ...splitList(readEnv("APP_PUBLIC_URL")), ]; const integrationOrigins = [ ...splitList(readEnv("INTEGRATION_ALLOWED_ORIGINS")), ...splitList(readEnv("PUBLIC_APP_URL")), ]; const webhookOrigins = [ ...splitList(readEnv("WEBHOOK_ALLOWED_ORIGINS")), ...splitList(readEnv("PUBLIC_APP_URL")), ]; const configured = mode === "public" ? publicOrigins : mode === "integration" ? integrationOrigins : webhookOrigins; if (configured.length > 0) { return Array.from(new Set(configured)); } const currentMode = readEnv("NODE_ENV") || "development"; if (currentMode === "production") { return []; } return [...DEFAULT_LOCAL_ORIGINS]; }; export class HttpError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; this.name = "HttpError"; } } export const jsonResponse = ( body: unknown, status = 200, headers: HeadersInit = {}, ) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...headers, }, }); export const getCorsHeaders = (request: Request, mode: CorsMode) => { const origin = getRequestOrigin(request); const allowedOrigins = resolveAllowedOrigins(mode); if (!origin) { if (allowedOrigins.length === 0) { return null; } return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Max-Age": "86400", Vary: "Origin", } satisfies Record; } const isAllowed = allowedOrigins.length === 0 ? false : allowedOrigins.some((allowedOrigin) => { if (allowedOrigin === "*") { return true; } return origin === allowedOrigin || origin.startsWith(`${allowedOrigin}/`); }) || (!readEnv("NODE_ENV") || readEnv("NODE_ENV") !== "production" && isLocalhostOrigin(origin)); if (!isAllowed) { return null; } return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Max-Age": "86400", Vary: "Origin", } satisfies Record; }; export const preflightResponse = (request: Request, mode: CorsMode) => { const corsHeaders = getCorsHeaders(request, mode); if (!corsHeaders) { return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } return new Response("ok", { status: 204, headers: corsHeaders, }); }; export const assertAllowedOrigin = (request: Request, mode: CorsMode) => { const corsHeaders = getCorsHeaders(request, mode); if (!corsHeaders) { throw new HttpError(403, "Origin not allowed"); } return corsHeaders; }; export const readJsonBody = async >( request: Request, options: JsonBodyOptions, ): Promise<{ body: T; rawBody: string }> => { const rawBody = await request.clone().text(); const byteLength = new TextEncoder().encode(rawBody).length; if (byteLength > options.maxBytes) { throw new HttpError(413, options.errorMessage || "Payload too large"); } if (!rawBody.trim()) { throw new HttpError(400, "Request body is required"); } try { return { body: JSON.parse(rawBody) as T, rawBody, }; } catch { throw new HttpError(400, "Invalid JSON payload"); } }; export const getClientIp = (request: Request) => { const forwardedFor = request.headers.get("x-forwarded-for") || request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || ""; return forwardedFor.split(",")[0]?.trim() || "unknown"; }; export const sha256Hex = async (value: string) => { const bytes = new TextEncoder().encode(value); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); }; export const hashText = sha256Hex; const hmacHex = async (secret: string, value: string) => { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); }; export const verifyInternalRequest = async ( request: Request, rawBody: string, options: IntegrationAuthOptions = { rawBody }, ) => { const tokenEnvNames = options.tokenEnvNames || ["INTEGRATION_API_KEY", "INTERNAL_API_KEY"]; const secretEnvNames = options.secretEnvNames || ["INTEGRATION_WEBHOOK_SECRET", "CHATBOT_WEBHOOK_SECRET"]; const bearerToken = request.headers.get("authorization") || ""; const token = bearerToken.toLowerCase().startsWith("bearer ") ? bearerToken.slice(7).trim() : ""; const requestId = request.headers.get(options.requestIdHeader || "x-request-id") || ""; const timestamp = request.headers.get(options.timestampHeader || "x-timestamp") || ""; const signature = request.headers.get(options.signatureHeader || "x-signature") || ""; const sharedTokens = tokenEnvNames.map((name) => readEnv(name)).filter(Boolean); const sharedSecrets = secretEnvNames.map((name) => readEnv(name)).filter(Boolean); if (token && sharedTokens.some((candidate) => candidate === token)) { return { requestId, authenticatedBy: "bearer" as const }; } if (sharedSecrets.length === 0) { throw new HttpError(401, "Integration auth is not configured"); } if (!timestamp || !signature) { throw new HttpError(401, "Missing integration signature"); } const timestampNumber = Number(timestamp); if (!Number.isFinite(timestampNumber)) { throw new HttpError(401, "Invalid integration timestamp"); } const now = Date.now(); const allowedSkew = (options.allowedClockSkewSeconds || 300) * 1000; if (Math.abs(now - timestampNumber) > allowedSkew) { throw new HttpError(401, "Stale integration request"); } const payload = `${timestamp}.${rawBody}`; const expectedSignatures = await Promise.all( sharedSecrets.map(async (secret) => hmacHex(secret, payload)), ); if (!expectedSignatures.some((candidate) => candidate === signature)) { throw new HttpError(401, "Invalid integration signature"); } return { requestId, authenticatedBy: "hmac" as const }; }; export const maskPhoneNumber = (phone: string | null | undefined) => { const value = String(phone || "").trim(); if (!value) { return null; } const digits = value.replace(/\D/g, ""); if (digits.length < 4) { return value; } const tail = digits.slice(-4); const country = digits.startsWith("7") || digits.startsWith("8") ? "+7" : "+"; return `${country} *** ***-${tail.slice(0, 2)}-${tail.slice(2)}`; }; export const maskCustomerName = (name: string | null | undefined) => { const value = String(name || "").trim(); if (!value) { return null; } const parts = value.split(/\s+/).filter(Boolean); if (parts.length === 1) { return `${parts[0].slice(0, 1)}.`; } return `${parts[0]} ${parts[1].slice(0, 1)}.`; }; export const maskOrderNumber = (orderNumber: string | null | undefined) => { const value = String(orderNumber || "").trim(); if (!value) { return null; } if (value.length <= 4) { return value; } return `…${value.slice(-4)}`; }; export const requireRateLimit = async ( supabase: { rpc: ( name: string, params: Record, ) => PromiseLike<{ data: RateLimitResult | null; error: Error | null }>; }, options: RateLimitOptions, ) => { const { data, error } = await supabase.rpc("check_rate_limit", { p_scope: options.scope, p_key: options.key, p_max_count: options.maxCount, p_window_seconds: options.windowSeconds, p_block_seconds: options.blockSeconds || 0, }); if (error) { throw error; } if (!data?.allowed) { throw new HttpError(429, "Too many requests"); } return data; };