Commit Graph
7 Commits
Author SHA1 Message Date
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
pastilhasandClaude Opus 5 2e56260a88 a Servers section: development and production, both under pm2
pm2 for BOTH, development included. A `bun dev` started in a terminal dies with
the terminal, and nobody can tell whether it is running without finding the
window it was started in. Under pm2 the two are the same kind of thing — a named
process with a status, an uptime and a log, startable from here and inspectable
from anywhere. The cost is that development stops printing to a console somebody
is watching, which is what `pm2 logs fofos-dev` is for; the process names are in
the panel so nobody has to guess them.

This plugin owns exactly two names, `fofos-dev` and `fofos-prod`, and every
command is built from that pair and from nothing a caller supplies. No request
can stop, restart or delete another process on the machine. That matters more
than usual here: Officer's own processes share this pm2 daemon, and a plugin
able to name any of them would be a way to take the host down through a web
form.

Start is really a replace — delete, then start. Not `restart`: it fails on a
process that has never existed, and on one that has it reuses the environment it
was started with, so a port or database changed here would be saved, displayed,
and not in effect. That is an hour of somebody's debugging.

The environment is assembled here because this plugin is what knows the ports
and the database names, and it is handed to pm2 through the invoking command's
own environment — pm2 has no flag for it; `--env` names a block in an ecosystem
file. The site reads .env files first and the environment last, so this fills
what is absent rather than overriding what somebody wrote down.

Ports have no default. It is the one setting where a plausible guess is worst:
it binds, the site appears to work, and it is quietly answering on a port
another service wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:43:48 +01:00
pastilhasandClaude Opus 5 5fc710f072 backups go to another machine, and old ones are pruned
Three things.

The local directory defaults to <officerdev root>/dockers/postgres/dumps,
derived from OFFICER_ROOT rather than written down — the same reasoning as
rootPath: a path fixed at install freezes the layout of the machine it was
installed on, and a restore elsewhere then points somewhere that is not there
while looking deliberately chosen.

The destination is SSH, and it is now what "set up" MEANS: a dump sitting on the
machine it came from is a second copy on one disk, lost to the same failure that
would make anybody want it. So local is staging and the copy off the box is the
backup, and a schedule with nowhere to send it does not count as configured.

Testable, in two steps reported separately, because they fail for different
reasons and the fixes differ: the connection is keys and networking, the
directory is permissions on the far side. It tests what is in the FORM rather
than what is stored — testing a saved destination while somebody looks at a
different one is worse than not testing.

Everything goes out with BatchMode=yes. Nobody is at a terminal when this runs,
so a key that has lost access must FAIL rather than block forever on a prompt
nothing will answer. It is also why no credential is stored: there is none to
store and none to leak.

The remote path is the one value here a shell interprets — `ssh host mkdir -p
<path>` runs through it — so shell characters are refused rather than escaped.
Refusing is checkable by reading it; escaping is checkable by being right about
every shell.

And rotation keeps the newest N per database at both ends. Per database, because
two sharing one budget means a busy one evicts the other's history. Sorted by
NAME rather than mtime: the timestamp is in the filename and is sortable, while
mtime stops meaning "when this was taken" the moment a file is copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:03:30 +01:00
pastilhasandClaude Opus 5 1b398121b7 backups: a schedule, a location, and a timer that survives a restart
Restore goes. What it would read from was never decided, and a disabled button
with a tooltip explaining it is not built is worse than no button.

In its place, backups. `cron` was already a dependency and used nowhere, so the
schedule is a real cron expression — a backup wants to happen at an HOUR, and an
interval counted from whenever somebody pressed save drifts across the clock
until it runs mid-afternoon.

Cron alone is not enough, and this is the part worth writing down: it does not
run a job it was down for. A daily backup at three on a machine that restarts
overnight is skipped silently — the schedule is still there, the next fire is
still in the future, and a month later there is one file. So `lastBackupAt` is
in the database, and both the startup and a fifteen-minute tick compare it
against the schedule's own period, computed from two successive fires. A backup
that should already have happened happens now.

The job handle is module-level and stopped before a new one is made. Two saves
leaving two jobs is a bug that surfaces as backups arriving in pairs, weeks
later, with nothing connecting it to a settings form.

Back up now exists because a scheduled job nobody has ever run by hand is a
guess — it is how you learn the location is writable and pg_dump can reach the
databases without waiting until three to find out it cannot. The list underneath
is a readdir of the location rather than a record of what should be there: a
schedule that has been failing quietly looks exactly like a working one until
you count the files.

pg_dump per database, not pg_dumpall — a cluster dump would also carry Officer's
own, which is not this plugin's to copy around. And a failed run still records
the timestamp, or one undumpable database would have the catch-up tick retrying
every fifteen minutes for the rest of the week.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:54:54 +01:00
pastilhasandClaude Opus 5 a1fe543dff 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>
2026-08-29 12:06:40 +01:00
pastilhasandClaude Opus 5 d5f2d8f370 Root location: an absolute path, with a picker that can make folders
defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a
tilde means "whoever is asking", and the people asking will not all be the same
person: this plugin is grantable, and the site is in one place regardless of who
is looking at it. stored as `~/fofos` it would give every user a different answer
to a question that has one.

THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on
every read, so it follows the machine. writing it in at install freezes whichever
home existed on install day, and a restore onto a different machine then points at
a directory that is not there while looking deliberately chosen. a saved path is a
decision and is stored exactly as given; only its absence is derived. the form
says which of the two it is showing, because "not set" reading identically to "set
to the default" is how somebody comes to believe they configured something they
did not.

THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes
of this plugin's own. they already refuse to leave home, and they already tell a
missing directory from a forbidden one, which is a distinction that cost somebody
an afternoon and would have been re-guessed here. shipping a second listing and
mkdir would be a second filesystem surface to audit for the sake of one form.

that API is an execution capability and never grantable, so for anybody but the
owner it 403s — said in a sentence rather than shown as an empty tree, because an
empty listing is data and must never be how a refusal looks. it also browses the
owner's home and nothing above it; a path outside home is still reachable by
typing, since the picker fills the field and the FIELD is what saves. one value on
screen, one Save, and picking a folder commits nothing by itself.

"Use this folder" takes the one that is OPEN rather than one highlighted in the
list. that makes looking inside and choosing one gesture, and it is the only way
to choose a folder with nothing in it — which a freshly made one always is.

a SECOND table, not a column on fofos_admin_connection, whose url and token are
both notNull: a root path could not be saved there without also having an HTTP
connection, and that connection is being replaced rather than extended. when it
goes, its table is a clean drop instead of surgery on columns that outlived it.
the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S
work, and this row holds a path somebody can retype.

needs `bun db:push`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:35:16 +01:00
PastilhasandClaude Opus 5 6b87956e44 Everything needed to be a plugin
manifest, one table, a router and two panels. Discovery is by convention, so
this is the point at which the directory stops being a directory and becomes
something Officer can install.

The manifest holds only what a listing cannot say. One permission, grantable,
with ownerOnly on /_config — reading and editing the site is exactly what a
member might be brought in for, but the token is unrestricted write authority
over everything the public sees, so handing it over is the owner's.

db/schema.ts is ONE table and should stay that way. fofos owns its own Postgres
and its own media store; this holds a URL and a token. That is what makes the
strongest thing about this plugin true: no operation on it can destroy the
client's work, checkbox ticked or not, because none of it was ever here. A cache
would be the thin end of that.

The router mostly forwards. fofos's /api/admin already answers in the shape a
panel wants, so re-wrapping it would be a second place for the contract to drift.
Two things in it are load-bearing rather than style:

  registerProxiedPrefix('/api/fofos-admin/media'). bodyParser calls parseBody on
  multipart across the whole protected router, and Hono then caches the parsed
  FormData — so asking for the bytes afterwards re-serialises them under a NEW
  boundary while the ORIGINAL content-type header is what gets forwarded. Header
  and body disagree and every upload fails with "Unexpected end of form".
  Narrow to the one route: everything else is JSON that is fine to parse.

  The catch-all is LAST in the file. Hono runs matching handlers in registration
  order, so /* above /_config would swallow it and the settings form would
  forward itself to fofos, which has no such endpoint — a 404 from a plugin that
  is working perfectly.

Panels are a nav and a detail pane that never speak to each other: the nav writes
?section=, the detail pane reads it. Real <Link>s rather than the buttons
proxy-manager uses — AGENTS.md says nav items are links, and the file documenting
that pattern says not to copy the older one. <Link> and not <NavLink> because
active state here is a query parameter and NavLink compares paths, which would
light every row at once.

Which component renders which section is a MAP, not a conditional on `built`.
The first version read `built ? <MessagesPanel/> : <Planned/>`, which is correct
only while exactly one section exists — the second one finished would have
rendered Messages under its own name.

Messages is the only section built, chosen because it is one read-only route: it
proves the path from an Officer panel through this proxy to fofos and back with
nothing else able to be wrong at the same time. The other eight are in the nav,
dimmed and labelled, saying what they will do and that the old admin still edits
them. A nav that hides what is unfinished gives no sense of what the thing is for.

Typechecked against the platform — and the typechecker was verified to actually
cover plugins/ by planting an error and watching it fail, because a clean run
that does not include your files is worse than no run.

NOT yet: installed, or run against a live fofos. Nothing here has been through
the permission gate or a real request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:05:34 +01:00