Files
fofos-admin/db/schema.ts
T
pastilhasandClaude Opus 5 ca43f7ef86 the site gets what it needs to boot, and the status stops lying
Three things, and the second is why the panel was confidently wrong.

The site refuses to start without FOFOS_API_TOKEN, ADMIN_SESSION_SECRET,
PUBLIC_URL, API_URL and FOFOS_MEDIA_DIR, and the checkout has no .env at all.
Every one of those is either already known here — the port, the media directory
— or a random secret nobody should be asked to invent. So they are generated
once, kept encrypted under this plugin's own purpose key, and supplied. Once,
not per start: regenerating would invalidate every session and token anybody had
noted down.

pm2 saying `online` is NOT the site being up. `bun --hot` survives a crash at
startup — the process stays alive, pm2 reports online, and nothing is listening.
That is precisely what development did: reported up, served nothing, and this
panel repeated it. The status now also asks whether the port answers, any
response counting, and says "not answering" out loud when the two disagree.

And the buttons are a fixed width, because Start and Restart are different
lengths and rows that size to their own label leave the inputs on different
vertical lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:50:16 +01:00

187 lines
8.9 KiB
TypeScript

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'),
/**
* Secrets the SITE needs at boot, generated here on first start and kept.
*
* `FOFOS_API_TOKEN` guards the site's own admin API — the one this plugin will call — and
* `ADMIN_SESSION_SECRET` signs its sessions. Both are required before the site will serve a request,
* and neither is a decision anybody wants to make: they are long random strings, and asking somebody
* to invent one is asking them to invent a worse one.
*
* Encrypted with `encryptSecret('fofos-admin', …)` — its own purpose key, so a leak of that one opens
* these and nothing else. Generated once and reused, because regenerating on every start would
* invalidate every session and every token anybody had noted down.
*/
siteApiToken: text('site_api_token'),
siteSessionSecret: text('site_session_secret'),
/**
* 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;