/** * 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; }; 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 { const url = `${normaliseUrl(credentials.url)}/api/admin${options.path}${options.search ?? ''}`; const headers: Record = { 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; 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 { 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.' }; } }