How it works
The three tenants, the rendering inversion the session guard depends on, and what happens between a keystroke and a commit.
You do not need this page to use the CMS. You need it when something behaves in a way that only makes sense once you know why it was built that way — and to decide whether you trust the thing writing to your repository.
Three tenants, one direction
host site → src/admin/ → src/components/primitives/
The host site owns src/pages/, src/content/, src/content.config.ts, src/actions/index.ts, src/admin.config.ts and src/site.domain.ts.
The admin package is all of src/admin/. It may import exactly seven things outside itself, and exactly four host files may name it. Both counts are asserted on every test run, in both directions.
The UI library is src/components/primitives/ and src/components/svg/. It knows nothing about the admin at all — a rule enforced by a scanner rather than by convention.
Dependencies run one way and never backwards. That is what makes the adoption cost a number you can read off a test run rather than a feeling.
The rendering inversion
output: 'server' makes on demand the default, and your public pages opt back out with export const prerender = true.
This is the load-bearing decision of the whole thing. Middleware runs at request time only for on-demand routes; for a prerendered route it runs at build time and never sees a visitor. Under output: 'static', an admin screen that forgot its prerender export would ship its HTML — drafts included — straight past the session guard, and look perfect doing it.
Two build-time hooks refuse both directions of that mistake, and both read Astro’s resolved route configuration rather than scanning source text, because a commented-out export is invisible to a regex.
The guard
A request to /admin/** hits the injected middleware first, which asks one question and gets one of three answers: open, screen, or action.
A missing session redirects a browser and returns a bare 401 to a fetch(). Those have to differ — redirecting a fetch hands JavaScript a page of sign-in HTML and it has no idea what happened.
The guard also covers /_actions/**, which is outside /admin/. A guard that only knew about /admin/ would wave the entire write path through.
And a valid session is not sufficient: the account is re-read from the store on every guarded request, so a demotion lands on the demoted person’s next request rather than at their next sign-in. That costs about 0.45 ms — measured against 12.6 ms for a single git status spawn.
From keystroke to commit
Opening an entry reads the real file off disk. Frontmatter is split with YAML’s document mode so comments and key order survive; the body is parsed into blocks, each keeping the source span it came from.
Editing happens in the browser through one pure command function. The DOM is read back rather than the keystroke being modelled, which is why pasting, input methods and browser autocorrect all behave.
Saving posts the changed frontmatter keys and, if you touched it, the body as markdown text. The server hashes the file’s bytes, compares against the hash taken when you opened it, and refuses rather than overwriting if they differ. Then it writes and commits.
The read-hash-compare-write sequence contains no await, which is what makes two racing saves strictly ordered and means the write needs no queue. Only the commit is queued.
Untouched bytes are re-emitted, never regenerated. Remark’s round trip is provably not byte-identical, so without spans every save would produce cosmetic churn around your actual edit.
The git service
Exactly one module spawns git, through a single invocation point, with argument arrays and never a shell — author names come from a sign-up form.
Reads run immediately. Writes serialise on two lanes: one for the index, one for the remote. That split was measured over 200 rounds each — commit alongside push produced zero failures, but push alongside push produced 200 out of 200 cannot lock ref.
Failures are typed rather than parsed out of messages: thirteen values, matched by an ordered table. no-commits, no-path and no-revision are three separate values specifically so that no catch can widen into another.
Path containment rejects .. rather than resolving it, and runs before the write rather than only inside the commit — the write is the danger.
Why there is no token dialog
There used to be a plan for a fine-grained PAT held in memory, because there was no server to keep one. There is one now: the CMS is a long-lived Node process with a real checkout, so a save is git commit on that checkout and the session you signed in with is the authorisation.
A token appears once more, later and elsewhere — pushing needs a credential in the server’s environment, which is a deployment setting rather than something anyone pastes into a dialog.
Where the fixtures went
Until part-way through development, the most important thing to know before reading a screen was which half of it was fake. That half is gone: all six stand-in modules were rewired onto the git service and the accounts store. The team roster is the accounts store unioned with its invites, the commit history is git log, the review queue is a real diff, Settings saves, and the image library is a filesystem scan plus a git grep.
What replaced that distinction is when an export is evaluated. A constant is a constant; a read is an async function of the request, because the admin is a long-lived server and a module-scope await freezes at boot. One module still owes that migration, which is why a relative date on a collection list can be measured from server boot rather than from your request.
The rules that fail loudly
Wherever an architectural rule exists here, there is a scanner test or a build hook that fails when it breaks — the package boundary counted both ways, the layering rule, the rendering split in both directions, the version stated in two files that cannot disagree, and the icon copy pinned byte-for-byte.
The house position is that a rule which is only written down regresses silently. That is also why the CMS refuses things rather than guessing: an unmodellable page opens read-only instead of being rewritten, an unreadable Zod field renders read-only with its reason instead of being coerced, and an unreachable database throws instead of falling back to files.
Related
Mounting for the rendering rule in practice, Review and publish for the publish gate, Commands and checks for how to verify any of this yourself.