import { importPKCS8, SignJWT } from "jose"; import { getAppConfig } from "./lib/config.server"; import { isDemoMode } from "./lib/demo-mode.server"; import { getUser } from "./lib/session.server"; const FABRO_JWT_PRIVATE_KEY = process.env.FABRO_JWT_PRIVATE_KEY; function decodePemEnv(value: string): string { if (value.startsWith("-----")) return value; return Buffer.from(value, "base64").toString("utf-8"); } let cachedKey: CryptoKey | null = null; async function getSigningKey(): Promise { if (cachedKey) return cachedKey; if (!FABRO_JWT_PRIVATE_KEY) { throw new Error("FABRO_JWT_PRIVATE_KEY environment variable is not set"); } cachedKey = await importPKCS8(decodePemEnv(FABRO_JWT_PRIVATE_KEY), "EdDSA"); return cachedKey; } async function signToken(sub?: string): Promise { const key = await getSigningKey(); return new SignJWT({ iss: "fabro-web", ...(sub ? { sub } : {}) }) .setProtectedHeader({ alg: "EdDSA" }) .setIssuedAt() .setExpirationTime("30s") .sign(key); } interface ApiOptions { init?: RequestInit; request?: Request; } /** * Fetch wrapper that signs requests with a JWT for service-to-service auth. * When a request is provided, the authenticated user's URL is included as * the JWT `sub` claim. */ export async function apiFetch( path: string, options?: ApiOptions ): Promise { const { base_url } = getAppConfig().api; const { init, request } = options ?? {}; let sub: string | undefined; if (request) { const user = await getUser(request); sub = user?.userUrl; } const headers = new Headers(init?.headers); if (FABRO_JWT_PRIVATE_KEY) { const token = await signToken(sub); headers.set("Authorization", `Bearer ${token}`); } if (request && isDemoMode(request)) { headers.set("X-Fabro-Demo", "1"); } const url = `${base_url}${path}`; try { return await fetch(url, { ...init, headers }); } catch (cause) { throw new Error(`API request to ${url} failed`, { cause }); } } /** * Typed JSON fetch helper. Calls apiFetch and parses the JSON response. */ export async function apiJson(path: string, options?: ApiOptions): Promise { const res = await apiFetch(path, options); if (!res.ok) throw new Response(null, { status: res.status }); return res.json() as Promise; }