Everything needed to be a plugin
manifest, one table, a router and two panels. Discovery is by convention, so
this is the point at which the directory stops being a directory and becomes
something Officer can install.
The manifest holds only what a listing cannot say. One permission, grantable,
with ownerOnly on /_config — reading and editing the site is exactly what a
member might be brought in for, but the token is unrestricted write authority
over everything the public sees, so handing it over is the owner's.
db/schema.ts is ONE table and should stay that way. fofos owns its own Postgres
and its own media store; this holds a URL and a token. That is what makes the
strongest thing about this plugin true: no operation on it can destroy the
client's work, checkbox ticked or not, because none of it was ever here. A cache
would be the thin end of that.
The router mostly forwards. fofos's /api/admin already answers in the shape a
panel wants, so re-wrapping it would be a second place for the contract to drift.
Two things in it are load-bearing rather than style:
registerProxiedPrefix('/api/fofos-admin/media'). bodyParser calls parseBody on
multipart across the whole protected router, and Hono then caches the parsed
FormData — so asking for the bytes afterwards re-serialises them under a NEW
boundary while the ORIGINAL content-type header is what gets forwarded. Header
and body disagree and every upload fails with "Unexpected end of form".
Narrow to the one route: everything else is JSON that is fine to parse.
The catch-all is LAST in the file. Hono runs matching handlers in registration
order, so /* above /_config would swallow it and the settings form would
forward itself to fofos, which has no such endpoint — a 404 from a plugin that
is working perfectly.
Panels are a nav and a detail pane that never speak to each other: the nav writes
?section=, the detail pane reads it. Real <Link>s rather than the buttons
proxy-manager uses — AGENTS.md says nav items are links, and the file documenting
that pattern says not to copy the older one. <Link> and not <NavLink> because
active state here is a query parameter and NavLink compares paths, which would
light every row at once.
Which component renders which section is a MAP, not a conditional on `built`.
The first version read `built ? <MessagesPanel/> : <Planned/>`, which is correct
only while exactly one section exists — the second one finished would have
rendered Messages under its own name.
Messages is the only section built, chosen because it is one read-only route: it
proves the path from an Officer panel through this proxy to fofos and back with
nothing else able to be wrong at the same time. The other eight are in the nav,
dimmed and labelled, saying what they will do and that the old admin still edits
them. A nav that hides what is unfinished gives no sense of what the thing is for.
Typechecked against the platform — and the typechecker was verified to actually
cover plugins/ by planting an error and watching it fail, because a clean run
that does not include your files is worse than no run.
NOT yet: installed, or run against a live fofos. Nothing here has been through
the permission gate or a real request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0a967bef98
commit
6b87956e44
@@ -0,0 +1,107 @@
|
||||
import { db } from 'officerdb/db';
|
||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fofosAdminConnection, type FofosAdminConnection } from '../db/schema';
|
||||
import { normaliseUrl, type FofosCredentials } from './fofos-client';
|
||||
|
||||
/**
|
||||
* Reading and writing the one connection row.
|
||||
*
|
||||
* The token is encrypted under this plugin's own purpose key, so a leak of that key opens this column
|
||||
* and nothing else — the per-purpose split the platform's secret store already decided on.
|
||||
*
|
||||
* **The token never leaves this module in plaintext except into `fofosRequest`.** No route returns
|
||||
* it, at any permission level, to anybody, including the owner. A form that wants to show "a token is
|
||||
* set" gets a boolean.
|
||||
*/
|
||||
|
||||
const PURPOSE = 'fofos-admin';
|
||||
|
||||
export type ConnectionView = {
|
||||
configured: boolean;
|
||||
url: string | null;
|
||||
/** Never the token itself. Only whether one is stored. */
|
||||
hasToken: boolean;
|
||||
sections: string[] | null;
|
||||
lastProbeAt: string | null;
|
||||
lastProbeOk: boolean | null;
|
||||
};
|
||||
|
||||
const EMPTY: ConnectionView = {
|
||||
configured: false,
|
||||
url: null,
|
||||
hasToken: false,
|
||||
sections: null,
|
||||
lastProbeAt: null,
|
||||
lastProbeOk: null,
|
||||
};
|
||||
|
||||
const row = async (): Promise<FofosAdminConnection | null> => {
|
||||
const rows = await db.select().from(fofosAdminConnection).limit(1);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
export async function readConnection(): Promise<ConnectionView> {
|
||||
const found = await row();
|
||||
if (!found) return EMPTY;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
url: found.url,
|
||||
hasToken: found.token.length > 0,
|
||||
sections: found.sections ?? null,
|
||||
lastProbeAt: found.lastProbeAt ? found.lastProbeAt.toISOString() : null,
|
||||
lastProbeOk: found.lastProbeOk === null ? null : found.lastProbeOk === 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decrypted, for the client only. Never serialise the result of this. */
|
||||
export async function readCredentials(): Promise<FofosCredentials | null> {
|
||||
const found = await row();
|
||||
if (!found) return null;
|
||||
return { url: found.url, token: decryptSecret(PURPOSE, found.token) };
|
||||
}
|
||||
|
||||
export type SaveParams = { url: string; token?: string };
|
||||
|
||||
/**
|
||||
* `token` omitted means "keep the stored one".
|
||||
*
|
||||
* So a typo in the URL can be corrected without going to find the token again, and a settings form
|
||||
* never has to round-trip a secret in order to send it back.
|
||||
*/
|
||||
export async function saveConnection(params: SaveParams): Promise<ConnectionView> {
|
||||
const url = normaliseUrl(params.url);
|
||||
const existing = await row();
|
||||
|
||||
if (!existing && !params.token) {
|
||||
throw new Error('A token is required the first time the connection is set.');
|
||||
}
|
||||
|
||||
const token = params.token ? encryptSecret(PURPOSE, params.token) : (existing?.token as string);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(fofosAdminConnection)
|
||||
.set({ url, token, updatedAt: new Date() })
|
||||
.where(eq(fofosAdminConnection.id, existing.id));
|
||||
} else {
|
||||
await db.insert(fofosAdminConnection).values({ url, token });
|
||||
}
|
||||
|
||||
return readConnection();
|
||||
}
|
||||
|
||||
export async function deleteConnection(): Promise<void> {
|
||||
await db.delete(fofosAdminConnection);
|
||||
}
|
||||
|
||||
export async function recordProbe(sections: string[] | null, ok: boolean): Promise<void> {
|
||||
const existing = await row();
|
||||
if (!existing) return;
|
||||
|
||||
await db
|
||||
.update(fofosAdminConnection)
|
||||
.set({ sections, lastProbeAt: new Date(), lastProbeOk: ok ? 1 : 0 })
|
||||
.where(eq(fofosAdminConnection.id, existing.id));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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<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;
|
||||
|
||||
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.' };
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter, type HonoVariables } from '@@/create-router';
|
||||
import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes';
|
||||
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
|
||||
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
||||
|
||||
/**
|
||||
* Mounted at `/api/fofos-admin`.
|
||||
*
|
||||
* ── Mostly a pass-through, and that is the design ──
|
||||
*
|
||||
* fofos's `/api/admin` is already the right shape for a panel: every route answers `{ data: … }`, and
|
||||
* a mutation returns the whole section it belongs to. Re-wrapping that here would add a second place
|
||||
* for the contract to be described and a second place for it to drift.
|
||||
*
|
||||
* So this holds the credential and forwards. What it does NOT do is decide what fofos's answers mean:
|
||||
* a 404 from fofos reaches the panel as a 404, because fofos's error envelope names the field and
|
||||
* nothing here could improve on that.
|
||||
*
|
||||
* The exception is `/_config`, which is this plugin's own and never leaves the machine.
|
||||
*
|
||||
* ── Permissions ──
|
||||
*
|
||||
* The platform's gate decides who gets here, and `/_config` is `ownerOnly` in the manifest. Nothing in
|
||||
* this file checks again: two places deciding the same thing is how they come to disagree.
|
||||
*/
|
||||
export const router = createRouter();
|
||||
|
||||
/**
|
||||
* The upload path forwards a body it must not have parsed. This is load-bearing.
|
||||
*
|
||||
* `bodyParser` runs across the whole protected router and calls `parseBody` on `multipart/form-data`.
|
||||
* Hono then caches the parsed FormData, so asking for the bytes afterwards RE-SERIALISES them under a
|
||||
* NEW multipart boundary — while the original `content-type` header, naming the old one, is what gets
|
||||
* forwarded. Header and body disagree and fofos rejects every upload with "Unexpected end of form".
|
||||
*
|
||||
* Registering the prefix tells `bodyParser` to leave these requests alone. Narrow on purpose — one
|
||||
* route, not the whole plugin — because everything else here is JSON that is genuinely fine to parse,
|
||||
* and claiming more than is needed turns an opt-out into a habit.
|
||||
*/
|
||||
registerProxiedPrefix('/api/fofos-admin/media');
|
||||
|
||||
/** fofos's own answer, passed through with its status and content type intact. */
|
||||
const passThrough = (response: FofosResponse) =>
|
||||
new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': response.contentType ?? 'application/json' },
|
||||
});
|
||||
|
||||
/**
|
||||
* A shaped failure for the two things that can go wrong before fofos answers at all.
|
||||
*
|
||||
* `unconfigured` is the one panels branch on: it is a setup step rather than an error, and rendering
|
||||
* it as a broken list would tell somebody their site was down when they have simply not connected it
|
||||
* yet.
|
||||
*/
|
||||
const noConnection = (ctx: Context<{ Variables: HonoVariables }>) =>
|
||||
ctx.json({ error: 'No fofos connection is configured.', unconfigured: true }, 409);
|
||||
|
||||
const unreachable = (ctx: Context<{ Variables: HonoVariables }>, error: unknown) => {
|
||||
if (error instanceof FofosError) {
|
||||
return ctx.json({ error: error.message, unreachable: error.status === null }, 502);
|
||||
}
|
||||
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 500);
|
||||
};
|
||||
|
||||
// ── Everything the panels read and write ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Uploads. Registered above so its body arrives untouched.
|
||||
*
|
||||
* Buffered rather than streamed: Bun's fetch can take a stream, but the platform is a hop in the
|
||||
* middle and a half-forwarded upload that fails mid-flight is harder to reason about than one that is
|
||||
* whole before it leaves. `arrayBuffer()` here is the ORIGINAL bytes with the ORIGINAL boundary,
|
||||
* which is exactly why the original `content-type` can be forwarded with them.
|
||||
*/
|
||||
router.post('/media', async (ctx) => {
|
||||
const credentials = await readCredentials();
|
||||
if (!credentials) return noConnection(ctx);
|
||||
|
||||
try {
|
||||
const body = await ctx.req.arrayBuffer();
|
||||
const response = await fofosRequest(credentials, {
|
||||
method: 'POST',
|
||||
path: '/media',
|
||||
body: new Blob([body]) as unknown as FormData,
|
||||
contentType: ctx.req.header('content-type') ?? null,
|
||||
});
|
||||
return passThrough(response);
|
||||
} catch (error) {
|
||||
return unreachable(ctx, error);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Settings — owner only, by the manifest ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/_config', async (ctx) => ctx.json(await readConnection()));
|
||||
|
||||
router.put('/_config', async (ctx) => {
|
||||
const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null;
|
||||
if (!payload?.url?.trim()) return ctx.json({ error: 'A fofos URL is required.' }, 400);
|
||||
|
||||
try {
|
||||
const saved = await saveConnection({ url: payload.url, token: payload.token?.trim() || undefined });
|
||||
|
||||
// Probed immediately, so the form can say whether it works rather than only that it saved. A
|
||||
// failure here is NOT a failed save — the connection is stored either way, which is what lets
|
||||
// somebody fix a token without re-entering the URL.
|
||||
const credentials = await readCredentials();
|
||||
if (credentials) {
|
||||
const result = await probe(credentials);
|
||||
await recordProbe(result.sections ?? null, result.authenticated);
|
||||
return ctx.json({ ...(await readConnection()), probe: result });
|
||||
}
|
||||
|
||||
return ctx.json(saved);
|
||||
} catch (error) {
|
||||
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/_config', async (ctx) => {
|
||||
await deleteConnection();
|
||||
return ctx.json(await readConnection());
|
||||
});
|
||||
|
||||
/**
|
||||
* Check a URL and token WITHOUT storing them.
|
||||
*
|
||||
* So a wrong token never replaces a working one — the same reason proxy-manager's test route exists.
|
||||
* When the token is omitted it tests the STORED one, which is how "is my connection still good?" is
|
||||
* answered without anybody having to retype a secret.
|
||||
*/
|
||||
router.post('/_config/test', async (ctx) => {
|
||||
const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null;
|
||||
|
||||
const stored = await readCredentials();
|
||||
const url = payload?.url?.trim() || stored?.url;
|
||||
const token = payload?.token?.trim() || stored?.token;
|
||||
|
||||
if (!url || !token) {
|
||||
return ctx.json({ reachable: false, authenticated: false, error: 'A URL and a token are needed.' }, 400);
|
||||
}
|
||||
|
||||
const result = await probe({ url, token });
|
||||
|
||||
// Only when testing what is actually stored. Recording a probe of some other credential would put a
|
||||
// verdict about a different server against this row.
|
||||
if (!payload?.url && !payload?.token && stored) {
|
||||
await recordProbe(result.sections ?? null, result.authenticated);
|
||||
}
|
||||
|
||||
return ctx.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
|
||||
*
|
||||
* Not owner-only, and not under `/_config` for that reason: a member needs to know whether to render a
|
||||
* section or a "not connected yet" message, and that answer contains no credential.
|
||||
*/
|
||||
router.get('/status', async (ctx) => {
|
||||
const connection = await readConnection();
|
||||
return ctx.json({ configured: connection.configured, lastProbeOk: connection.lastProbeOk });
|
||||
});
|
||||
|
||||
/**
|
||||
* Everything else, forwarded verbatim.
|
||||
*
|
||||
* A catch-all rather than a route per section, because the sections are fofos's to define — see its
|
||||
* `docs/SURFACES.md`. Enumerating them here would mean this plugin needs a release every time fofos
|
||||
* adds one, which is a coupling with nothing to show for it.
|
||||
*
|
||||
* ── LAST in the file, and that is load-bearing ──
|
||||
*
|
||||
* Hono runs every matching handler in REGISTRATION order, so `/*` declared above `/_config` would
|
||||
* swallow it — the settings routes would forward themselves to fofos, which has no such endpoint, and
|
||||
* the settings form would 404 against a plugin that was working perfectly. Everything this plugin
|
||||
* serves itself is registered before this point.
|
||||
*/
|
||||
router.all('/*', async (ctx) => {
|
||||
const credentials = await readCredentials();
|
||||
if (!credentials) return noConnection(ctx);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
// Everything after the mount, so `/gallery/3/media` reaches fofos as `/api/admin/gallery/3/media`.
|
||||
const path = url.pathname.slice(url.pathname.indexOf('/fofos-admin') + '/fofos-admin'.length) || '';
|
||||
|
||||
const method = ctx.req.method;
|
||||
// Read as TEXT and forwarded as text, so what fofos parses is what the panel sent. Safe after
|
||||
// bodyParser because Hono caches the body — and correct because these are all JSON.
|
||||
const body = method === 'GET' || method === 'HEAD' ? null : await ctx.req.text();
|
||||
|
||||
try {
|
||||
const response = await fofosRequest(credentials, {
|
||||
method,
|
||||
path,
|
||||
search: url.search,
|
||||
body,
|
||||
contentType: body ? 'application/json' : null,
|
||||
});
|
||||
return passThrough(response);
|
||||
} catch (error) {
|
||||
return unreachable(ctx, error);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user