Spike 0 / WS-0 — Cloudflare Access for the emdash admin¶
✅ RESULT — Gate 1 PASSED (2026-07-04). A non-developer logged into the deployed
/_emdash/adminvia Cloudflare Access (One-time PIN, an@goldenpages.ieemail), no passkey, no SQL. Live config: - Custom domainemdash.dcathal.orgattached to the worker (via API, personal/1ceb…account that also owns the zone). - Access self-hosted app scoped to path_emdash; Allow policy on@fcrmedia.ie/@goldenpages.ie(+cathaldempsey@gmail.com). Public site stays open. -CF_ACCESS_TEAM_DOMAIN = fcr-copysheet-pages.cloudflareaccess.com(defaulted inastro.config.mjs),CF_ACCESS_AUDIENCEset as a worker var. - Gotcha that cost a deploy: passing an emptyroleMapping: {}toaccess()crashes One-time-PIN logins withTypeError: groups is not iterable(OTP carries no groups; a truthy empty map skips the early-out and iteratesundefined). OmitroleMappingentirely unless the IdP returns groups. - Corporate email note:@fcrmedia.ie/@goldenpages.ieOTP codes may be blocked at the mail gateway; allowlistnoreply@notify.cloudflare.comor use SSO to use those addresses.
Goal (Gate 1 of the Phase 0 spike): a person who is not Cathal can
log into the deployed /_emdash/admin with their own identity — no
passkey bootstrap, no SQL user provisioning.
Scope of this doc: the code side is already wired (see "What's already done"). What remains is Cloudflare dashboard config + two env values + a redeploy. Everything below is click-through you do once.
How it works (so the clicks make sense)¶
- emdash's auth middleware gates only
/_emdash/adminand/_emdash/api. The public marketing site is never gated — visitors keep seeing garvanbay normally. (Verified innode_modules/emdash/dist/astro/middleware/auth.mjs: public routes get a soft-auth that never blocks.) - Cloudflare Access sits in front of the
/_emdashpath, authenticates the visitor against an identity provider, and injects a signed JWT header. emdash'saccess()adapter validates that JWT and logs the user in. - The first person to log in through Access is auto-provisioned as Admin (role 50). Later users get their mapped/default role. So there is no manual user-creation step — just log in first as whoever should be admin.
- Access only engages on the deployed Worker. Local
astro devstill uses passkey/dev-bypass, so this can only be tested on the deploy.
What's already done (code side)¶
apps/cms/astro.config.mjs— importsaccessand wiresemdash({ auth: adminAuth }), whereadminAuthis built fromaccess({ teamDomain, audienceEnvVar: "CF_ACCESS_AUDIENCE", defaultRole: 30, roleMapping }). It staysundefined(→ passkey, no change) untilCF_ACCESS_TEAM_DOMAINis present at build time, so nothing breaks before you finish the steps.apps/cms/wrangler.jsonc— has a commentedCF_ACCESS_AUDIENCEvar slot.
Prerequisite: a custom domain¶
Cloudflare Access cannot protect a *.workers.dev URL. The Worker
must be served on a hostname in a Cloudflare zone you control (e.g.
cms.fcr.media or cms.garvanbay.ie). If you don't have a zone handy,
that decision comes first — pick a domain already in your Cloudflare
account.
- Add a custom domain to the Worker. Cloudflare dashboard →
Workers & Pages →
replatform-emdash-demo→ Settings → Domains & Routes → Add → Custom Domain → e.g.cms.fcr.media. Cloudflare provisions the cert and routes the hostname to the Worker. (Equivalent wrangler: add aroutesentry with{ "pattern": "cms.fcr.media", "custom_domain": true }.) - Confirm
https://cms.<yourzone>/renders the garvanbay home page.
Step 1 — Enable an identity provider (Zero Trust)¶
You need at least one login method. One-time PIN (email OTP) is built in and needs no external IdP — perfect for the spike.
- Cloudflare Zero Trust dashboard → Settings → Authentication → Login methods → confirm One-time PIN is present (add Google Workspace / Azure AD later if you want SSO).
- Note your team domain (Zero Trust → Settings → Custom Pages, or
the URL): it looks like
your-team.cloudflareaccess.com. You'll need it forCF_ACCESS_TEAM_DOMAIN.
Step 2 — Create the Access application¶
- Zero Trust → Access → Applications → Add an application → Self-hosted.
- Application name:
emdash admin (garvanbay). - Application domain: hostname
cms.<yourzone>, path_emdash. This scopes Access to/_emdash/*only — the public site stays open. - Session duration: your call (e.g. 24h).
- Save, then open the app's Overview and copy the Application
Audience (AUD) Tag — a long hex string. This is
CF_ACCESS_AUDIENCE.
Policy — who's allowed in¶
- Add a policy: Allow, action Allow.
- Include rule: Emails = the operator's email(s), or Emails
ending in
@fcrmedia.ie. (Whoever should be the admin — log in as them first so they get role 50.)
(Optional, only when operators upload NEW images through the admin)¶
emdash-uploaded media is served from /_emdash/api/media/file/*. If a
public page ever references such an image, Access would block it for
anonymous visitors. Not needed for the spike (garvanbay's migrated images
are served from the R2 public URL, not this path), but when WS-4(full)
lands:
- Add a second Access app for path
_emdash/api/media/filewith a Bypass policy (Everyone), so public media stays public.
Step 3 — Set the two env values¶
-
CF_ACCESS_AUDIENCE(runtime Worker var) — the AUD tag from Step 2. Either uncomment the slot inapps/cms/wrangler.jsoncand paste it, or set it in the dashboard (Worker → Settings → Variables). -
CF_ACCESS_TEAM_DOMAIN(build-time) — youryour-team.cloudflareaccess.com. This is read whenastro buildruns, so export it in the shell that deploys:(Alternative: hardcode it by replacing the
process.env.CF_ACCESS_TEAM_DOMAINline inastro.config.mjswith the literal string — it's not a secret. Hardcoding avoids the failure mode where a deploy without the env var silently reverts the admin to passkey.)
Step 4 — Deploy¶
-
From
apps/cms/, withCF_ACCESS_TEAM_DOMAINexported: -
Sanity-check the build log didn't error on the emdash integration.
Step 5 — Gate 1 acceptance test¶
- In a fresh/incognito browser, open
https://cms.<yourzone>/_emdash/admin. - You should be redirected to the Cloudflare Access login (email OTP), not the emdash passkey page. Enter the operator email → get the PIN → submit.
- You land in the emdash admin, logged in as that user. Confirm the user shows as Admin (first login = role 50).
- Separately confirm
https://cms.<yourzone>/(the public home page) still loads without any Access prompt.
Gate 1 passes when: a non-Cathal email can reach the emdash admin via Access, and the public site is unaffected.
Then: Gates 2 & 3¶
Once logged in, proceed to the rest of the Phase 0 spike (in the scope
doc, scope-2026-07-04-one-site-operational.md):
- Gate 2 — edit a hero heading on an image-bearing block → Save →
confirm it's a draft (not live) → Publish → confirm
/updates and the image is not blanked. Inspect the D1 row. - Gate 3 — set/upload an image through the admin → inspect the exact
value written to
content[].imageUrl→ confirm it renders live and provenance survives (this is where the image-shape decision — custom widget vs split-scalar — gets settled).
Gotchas captured¶
- workers.dev can't host Access → custom domain is non-negotiable.
- Access engages in production only — local dev stays on passkey.
- First login = Admin — log in as the intended admin first.
- Public site is not gated by emdash or by a correctly path-scoped
Access app; if you accidentally scope Access to the bare hostname
(no
/_emdashpath) you'll gate the whole marketing site. CF_ACCESS_TEAM_DOMAINunset at build → passkey (silent). Hardcode it if that footgun worries you.