the site sections open, because there is finally something behind them

`ready` was hardcoded false for as long as the sections proxied to an address
nobody could set — the connection form was removed and nothing replaced it, so
nine doors led to the same "not connected" message.

Both halves exist now and neither is typed by a person: the development server
runs on a port this plugin chose, holding a FOFOS_API_TOKEN this plugin
generated and handed it at start. So the credentials are DERIVED rather than
stored. Asking somebody to copy a value out of one part of this plugin into
another was never a setting, it was a chore.

Development and not production, and that is not a preference. Production is a
directory of files behind nginx — no API, no database, nothing listening — so
editing against it is impossible rather than merely wrong. Sections read and
write dev; Build is what publishes. An edit is visible immediately and public
only when somebody decides it should be.

The gate asks whether the server ANSWERS, not whether pm2 says online. `bun
--hot` survives a crash at startup: the process stays up, pm2 reports online,
and nothing is listening. Opening nine sections onto that would make one failed
boot look like nine broken screens.

And when /status itself cannot be reached, `configured` stays true while `ready`
goes false. Telling somebody they have set nothing up is alarming and wrong;
opening nine sections onto a server we could not confirm is answering is nine
screens that each fail separately. Cannot-tell keeps them shut and lets the next
poll open them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 20:00:18 +01:00
co-authored by Claude Opus 5
parent 2153d4875b
commit f0d29c8000
3 changed files with 73 additions and 18 deletions
+27 -4
View File
@@ -1,7 +1,7 @@
import { db } from 'officerdb/db';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { eq } from 'drizzle-orm';
import { fofosAdminConnection, type FofosAdminConnection } from '../db/schema';
import { fofosAdminConnection, fofosAdminSettings, type FofosAdminConnection } from '../db/schema';
import { normaliseUrl, type FofosCredentials } from './fofos-client';
/**
@@ -56,10 +56,33 @@ export async function readConnection(): Promise<ConnectionView> {
}
/** Decrypted, for the client only. Never serialise the result of this. */
/**
* How to reach the site's admin API — DERIVED now, not stored.
*
* ── Why there is nothing to configure ──
*
* This used to read a URL and a token somebody typed into a form. Both are now facts this plugin
* already holds: the development server runs on a port it chose, and `FOFOS_API_TOKEN` is a secret it
* generated and handed to that server at start. Asking a person to supply either would be asking them
* to copy a value out of one part of this plugin and into another.
*
* ── Why DEVELOPMENT and not production ──
*
* Production is a directory of files behind nginx. It has no API, no database connection and nothing
* listening — editing against it is not merely wrong, it is impossible. So the sections read and write
* the development server, and Build is what publishes. That is the shape the site's own build script
* already implies, and it means an edit is visible on dev immediately and public only when somebody
* decides it should be.
*
* Null when there is no port or no secret yet, which the panel reports as not being set up.
*/
export async function readCredentials(): Promise<FofosCredentials | null> {
const found = await row();
if (!found) return null;
return { url: found.url, token: decryptSecret(PURPOSE, found.token) };
const settings = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
if (!settings?.devPort || !settings.siteApiToken) return null;
return {
url: `http://127.0.0.1:${settings.devPort}`,
token: decryptSecret('fofos-admin', settings.siteApiToken),
};
}
export type SaveParams = { url: string; token?: string };
+16
View File
@@ -539,10 +539,26 @@ router.get('/status', async (ctx) => {
that answer. The path itself stays behind `/_config`, which is owner-only — this says only whether
one has been chosen, which reveals nothing about the machine.
*/
/*
`ready` is whether the site sections can be OPENED, and it is now answerable.
It was hardcoded false for as long as there was nothing behind them: the sections proxy to the
site's admin API, and no address for one was ever stored. Both halves exist now — the development
server runs on a port this plugin chose, holding a token this plugin generated — so the question
became a real one and this is the answer.
It asks whether the server is ANSWERING, not whether pm2 says online. `bun --hot` survives a crash
at startup: the process stays up, pm2 reports online, and nothing is listening. Opening nine
sections onto that would make one failed boot look like nine broken screens.
*/
const servers = await readServers();
const development = servers.find((server) => server.name === 'development');
return ctx.json({
configured: connection.configured,
lastProbeOk: connection.lastProbeOk,
rootSet: settings.isSet,
ready: development?.reachable === true,
});
});
+30 -14
View File
@@ -19,11 +19,11 @@ import { useFofosChanged } from './channel';
* nothing behind any of them; offering nine doors onto the same "not connected" message taught nobody
* anything and made a setup step look like nine faults.
*
* ── Hardcoded false, deliberately, for now ──
── It is answered by the server ──
*
* The shape is real and the plumbing is real: it asks `/status`, it re-asks whenever the connection
* changes, and it reports while it is still asking. Only the final verdict is pinned, so turning the
* gate on is one line rather than a feature.
* This was pinned to `false` for as long as nothing was behind the sections. What changed is not this
* hook: the site's admin API is served by the development server, which this plugin now starts on a
* port it chose with a token it generated. So there is something to ask about, and `/status` asks it.
*
* ── Both panels call this, so `/status` is fetched twice ──
*
@@ -33,13 +33,13 @@ import { useFofosChanged } from './channel';
* here rather than hidden in a wrapper. If the condition grows expensive, that is the moment to fix
* it, not before.
*
* What it will become is roughly `configured && lastProbeOk` — the connection is stored AND the last
* probe reached fofos — but "all the info needed in Administration" is not settled yet and will grow
* past those two. That is why this returns a single boolean and not the parts: callers must not start
* assembling their own version of the condition out of the pieces.
* It returns a single boolean and not the parts, and that still matters: callers must not start
* assembling their own version of the condition, because the condition has already changed shape once
* — from "a stored HTTP connection" to "the development server is answering" — and every caller that
* had built its own would have been quietly wrong on the day it did.
*/
type Status = { configured: boolean; lastProbeOk: boolean | null; rootSet: boolean };
type Status = { configured: boolean; lastProbeOk: boolean | null; rootSet: boolean; ready: boolean };
export type Readiness = {
/** The fofos connection works. Gates every section that READS the site. */
@@ -73,7 +73,15 @@ export const useReady = (): Readiness => {
"you have not set this up", which is a far more alarming thing to be told and invites somebody
to re-enter a connection that was never the problem.
*/
setStatus({ configured: true, lastProbeOk: null, rootSet: false });
/*
`ready: false` here, unlike `configured`, and the asymmetry is deliberate.
Being told "you have not set this up" when the request merely failed is alarming and wrong, so
configured stays true. But OPENING nine sections onto a server we could not confirm is
answering would show nine screens that each fail on their own — the honest read of "cannot
tell" is to keep them shut and let the next poll open them.
*/
setStatus({ configured: true, lastProbeOk: null, rootSet: false, ready: false });
}
}, []);
@@ -81,10 +89,18 @@ export const useReady = (): Readiness => {
void check();
}, [check, signal]);
// ────────────────────────────────────────────────────────────────────────────────────────────────
// The gate. Replace `false` with the real condition — nothing else here has to change.
// ────────────────────────────────────────────────────────────────────────────────────────────────
const ready = false;
/*
The gate, and it is the server's answer rather than one assembled here.
It was `false` for as long as there was nothing behind the sections. The server now knows: the
development server is what serves the site's admin API, so `ready` is whether that is ANSWERING —
not whether pm2 says online, because `bun --hot` stays alive after a startup crash and would have
made one failed boot look like nine broken screens.
Computed there and not here on purpose. The note above says callers must not assemble their own
version out of the parts, and that applies to this hook too: one place decides, everything reads it.
*/
const ready = status?.ready ?? false;
return { ready, rootSet: status?.rootSet ?? false, loading: status === null };
};