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 => { 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 { 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 { 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() }; }