Every save is a pull request: a visual, PR-gated CMS for an Astro site
The previous post about site.noprofits.org
described a static Astro site with no server, no
database, and a Google Sheet for a backend. That architecture has one obvious
cost: changing a headline means editing an .astro source file, and the
people who should be able to fix a typo — future volunteer editors of a
nonprofit’s site — should not need a code editor, a git tutorial, or commit
rights to do it.
Last night the site grew a content management system (CMS). It is not a conventional one. There is still no database: the git repository is the content store, the CMS’s write primitive is a git commit, and its publish primitive is a pull request (PR) that a human must merge. The editor itself renders the site as a node map — one node per page, one edge per internal hyperlink — and clicking a node opens every piece of text on that page for editing. The whole thing is about two thousand lines with zero runtime dependencies beyond the platform SDKs, it costs roughly nothing to run, and its security model does not require trusting the editor, the editor UI, or even the CMS’s own GitHub credential.
The source is public in
noprofits-web-engineering:
the engine and local editor under
tools/site-editor/,
the hosted variant under
functions/,
and the continuous integration (CI) gate in
scripts/verify-content-pr.mjs.
This post documents how it works, what the design buys, what it costs, and
what I would build differently.
The shape of the thing
There are two variants sharing one engine. The local editor
(npm run edit) is a single plain-Node server on 127.0.0.1:4400 that reads
the working tree and writes edits straight back to disk — a solo dev tool;
the normal git workflow reviews the changes. The hosted editor is one
Cloud Function on Firebase that serves the same user interface behind
Google sign-in, and instead of a filesystem it reads and writes the GitHub
repository through the API. That is the variant a non-technical editor uses,
and it is where the interesting security problems live.
The publish pipeline for the hosted variant looks like this:
Figure 1. The publish pipeline. Saves flow left to right into a
cms/edits branch and an open pull request; publishing flows right to left
only through a green CI check and a human merge. The deploy workflow’s
Workload Identity Federation (WIF) trust is pinned to main, so the CMS
branch — and therefore the CMS itself — physically cannot deploy.
Two properties fall out of this shape. First, the CMS has no publish button:
merging the PR is publishing, because the existing deploy workflow ships
main to Firebase Hosting on every push. Second, the blast radius of a fully
compromised CMS is a noisy branch and an unmerged PR — annoying, visible,
and inert.
What the editor edits: four kinds of text block
The engine
(extract.mjs,
about five hundred lines, no dependencies) parses each .astro page into a
flat list of editable text blocks, each with the exact byte span it came
from. Copy lives in four places on this site, and each becomes a block kind:
Figure 2. Block anatomy of a miniature page. The engine records the exact source span of every highlighted region; everything outside a highlight — markup, expressions, structure — is not editable and is never touched by a save.
The four kinds:
fm— frontmatter string constants (const title = '…'and friends), the strings that flow into<title>, meta descriptions, and Open Graph tags. Only plain string literals qualify; a const built with template interpolation is skipped rather than half-edited.text— ordinary HTML text nodes in the template body.attr— human-readable quoted attributes (alt,placeholder,aria-label,title) and string-literal component props (a call-to-action band’sheading={`…`}, for example).shtml— text nodes insideset:html={"…"}string payloads, which is how some longer guide pages carry their body copy. The engine decodes the JavaScript string, scans the HTML inside it, and edits text nodes within the payload.
Saving splices each changed block back at its recorded offset,
highest-offset-first so earlier spans stay valid, and re-encodes the new text
for its context. Unchanged blocks contribute zero bytes of change: saving a
page without edits leaves the file byte-identical, which matters later when a
CI job has to reason about diffs. A detail I like: the engine detects whether
each block already used named HTML entities (—) or literal Unicode
punctuation (—) and keeps whatever convention it found, so an edit doesn’t
churn the file’s style.
The scanner knows just enough Astro to be safe: it skips <script>/<style>
elements, balances {…} expressions (quote- and template-literal-aware, so a
stray > inside an arrow function doesn’t end a tag), and refuses anything
it cannot round-trip exactly — a template literal with ${…} interpolation
returns “not editable,” not a guess.
The character that keeps you honest: {
Every CMS has to answer “what if the editor types <script>?” — entity-encode
< > & and the answer is boring, the payload renders as visible text. Astro
adds a much better trap. Inside an Astro template, {expression} is
JavaScript evaluated at build time. If saved copy could smuggle a raw
brace into a template, the payload would not run in a visitor’s browser — it
would run inside the CI build, with the build’s credentials. A content
editor typing curly braces is one build away from remote code execution (RCE)
in the deploy pipeline.
So the engine’s encoder treats braces as mandatory targets alongside the
HTML-significant characters: every { and } in an HTML-context block is
written as {/}, which renders identically and evaluates never.
Frontmatter strings get the mirror-image treatment for their context — angle
brackets are written as \u003c/\u003e escapes inside the JavaScript
string literal, so a title like </script><script>… cannot break out of a
<title> tag or a JSON-LD block no matter where that string is later
rendered. Attribute blocks encode their own quote character; template-literal
props escape backticks and ${.
None of this is taken on faith:
security.test.mjs
feeds hostile payloads — script tags, brace expressions, attribute breakouts,
</script> title escapes, "} payload escapes — through every block kind,
splices them, and then runs a real astro build over the result,
asserting that the built HTML contains zero live script tags and no evaluated
expressions. The test suite attacks the actual encoder against the actual
compiler, not a model of it.
The hosted variant: auth in one function
The hosted editor is a single Cloud Function
(functions/index.js)
that does three jobs: serve the admin page, enforce authentication, and proxy
saves to GitHub. The auth chain is deliberately short. Every API call must
carry a Firebase ID token from Google sign-in; the token is verified
server-side; the account’s email must be verified and present on a
comma-separated allowlist in the function’s environment. The allowlist is
fail-closed — empty means nobody can edit. There are no cookies (so
cross-site request forgery has nothing to ride) and no CORS headers (so
cross-origin JavaScript cannot call the API at all).
The function’s GitHub credential is a personal access token (PAT) for a
dedicated non-admin machine account with write access to this one
repository. It lives in Google Secret Manager and never reaches the browser.
The non-admin part is load-bearing: GitHub branch protection exempts
administrators by default, so an admin-owned token could push straight to
main and quietly defeat the whole PR gate. (A fine-grained PAT would have
been nicer than a classic one, but fine-grained PATs cannot be granted write
access to a repository owned by a different personal account — a limitation
that only surfaces when the write fails, because reads on a public repo
appear to work.)
Saves are also guarded against a mundane failure: every save carries the blob SHA (Secure Hash Algorithm digest) of the file version the editor loaded, and GitHub rejects the write atomically if the file has moved on — two editors cannot silently clobber each other; the loser gets “reload.”
The CI gate: reproducibility as authorization
Here is the part of the design I would defend in any review. Everything above
— auth, allowlist, encoding — assumes the CMS is working as intended. The CI
gate assumes it is not. Suppose the PAT is stolen, or the function itself is
compromised: the attacker can now push arbitrary commits to cms/edits and
dress them up as innocent copy edits waiting for review. A tired human
skimming a diff at the end of the day is the only remaining control, and
“tired human skims diff” is not a security boundary.
So content-pr.yml
runs on every cms/* pull request and re-derives the entire diff from
scratch:
Figure 3. The content-PR gate. CI extracts blocks from both versions of every changed file, requires the block skeleton (count, kinds, labels, order) to be identical, replays the text differences through the same splice engine, and fails unless the result reproduces the PR’s version of the file byte for byte.
The rule this enforces: a cms/* PR may only contain changes that are
reproducible as pure text-block edits on routed pages. Changed a workflow
file? Fail — only src/pages/*.astro is allowed. Added a component, an
attribute, an import? Fail — the block skeleton changed. Slipped one byte
outside an editable span? Fail — the replay will not reproduce it. The same
workflow also re-runs the adversarial security suite and a full site build,
so a content PR that would break the build is caught before review rather
than after merge.
I think of this as reproducibility as authorization: the credential authorizes writing to a branch, but the only thing that can pass review automation is the subset of writes the engine could have produced. A stolen token keeps the ability to make noise and loses the ability to disguise code as content. The defense does not depend on the function being honest, because CI re-checks the claim from nothing but the diff.
The layers stack like this — each one assumes everything above it has already failed:
Figure 4. The layered model, outermost first. A malicious editor beats
layer one by being on the allowlist; the encoder stops their payload; a
stolen token bypasses the encoder but not the CI proof; a fooled reviewer is
still merging something CI screamed about; and even then, only main
deploys. Every layer has to fail before malicious code reaches the site.
The residual risks are worth naming because no diagram removes them: an allowlisted editor can still write misleading text — no encoder prevents lies, and the PR review is the only control; the reviewer’s GitHub account is the real trust anchor and needs two-factor authentication; and on a public repository, every edit is a public commit with the editor’s name on it — transparency, but tell your editors before they find out.
What this buys, and what it costs
The strengths are mostly consequences of refusing to add infrastructure. There is no CMS database to secure, back up, or drift out of sync with the repo — git is the content store, so history, attribution, rollback, and review come free. The engine has zero runtime dependencies, the function fits in the free tier at this usage volume, and the entire system is small enough to read in a sitting. And the security story degrades loudly rather than silently: every failure mode ends in an unmerged PR with a red check, in public.
The costs are just as structural, and worth stating plainly:
- It is a hand-rolled parser, not the Astro compiler. The scanner handles the authoring patterns this repository actually uses. An unfamiliar pattern is skipped (safe, but the text becomes invisible to editors) or, worse, could be mis-spanned. Brittleness scales with template creativity.
- Text only, structure frozen. Editors cannot add a paragraph, reorder sections, insert an image, create a page, or change a link. This is not a missing feature so much as the security model itself: the CI gate requires an identical block skeleton, so the feature ceiling and the safety proof are the same fact. Lifting one means redesigning the other.
- No preview. An editor saves into a pull request sight unseen; nothing shows them the rendered page before a human merges. For copy tweaks this is tolerable; for anything layout-adjacent it is faith-based editing.
- One shared
cms/editsbranch. All editors’ pending work piles into a single PR. One rejected edit holds every other edit hostage until someone untangles the branch. - Content lives inside code files. All the escaping machinery in this post exists because copy sits in the middle of executable templates. The entire threat model in Figure 4 is rent paid on that decision.
Ways to get the same result with fewer drawbacks
Three alternatives are worth taking seriously, in increasing order of ambition.
Use the real parser. Astro publishes its compiler
(@astrojs/compiler, the same
WebAssembly module the build uses) with a full abstract syntax tree (AST)
API. Swapping the hand-rolled scanner for compiler-produced spans keeps the
node map, the splice-back model, and the CI gate exactly as they are, while
deleting the class of “authoring style the regex didn’t anticipate” bugs.
This is the cheapest structural upgrade available.
Move the copy out of the templates. Astro’s content collections exist to separate content from presentation: put page copy in Markdown, JavaScript Object Notation (JSON), or YAML data files and have templates render it. Then a content edit touches only data files — trivially reviewable by construction, no brace-encoding required, and the CI gate shrinks from “replay the splice engine” to “only these paths, only these schemas.” It also unlocks adding and reordering content, which the current model structurally cannot allow. The cost is a real refactor of every page, and losing the “edit exactly what is in the template” property that makes the current tool honest.
Use an off-the-shelf git-backed CMS. Decap,
TinaCMS, Sveltia,
and Pages CMS all implement the same publish
philosophy — commits and PRs, no CMS database — with mature editing UIs, and
most support per-editor GitHub OAuth, which dissolves the shared-PAT threat
class entirely (every edit is authored by the editor’s own account, with the
editor’s own permissions). What none of them will do is edit text inside
.astro templates: they all assume content collections. So this alternative
is really the previous one plus a free editing interface — and it gives up
the node map, which is, honestly, the part people smile at.
The pattern across all three: the current design spends its complexity defending a decision (copy inside templates) that could instead be unmade. Whether that trade is worth it depends on whether “editors see exactly the page structure that exists” matters more than “editors can add a paragraph.”
What’s next
The plan is foundations before features, because the two most-wanted features
are both blocked by foundations. Swapping the scanner for the Astro
compiler’s AST removes the brittleness ceiling without changing the user
experience. Per-save branches (cms/edit-<id> instead of one shared branch)
plus Firebase preview channels would fix the pileup problem and the
no-preview problem in one move — every save becomes its own PR with its own
throwaway preview URL. After that comes the interesting design problem:
letting editors add or remove whole blocks within a whitelist, which means
teaching the CI gate to verify skeleton transitions instead of skeleton
identity — loosening the proof without losing it.
The longer arc: nothing in this design is specific to this site. The engine, the function, and the workflow are a weekend away from being a template — a drop-in, self-hosted, PR-gated visual CMS for any Astro repository, with “your repo, a free Firebase project, and branch protection” as the entire requirements list. That seems worth building in the open.
Glossary
- allowlist — an explicit list of permitted identities; everything not on it is denied. The CMS allowlist is email addresses, and empty means nobody.
- AST (abstract syntax tree) — the structured representation of source code a parser produces; what the Astro compiler exposes and the hand-rolled scanner approximates.
- Astro — the static site generator that builds the site; its templates
evaluate
{…}expressions as JavaScript at build time. - blob SHA — GitHub’s content hash for a file version; sending it with a write makes the write atomic (the API rejects stale updates).
- branch protection — GitHub repository rules requiring a pull request
and approval before
maincan change. Exempts admins unless told otherwise — which is why the CMS token belongs to a non-admin account. - CI (continuous integration) — automation that runs on every push or pull request; here, the content gate, the security suite, and a full build.
- CMS (content management system) — software that lets people edit site content without editing code. This one’s storage backend is git itself.
- CORS (Cross-Origin Resource Sharing) — headers that let a browser page on one origin call an API on another. The CMS emits none, so no other origin can call it from a browser.
- CSP (Content Security Policy) — a response header restricting what a page may load and run. The admin page pins its one inline script by hash.
- JSON-LD (JavaScript Object Notation for Linked Data) — structured
metadata embedded in a
<script>block; one of the sinks the frontmatter escaping protects. - PAT (personal access token) — a GitHub API credential. The CMS’s PAT belongs to a dedicated non-admin machine account and lives in Secret Manager.
- PR (pull request) — GitHub’s proposed-change review unit. In this CMS, the PR is the publish mechanism: merging it deploys.
- RCE (remote code execution) — an attacker running arbitrary code on a
system. Here the risk lives in CI, via Astro’s build-time
{…}evaluation — which is why braces are always entity-encoded. - Secret Manager — Google Cloud’s secret store; holds the GitHub token so it never appears in code, config, or the browser.
- SHA (Secure Hash Algorithm) — a cryptographic hash function family; used for GitHub blob identity and for pinning the admin page’s inline script in its CSP.
- splice — replacing an exact byte range of a file with new bytes, leaving everything outside the range untouched.
- WIF (Workload Identity Federation) — keyless deploy auth: GitHub
Actions proves its identity to Google Cloud and receives short-lived
credentials, with trust configured for
mainonly.