Skip to content

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/admin via Cloudflare Access (One-time PIN, an @goldenpages.ie email), no passkey, no SQL. Live config: - Custom domain emdash.dcathal.org attached 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 in astro.config.mjs), CF_ACCESS_AUDIENCE set as a worker var. - Gotcha that cost a deploy: passing an empty roleMapping: {} to access() crashes One-time-PIN logins with TypeError: groups is not iterable (OTP carries no groups; a truthy empty map skips the early-out and iterates undefined). Omit roleMapping entirely unless the IdP returns groups. - Corporate email note: @fcrmedia.ie / @goldenpages.ie OTP codes may be blocked at the mail gateway; allowlist noreply@notify.cloudflare.com or 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/admin and /_emdash/api. The public marketing site is never gated — visitors keep seeing garvanbay normally. (Verified in node_modules/emdash/dist/astro/middleware/auth.mjs: public routes get a soft-auth that never blocks.)
  • Cloudflare Access sits in front of the /_emdash path, authenticates the visitor against an identity provider, and injects a signed JWT header. emdash's access() 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 dev still uses passkey/dev-bypass, so this can only be tested on the deploy.

What's already done (code side)

  • apps/cms/astro.config.mjs — imports access and wires emdash({ auth: adminAuth }), where adminAuth is built from access({ teamDomain, audienceEnvVar: "CF_ACCESS_AUDIENCE", defaultRole: 30, roleMapping }). It stays undefined (→ passkey, no change) until CF_ACCESS_TEAM_DOMAIN is present at build time, so nothing breaks before you finish the steps.
  • apps/cms/wrangler.jsonc — has a commented CF_ACCESS_AUDIENCE var 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 a routes entry 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 for CF_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/file with 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 in apps/cms/wrangler.jsonc and paste it, or set it in the dashboard (Worker → Settings → Variables).
  • CF_ACCESS_TEAM_DOMAIN (build-time) — your your-team.cloudflareaccess.com. This is read when astro build runs, so export it in the shell that deploys:

    export CF_ACCESS_TEAM_DOMAIN=your-team.cloudflareaccess.com
    

    (Alternative: hardcode it by replacing the process.env.CF_ACCESS_TEAM_DOMAIN line in astro.config.mjs with 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/, with CF_ACCESS_TEAM_DOMAIN exported:

    npm run deploy
    
  • 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 /_emdash path) you'll gate the whole marketing site.
  • CF_ACCESS_TEAM_DOMAIN unset at build → passkey (silent). Hardcode it if that footgun worries you.