Cloudflare readiness
cairn-doctor checks a site’s local config, its Cloudflare account, and its GitHub App
credentials before you trust the result with a real editor. Run it from the project root, the
directory holding wrangler.jsonc, svelte.config.js, site.config.yaml, and
package-lock.json:
npx cairn-doctor --from editor@your-site.com --repo you/your-site
A failing check never stops the run, so one pass prints every check the environment can answer.
The sections below follow the checks in setup order. Each one describes what the check verifies,
what a failure means, and how to fix it. For the flags, the exit codes, and how each input
resolves from a flag, an environment variable, or the repo itself, see the
cairn-doctor reference.
Deploy the Worker with its bindings
The wrangler config needs a send_email binding named EMAIL and a d1_databases binding named
AUTH_DB. Without them the magic-link send and the session store have nothing to call, and no
editor can sign in:
{
"send_email": [{ "name": "EMAIL" }],
"d1_databases": [
{ "binding": "AUTH_DB", "database_name": "my-site-auth", "database_id": "<uuid>" }
]
}
If the adapter declares a media bucket, the same config needs a matching r2_buckets binding;
this check only runs when media is actually configured, so a site with no media assets never
fails on a bucket it doesn’t need. If the site config turns on tidy (tidy.enabled: true),
ANTHROPIC_API_KEY needs to be visible in the wrangler vars or .dev.vars. A wrangler secret is
invisible to the command-line tool, so this last check is a presence heuristic, not a definitive read. On a
pass, confirm the visible value is the real key and not a placeholder. On a fail, set the secret
with wrangler secret put ANTHROPIC_API_KEY.
Turn on observability
observability.enabled needs to be true in the wrangler config. Without it, the structured log
records the engine writes for every sign-in, save, and publish go nowhere, and a production
failure leaves nothing to read afterward. Set it, redeploy, and see
log events for what the records look like once they arrive.
Wire cairn’s CSRF guard
Two files need to agree: svelte.config.js sets csrf: { checkOrigin: false }, and
src/hooks.server.ts wires cairn’s guard as the handle.
export default {
kit: { csrf: { checkOrigin: false } },
};
import { createAuthGuard } from '@glw907/cairn-cms/sveltekit';
export const handle = createAuthGuard();
With checkOrigin left on, SvelteKit’s own Origin check runs alongside cairn’s guard on every
admin request, and the two are redundant and can conflict. Disabling that check without wiring
the guard is worse: the admin then has no CSRF protection at all. With both in place, cairn’s
guard alone runs the Origin check and the double-submit token check. The
admin mount reference shows the route files that assume it.
Validate the site config
site.config.yaml (or its src/lib/ or src/ counterpart) needs to parse and pass its
URL-policy validation, or the build and the admin can’t resolve the content concepts at all. The
parse or validation error names the exact field or rule that failed, so fix that field and rerun
the doctor. The per-concept URL policy itself lives on the adapter’s defineConcept calls, which
are TypeScript the doctor cannot evaluate from the command line, so this check only proves the
YAML side.
Set the public origin
PUBLIC_ORIGIN needs to parse as a URL and use https, with http allowed only on localhost or
127.0.0.1. Magic-link confirmation links and absolute feed URLs derive from this value alone. It
comes from config, never from a request header, so a forged Host header can’t redirect a sign-in
link. Set it in the wrangler config’s vars for the deployed origin, and add a .dev.vars
override for local http.
Wire the admin mount
This check reads the four conventional /admin route files, looking for a shellLoad call and a
CairnAdminShell render across them. It never fails: an unconventionally wired site skips with a
one-line fix instead of going red, since the read is a best-effort heuristic, not proof the mount
is broken. If it skips and your /admin route works, ignore it. When the route doesn’t render the
shared chrome, the skip message names the two calls to wire. The
admin mount reference has the working files to copy.
Meet the dependency floors
The engine declares peer ranges on svelte (^5.56.3) and @sveltejs/kit (^2.12), read at
doctor-run time from the installed package’s own package.json so the floors live in one place.
This check compares those ranges against what package-lock.json actually resolved, which can sit
below the range in package.json when another dependency pins an older version transitively. A
site without package-lock.json, a pnpm or yarn lockfile, skips rather than failing; the check
reads npm’s lockfile format only. Below the floor, raise the devDependency range and reinstall so
the lockfile re-resolves: npm install --save-dev svelte@^5.56.3.
Onboard the sending domain
The from-address domain needs an enabled Cloudflare Email Sending subdomain on its zone. Without
it, env.EMAIL.send throws E_SENDER_NOT_VERIFIED on the very first magic-link send, and no
editor ever receives a link. Onboard it once with wrangler email sending enable <domain>, then
redeploy; the domain has to match the address your site actually sends from. A send that fails for
a different reason (a bad binding, a delivery error) surfaces the same way at runtime as the
auth.link.send_failed log event, whose code and error fields name the real cause.
Force HTTPS at the edge
Always Use HTTPS needs to be on for the zone. cairn’s guard intercepts any admin request reaching
the site over plain http and serves a branded 400 help page pointing at the https URL (log event
guard.rejected, reason: https), so an admin over http never reaches a working sign-in. Turn it
on under SSL/TLS, Edge Certificates.
Turn on HSTS
HSTS needs to be enabled with a max-age of at least 30 days. cairn tags this condition warning
severity in the report, but like every failed check it still exits 1, so it fails a CI doctor
gate. Treat it as required for production: without it, browsers don’t pin https for the domain,
so a stray http link reaches the origin over plain transport on a later visit. The admin host is
covered either way, since cairn’s admin responses carry their own max-age, so what the zone
setting buys you is every other route. Turn it on under the same SSL/TLS, Edge Certificates panel,
with a max-age of six months or more.
The doctor reads the zone setting and nothing else, so a site that adds the header another way (a
Transform Rule, a _headers file, its own hook) still reports a failure here. If your zone sends
includeSubDomains for the admin’s host, set createAuthGuard({ includeSubDomains: true }) so
cairn’s admin responses state the same policy instead of a weaker one.
Make the stated AI posture effective
This one only applies when the site declares an aiPosture on its adapter. A site that declares
none states nothing, which is a legitimate choice, and the check reports it without failing.
When a site does declare one, the doctor fetches the origin’s live /robots.txt and compares what
it carries against what the site says. A mismatch means crawlers read a different stance from the
one the site states. Two causes account for most of them. The site’s robots.txt route might not
pass aiPosture to robotsResponse, or the build carrying it might not have deployed. Or the
zone’s managed robots.txt might be writing the served file, which Cloudflare does by prepending to
the origin’s rather than replacing it, so the shipped file carries two User-agent: * groups and
matches neither layer’s intent. The doctor reports that shape separately and does not fail on it,
because whether it’s wanted is the zone owner’s call. AI Crawl Control in the Cloudflare dashboard
is where to look.
Provision the auth store
The AUTH_DB D1 database needs to answer, carry the editor, magic_token, and session
tables, and hold at least one owner-capability row. Missing any of the three means no magic-link
token can be minted and nobody signs in. Create the database, copy 0000_auth.sql (and
0001_roles.sql if you declare custom roles) from node_modules/@glw907/cairn-cms/migrations/
into your migrations/ directory, apply the schema with
wrangler d1 migrations apply your-site-auth --remote, and seed the owner row (or declare a
config bootstrap owner, which needs no D1 access); the
configure auth and D1 guide walks the full sequence. Leave
0002_audit.sql uncopied unless you’re also wiring the opt-in
createD1AuditSink: it adds a separate
audit_log table this check never looks at.
Three more checks build on this. auth.role-vocabulary flags an editor row whose role isn’t
one of the site’s declared names, a pruned config or a hand-edited row (the runtime resolves it to
none capability rather than locking the person out, but the doctor still surfaces the drift).
auth.email-normalization flags a row whose email isn’t trimmed and lowercase. A manual wrangler d1 execute insert is the only way such a row gets in. Both skip under the same conditions
auth.store does. auth.role-wiring checks the other half of the same vocabulary: when the
adapter declares custom roles, it reads src/hooks.server.ts and confirms createAuthGuard is
passed that same vocabulary, so those roles resolve to their own capability instead of falling
back to owner/editor’s implicit none. It skips instead when no custom roles are declared, the
file can’t be found, or the guard call can’t be read by the heuristic.
Install the GitHub App
GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 need to resolve to
an App whose key parses and signs, whose installation mints a token, and whose installation can
read the target repository. This is the same chain a save or a publish walks in production, so a
green check here confirms the credentials run it live. The set up the GitHub App
guide covers creating the App and its key.
Probe the deployed admin
--probe runs one live GET and one live POST against a deployed site’s /admin/login, checking
for the CSRF cookie, the hidden CSRF field, and a working request action. It tests the deployed
site from the outside, unlike every check above, which reads local config. It only runs when you
pass the flag, since it’s a real network request against production. The probe submits a random
address at the reserved example.invalid domain, and cairn’s non-leak design answers a non-editor
exactly as it answers a real one, so the probe sends no mail and changes nothing on the site; run
it after a deploy or whenever an editor reports trouble signing in. The cairn-doctor reference has the
two assertions it makes and what a failure of each one means.
Runtime conditions
Two failure modes never show up in a doctor run. They’re rejections cairn’s admin guard raises on
a live request, so the first sign of either is a real editor hitting them, or the
guard.rejected log event.
Admin CSRF token rejected
An admin form POST arrived without a valid __Host-cairn_csrf double-submit token, usually a
stale tab or a browser blocking cookies for the site. Open the sign-in page fresh, allow cookies,
and request a new link.
Non-admin origin rejected
A non-admin form POST arrived with an Origin header that didn’t match the site. Cairn restores
SvelteKit’s own Origin check for every route outside /admin once checkOrigin is disabled
globally, so this rejection means a form posted from somewhere other than the site itself, or a
proxy in front of it stripped or rewrote the header.
Both correlate with guard.rejected in the logs, where the reason field names which of the two
fired; see log events and
troubleshooting for the rest of the day-two symptoms.