import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; /** * The connection to the fofos server. One row, and the ONLY thing this plugin stores. * * ── Why there is nothing else here ── * * Every temptation to add a table to this file should be resisted, and it is worth writing down why * rather than relying on taste. fofos owns its own Postgres and its own media store; this plugin * reaches them over HTTP. If the client's collections and photographs lived here instead, they would * live under Officer's plugin installer — which drops a plugin's tables when the "delete its data" * checkbox is ticked. * * So the strongest thing that can be said about this plugin is a consequence of this file being one * table long: **no operation on it can destroy the client's work.** Deleting it removes a URL and a * token, and the site is untouched. * * A cache would be the thin end of it. If a panel ever needs one, it belongs in memory or in the * browser, not in a table that outlives the request. * * ── Why not core `service_connections` ── * * It fits the shape — `url`, an encrypted `secret` — but its `ServiceName` is a closed TypeScript * union, so using it means editing the platform to add a name, and a plugin should install into a * platform it did not have to change. The same reasoning `plugins/proxy-manager/db/schema.ts` gives, * and worth revisiting on the day that union opens. */ export const fofosAdminConnection = pgTable('fofos_admin_connection', { id: serial('id').primaryKey(), /** Where the fofos server is — the origin its API is served from. Normalised without a trailing slash. */ url: text('url').notNull(), /** * fofos's `FOFOS_API_TOKEN`, encrypted with `encryptSecret('fofos-admin', …)` — its own purpose * key, so a leak of that one opens this column and nothing else. * * **Never returned across the API boundary**, at any level, to anybody, including the owner. A * settings form asking "is one set" gets a boolean. * * There is exactly one of these rather than one per user, and that is not laziness: fofos has a * single token and no notion of accounts. WHO is editing is a question Officer answers with its own * accounts and permissions, in front of this. */ token: text('token').notNull(), /** From the last successful probe — which sections that server says it serves. Diagnostic only. */ sections: text('sections').array(), lastProbeAt: timestamp('last_probe_at', { withTimezone: true }), /** * `integer` rather than `boolean` for the same reason proxy-manager uses one: three states, not * two. Null means never probed, which reads differently from probed-and-failed. */ lastProbeOk: integer('last_probe_ok'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export type FofosAdminConnection = typeof fofosAdminConnection.$inferSelect; /** * Administration's own settings. One row, and nothing in it belongs to the client. * * ── Why a second table and not a column on the one above ── * * `fofos_admin_connection.url` and `.token` are both `notNull`, so a root path could not be saved * without also having an HTTP connection — and that connection is being replaced, not extended. When * it goes, its table is a clean `drop` rather than surgery on columns that outlived it. * * The rule at the top of this file still holds and this does not bend it: what is forbidden here is * the CLIENT'S WORK — collections, photographs, copy. This row holds a path and, later, how to read * fofos's own database. Deleting it loses a setting somebody can retype, never a thing they made. */ export const fofosAdminSettings = pgTable('fofos_admin_settings', { id: serial('id').primaryKey(), /** * Where the fofos site lives on this machine. ABSOLUTE, always. * * Never `~/fofos`. A tilde means "whoever is asking", and the people asking will not all be the same * person — this plugin is grantable, and a member reading this setting must see the one real * directory the site is in rather than a path that resolves to their own home and is not there. * * Null means never set, and the default is computed at read time rather than written here. See * `api/settings.ts` for why that is not the same as writing it once at install. */ 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'), /** * Backups: when, and where they land. * * A cron expression, because a backup wants to happen at an HOUR — "every day at three" — and an * interval measured from whenever somebody last pressed save drifts across the clock until it is * running at two in the afternoon. The panel offers the handful of schedules anybody wants, and the * expression is validated on the way in so a malformed one cannot be stored and then fail at a time * nobody is watching. * * Null in either means backups are not set up. Both are needed, so one without the other is not a * half-configured state to interpret — it is off. */ backupCron: text('backup_cron'), /** * Where dumps are written on THIS machine, before they are sent anywhere. * * Null means the default, computed at read time from `OFFICER_ROOT` — the same reasoning as * `rootPath` above: a path written in at install freezes the layout of the machine it was installed * on, and a restore elsewhere then points at a directory that is not there while looking chosen. */ backupDir: text('backup_dir'), /** * Where they are COPIED to. `user@host`, and an absolute path on it. * * A backup that only exists on the machine it came from is not a backup — it is a second copy on the * same disk, lost to the same failure. So the local directory is staging and this is the destination. * * Authenticated by the owner's existing SSH key, never a password: there is nobody at a terminal when * this runs, and a credential stored here would be one this plugin had to hold and could leak. */ backupSshHost: text('backup_ssh_host'), backupSshPath: text('backup_ssh_path'), /** * How many dumps to keep, per database, in each place. * * Null means keep everything, which is a real choice and a bad default — a nightly dump of a small * site fills a disk slowly enough that nobody notices until something else fails. */ backupKeep: integer('backup_keep'), /** * The ports the site's two servers listen on. * * Null means not chosen, and there is no default: a port is the one setting where a sensible-looking * guess is worst — it binds, the site appears to work, and it is quietly answering on something * another service wanted. */ devPort: integer('dev_port'), prodPort: integer('prod_port'), /** * When the last one finished, which is what makes the schedule survive a restart. * * The timer does not measure elapsed time from when the process started; it asks whether a backup is * DUE, against this. A daily backup on a machine that restarts every few hours would otherwise never * fire once — the interval would be reset before it ever elapsed, and nothing would look wrong. */ lastBackupAt: timestamp('last_backup_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export type FofosAdminSettings = typeof fofosAdminSettings.$inferSelect;