two database names, and a button that creates what is missing

Production and development, stored as NAMES rather than URLs. The site runs on
the same Postgres Officer is already connected to — that is the whole reason a
name is enough — and a URL here would be a second copy of a host, a port and a
credential that already exist in the platform's configuration, free to drift
from it and free to point at another machine.

Naming and creating are separate, and were worth two buttons. A database that
already exists still needs its name recorded, and making Create the only way to
save would leave nobody able to point the site at something already set up. The
tick beside each field comes from pg_database, not from what was typed — the
useful question is whether the thing exists, and that came apart from "somebody
saved a name" the moment the two actions split.

CREATE DATABASE cannot be parameterised, so the name goes into the statement as
text and the identifier check is the entire security boundary. It is therefore
strict rather than accommodating — lowercase, leading letter or underscore,
nothing else — and it is re-run immediately before the statement is built,
because the gap between storing a name and using it is exactly where a check
that happened earlier stops being a check. Postgres folds unquoted identifiers
to lowercase, so accepting `Fofos` would create `fofos` and then report a name
nobody could find; refusing is kinder than silently changing it.

Officer's own database and the Postgres templates are refused outright. Pointing
the site at Officer's database would not read as a configuration mistake — it
would read as the platform corrupting itself.

Nothing here drops a database, and nothing should: `fofos_website` and
`fofos_old` are one keystroke apart and one of them holds the client's site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:06:40 +01:00
co-authored by Claude Opus 5
parent 300ae1b94f
commit a1fe543dff
4 changed files with 387 additions and 10 deletions
+166
View File
@@ -0,0 +1,166 @@
import postgres from 'postgres';
import { db } from 'officerdb/db';
import { eq } from 'drizzle-orm';
import { fofosAdminSettings } from '../db/schema';
/**
* The two Postgres databases the fofos site reads — production and development.
*
* ── Names, on the instance Officer is already using ──
*
* The site runs on the same box and the same Postgres as Officer, so what has to be stored is a NAME.
* Storing a URL would be a second copy of a host, a port and a credential that already exist in the
* platform's configuration, free to drift from it and free to point at another machine entirely.
*
* ── This module creates databases, which is worth saying out loud ──
*
* `CREATE DATABASE` cannot be parameterised — the name goes into the statement as text. So the name is
* validated to an identifier this module is willing to write, and anything else is refused before a
* statement is built. That check is the whole security boundary here, which is why it is strict rather
* than accommodating: lowercase, starts with a letter or underscore, and nothing else in it.
*
* Postgres folds an unquoted identifier to lowercase, so accepting `Fofos` would create `fofos` and
* then report a name nobody would find. Refusing it is kinder than silently changing it.
*
* ── It never drops, and never overwrites ──
*
* Create makes what is missing and leaves what is there. There is no route here that destroys a
* database, and there should not be: the difference between `fofos_website` and `fofos_old` is one
* keystroke, and one of those holds the client's site.
*/
/** Postgres's own rule, minus the parts nobody should be using in a name typed into a form. */
const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/;
export type DatabaseName = {
name: string | null;
/** Null when no name is set — "does it exist" has no answer for a database nobody has named. */
exists: boolean | null;
};
export type DatabasesStatus = {
production: DatabaseName;
development: DatabaseName;
/** Every database on the instance, so the form can say what is already there. */
available: string[];
};
/**
* The connection used to ask about and create databases.
*
* Against `postgres`, the maintenance database, because `CREATE DATABASE` cannot run from inside the
* database it is creating and cannot run in a transaction. A fresh client per call, closed after:
* these are rare administrative actions, and a pool held open for them would outlive every one.
*/
const maintenance = () => {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) throw new Error('The platform has no Postgres connection configured.');
const url = new URL(raw);
url.pathname = '/postgres';
return postgres(url.toString(), { max: 1 });
};
/**
* Names this module refuses, whatever anybody types.
*
* `postgres` and the templates are the instance's own machinery. Officer's database is the important
* one: pointing the site at it would have fofos reading and eventually writing Officer's tables, and
* the failure would not look like a configuration mistake — it would look like the platform corrupting
* itself.
*/
const reserved = (): Set<string> => {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
const own = raw ? new URL(raw).pathname.replace(/^\//, '') : '';
return new Set(['postgres', 'template0', 'template1', ...(own ? [own] : [])]);
};
/** Throws with a sentence a person can act on, rather than returning false. */
export function checkName(name: string, field: string): string {
const trimmed = name.trim();
if (!trimmed) throw new Error(`The ${field} database needs a name.`);
if (!IDENTIFIER.test(trimmed)) {
throw new Error(
`"${trimmed}" is not a database name this will create. Lowercase letters, digits and underscores, starting with a letter or underscore.`,
);
}
if (reserved().has(trimmed)) throw new Error(`"${trimmed}" belongs to Officer or to Postgres itself.`);
return trimmed;
}
const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
export async function readDatabases(): Promise<DatabasesStatus> {
const row = await readRow();
const production = row?.dbProduction ?? null;
const development = row?.dbDevelopment ?? null;
const sql = maintenance();
try {
const rows = await sql<{ datname: string }[]>`
select datname from pg_database where not datistemplate order by datname
`;
const available = rows.map((entry) => entry.datname);
return {
production: { name: production, exists: production ? available.includes(production) : null },
development: { name: development, exists: development ? available.includes(development) : null },
available,
};
} finally {
await sql.end({ timeout: 5 });
}
}
/**
* Store the two names. Saving does NOT create anything — see the note in the panel.
*
* Validated here as well as at creation, so a name that could never be created cannot be stored and
* sit there looking configured.
*/
export async function saveDatabases(production: string, development: string): Promise<DatabasesStatus> {
const prod = checkName(production, 'production');
const dev = checkName(development, 'development');
if (prod === dev) throw new Error('Production and development must be different databases.');
const row = await readRow();
if (row) {
await db
.update(fofosAdminSettings)
.set({ dbProduction: prod, dbDevelopment: dev, updatedAt: new Date() })
.where(eq(fofosAdminSettings.id, row.id));
} else {
await db.insert(fofosAdminSettings).values({ dbProduction: prod, dbDevelopment: dev });
}
return readDatabases();
}
/**
* Create whichever of the two is missing.
*
* Idempotent by checking first rather than by catching "already exists": the second would also swallow
* a name that failed for a different reason, and reporting "created" about a database that is not
* there is the one outcome worth engineering against.
*/
export async function createDatabases(): Promise<{ created: string[]; status: DatabasesStatus }> {
const status = await readDatabases();
const wanted = [status.production.name, status.development.name].filter((name): name is string => Boolean(name));
if (!wanted.length) throw new Error('Name the databases before creating them.');
const missing = wanted.filter((name) => !status.available.includes(name));
if (!missing.length) return { created: [], status };
const sql = maintenance();
try {
for (const name of missing) {
// Re-checked immediately before the statement is built. The name came from our own row, but the
// gap between storing and creating is exactly where a check that happened "earlier" stops being
// a check — and this is the one line in the plugin that interpolates into SQL.
const safe = checkName(name, 'database');
await sql.unsafe(`CREATE DATABASE "${safe}"`);
}
} finally {
await sql.end({ timeout: 5 });
}
return { created: missing, status: await readDatabases() };
}
+27
View File
@@ -8,6 +8,7 @@ import { readMediaStatus, createMediaDir } from './media';
import { readSync, startSync } from './sync';
import { readRuns } from './ledger';
import { reconcile } from './derive';
import { createDatabases, readDatabases, saveDatabases } from './databases';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
/**
@@ -283,6 +284,32 @@ router.get('/_config/media/sync', async (ctx) => {
*
* It walks the media tree, which is the expensive part, and is therefore deliberately NOT polled.
*/
/**
* The two databases the site reads. Owner-only, like everything else under `/_config`.
*
* Saving and creating are separate routes because they are separate decisions: a name can be stored
* for a database that already exists and needs no creating, and pressing Create must never be the only
* way to record what you typed.
*/
router.get('/_config/databases', async (ctx) => ctx.json(await readDatabases()));
router.put('/_config/databases', async (ctx) => {
const { production, development } = (ctx.get('body') ?? {}) as { production?: string; development?: string };
try {
return ctx.json(await saveDatabases(production ?? '', development ?? ''));
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
router.post('/_config/databases/create', async (ctx) => {
try {
return ctx.json(await createDatabases());
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
router.get('/_config/summary', async (ctx) => {
const user = ctx.get('user');
const settings = await readSettings(user.email);
+14
View File
@@ -88,6 +88,20 @@ export const fofosAdminSettings = pgTable('fofos_admin_settings', {
*/
rootPath: text('root_path'),
/**
* The two Postgres databases the site uses, by NAME.
*
* Names and not URLs, deliberately. They live on the same instance Officer is already connected to —
* that is the whole reason this is a name — so a URL here would be a second copy of a host, a port
* and a credential that already exist in the platform's own configuration, able to drift from it and
* able to point somewhere nobody intended.
*
* Null means not chosen yet. There is no default: a database name is not something to guess at, and
* a wrong guess here is a site reading somebody else's tables.
*/
dbProduction: text('db_production'),
dbDevelopment: text('db_development'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
+180 -10
View File
@@ -1,15 +1,185 @@
import { Database } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { Block } from './Block';
import { useFlash } from './useFlash';
/**
* Where the fofos site's own data lives.
* Where the fofos site's own data lives — two databases, named here.
*
* Deliberately empty. The block exists so the shape is settled and so the screen says what is coming
* rather than looking like a section nobody finished — but nothing is decided about what goes in it,
* and inventing fields to fill the space would be deciding it in the wrong place.
* ── Naming and creating are separate ──
*
* Save stores what you typed; Create makes whichever of the two is not there yet. They are different
* decisions and were worth two buttons: a database that already exists still needs its name recorded,
* and making Create the only way to save would mean nobody could point the site at something that was
* already set up.
*
* ── It reports what is THERE, per name ──
*
* A tick or a dashed circle beside each field, from `pg_database` rather than from what was saved. The
* useful question about a database name is not whether somebody typed it — it is whether the thing it
* names exists, and those two came apart the moment saving stopped creating.
*
* ── Create only offers itself when there is something to create ──
*
* Both present and the button goes. A button that does nothing is a thing to wonder about later.
*/
export const DatabaseBlock = () => (
<Block icon={Database} title="Database" hint="Where the fofos site's own data lives.">
<p className="text-xs text-muted-foreground/70">Nothing to set yet.</p>
</Block>
);
type Named = { name: string | null; exists: boolean | null };
type Status = { production: Named; development: Named; available: string[] };
export const DatabaseBlock = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [status, setStatus] = useState<Status | null>(null);
const [production, setProduction] = useState('');
const [development, setDevelopment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [flashing, flash] = useFlash();
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<Status>('/fofos-admin/_config/databases');
setStatus(current);
// Only seed the fields from the server when they are untouched, so a reload landing mid-typing
// cannot replace what somebody is in the middle of writing.
setProduction((typed) => typed || (current.production.name ?? ''));
setDevelopment((typed) => typed || (current.development.name ?? ''));
} catch {
setStatus(null);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
setBusy(true);
setError(null);
try {
setStatus(
await clientRef.current.put<Status>('/fofos-admin/_config/databases', { production, development }),
);
flash();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const create = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
'/fofos-admin/_config/databases/create',
{},
);
setStatus(result.status);
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const field = (
label: string,
hint: string,
value: string,
onChange: (next: string) => void,
state: Named | undefined,
) => {
// Only about what is SAVED. A tick next to a name somebody is still typing would be about a
// different database from the one on screen.
const settled = state?.name === value.trim() ? state : undefined;
const Icon = settled?.exists ? CircleCheck : CircleDashed;
return (
<div className="space-y-1">
<div className="flex items-baseline gap-2">
<label className="text-xs font-medium">{label}</label>
<span className="text-xs text-muted-foreground/70">{hint}</span>
</div>
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={label === 'Production' ? 'fofos_website' : 'fofos_website_dev'}
className="w-full font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<span className="flex w-24 shrink-0 items-center gap-1 text-xs text-muted-foreground">
{settled ? (
<>
<Icon className={`size-3.5 ${settled.exists ? 'text-success' : ''}`} />
{settled.exists ? 'exists' : 'not yet'}
</>
) : null}
</span>
</div>
</div>
);
};
const dirty =
production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? '');
// From the SAVED names, not the typed ones — Create acts on what is stored.
const missing = [status?.production, status?.development].filter((entry) => entry?.name && entry.exists === false);
return (
<Block
icon={Database}
title="Database"
hint="Where the fofos site's own data lives — on Officer's own Postgres."
status={
flashing ? (
<span className="flex items-center gap-1 text-xs text-success">
<Check className="size-3.5" />
Saved
</span>
) : undefined
}
>
{!status ? null : (
<>
{field('Production', 'what the live site reads', production, setProduction, status.production)}
{field('Development', 'what you work against', development, setDevelopment, status.development)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save
</Button>
{/* Only when a saved name has nothing behind it. Nothing to create, no button. */}
{missing.length > 0 && (
<Button variant="outline" size="sm" onClick={() => void create()} disabled={busy || dirty}>
<Plus className="size-3.5" />
Create {missing.length === 1 ? missing[0]!.name : 'both'}
</Button>
)}
{/* Save first. Create acts on what is stored, and a button that quietly used a different
value from the one in the field would be the worst kind of surprise here. */}
{missing.length > 0 && dirty && (
<span className="text-xs text-muted-foreground/70">Save the names first.</span>
)}
</div>
</>
)}
</Block>
);
};