Enabling Editors to Use Git Without Knowing

Updated on 13 October 2026. The translator’s 0.2.0 release tightened the security model, and the mechanism sections below now describe that release: signed OAuth state, claims read from Google’s id_token, a sign-in gate on the editor list, a default-deny route table, parsed GraphQL, per-repo write roots and 8-hour sessions.
The school my kids attend was migrating off WordPress, and the editor flow became the part I spent the most time on. The old site was a plugin-heavy monolith that a small school with no IT staff is the wrong place to be running. The migration goal was something more maintainable and less concerning from a supply-chain standpoint: fewer moving parts, fewer auto-updating dependencies in the request path, content versioned in git where every change has a name attached. The technical asks were modest: let educators write blog posts and update classroom pages without involving a developer for each change. The site itself is a few-thousand-line Astro project on Cloudflare Pages.
The standard advice is: pick a git-backed CMS (Decap, Sveltia, Tina), wire its OAuth proxy to the git host, every editor gets an account on the git host, every save is a commit by that editor.
That advice doesn’t work here. Most of the school’s educators are in Google Workspace, which is where their email, calendar, and shared drive live. They don’t have GitLab accounts. Asking them to create one, manage credentials, accept invitations to the project, and remember which password is which is a non-starter. So is paying for a hosted CMS like Sanity or Contentful, partly on cost and partly because the editor UI lives on a third-party domain that won’t survive a vendor change. The school wants control of where the words live and who’s allowed to change them, and the engineering for it has to be cheap, frictionless for non-technical editors, and simple enough that the next volunteer can maintain it.
What we wanted was a fourth path that the canonical tutorials don’t describe.
The mismatch
The default CMS auth model assumes a 1:1 mapping between editor and git-host user. The OAuth flow goes editor → git host (via a relay), and the editor ends up holding a token tied to a real account on the host. Every commit is authored by that account; permissions come from the host’s project membership. Clean, well-understood, and exactly wrong for our shape.
One editor, one account is the default: four editors mean four seats at the git host, four invitations, four passwords to remember. Translated identity breaks the mapping by putting a translator in the middle that verifies who the editor is, so the same four editors cost a single bot seat.
What we needed was something more like:
- Editors sign in with our identity (the school’s Google Workspace), and the system verifies they’re from the school’s domain and on the editor list.
- A single bot account at the git host holds the only credential that ever touches the API.
- Commits show the editor’s name and email in
git log, even though the bot is the agent that recorded them. - Per-editor write rules (“teachers can save blog posts; admins can save anything”) live on our side, not as git-host project membership.
I went looking for prior art. The two patterns closest in shape are vencax/netlify-cms-github-oauth-provider (the canonical OAuth provider for Decap CMS) and sveltia/sveltia-cms-auth. Both relay the OAuth dance to a git host. Neither does the identity translation we needed: Decap’s callback.js returns the editor’s own git-host token verbatim to the CMS, and Sveltia’s handleCallback does the same. Neither has an authorization hook, a token-swap callback, or an author-injection point. They’re built for the 1:1 model.
The pattern
I’ll call it translated identity. Editor identity in (verified by an OIDC IdP your org already runs); attributed git history out (without the editor ever touching the git host). The translation happens in a small relay we wrote as a Cloudflare Worker. The deployed instance at the school is private; the publishable distillation lives at andrewdunndev/sveltia-identity-translator and is what this post is really about.
One worker, five jobs. Google proves the editor belongs to the school’s domain, the translator checks they are on the editor list and mints an 8-hour JWT of its own; every save then comes back to the same worker, where the REST proxy matches the call against a fixed route table, checks the write allowlist and swaps in the bot token, GraphQL is parsed and only Sveltia’s read queries pass, and the identity endpoints answer from the JWT without ever calling the git host.
The translator does five jobs. Each is a paragraph on its own; each has a “why this way” that turns out to matter.
1. OAuth handshake
OAuth 2.0’s authorization-code flow is the standard “redirect the user to the IdP, the IdP redirects them back with a one-shot code, the server exchanges the code for an access token” dance. The translator implements the server half. The state it sends to Google is signed and bound to a PKCE verifier in a __Host- cookie, so the callback accepts only a round trip the same browser started, and the session token goes back only to the site origin recorded in that state. The other thing to know is that the hd claim is checked twice.
hd is Google’s hosted-domain claim, the field marking which Workspace domain the user belongs to. The translator includes hd=example.org as a query param at authorize time, which scopes Google’s account chooser to accounts in that domain, and verifies the same field on the id_token Google returns with the code, after checking that token’s issuer, audience and expiry. Those two checks are not equivalent. The URL parameter is a UI hint; a user with both a personal @gmail.com and a @example.org account can still pick the personal one. The id_token claim is the boundary. Removing the URL hint is a UX regression. Removing the id_token check is a security bug. Passing it is still not enough on its own: the verified email also has to appear in editors.yml, or no session is minted. Everyone in the domain can sign in to Google; only the people on the list get a token.
2. JWT mint
JSON Web Tokens are signed, base64’d blobs carrying claims. The translator mints an 8-hour HS256 (HMAC with SHA-256) token after a successful OAuth handshake, signed with a server-side secret. HS256 is symmetric, which would be a problem if the verifier were a different service from the issuer; it isn’t here. There’s exactly one issuer and one verifier (the same Worker), so the public-key distribution problem that motivates RS256 doesn’t exist. The token carries sub, email, and name, enough for everything downstream.
3. REST proxy
Every API call from Sveltia goes through /gitlab/*. The Worker validates the JWT, matches the call against a route table of the requests Sveltia makes (anything else is refused, and query parameters the route does not list are dropped), checks the write allowlist on commits, swaps the editor’s JWT for the service-account Personal Access Token (PAT), and forwards. A PAT is GitLab’s API credential tied to a user account; the leverage of the translator is that one bot service-account holds one PAT, and every editor’s request flows through it server-side. Editors never touch the PAT or the bot account.
On commit POSTs, the translator injects author_name and author_email into the request body so the editor’s identity survives onto the commit. This is the trick. More on it in the next section.
4. GraphQL passthrough, read-only
Sveltia uses GraphQL for all reads (file tree, blobs, default branch) and REST for writes. The upstream comment in commits.js is explicit: “the commitCreate GraphQL mutation is broken and images cannot be uploaded properly, so we use the REST API instead.” The translator parses each query and forwards only the read shapes Sveltia’s GitLab backend sends (root ref, tree, blobs, and so on) against a repo the editor is listed for. A mutation, a field outside that set, or a query that reaches another project gets a 403, on the principle of failing closed: when an input is ambiguous or unrecognized, deny rather than allow.
5. Synthesized identity endpoints
Two GitLab API endpoints don’t get forwarded; the translator answers them from the JWT directly. GET /user returns the editor’s identity (the PAT’s userinfo would be the bot, which is wrong). GET /projects/<repo>/users?search=... is the access probe Sveltia has sent since 0.179, and the translator answers it with the editor’s synthesized identity (the older /members/all/0 probe is answered the same way). Both exist because Sveltia checks repo access by user id after fetching /user; with our synthesized id: 0, the upstream calls would 404 or name the bot. These are mechanical requirements of the model.
Authorization is a pluggable hook: authorize(identity, repo, method, path) → bool. The default impl is a YAML allowlist (editors.yml) checked into the relay’s repo with per-(email, repo, path-glob) tuples, plus a repos: section that pins the one branch each repo’s commits may target and declares its write_roots: the subtrees a save may touch and the file extensions allowed in each. Default-deny throughout. A write passes only when it lands inside a root, carries one of that root’s extensions and matches the editor’s own glob; a repo with no roots accepts no writes from anyone, admins included. A teacher whose globs are src/content/blog/** can save blog posts, can’t save anything else, can read what Sveltia exposes in the UI. The hook shape is what matters; the YAML is one implementation.
The unlock: author and committer
Git tracks two identities per commit: author (who wrote the change) and committer (who recorded it). Usually they’re the same person. Translated identity uses the split deliberately: editor as author, bot as committer. git log shows who wrote what; the bot is just the agent that did the API call.
The split identity is what keeps attribution honest. The translator injects author_name and author_email from the JWT claims, so git log names the editor who wrote the change, while committer stays the service account whose token made the call.
The mechanic that makes this work is a GitLab REST endpoint quirk: POST /repository/commits accepts top-level author_name and author_email fields that override what the calling user would otherwise be. The translator slips those fields into the body before forwarding. GitLab honors them on the commit object; committer defaults to the PAT owner.
GraphQL’s commitCreate mutation has no equivalent override field. Commits made via GraphQL attribute to the authenticated user, period. That’s why the GraphQL handler is read-only: routing writes through it would silently break editor attribution. Forwarding only Sveltia’s read queries keeps the model honest until upstream commitCreate gains an author override.
If GraphQL ever does grow that override, the translator loosens. Until then, REST-only writes is the price of correct attribution.
What’s load-bearing
A few non-obvious choices that took thinking-through and aren’t derivable from “it’s a proxy.”
Why one Worker for many sites. The relay is multi-tenant by construction. A second consumer site (a sister blog, a documentation site, anything Sveltia-driven) is a Sveltia config drop, its origin added to ALLOWED_SITES, and a repos: entry plus editor entries in editors.yml. No new Worker, no new domain, no new secrets.
One Worker fronts every site. A second site is a Sveltia config drop, an origin in ALLOWED_SITES and a few lines in editors.yml, with no new Worker, domain, or secret, and write access stays scoped per identity, repo, and path.
An in-repo proxy (one Worker per site) is per-site by construction; every new site doubles the auth surface to operate, monitor, and rotate secrets across. The relay keeps the auth surface consolidated. The boundary is per-(identity, repo, path-glob), so cross-site access is explicit: editor@example.org lists each repo separately, and a typo in one entry can’t accidentally unlock a different site.
Why JWT is 8h with no refresh. Long enough that an editor’s saving session never re-authenticates mid-flight. Short enough that a stolen JWT expires within a school day.
Refresh-token machinery would gain rotation but cost code and a flow we don’t need at this scale. Force-revoke all sessions by rotating JWT_SECRET (wrangler secret put JWT_SECRET).
Why editors.yml lives in the relay’s repo, not a database. Versioning the allowlist in git gives blame, review, and rollback for free. Adding an editor is a commit; CI runs and any parse error surfaces before deploy. There’s no separate database to back up, schema-migrate, or authenticate against. The deploying project hands the file’s text to createWorker({ editors }), which parses it once at startup and compiles the globs; a deployment is then a wrangler.toml, an editors.yml and a few lines of index.ts, with the translator as a dependency. Per-request authorization is a synchronous map lookup + regex test on already-compiled patterns.
What didn’t work
Per-collection visibility doesn’t exist. Sveltia’s config.yml is global: every signed-in editor sees the same collections. We labelled the admin-only collections (Administrative) as a UX hint, but the actual gate is the relay’s allowlist. A teacher who clicks into Staff Bios and tries to save gets a 403 from the relay. That’s a confusing UI. The local workaround is multiple admin paths (/admin/teacher/, /admin/admin/, each with its own scoped config.yml). The cleaner shape is a per-collection visible_to predicate in Sveltia itself, keyed off identity claims from the auth response, so the UI scopes what each editor sees. We’ll be proposing it upstream. With 1-3 distinct edit roles right now, the trust+403 model holds.
What we’d contribute back
The relay is small (about 1,500 lines of TypeScript, plus an offline test suite and CI). Three contributions are planned, gated on real in-prod soak. Filing speculative PRs against unobserved code wastes the maintainer’s time and ours.
| Where | What | Status |
|---|---|---|
sveltia/sveltia-cms-auth | Discussion: “translated-identity mode” (IdP-as-config + authorize hook + author injection on commits + parsed, read-only GraphQL) | Planned, after ~1 week of in-prod editor traffic |
sveltia/sveltia-cms | Issue: investigate the broken commitCreate GraphQL comment in commits.js. Author override would unlock end-to-end GraphQL | Planned, file after the auth conversation has signal |
sveltia/sveltia-cms | Feature request: per-collection visible_to predicate keyed off identity claims, so role-based UI gating doesn’t require multi-admin-path configs | Planned, when we hit real UX friction |
The first one is the load-bearing contribution. If it lands, “translated identity” becomes a published auth mode any small org with editors-in-Workspace-but-not-on-the-git-host can adopt without writing a relay. The shape upstream gets is config-as-config (provider_url, client_id_env, hd_claim_field) and policy-as-hook (a function the deployer registers); our YAML allowlist is one impl shipped as an example.
Generalizing
I keep coming back to the meta-pattern: when a framework’s auth model assumes the wrong thing, write the proxy, contribute back. The school could have:
- Forked Sveltia and modified its OAuth client to talk to Google directly. (Maintains a fork; loses upstream improvements.)
- Adopted a hosted CMS. (Cost; vendor lock; editor UI on someone else’s domain.)
- Given every educator a git-host account. (Seat cost; ongoing user-management overhead; passwords; reviews.)
- Stayed on WordPress. (The flat tax of running WordPress is meaningful, and the actual school’s WordPress was creaking.)
The relay path costs a small Worker we own, runs on Cloudflare Workers’ free tier, and the work-product we own is upstream-shaped from day one. The bet is that another school, nonprofit, or club hits the same shape and we save them the writing. If the upstream maintainer has appetite, the second adopter doesn’t need to write any of it.
Thanks to Andrew DeJong (@adejong5) for collaboration on this project.
If you’re in a similar shape (small org, editors in Workspace, Microsoft, or Okta, static site with a git-backed CMS), the translator code, smoke harness, and end-to-end deploy walkthrough are at andrewdunndev/sveltia-identity-translator. The README walks through Google Cloud Console, GitLab service-account setup, Cloudflare Worker deploy, and Sveltia config wiring; about 30 minutes end-to-end if you’ve used these tools before. Lift what’s useful.