mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-19 00:03:30 +00:00
When the API server is unreachable, the raw "fetch failed" error gives no indication of what URL was being requested. Wrapping the error includes the target URL in the message while chaining the original cause. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
76 lines
2.2 KiB
TypeScript
76 lines
2.2 KiB
TypeScript
import { importPKCS8, SignJWT } from "jose";
|
|
import { getAppConfig } from "./lib/config.server";
|
|
import { getUser } from "./lib/session.server";
|
|
|
|
const ARC_JWT_PRIVATE_KEY = process.env.ARC_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<CryptoKey> {
|
|
if (cachedKey) return cachedKey;
|
|
if (!ARC_JWT_PRIVATE_KEY) {
|
|
throw new Error("ARC_JWT_PRIVATE_KEY environment variable is not set");
|
|
}
|
|
cachedKey = await importPKCS8(decodePemEnv(ARC_JWT_PRIVATE_KEY), "EdDSA");
|
|
return cachedKey;
|
|
}
|
|
|
|
async function signToken(sub?: string): Promise<string> {
|
|
const key = await getSigningKey();
|
|
return new SignJWT({ iss: "arc-web", ...(sub ? { sub } : {}) })
|
|
.setProtectedHeader({ alg: "EdDSA" })
|
|
.setIssuedAt()
|
|
.setExpirationTime("30s")
|
|
.sign(key);
|
|
}
|
|
|
|
export 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<Response> {
|
|
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 (ARC_JWT_PRIVATE_KEY) {
|
|
const token = await signToken(sub);
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
|
|
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<T>(path: string, options?: ApiOptions): Promise<T> {
|
|
const res = await apiFetch(path, options);
|
|
if (!res.ok) throw new Response(null, { status: res.status });
|
|
return res.json() as Promise<T>;
|
|
}
|