import { Platform } from "react-native"; import { fetchWithTimeout } from "./fetch"; import { getBaseUrl, getSessionToken } from "../services/api-config"; const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const CSRF_COOKIE_NAME = "csrf_token"; const CSRF_HEADER_NAME = "X-CSRF-Token"; function readCookie(name: string): string | null { if (typeof document === "undefined") { return null; } const prefix = `${name}=`; for (const part of document.cookie.split(";")) { const trimmed = part.trim(); if (trimmed.startsWith(prefix)) { return decodeURIComponent(trimmed.slice(prefix.length)); } } return null; } /** * Cross-platform authed `fetch` for the entropy-ui auth flows. * * **Web**: HttpOnly session cookie (auto-attached, used in same-origin / * production) + `X-CSRF-Token` on writes. Also attaches a `Bearer` token when * one is stored (sessionStorage) — needed for split-origin dev where the Lax * cookie can't ride cross-origin XHR. * **Native**: Bearer token loaded from SecureStore. */ export async function apiFetch( path: string, init: RequestInit = {}, timeoutMs?: number ): Promise { const url = `${getBaseUrl()}${path}`; const method = (init.method ?? "GET").toUpperCase(); const headers = new Headers(init.headers); if (Platform.OS === "web") { if (!SAFE_METHODS.has(method)) { const csrf = readCookie(CSRF_COOKIE_NAME); if (csrf) { headers.set(CSRF_HEADER_NAME, csrf); } } const webToken = await getSessionToken(); if (webToken && !headers.has("Authorization")) { headers.set("Authorization", `Bearer ${webToken}`); } return fetchWithTimeout(url, { ...init, headers, credentials: "include" }, timeoutMs); } const token = await getSessionToken(); if (token) { headers.set("Authorization", `Bearer ${token}`); } // Explicit `credentials: "omit"` — native auth is Bearer-only, and the // server's CSRF middleware branches purely on whether a session cookie // is PRESENT on the request (see server/src/auth/middleware.rs's // csrf_protect: any request carrying the session cookie is treated as // cookie-auth and required to also carry a matching X-CSRF-Token + // Origin, neither of which this native branch ever sends). React // Native's Android fetch persists Set-Cookie responses in a shared // CookieManager and reattaches them to matching-origin requests // regardless of the web `credentials` semantics being followed // elsewhere — so if the server ever sets a session cookie as a side // effect for a native client (e.g. during passkey/WebAuthn login, // which can involve a system browser or Credential Manager sheet), // every subsequent native mutation silently starts requiring CSRF // headers it can never provide, failing with "Missing CSRF header". // Reported as folder-creation failing this way, 2026-08-17 — but the // same stray cookie would affect ANY mutating request once present, // not just this one. Omitting credentials here is the actual fix: // native should never send or rely on cookies at all. return fetchWithTimeout(url, { ...init, headers, credentials: "omit" }, timeoutMs); }