# Gated access (Cloudflare Access)

Put a deployed page — or one document path on it — behind a login. This composes
the proven Cloudflare Access pattern; it authors no new gating mechanics. The
complete, operationally-validated steps live in the host references:

- `cloudflare/references/api.md` § Access — token minting and the Access app +
  policy endpoints (`POST /accounts/${ACC}/access/apps`,
  `.../apps/{app_id}/policies`), with the Access token resolved by
  `cf-token.sh access`.
- `business-assistant/skills/e-sign/SKILL.md` § 1a — the full gating design,
  proven on real deployments.

Follow those. This note names the load-bearing invariants so a gating run does not
skip one.

## The invariants that make or break a gate

- **Identity provider first.** An Access app and policy do not enable a login
  method. If zero identity providers exist when the gate goes live, the login page
  offers nothing and locks everyone out, including the operator. List providers
  (`GET /accounts/${ACC}/access/identity_providers`) and confirm the set is
  non-empty before creating the app. For external visitors who belong to no
  organisation, **One-Time PIN** is the correct provider — it emails a code to any
  allowlisted address.
- **Gate the document path and `/api/*` together, same origin.** Gate the document
  path(s) and the form/signing endpoints (`/api/*`), and leave `/` and static
  assets public so the neutral root portal loads without a PIN. Gating only the
  document path is a bypass: anyone can POST to an ungated `/api/*` and forge a
  submission. The `/api/*` handlers must be Pages Functions of the **same** Pages
  project as the gated document — a second origin escapes the gate.
- **Verify with a gate-probe.** Unauthenticated (no Access session, no cookies),
  POST and GET each `/api/*` endpoint against the live origin. Each must return
  **302** (Access login) or **403** — never 200/400/409/500 (those are
  function-originated and prove the API is ungated). This is the only check that
  catches a split-origin escape.
- **Ship a neutral root portal.** A gated host must serve a `/` page: Cloudflare
  lands visitors on the bare root after logout and sometimes after login, so no `/`
  page is a 404 on a client-facing site. The root names no document, branches on no
  auth state, and needs zero change when a second gated document is added. Build
  logout links as `/cdn-cgi/access/logout?returnTo=https://<host>/` (camelCase
  `returnTo`, pointing at the public root).
- **Token caveats.** "Access: Apps and Policies Write" can resolve to more than one
  permission-group id; the wrong one poisons the token (a persistent `10000` on
  every call). Verify a freshly-minted Access token with a read
  (`GET /accounts/${ACC}/access/apps`) before relying on it. Update apps with
  `PUT` and the full body, not `PATCH` (`PATCH` returns `10405` under API-token
  auth). `cf-token.sh access` already handles the poison-id retry.

## Editions

The token is resolved through the same seam as deploy (`bin/resolve-cf.sh
token-key`), so gating works in both editions once an Access-scoped token is
available. Inside maxy-code that token is minted and persisted by
`cf-token.sh access`; standalone it is an Access-scoped `CLOUDFLARE_API_TOKEN`.
