Commit Graph
90 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 cd4cb18ccc a year on assets, now that their URLs change
Seven days was correct while the URLs were bare: a stable name with a long life means somebody running
last week's script against this week's markup.

The site's layout now stamps every asset URL with the build's id, so a build that changes a file
changes its URL, and the HTML carrying those URLs is no-cache and revalidated every visit. The two
cannot disagree, which is what earns `immutable` and a year.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:55:19 +01:00
pastilhasandClaude Opus 5 94eb308e78 480 is an image rung too
The ladder went 320 then 640, and a phone lands between them. Lighthouse measured the home gallery's
slots at 454 device pixels; with only those two rungs the browser correctly took 640, which is a
184 KB file for a 454-pixel slot — and the same on every image in that carousel.

A rung is one more WebP beside files that already exist, chosen only when it fits better than its
neighbours. Not having it is paid for on every visit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:46:41 +01:00
pastilhasandClaude Opus 5 b97c87fb5e 480 is a real video rung, with lighter audio
It was reachable only when the SOURCE was smaller than 720, which is backwards: the rung a phone wants
is the one a 720p master never produces. The hero here is 1280x720 and the only file that existed for
it was 7.8 MB of 720p, served to every device.

`b:a` overrides the preset's 96k with 64k. The audio on a hero loop is ambience, and 64k AAC carries
it — the same argument as the preset's crf 26 for the picture. The rung exists for a phone on a mobile
connection and every kilobit is one it is paying for.

The override goes through av-club's documented contract: a preset supplies the options and anything
passed explicitly wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:38:11 +01:00
pastilhasandClaude Opus 5 c5fe7c0f86 the build can be pointed at one page
TEMPORARY, and inert unless set. Bisecting a Performance score means a build between every
measurement, and rendering all 48 routes to change one of them is most of the wait.

FOFOS_BUILD_ONLY is passed through to the site's build script, which renders only the listed paths and
— the part that is not optional — skips the prune at the swap. The swap deletes anything in dist/ that
the run did not stage, so a one-page build would otherwise take every other page of the live site with
it, 404 until the next full build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:12:50 +01:00
pastilhasandClaude Opus 5 3b962fe3ca the typefaces get a year, the build output does not
Everything sat at seven days, which Lighthouse counts as short for a file that never changes.

/fonts/ now gets a year and `immutable`. A typeface is not edited — it is replaced, and a replacement
arrives under a new name. It is the one directory here where a long life cannot pin something stale.

Everything else stays at a week deliberately. Those names are stable across builds and the HTML that
references them revalidates every time, so a longer life means somebody running last week's stylesheet
against this week's markup. Fingerprinted filenames are what would earn a year, and the build does not
write them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:32:33 +01:00
pastilhasandClaude Opus 5 cd0d3fe09e every ladder carries a 16-pixel placeholder
A photograph here is 189 KB of tufted yarn — high-frequency detail in every pixel, which is the worst
case for any lossy codec and not something a quality setting can fix. Re-encoding the same original
with ImageMagick at the same quality 82 gives 199 KB, so av-club is already slightly ahead of a
reference encoder. The 858 KB Lighthouse wants back is not available without softening the texture
the site exists to sell.

What was available was the wait. Each ladder now carries the picture at 16 pixels wide as a `data:`
URI inside its manifest — 300 to 500 bytes — and the site paints it as the image's own background. It
costs no request, which is the point: a placeholder you have to fetch is a smaller version of the
problem. When the real photograph arrives it paints over its own background and the placeholder is
gone, with no load handler, no class to remove and nothing to clean up if the script never ran. The
blur is the browser stretching 16 pixels across the box.

Videos take theirs from the poster still, before it is deleted — the same frame, so the two agree, and
sharp can read a JPEG where it cannot read an MP4.

── Existing directories are topped up, not rebuilt ──

The obvious move is to make `upToDate` insist on a placeholder, which turns all 62 ladders stale and
re-derives the library: minutes of CPU and 67 MB rewritten to add 400 bytes to each. Every rung in
them is already correct.

So `topUp` makes only the missing piece, and reads `320.webp` rather than the original — already
small, already browser-readable, already rotated. Going back to a 4000×6000 HEIC to produce a
16-pixel picture is the expensive half of a rebuild and precisely what this avoids. A directory with
no small rung is skipped rather than forced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:27:49 +01:00
pastilhasandClaude Opus 5 a331b85dc3 a host that predates HTTP/2 can be repaired
`http2_support: true` is the default now and is set when a host is secured, but both hosts here were
created and secured before either change, so nothing re-runs and they stay on HTTP/1.1 forever.
Lighthouse costs that 670 ms.

An explicit action rather than a fix applied on load: a panel that silently rewrites the proxy while
somebody is reading it is not a thing to build, and the change reloads nginx for every site behind it.

Only offered where it can do something — a host with a certificate that lacks it. HTTP/2 is negotiated
during the TLS handshake, so on a plain host the field is stored and never used, and the button would
do nothing visible. A host created since the default never shows it: this is a repair, not a setting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:31 +01:00
pastilhasandClaude Opus 5 9b64933797 proxy hosts get HTTP/2
Created with `http2_support: false`, so the live site runs HTTP/1.1 — six connections and everything
else queued behind them. Lighthouse costs it 900 ms, most of it the gallery images waiting on each
other.

Set in the defaults and again in `attach`, because HTTP/2 only means anything once there is a
certificate. A host created before this — both of them here — is secured but still on 1.1, and the
attach path is the one that can fix it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:12:43 +01:00
pastilhasandClaude Opus 5 a3e8f3fbc4 nginx gets a configuration instead of the stock one
`nginx:alpine` ships a config that serves the directory and does nothing else: no compression, and no
cache headers at all. Every asset on the live site carried a TTL of zero, so a returning visitor
re-downloaded all of it — 1.1 MB by Lighthouse's count.

Written out on every start rather than kept as a file somebody edits, so what nginx does is decided in
this file and cannot drift from it.

Three lifetimes, because there are three kinds of file. `derived/` renditions are rebuilt IN PLACE —
av-club replaces the contents of `1280.webp` rather than writing a new name — so they cannot be
`immutable` however much they look like they could be, or a re-crop stays invisible for a year to
everyone who saw the old one. Bundled assets change only when a build runs, and a build changes the
HTML that names them: a week. HTML revalidates every time, because anything longer makes a publish
invisible to whoever already visited, which is the one failure that makes the whole panel look broken.

`^~` on the prefix locations. A regex location beats a prefix location in nginx, so the extension rule
would otherwise capture /derived/…/1280.webp and give it the wrong lifetime.

Validated with `nginx -t` in the real image rather than by reading it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:07:50 +01:00
pastilhasandClaude Opus 5 1d9a621bd2 publish clears and loads in one transaction
pg_dump warns that `service_nodes` carries a circular foreign key and that a --data-only dump of it
may not restore. Today the rows come out parents-first and it loads either way, but the previous shape
made that a load-bearing accident: truncate in one psql call, load in the next, so the day the
ordering changes the live site is left EMPTY until somebody notices and restores by hand.

Both statements now run as one transaction — TRUNCATE is transactional in Postgres — with foreign-key
checks off for the session. A failed load rolls the truncate back with it.

Verified against a scratch copy of production rather than reasoned about: a `create database ...
template fofos_website`, a deliberately failing load, and all nine rows still there afterwards. The
same copy confirmed what the real run will do — gallery and its nine items promoted, hero pointing at
the new file, and `users` and `contact_submissions` untouched at 2 and 8 rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:55:02 +01:00
pastilhasandClaude Opus 5 472fd9cb88 publish promotes development's content, then builds
The admin edits development and the build renders production, so nothing written in the panel could
reach the live site. Build was only half of publishing, and the missing half was invisible: it
succeeded, against the content somebody last promoted, and a successful build of yesterday's site
reads as a broken renderer.

Publish copies the site's content tables across and then builds. Two things it will not copy: `users`,
because production's logins are not development's and nobody should be locked out of the live site to
publish a photograph; and `contact_submissions`, the legacy inbox, because development's is empty and
production's is not — copying it would destroy real messages in order to publish nothing.

The table list is written out rather than derived. A table added later should be considered rather
than swept along, and an empty development table quietly overwriting production-only data is
discovered far too late to fix.

Order is load-bearing in two places. Production is dumped before anything is destroyed, and the
publish refuses to start if that dump fails. Development is read BEFORE production is cleared, so a
failure to read leaves production untouched rather than empty for a reason that had nothing to do with
it. `TRUNCATE ... RESTART IDENTITY`, so the sequences the copy sets are not merely ahead of the rows
it inserts — otherwise the collision arrives on the client's next save, hours later, somewhere else.

`backupAfterPublish` has been sitting in backups.ts without a caller since the day "On publish" became
a backup schedule. This is the caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:52:08 +01:00
pastilhasandClaude Opus 5 ccd4e23217 Home picks a gallery instead of managing one
Managing items on the page would be a second editor for something that already
has one, and the two would drift — a caption fixed in one place and not the
other. The page chooses; the Galleries section arranges; every page using that
gallery changes with it.

The list of galleries is read from the section that owns them rather than
embedded in the home payload, because a copy there would be stale the moment
somebody edited one.

While no gallery is chosen the old per-page rows still render, and the panel says
so — a carousel that appears to come from nowhere is worse than a sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:42:20 +01:00
pastilhasandClaude Opus 5 300edfb023 sort the walk, because readdir does not
`readdir` returns entries in the filesystem's own order — inode order on ext4 —
which is arbitrary and stable only until somebody moves a file.

Harmless for the sync, which does the same work whatever the order. Not harmless
for "Create Gallery": that list becomes the gallery's initial order, and nine
photographs arriving as 05, 08, 01, 07 reads as a bug even though nothing is
wrong.

`numeric` collation, so galeria2 sorts before galeria10 — which is what anybody
naming files that way means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:32:32 +01:00
pastilhasandClaude Opus 5 9c62777f5b thumbnails need the token in the query string
An <img> cannot send an Authorization header, which is why user-middleware and
the permission gate both already accept `?token=` — it is exactly what the file
browser's own thumbnails do.

Built without it, every picture 401'd while the layout looked perfectly correct:
the space was reserved, the rows were right, and nothing appeared. The route was
fine and had been tested with a bearer token, which is precisely the case the
browser cannot reproduce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:26:19 +01:00
pastilhasandClaude Opus 5 9fd5f1b3ab gallery items get a thumbnail and a caption
The thumbnail comes from the DERIVED ladder, which is the point of the whole
pipeline: most of these are HEIC and a browser cannot display the original at
all. 320 is the smallest rung and still sharp at this size.

Served by this plugin from disk rather than by the site. The panel talks to the
plugin and the catch-all forwards everything to the site's /api/admin, which is a
different route — and more importantly, looking at a gallery should not require
the development server to be running. Editing the site is not the same as
reading it.

Not under /_config, because that prefix is owner-only and a thumbnail is not a
credential: a member who can open the section can see the pictures in it, which
is what the section is. Registered before the catch-all, or the catch-all would
forward it to fofos.

Path traversal is refused by resolving first and comparing against the directory
plus a separator — a bare startsWith accepts `derived-old` for a root of
`derived`.

An item whose conversion has not run has no rung, and the thumbnail hides rather
than showing a broken-image glyph, which reads as corrupt rather than pending.

Captions save on blur. A caption is a sentence somebody is in the middle of, and
a request per keystroke would be wasteful and a race with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:23:40 +01:00
pastilhasandClaude Opus 5 5cfe571db0 the expansion route was doubling the folder into every path
`listOriginals` already includes the prefix in what it yields — it builds
`prefix/name` as it descends — so prefixing again produced
Home_Page_Gallery/Home_Page_Gallery/…, a real-looking path to nothing.

Found by calling the route rather than reading it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:20:27 +01:00
pastilhas 6abe5bd337 use the context's client to expand a folder
A bare fetch was returning 401: Officer's API authenticates with a bearer token,
not a cookie, and only the hook holds it. The browser now supplies an
authenticated client in the action's context.
2026-08-29 21:18:53 +01:00
pastilhasandClaude Opus 5 a610f93189 Create Gallery on a folder means the files inside it
It stored the folder. What somebody right-clicks is a directory; what a gallery
holds is pictures — obvious the moment you look at the result, and not before.

Expanded by the server, because that side already knows which extensions are
media and how to walk the tree. It is the same `listOriginals` the sync uses, so
a gallery cannot end up holding a file the pipeline would never convert.

Recursive, deliberately: a folder of folders is still "these pictures", and
refusing to descend would silently miss most of what was pointed at.

An expansion that returns nothing creates nothing. Falling back to the folder
itself is exactly the bug being fixed, and doing it quietly is worse than doing
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:16:05 +01:00
pastilhasandClaude Opus 5 f61653f7af Galleries: a panel, and Create Gallery in the file browser
The section reads the site's own admin API, so it requires a CONNECTION where
Media beside it requires only a root. The difference is real rather than
copy-paste: Media is a file browser over a folder on this disk, while a gallery
is a row in the site's database — opening it without the site answering would be
a screen where every action fails.

The context action hands its selection over in the URL and sends you to the
panel rather than opening a dialog of its own. Naming a gallery, seeing the slug
it will get and watching the items land belongs on the screen that owns
galleries, not in a modal over a file browser that would then have to explain
where the result went.

It offers itself only inside originals/, because a gallery item is a path
relative to that folder and an entry that appears everywhere while working in one
place is worse than one that appears where it works. The test is on the PATH, not
on which panel is open — the same browser is mounted in several places and only
the path says where it is pointed.

A mixed selection is refused. A folder among files is either a mistake or a
request to recurse, and neither is something to guess from a right-click.

The handoff is consumed once and stripped from the URL. Left there, a refresh
would create the gallery again, which somebody discovers as four copies.

Nothing is written until Create: the folder's name is a suggestion in the field,
not a decision already taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:08:25 +01:00
pastilhasandClaude Opus 5 063d2c4a88 watch originals, because a move is not an event
You should not have had to press Sync. Nine files moved into a folder and
nothing noticed.

Two gaps, and neither would have caught it. The platform's event bus emits
`files:uploaded` and `archive:extracted` — things Officer's own file browser
DID — so a move emits nothing at all, and neither would an rsync, a drop over
SMB, or anything done in a terminal. And no plugin subscribes to hooks anyway.

That is not a bug in the bus: it reports actions, and this needs to know about a
directory. So a filesystem watcher, the same shape as the mailbox one already
here.

The debounce is twenty seconds and long on purpose. A move of nine files is
dozens of events and an upload of two hundred is thousands, while a sync walks
the whole tree — so waiting for quiet means one run after the dust settles rather
than one per file. Being slightly late costs nothing, because the sync skips
everything already current; being eager costs a full walk per event.

It cannot feed itself: only originals/ is watched and the sync writes only to
derived/. Worth stating, because the opposite arrangement is a loop that looks
like a busy machine and nothing else.

A run already in flight is left alone rather than queued — it walks the tree when
it starts, and whatever lands after that is what the next quiet stretch is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:50:07 +01:00
pastilhasandClaude Opus 5 01652f3480 a moved file relocates its ladder instead of rebuilding it, and orphans go
Moving a file into a folder made the sync look at a new path, find nothing, and
convert it again — minutes of work to produce bytes that already existed a
directory away — while the old folder stayed behind forever, because nothing ever
deleted one.

A move preserves size and mtime, and both are already in the manifest and in the
stat. So the sync indexes what is already derived on that pair, and a file that
has moved has its ladder RENAMED across.

An ambiguous match is refused rather than guessed. Two originals can share a size
and an mtime — a camera dump can write a whole card in one second — and
rebuilding is merely slow where attaching the wrong photographs to a name is
wrong.

Orphans are removed afterwards, and only on a WHOLE-TREE sync. A scoped run knows
about one folder, so everything outside it looks abandoned; deleting on that
basis would take out the library the first time somebody re-synced a subfolder.
Safe otherwise because derived/ is reproducible by definition, which is what its
name has meant since these two folders stopped being called `user` and `treated`.

Both are counted, recorded in the ledger and shown only when they happened, so an
ordinary run stays one short line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:42:20 +01:00
pastilhasandClaude Opus 5 b634de5604 a Galleries section, above Media
Galleries become an entity rather than a list hanging off a page: a set of media
somebody has chosen and put in an order is a decision worth keeping once, not
remaking on every page that wants it.

Pinned with Media and above it, because the two are the same kind of thing — the
raw material the site is built from, held on this machine. Media is the files; a
gallery is a chosen, ordered subset of them.

`requires: 'root'`, not `'connection'`. A gallery is made of originals on this
disk, so it is reachable as soon as there is a root location. Gating it behind a
site connection it does not use is the bug that made that distinction necessary
in the first place, and Media already learned it.

Not built yet, so it renders the Planned card — the entity is the next piece and
this is where it will live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:36:10 +01:00
pastilhasandClaude Opus 5 fc7bc5b8cf Hero is chosen by browsing originals, not typed
Nobody knows the path of a file they uploaded from a phone, and a text box
invites a typo that fails silently as a broken image on the live site. Browsing
`originals/` means the only values that can be stored are ones that exist.

The modal is the platform's file browser in pick mode — the same listing,
breadcrumb, sorting and thumbnails as everywhere else, with one branch changed.

It browses ORIGINALS and the site renders DERIVED, and that indirection is the
point of the pipeline: what somebody picks is the .mov or the 12-megapixel HEIC
that came off the camera, and what a browser can display is the ladder av-club
built beside it. Storing a derived file directly would pin the site to one
rendition, so the day the ladder changes every stored reference names a file that
is no longer the right one to serve.

The path is stored relative to `originals/`. The root is a setting and moves
between machines, so an absolute path would be a fact about one installation
written into content that outlives it.

No pathParam and no panelId on the modal's browser: its folder is not something
to put in a URL or remember, and both would leak a transient choice into shared
state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:30:33 +01:00
pastilhasandClaude Opus 5 95d5661e73 Intro and Hero are their own sections, and Intro comes first
The page reads hero-then-intro and this deliberately does not. The intro is the
thing that gets edited; the hero is a path that changes once a season. Ordering
by what somebody came to do beats mirroring the layout of the page they are
describing.

One PATCH per card, carrying only that card's field. `take()` on the server
treats an absent key as leave-it-alone, so sending the whole object from either
card would have each one saving the other's unsaved edits — a half-finished
intro riding along with a hero somebody meant to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:24:00 +01:00
pastilhasandClaude Opus 5 d57bd3e72f a real editor for the site's copy, and the media param stops following you
Two things.

`?media=` is Media's own folder and now goes when you leave it. It survived
every navigation, so `?section=home&media=GALLERY/CALAVERA+1` was an ordinary
URL — a parameter naming a folder in a section that is not open, carried into
every link and shared or bookmarked that way.

And the intro is a tiptap editor rather than a textarea of raw HTML. HTML in and
HTML out, because that is already what the site's database holds and renders —
introducing a second format would buy a migration and a round-tripping bug class
for nothing.

Declared as the PLUGIN's npmDependencies, not added to the platform's
package.json. That lockfile is frozen and shared, and npm-deps.ts exists for
exactly this: everything lands in plugins/node_modules/, so a plugin's editor
cannot move a version the platform depends on.

The toolbar is only what the site's stylesheet can render. Anything more would
produce copy that looks right while editing and wrong when published, which the
person only discovers after saying it was done.

The content effect compares against getHTML() before writing back, and that
comparison is the whole thing: while somebody types the two already agree, so
nothing is pushed in and the caret stays put. Without it this is the classic
controlled-contentEditable bug where the cursor jumps to the end on every
keystroke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:15:55 +01:00
pastilhasandClaude Opus 5 3ae8c960e5 the Home section, built against the site's own admin API
Hero, intro and an ordered gallery — the shape the old htmx admin had, as a
panel here.

Every write returns the WHOLE payload and this never patches its own state from
what it sent. That matters more than it looks: the server clears the hero crop
when the hero changes, because a rectangle describing a region of a different
picture would crop the new one somewhere arbitrary. A client keeping its own
copy would go on showing a framing the server had already discarded.

Ordering sends every id rather than one item's position, which is what makes it
impossible to end up with two things claiming the same place.

Thumbnails load through this plugin's proxy rather than straight from the dev
server, so they arrive with the session instead of requiring the browser to
reach a port on the host.

It edits DEVELOPMENT, because production is a directory of static files with no
API to write to. An edit lands on dev immediately and becomes public when
somebody presses Build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:09:29 +01:00
pastilhasandClaude Opus 5 f0d29c8000 the site sections open, because there is finally something behind them
`ready` was hardcoded false for as long as the sections proxied to an address
nobody could set — the connection form was removed and nothing replaced it, so
nine doors led to the same "not connected" message.

Both halves exist now and neither is typed by a person: the development server
runs on a port this plugin chose, holding a FOFOS_API_TOKEN this plugin
generated and handed it at start. So the credentials are DERIVED rather than
stored. Asking somebody to copy a value out of one part of this plugin into
another was never a setting, it was a chore.

Development and not production, and that is not a preference. Production is a
directory of files behind nginx — no API, no database, nothing listening — so
editing against it is impossible rather than merely wrong. Sections read and
write dev; Build is what publishes. An edit is visible immediately and public
only when somebody decides it should be.

The gate asks whether the server ANSWERS, not whether pm2 says online. `bun
--hot` survives a crash at startup: the process stays up, pm2 reports online,
and nothing is listening. Opening nine sections onto that would make one failed
boot look like nine broken screens.

And when /status itself cannot be reached, `configured` stays true while `ready`
goes false. Telling somebody they have set nothing up is alarming and wrong;
opening nine sections onto a server we could not confirm is answering is nine
screens that each fail separately. Cannot-tell keeps them shut and lets the next
poll open them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:00:18 +01:00
pastilhasandClaude Opus 5 2153d4875b open the firewall to the proxy when the dev server starts
NPM runs in a container and crosses the docker bridge to reach host services;
ufw allows that subnet to named ports only. A development server on a port
nobody opened answers `curl 127.0.0.1` perfectly and is invisible to the proxy.

What that looks like is a browser timing out against a valid certificate, a
correct proxy host and a healthy process — with the cause written down in
exactly one place nobody thinks to read: nginx's own error log, "upstream timed
out while connecting to upstream: http://172.20.0.1:3011/". It cost an evening,
which is why this is code rather than a line in a README.

The panel is what chooses the port, so the panel is what opens it. A setting that
silently requires a second undocumented step elsewhere will be wrong most times
it is changed.

Opened BEFORE the process starts, so the port is reachable the moment it binds,
and idempotent by ufw's own behaviour — it runs on every start rather than only
the first, so a firewall reset repairs itself next time somebody presses Start.
Never fatal: a rule that could not be added is a line in the output and the
server still starts.

Closed when the port CHANGES, in savePorts, which is the only place that knows
both values. Not on stop — a stopped dev server is usually about to start again,
and a rule for a port nothing listens on grants nothing, while leaving it on
every change is how a firewall accumulates holes nobody remembers opening.

`sudo -n`, so a host without passwordless sudo fails rather than hanging on a
prompt nothing will answer. The port is an integer validated before it arrives
and every command is an argv array, so there is no shell and nothing to inject —
that is the whole boundary, and it is why the parameter is typed number rather
than read from a request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:51:07 +01:00
pastilhas 89adbe37ce the build needs a working directory, and its absence was silent
`bun run build:site` resolves the script from the package.json in the current
directory. The helper had no cwd parameter, so it ran in the platform's own
directory, which has no such script — and the error, "Script not found
build:site", reads as the site missing a script rather than as it being asked in
the wrong place.

Second time today a spawn helper quietly lacked something the caller needed: the
first was the environment for pm2. Both had the same shape — assembled correctly
and then not passed.

Verified by running the real build with the same environment the plugin builds:
29 entries in dist and an index.html with the site in it.
2026-08-29 19:42:09 +01:00
pastilhasandClaude Opus 5 27f93d6e14 production is a static build behind nginx, not a process
The site's own build script says it outright: "The public site is not served by
Bun in production — it is a directory of files behind nginx. Output: dist/."
So production has no port, nothing to restart, and nothing that can crash at
three in the morning. It is built or stale, and served or not served.

Showing it as a pm2 process was showing something that does not exist, and its
Start button could only ever have failed. It now has Build, Serve and Stop, and
the two states are reported separately — a build can succeed while nothing
serves it, and a container can serve a directory built weeks ago. They fail
independently and are fixed differently.

The container exists because nginx could not see the files. NPM mounts only its
own /data and /etc/letsencrypt, and there is no other nginx on the box. The
alternatives were putting site files inside NPM's own volume, or editing NPM's
compose and restarting it — which takes every site on this machine down for a
moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with
NO published port. NPM reaches it by name, and nothing about the site is exposed
except through the proxy.

Recreated rather than restarted, for the same reason pm2 is: a container keeps
the mounts it was created with, so a root location changed here would be saved,
displayed, and still serving the old directory.

Domains follows. Production's forward target is a container NAME, not the docker
gateway that reaches a host process — and matching what already points at it
uses the full target rather than the port, since every other site behind this
NPM on port 80 would otherwise be listed as fofos's.

The compiler found the dead branches this left in startServer once production
could no longer reach it. Removed rather than left to read as though the case
were still possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:36:29 +01:00
pastilhasandClaude Opus 5 752c06d24a save the pm2 list after starting or stopping a server
Without it, pm2 resurrects the list as it was the last time somebody happened to
run `pm2 save` — so a machine that reboots comes back without the site, and with
nothing to indicate anything was meant to be there. The systemd unit is enabled
on this host, so the resurrect definitely happens; the only question was what it
would restore.

After stopping too, not only after starting. The dump has to match reality in
both directions: a server stopped here and left in the dump comes back on its
own at the next reboot, which is the same bug pointing the other way.

A failed save does not fail the action — the process is already started or
stopped by then, and reporting "could not start" about something that did start
is worse than a line saying the list was not persisted. It is in the console
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:16:46 +01:00
pastilhasandClaude Opus 5 0a95647b8e once a server has a domain, stop asking for another
A form offering work nobody came to do. Both servers were pointed at, both had
certificates, and the block still showed two empty fields and two buttons — the
second address for one site is the rare case, and it lives in Proxy Manager with
the rest of the picture.

So the field appears only while that server has nothing, and the forward address
goes with them: once both are pointed somewhere it configures nothing.

`min-w-0` on the input, because a flex child refuses to shrink below its content
by default, which is what was pushing "Point here" off the edge of the card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:12:17 +01:00
pastilhasandClaude Opus 5 3e07ee38c2 a certificate request outlives the browser, so stop treating a timeout as failure
Issuing waits for a DNS record to propagate — two minutes before certbot even
looks — and the proxy in front of Officer gives up at sixty seconds with a 504
while certbot carries on and succeeds. So the only thing anybody sees is a
timeout, and underneath it the certificate exists and is attached to nothing.
That is exactly what happened: npm-7 was issued for
dev.fofosfluffybastards.com and the host still had ssl:0, because the request
died between issuing and attaching.

So a timeout is no longer a failure. The request is fired, and afterwards —
returned, failed or cut off — this asks what certificates exist and attaches the
one covering these names, polling for up to five minutes.

And it looks for an existing certificate BEFORE asking for anything. A retry
would otherwise spend a Let's Encrypt rate limit to obtain a duplicate of one
already on the disk, and those limits are per-week: getting it wrong means not
being able to fix it today. It also means the stuck host above is repaired by
pressing the same button.

Matching requires the certificate to cover EVERY name the host serves. One that
covers a single domain of two is not this host's certificate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:07:13 +01:00
pastilhasandClaude Opus 5 26b3ef2ce4 certificates go over the DNS challenge, using the key Officer already holds
The HTTP challenge proves a domain by being visited on port 80, which requires
the name to already resolve to this machine — which is exactly what is not true
at the moment somebody is setting one up. certbot then fails with "Some
challenges have failed" and a 500 that says nothing about why.

The DNS challenge has no such requirement: it proves ownership by writing a
record. That needs the registrar's API key, and this must not go looking for
one — Officer stores DNS provider keys encrypted and never returns them, to
anybody. /domains/certificate exists for precisely this: it works out which
provider owns the name from the Domains list, decrypts the key itself, and hands
back NPM's answer. The key is used and never seen.

Its refusals are translated rather than shown raw. "Not in the domain list" is a
true sentence that tells nobody what to do; which list, and who can add to it,
is the part that matters.

And the button says it is issuing, because a DNS record has to propagate before
Let's Encrypt will look for it — the wait is minutes, and a spinner sitting for
three of them with no explanation is indistinguishable from one that has hung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:02:36 +01:00
pastilhasandClaude Opus 5 a064a3d0d6 a Domains block, pointing NPM at the two servers
It talks to the proxy-manager PLUGIN, from the browser, with the user's own
session. Not to NPM, and not from this plugin's server: NPM's credential belongs
to proxy-manager and its skill is explicit that the pass-through route is the
sanctioned path. Calling it this way means this plugin never holds that
credential, and two plugins do not acquire a server-to-server dependency that
each would then have to know the installed state of.

forward_host is copied from what already works here. NPM runs in a container, so
127.0.0.1 there is NPM itself rather than this machine — a host pointed at it
resolves, serves nothing, and looks exactly like the site being down. The
correct value is the Docker gateway, which is a fact about this machine's
networking that neither this plugin nor a person should have to know. So the
default is taken by majority from the hosts NPM already serves, since those are
by construction addresses that work. It is shown in an editable field, because a
guess made from other people's configuration should be visible before it is used.

Creating is offered and deleting is not. A new host is additive and reversible;
removing or repointing one is a live domain changing where it goes, and this
panel would be offering that with a fraction of the context the Proxy Manager
screen has.

Certificates are two calls, because NPM issues one as its own object and a host
merely references it. The body is exactly {provider, domain_names} —
letsencrypt_email and letsencrypt_agree are what every tutorial shows and NPM
2.15 rejects the request for including them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:58:50 +01:00
pastilhasandClaude Opus 5 b27969ba57 Servers is a block in Administration, not a section of its own
It is the same KIND of thing as the root location and the databases — this
machine, not the site's content — and a nav row leading to one card was a whole
screen to reach three lines.

Last of the three, because it needs both of the others: a server has nowhere to
run without the checkout and nothing to read without a database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:53:39 +01:00
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 b564282609 the naming form is about naming, and it had been wired to the wrong question
Two conditions with similar names got conflated by a search-and-replace.
`settled` means the databases are named and exist, which is when the form has
done its job. `settledNow` means everything including BACKUPS is configured,
which is when the whole block can fold away. The form/rows branch was using the
second.

That deadlocked it. The component reporting whether backups are configured lives
inside the rows branch — so showing the form meant it never mounted, never
reported, and the form could never be replaced. The panel went back to asking
for names it already had, with no way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:39:15 +01:00
pastilhasandClaude Opus 5 51ccf55a3c stop the Database block flickering, properly this time
It switched to the live answer as soon as `status` arrived — while
`backupsReady` was still its initial `false`, which is a REPORT of not-ready
rather than the absence of one. So it decided the section was unfinished,
painted open, and shut again a moment later when the real answer came.

`backupsReady` is now null until BackupsRow has actually answered, and the live
value is used only once every local part has. Until then the summary decides,
which is what it was added for.

And nothing but the header renders until the summary lands. The title and hint
are constants and can paint immediately; only the check and the body wait on
data — the same shape Root location settled on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:35:02 +01:00
pastilhasandClaude Opus 5 af31b57cdf the databases say what is in them, and the block folds away when it is done
Two databases with identical structure look identical until you count what is in
them — one is the live site and one is a copy. So a row now reads "18 tables ·
155 rows" with the time anything was last written, rather than only reporting
that a schema exists.

Counted with query_to_xml over information_schema, which is exact. reltuples
from the planner would be free and is an ESTIMATE: it reads as zero on a table
nobody has analysed, which is precisely the freshly restored database this is
meant to reassure somebody about. That is not a hypothetical — it is why
pg_stat_user_tables reported an empty database earlier today that had 155 rows
in it.

And the block collapses with a check once everything is in order, on the same
terms as Root location: both databases existing with a schema, and backups
configured. Not backups having RUN — a site set up an hour ago has taken none,
and calling that incomplete is telling somebody to fix what is not broken.

Seeded from /summary so the first paint is already right, then kept live by what
the block and BackupsRow know. The `null` sentinel in `collapsed` is left alone
while unsettled, which is the bug that stopped Root location folding at all.

BackupsRow reports its bit from an EFFECT, not during render: calling the
parent's setter mid-render is an update to a different component, which React
refuses outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:32:42 +01:00
pastilhasandClaude Opus 5 fb234ede5d the contact inbox is a mailbox, and it is copied the moment it changes
Messages are the one thing that happens between publishes. A backup triggered by
publishing would never contain them, and a RESTORE would destroy every message
received since the dump being restored — turning a routine recovery into losing
the only copy of something a stranger sent, with nothing about the operation
looking like it had done that.

So they live in their own SQLite file, in the shape Officer's email plugin
already uses. Not a schema that fits a contact form: this IS mail — a from, a
subject, a date and a body wearing a form — and storing it as a mailbox means
the mail client can open it, and anybody's agent already knows how to read it
without being told. The form's own fields become labelled lines in text_body,
where a reader and a grep both find them; inventing columns would be the moment
it stopped being a mailbox.

This side watches the file and copies it within seconds of a write, to the local
backup directory and to the machine off this one. The alternative is a window: a
message at ten past nine and a disk that fails at twenty past is a message
nobody sees, while the sender believes they contacted you.

Two details are the whole reason this is not a `cp`. The WAL is checkpointed
first — the site writes in WAL mode, so a new message lives in messages.db-wal
and not yet in messages.db, and copying the main file alone yields a database
that is intact, readable, and missing exactly the message that triggered the
copy. And the DIRECTORY is watched rather than the file, because SQLite replaces
files during a checkpoint and a watch on a replaced inode goes quiet for good —
a watcher that silently stops is worse than none, since the panel would go on
implying the inbox is protected.

The copy is current, not history; the ordinary backup runs take a timestamped
one alongside the dumps, so a restored site can have the inbox that matches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:27:36 +01:00
pastilhasandClaude Opus 5 9ff25256c1 back up on publish, which is when this site actually changes
A nightly backup of a site that only changes when somebody publishes spends most
of its life copying a database identical to yesterday's, and misses the one
moment worth capturing. So "On publish" joins the schedules, and goes first.

Stored in the same column as a cron expression because it answers the same
question — WHEN — and a second column would mean every read had to decide which
of the two was authoritative.

It creates no cron job and no catch-up tick. A missed run is a run whose TIME
passed while the machine was down; an event has no time, so there is nothing to
have missed, and a catch-up here would take a backup every fifteen minutes of a
site nobody had touched.

`backupAfterPublish()` does nothing unless this is the configured trigger, so
the publish path can call it unconditionally and never has to know what the
backup settings say. It swallows failures for the same reason the timer does: a
backup that could not be taken must not present as a publish that failed, when
the thing the person asked for worked.

Nothing calls it yet — there is no publish in this plugin, because the sections
that would do the publishing are not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:19:22 +01:00
pastilhasandClaude Opus 5 89c9dd6f63 restore: pick a dump, pick a database, say yes
A pg_dump carries CREATE TABLE, so it cannot be layered onto a database that
already has the schema — it fails on every object. The only honest restore
empties the target first, and that is not a detail to bury behind a button. So
choosing a destination and confirming are two separate clicks, and the second
names what it is about to destroy.

`confirm` is required by the function, not merely by the panel, so no code path
can reach a restore by accident.

The file is matched against what listDumps actually found in the backup
directory rather than taken as given. There is no path to traverse out of and no
way to point psql at a file this module did not write: an unknown name is
refused before anything is opened, rather than sanitised and hoped about.

Emptied with DROP SCHEMA public CASCADE through psql, not by dropping the
DATABASE — that would need a connection to another one, fail while anything is
attached, and lose whatever was granted on it. Replacing the schema does the
same job to the part that matters.

psql rather than pg_restore, because these are plain-SQL dumps, which is what
pg_dump writes without -F c.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:11:57 +01:00
pastilhasandClaude Opus 5 c429dc3455 a remote path without a leading slash is relative to the remote home
Requiring an absolute one was simply wrong. `scp file host:db-backups/fofos/`
lands under the remote account's home — that is what every ssh tool does, and
what anybody typing a destination expects, since the home directory is the one
place they are certain to be able to write.

What is still refused is unchanged and is the part that matters: the characters
a shell would act on. This value IS interpreted remotely, since `ssh host
mkdir -p <path>` runs through a shell, so they are rejected rather than escaped
— refusing is checkable by reading the function, escaping is checkable only by
being right about every shell.

The summary shows a relative destination as ~/… because that is where it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:07:50 +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 ac3d8b76d9 every six hours, between hourly and daily
Anchored to the clock — midnight, six, noon, six — rather than to whenever the
schedule was saved, which is the whole reason these are cron expressions.

Verified the catch-up check reads its period as six hours, since that is what
decides whether a run was missed while the machine was down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:57:09 +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 bda59bdf03 one spinner per action, one button for both, and the shared console
Four things, all of them the panel telling a small lie.

A single `busy` boolean put BOTH rows into a spinner while only the pressed one
was doing anything — the other looked started, then finished without having done
a thing. It is now which action is running, so a row spins when it is the one
running or when both are.

Pushing both is the common case and was two clicks. It is one, running
production then development sequentially: they run the same drizzle against one
Postgres, and two at once buys nothing but an interleaved transcript.

The status is re-read after every push regardless of outcome. It was taken from
the route's reply, which only exists on the path where the push SUCCEEDED — a
failure answers 400 and throws, and a push that failed halfway can still have
changed the table count. That is the difference between the panel being right
and the panel being right when nothing went wrong.

And the output is the same console the checkout uses, extracted rather than
copied: black, copyable, gone a few seconds after a success and never after a
failure. WebsiteCheckout keeps its behaviour and loses the state that used to
implement it.

Restore stays disabled. What a backup IS here is still undecided, and a button
that guesses at the source of a restore does not belong near a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:47:58 +01:00
pastilhasandClaude Opus 5 f7df5f8b10 the plugin tells the site what its databases are called
Both names, on every push, whichever target it is. The site's guard checks that
the two environments expect DIFFERENT databases, so it needs both to do that at
all — and passing only the one being pushed to would leave the other falling
back to the name the repository shipped with, which is a comparison against
something nobody configured.

This makes the plugin the authority on what the site's databases are called,
which is the whole point of the change on the other side: the site stopped
hardcoding them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:42:24 +01:00