`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>
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
import { db } from 'officerdb/db';
|
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
|
import { eq } from 'drizzle-orm';
|
|
import { fofosAdminConnection, fofosAdminSettings, 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. */
|
|
/**
|
|
* 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 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 };
|
|
|
|
/**
|
|
* `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));
|
|
}
|