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>
153 lines
5.8 KiB
TypeScript
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.' };
|
|
}
|
|
}
|