/** * Thin HTTP wrapper for the bridge API (R3.1.a). * * Why a separate client and not the shared `api` axios instance: * The bridge lives at a different origin (VITE_BRIDGE_URL) and uses the * DocAdenice JWT forwarded via the Authorization header, whereas `api` targets * the Docmost backend at "/api" with cookie-based auth. * * The JWT is read from the cookie `authToken`. In production the cookie is * HttpOnly so JS cannot read it — SSE auth uses the cookie automatically via * credentials. For REST calls we rely on the cookie being sent automatically * by withCredentials when the bridge is same-site, OR on the server proxying * the calls through Docmost (future R3.2). For now we send credentials and * leave the Authorization header empty when the token is not readable — the * bridge falls back to cookie auth (R2.3b). */ import axios, { AxiosInstance } from "axios"; /** Resolved bridge base URL: per-instance override > env var > same-origin proxy default. * * In dev, the Vite server proxies `/bridge/*` to `http://localhost:4000/*` (see vite.config.ts). * Same-origin = the auth cookie is sent automatically without CORS gymnastics. * In prod, set VITE_BRIDGE_URL at build time to an absolute URL behind your reverse proxy * (or keep `/bridge` and proxy server-side). */ export function resolveBridgeUrl(bridgeUrlOverride?: string | null): string { const metaEnv = (import.meta as unknown as { env?: { VITE_BRIDGE_URL?: string } }).env; return bridgeUrlOverride ?? metaEnv?.VITE_BRIDGE_URL ?? "/bridge"; } /** Attempt to read the auth token from JS-accessible cookie or jotai storage. */ function readTokenFromCookie(): string | null { try { // authToken is HttpOnly in production; authTokens may be readable. const raw = document.cookie .split(";") .map((c) => c.trim()) .find((c) => c.startsWith("authTokens=")); if (raw) { const val = decodeURIComponent(raw.slice("authTokens=".length)); const parsed = JSON.parse(val); return typeof parsed?.token === "string" ? parsed.token : null; } } catch { // cookie not accessible or malformed — silently fall through } return null; } /** Build a one-shot axios instance targeting the resolved bridge URL. */ export function createBridgeClient(bridgeUrl: string): AxiosInstance { const instance = axios.create({ baseURL: bridgeUrl, withCredentials: true, timeout: 15_000, }); // Vite auto-exposes VITE_* vars from apps/client/.env(.local) via // import.meta.env. The monorepo-root .env is not picked up automatically // for client-side consumption, so the dev token lives in apps/client/.env.local // (gitignored). const envToken: string | undefined = ( import.meta as unknown as { env?: { VITE_BRIDGE_TOKEN?: string } } ).env?.VITE_BRIDGE_TOKEN; instance.interceptors.request.use((config) => { // Priority: cookie token (prod) > VITE_BRIDGE_TOKEN env (dev fallback) const cookieToken = readTokenFromCookie(); const token = cookieToken || envToken; if (token) { config.headers["Authorization"] = `Bearer ${token}`; } return config; }); instance.interceptors.response.use( (res) => res.data, (err) => Promise.reject(err), ); return instance; } /** Singleton map: bridgeUrl -> axios instance (one per origin). */ const _clients = new Map(); export function getBridgeClient(bridgeUrl: string): AxiosInstance { if (!_clients.has(bridgeUrl)) { _clients.set(bridgeUrl, createBridgeClient(bridgeUrl)); } return _clients.get(bridgeUrl)!; } /** * Patch a single row on the bridge. * * Why a named helper and not a direct client.patch(): * Callers (useUpdateRow) would have to resolve the URL themselves. This helper * keeps the URL construction in one place and makes the intent explicit. * * The response is typed as unknown — callers should not assume a specific shape * as the bridge returns the updated row in its envelope format. */ export async function patchRow( tableId: string, rowId: string, payload: { fields: Record }, bridgeUrl: string, ): Promise { const client = getBridgeClient(bridgeUrl); return (client.patch( `/api/v1/tables/${tableId}/rows/${rowId}`, payload, ) as unknown) as unknown; }