This fall I built and now run a civic-action publishing site. It's small: a handful of posts a day, a few banners, one editor. It's also the kind of site where a wrong post at the wrong hour does real harm, and where somebody may well try to get in. So most of the work went into the parts nobody sees. This is a tour of those parts and why each one is there.
Signing in: passkeys, and nothing else
There's no password to steal, because there isn't one. Editing goes through a self-hosted OpenID Connect provider that takes passkeys only. The editor tool doesn't trust a name or an email address. It binds the one editor to an exact issuer and subject pair, so a second account on the same provider, or an account with the same email on a different one, gets nothing.
Nothing is published until it's approved
Posts are written in my own data engine, which runs on a box I control. Every post has one switch: publish. Untick it and the post stays a draft. Nothing reaches the public site unless that switch is on.
What "published" means is defined in exactly one place: a database view that picks the approved rows and only the public fields. The view's definition is pinned by its hash. If anyone changes it, through a bad migration or a well-meant edit, the install refuses to go ahead.
A publisher that won't wipe the site
The publisher pushes a complete snapshot of everything approved, and the site replaces what it has with that snapshot. It also means a push from an empty or half-loaded store would erase the site.
So it starts in dry mode. It does all the work, reports what it would send, and sends nothing. Going live is a separate, deliberate change. Even then it refuses if the public site is showing any post or banner the store doesn't hold. On launch day the site was carrying time-sensitive posts loaded by hand. Tested against the live site, the go-live check found every one of them missing from the store and refused.
After the first live run, the publisher doesn't call it done because the request returned 200. It reads the public feed back. The feed has to be fresh, carry no more posts than were approved, and still carry every post it served before, unless that post has expired.
Every number says what it counted
Every status line in this system names its scope and its source: "5 posts on the public feed, 0 of them missing from the store," not "looks good." Logs carry counts, never content. It's a habit from audit work: a number without a scope can't be checked.
Secrets live with root
The publishing token sits in a root-only file. A small wrapper hands it to the one process that needs it, so it's never on a command line, never in a log, and never in a repository. Proving the token works is its own step, and it publishes nothing. It sends an empty request that the site refuses only after the token has been accepted.
Two reviewers, frozen sets, and a rollback that checks first
Nothing gets installed on that box without review. Every install, rollback or flip is written as a script with gates. It's frozen by hash and reviewed by two independent reviewers, and it runs only once both say yes. Any change after review means a new version and a new review.
That sounds heavy. Here's what it caught in one day:
- A rollback that put the old program back but not the old database. By then the new program had already upgraded the database, so the old one would have refused to start. The fix: one restore routine, used by the rollback and by every failure path, so the two can't drift.
- A gate that compared a value which changes on every restart. It failed a good install; the rollback worked.
- The wipe risk above. One reviewer approved the go-live script and the other caught it. That's why there are two.
Fixes that came out of review
While wiring up sign-in, review found that my data engine answered differently for a screen that doesn't exist ("no such form") and a screen that exists but is closed to you (forbidden). A stranger could map every screen name just by asking. We closed it so a stranger gets one identical refusal either way. The second review found the same leak one step further in: a signed-in user with no useful role still got different answers. Now anyone who can't edit the structure gets the same answer, byte for byte. Only an author learns names.
What I'd tell anyone building something like it
None of this is clever. Passkeys, a single approval switch, a hash on what's published, a dry run, a go-live check that reads the real site, two reviewers. Each is a page of code at most. What took the work was deciding, ahead of time, that every one of them was required. On a site like this, those parts are the product.