Files
fofos-admin/api/fofos-client.ts
T
pastilhasandClaude Opus 5 b165e10f53 every proxied request carries who is asking
Officer's session is the only place the acting user is known, and fofos writes it
into its own change history. An email in X-Actor, because that database has no
users to point at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:36:34 +01:00

153 lines
5.8 KiB
TypeScript

/**
* Talking to the fofos server.
*
* A thin client on purpose. fofos's `/api/admin` already answers in the shape a panel wants — every
* route returns `{ data: … }`, and a mutation returns the whole section it belongs to — so there is
* nothing here to reshape. What this owns is the credential, the failure vocabulary, and the one
* decision about what a non-answer means.
*
* See `docs/SURFACES.md` in `fofos/platform` for the routes.
*/
export type FofosCredentials = { url: string; token: string };
/** No trailing slash, so joining a path never produces a double one. */
export const normaliseUrl = (url: string): string => url.trim().replace(/\/+$/, '');
/**
* A failure with the two things a panel needs to say something useful: whether fofos answered at all,
* and whether it refused the credential.
*
* Those are genuinely different problems — one is "the server is down or the URL is wrong", the other
* is "the token is wrong" — and a panel that cannot tell them apart can only say "something went
* wrong", which sends someone to check the wrong half.
*/
export class FofosError extends Error {
constructor(
message: string,
readonly status: number | null,
/** fofos refused the credential. `status` is 401. */
readonly unauthorised = false,
) {
super(message);
this.name = 'FofosError';
}
}
/** `null` status means the request never got an answer — DNS, refused connection, timeout. */
const unreachable = (url: string, cause: unknown) =>
new FofosError(
`Could not reach fofos at ${url}. ${cause instanceof Error ? cause.message : String(cause)}`,
null,
);
type RequestOptions = {
method?: string;
path: string;
search?: string;
/** Already-serialised. Forwarded as given so what fofos receives is what the caller sent. */
body?: string | FormData | null;
contentType?: string | null;
/**
* Who is asking, as an email — sent as `X-Actor`, and written into fofos's own change history.
*
* fofos has no users, so it cannot work this out and cannot check it: the header is as trustworthy
* as the token that carried it. That is enough for what it is for, which is telling two colleagues
* apart rather than proving anything.
*/
actor?: string | null;
};
export type FofosResponse = { status: number; body: string; contentType: string | null };
/**
* One request, returning fofos's own answer verbatim — including the bad ones.
*
* It does NOT throw on a 4xx or 5xx. fofos's error envelope is more informative than anything this
* could substitute (it names the field), and the caller is forwarding to a panel that should see what
* the server actually said. Only a request that got no answer at all throws, because there is nothing
* to forward.
*/
export async function fofosRequest(
credentials: FofosCredentials,
options: RequestOptions,
): Promise<FofosResponse> {
const url = `${normaliseUrl(credentials.url)}/api/admin${options.path}${options.search ?? ''}`;
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.token}` };
// Left unset for FormData: fetch has to write its own, including the multipart boundary, and a
// hand-set Content-Type here produces a body the other end cannot parse.
if (options.contentType) headers['Content-Type'] = options.contentType;
if (options.actor) headers['X-Actor'] = options.actor;
let response: Response;
try {
response = await fetch(url, {
method: options.method ?? 'GET',
headers,
body: options.body ?? undefined,
// fofos is a small server on the same network and every one of these is a database read. A
// request still hanging after this is not going to complete.
signal: AbortSignal.timeout(30_000),
});
} catch (cause) {
throw unreachable(normaliseUrl(credentials.url), cause);
}
return {
status: response.status,
body: await response.text(),
contentType: response.headers.get('content-type'),
};
}
export type ProbeResult = {
reachable: boolean;
authenticated: boolean;
sections?: string[];
error?: string;
};
/**
* Is this URL a fofos server, and does it accept this token?
*
* Both questions at once, because a person setting this up has one form and should not have to work
* out which half is wrong. `GET /api/admin` sits behind the guard for exactly this reason: a 200 is
* both answers, and a 401 separates them.
*/
export async function probe(credentials: FofosCredentials): Promise<ProbeResult> {
let response: FofosResponse;
try {
response = await fofosRequest(credentials, { path: '' });
} catch (error) {
return { reachable: false, authenticated: false, error: error instanceof Error ? error.message : String(error) };
}
if (response.status === 401) {
return { reachable: true, authenticated: false, error: 'fofos refused that token.' };
}
if (response.status !== 200) {
return {
reachable: true,
authenticated: false,
// A 404 here is the interesting one: something answered, but it is not serving /api/admin. That
// is usually a URL pointing at the built static site rather than at the live server.
error:
response.status === 404
? 'That address answered, but it is not serving /api/admin. Is it pointing at the static site rather than the fofos server?'
: `fofos answered ${response.status}.`,
};
}
try {
const parsed = JSON.parse(response.body) as { data?: { service?: string; sections?: string[] } };
if (parsed.data?.service !== 'fofos') {
return { reachable: true, authenticated: false, error: 'That address answered, but it is not fofos.' };
}
return { reachable: true, authenticated: true, sections: parsed.data.sections ?? [] };
} catch {
return { reachable: true, authenticated: false, error: 'That address answered with something that is not JSON.' };
}
}