# Ephemera — Cloudflare account onboarding (nothing → authed CLI)

> The zeroth plan: from "I don't have a Cloudflare account" to "my agent can deploy." Signing up
> should not be friction — the human does only what genuinely requires a human (a signup form, an
> email click, an OAuth consent), and the agent does everything else, in the conversation.
> Contributed binding; onboarding-focused origin.

> **Provides:** `cf-account(ACCOUNT_ID)` + an authed `wrangler` — as `authed-wrangler(TOKEN_SCOPE)`, the
> unstated **Requires** of every `*.cloudflare.md` plan. Consumers discover it with `wrangler whoami`
> (§4's verify) and assert *sufficiency* with their own first real call (a token authed for Workers does
> not necessarily carry R2 / D1 / DNS-edit / Notifications — see §3's scope table). If whoami fails, run
> this plan first.

---

## 🤖 Director prompt (read first, every run)

- Observe before acting: every step starts with "is this already true?" — re-running is safe.
- The human steps (§1 signup, §3 consent) are ⏳ **human-action waits**, not gates: print exactly
  what they'll see, wait for their "done", then verify. Never rush them; never do it for them.
- **Secrets discipline:** an API token must never appear in the transcript, argv, shell history, or
  a file. Guide its creation, have it land in an env var / secret store, and only ever *probe* it
  (`[ -n "$CLOUDFLARE_API_TOKEN" ] && echo set`). Live State records names, never values.
- Write realized values (ACCOUNT_ID, auth mode) back into Live State; on failure annotate and stop.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```
> **⏳ human-action wait** (this plan's dialect of the shared ⏳): the agent prints the exact steps, then
> *waits* for the human to do the one out-of-band thing only they can (sign a form, click an email link,
> click **Allow**) and say "done" — it is a wait for a human, not a 🔴 authority gate on a billable/global
> mutation.

> **Candor — AUTHORED-FROM-CONTRIBUTION, partially verified live 2026-07-23.** Proven on an already-authed
> machine (node v24.11.1, wrangler 4.111.0), token sourced from Keychain, probe-only, no value in the
> transcript: **§0.1 observe** + **§4 whoami** (`npx wrangler whoami` printed the email + Account ID table —
> email printed because that token had *broader* scopes than the recommended Workers template, see
> contract #1 / §3), **hand-off (a)** Keychain injection, and **§3's liveness probe both directions**
> (valid Keychain token → "token valid", exit 0; empty-token negative → "token INVALID/expired/revoked",
> exit 1 — fail-closed both ways). Still **UNRUN** (all need a fresh account): the nothing→account signup
> path (§1), the `oauth-login` branch (§3), a fresh-token mint from exactly the recommended template, and
> live verification of the §3 scope-table permission labels against the Create Custom Token picker.

## Intent

A person with **nothing installed and no account** ends with: a free Cloudflare account, a working
`wrangler` CLI, and verified authentication — the precondition every other Cloudflare plan assumes.
Free plan throughout: no credit card, $0. The agent narrates; the human clicks twice.

**Acceptance contract:**
1. `npx wrangler whoami` succeeds and prints the **Account ID** (the account **email** additionally in
   `oauth-login` mode, or in `api-token` mode when the token carries *User Details:Read* — the recommended
   Workers template alone does not, so a token-mode whoami may print "Unable to retrieve email" beside a
   valid Account ID). *The api-token whoami-prints-email behavior is needs-dogfood on a token minted from
   exactly the recommended template.*
2. The human never left the conversation without a **verbatim script** for the one thing only a human can
   do (a signup form, an email click, an OAuth consent) — never sent to "go figure out" a toolchain or token.
3. No secret **value** anywhere in the transcript, argv, shell history, or on disk (probe-only) — the one
   carve-out is the **provider-CLI-managed credential store** (the OAuth refresh token wrangler writes to
   its own config and clears on `logout`); that is wrangler's to hold, not a value this plan handles.

## What you need, and why

- **A working email address** — Cloudflare verifies it at signup. That's the only credential a
  human must invent (plus a password; suggest their password manager).
- **Node ≥ 18** — `wrangler` runs on it. The agent checks (§0.2 enforces the floor numerically) and, if
  missing or too old, offers to install it (macOS `brew install node` · Windows
  `winget install OpenJS.NodeJS.LTS` · Debian/Ubuntu: prefer NodeSource or `nvm` — the distro
  `apt install nodejs` can ship a years-stale major that fails the ≥ 18 floor) — one consent, one command.
  The human should never have to know what Node is.

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | How will the CLI authenticate? | `oauth-login` / `api-token` | `oauth-login` | `AUTH_MODE` | §3 branch. `oauth-login` = browser consent, ideal for a human at a keyboard; `api-token` = headless/CI, or when the browser flow isn't possible |
| 2 | Account already exists? | `use-existing` / `create-new` | discover: `create-new` unless the human says otherwise | `ACCOUNT_MODE` | whether §1 runs at all |
| 3 | Token scope (api-token mode only) | `workers` / `fleet` / `custom` | `workers` | `TOKEN_SCOPE` | §3 scope table — which permissions the token recipe asks for |

```yaml
# → written into Live State once resolved
resolved_inputs:
  auth_mode:    oauth-login
  account_mode: create-new
  token_scope:  workers        # api-token mode only
  resolved_by:  <human>
  resolved_at:  <timestamp>
```

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   —
last_verified: —
```

| key         | value (filled on apply) |
|-------------|-------------------------|
| ACCOUNT_ID  | — |
| AUTH_MODE   | — |
| TOKEN_SCOPE | — (api-token mode only — `workers` / `fleet` / `custom`; qualifies the Provides) |
| TOKEN_NAME  | — (api-token mode only — the token's *name*, never its value) |
| signup_ack  | — (api-token/create-new: who confirmed the dashboard + when — §1's only write-back) |

| ✔ check | expected | observed | result |
|---------|----------|----------|--------|
| `npx wrangler --version` | prints a version (toolchain OK) | — | — |
| `npx wrangler whoami` | Account ID (email additionally in oauth mode / with User Details:Read) | — | — |
| secret hygiene (negative) | token value absent from transcript, history, and disk | — | — |

## Dependency frontier

```
Node (§0 preflight) ─> wrangler (§2) ─┬─ oauth-login ─> browser consent ⏳ ─> ✔ whoami
email (human) ─> account (§1 ⏳) ─────┘└─ api-token ──> token created ⏳ → env/secret store ─> ✔ whoami
```

## 0. Variables  🟡

```bash
# provenance — Cloudflare has no resource-tag API, so provenance rides names + this ledger
PLAN_SOURCE="account.cloudflare.md"
PLAN_VERSION="2026-07-23"

# resolved Provisioning Inputs — exported so §1–§4 branch on them (a fresh agent shell re-reads these)
export AUTH_MODE="${AUTH_MODE:-oauth-login}"          # oauth-login | api-token   (Input 1)
export ACCOUNT_MODE="${ACCOUNT_MODE:-create-new}"     # use-existing | create-new (Input 2)
export TOKEN_SCOPE="${TOKEN_SCOPE:-workers}"          # workers | fleet | custom  (Input 3 — §3 scope table; api-token mode)
export TOKEN_NAME="ephemera-wrangler-$(hostname -s 2>/dev/null || echo host)"   # api-token mode — deterministic; the name is the ONLY provenance channel CF gives a token
```

## 0.1 Observe first — is this already true?  ✔

```bash
# observe-before-act: if wrangler is already authed, §1–§3 are a no-op — record ACCOUNT_ID and jump to §4.
# Concrete re-run harms this prevents: signup "email already registered"; a needless second OAuth consent.
npx wrangler whoami    # success + an Account ID row => already authed; failure => start at §0.2
```
> → Live State: on success record ACCOUNT_ID + set `status: live` and skip to §4; on failure, proceed.

## 0.2 Toolchain preflight  🟡 ✔

```bash
# wrangler needs Node >= 18 — enforce the floor numerically, fail closed
ver="$(node --version 2>/dev/null)" || { echo "Node MISSING — offer to install (see 'What you need'); ask consent, run, re-check"; exit 1; }
major="${ver#v}"; major="${major%%.*}"
if [ "$major" -ge 18 ] 2>/dev/null; then echo "Node $ver OK"; else echo "Node $ver too old — need >= 18 (install a current LTS via NodeSource or nvm; a distro 'apt install nodejs' can be years stale)"; exit 1; fi
```

## 1. Create the account  ⏳ (human)  *(skipped when `ACCOUNT_MODE=use-existing`)*

> Print this, then wait for the human's "done":
>
> 1. Open **https://dash.cloudflare.com/sign-up**
> 2. Email + a password (password manager recommended) → **Sign up**. Free plan; no card.
> 3. Click the verification link Cloudflare emails you.
> 4. Say "done" when you can see the dashboard.

> **No machine-verify is possible here** — pre-auth, the agent has no credential to probe with; the real
> acceptance lands in §4 (`whoami`). §1's only durable output is recording *that a human acked*, and when.

```bash
# record the human's acknowledgement (not a verify — nothing to assert against the cloud yet)
echo "human confirmed dashboard access at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
```
> → Live State: signup_ack = `<human> @ <timestamp>` (the ack above; ACCOUNT_ID stays `—` until §4).

## 2. Wrangler  🟡 ✔

```bash
# npx fetches it on first use — nothing global to install or maintain
npx wrangler --version
```

## 3. Authenticate — branch on AUTH_MODE  ⏳ (human consent)

**`oauth-login`** (default): `wrangler login` is the **lone mutation the agent runs itself** — it opens a
browser; the human's **Allow** click *is* the 🔴 human-go for this step (there is no separate gate command).

```bash
npx wrangler login
```
> **oauth scope asymmetry (UNVERIFIED — needs-dogfood):** wrangler's OAuth consent may not grant
> DNS-record *write*, so `domain.cloudflare.md` likely needs an `api-token` regardless of this branch.
> Do **not** assert oauth covers the whole fleet — mark for dogfood and let each consumer prove sufficiency.

**`api-token`** (headless / CI, or when the browser flow isn't possible): guide the human — the token is
**scoped by `TOKEN_SCOPE`** (Input 3). Least-privilege is per *fleet-need*, not one template for everything:

| `TOKEN_SCOPE` | Ask for (Create Custom Token → Permissions) | Serves |
|---------------|---------------------------------------------|--------|
| `workers` (default) | *Account* · Workers Scripts: Edit **+** *User* · User Details: Read | `web` / `service` / `workflow` / `realtime` / `task-runner` deploys (User Details:Read so `whoami` can print the email — see contract #1) |
| `fleet` | the **union** across every `*.cloudflare.md` in this repo: *Account* · Workers Scripts: Edit · Workers KV Storage: Edit · Workers R2 Storage: Edit · D1: Edit · Queues: Edit · Vectorize: Edit · Workers AI: Read · Notifications: Edit · Email Routing: Edit · Zone: Edit (`domain.cloudflare.md` *creates* the zone — DNS:Edit alone can't add a zone) **+** *Zone* · Zone: Read · DNS: Edit **+** *User* · User Details: Read | everything: `storage` (R2) · `database`+`task-runner` (D1/KV/Queues) · `vector` (Vectorize) · `inference` (Workers AI) · `domain` (Zone create/read + DNS) · `email` (DNS/Email) · `cost-guard` (Notifications) |
| `custom` | the human picks, keyed by the consumer plans they'll actually run (rows above are the à-la-carte menu) | a subset — least privilege by hand |

> **Email row caveat:** Cloudflare's picker splits Email Routing into an *account*-level "Email Routing
> Addresses" and a *zone*-level "Email Routing Rules", and the send endpoint (`email.cloudflare.md`) may
> need a distinct **Email Sending** permission — reconcile against the live picker.
> **⚠ These permission labels are best-effort — verify the whole table against the live Create Custom Token
> picker at the fresh-account dogfood** (Cloudflare renames/splits permissions; the candor block tracks this
> as UNRUN).

> Print this, then wait for the human's "done":
>
> 1. Dashboard → **My Profile → API Tokens → Create Token → Create Custom Token**.
> 2. Add the permissions for your `TOKEN_SCOPE` from the table above (the default `workers` row is the
>    minimum that also lets `whoami` print the account email).
> 3. **Name the token `ephemera-wrangler-<host>`** (the exact `$TOKEN_NAME` from §0 — the token's *name*
>    is the only provenance channel Cloudflare gives it; matching it makes the token self-documenting).
> 4. **Copy the token once** — into your password manager / secret store (macOS Keychain, a sigil-class
>    runner), NOT into this chat. **Store it under the service name `$TOKEN_NAME`** (the exact key hand-off
>    (a) queries), e.g. `security add-generic-password -s "$TOKEN_NAME" -a "$USER" -w` (paste at its prompt).
> 5. Get it into the environment. **The catch:** the agent's `whoami`/probe below run in *fresh* shells
>    that never inherit an `export` you typed in your own terminal — so pick a hand-off the agent's shell
>    can actually see (closed enum):
>    - **(a) secret store the agent reads at fire time** — the durable path: keep the token in Keychain /
>      a sigil-class vault and have the agent inject it at fire time (reads from the store into env, value
>      never on a command line):
>
>      ```bash
>      export CLOUDFLARE_API_TOKEN="$(security find-generic-password -s "$TOKEN_NAME" -w)"
>      ```
>      OR
>    - **(b) member of the loop runs the verify block** in the shell that holds the token, and pastes back
>      only the *output* (never the value). If you export it yourself, silent-prompt so it stays off disk
>      and out of history — do NOT type the value as an argument:
>
> ```bash
> read -rs CLOUDFLARE_API_TOKEN && export CLOUDFLARE_API_TOKEN    # silent; nothing lands in argv or ~/.*history
> ```

```bash
# ✔ probe only — never echo, grep, or log the value. Runs in WHICHEVER shell holds the token (see hand-off).
[ -n "$CLOUDFLARE_API_TOKEN" ] && echo "token present" || echo "not set — supply it via the hand-off (a) or (b)"
```

```bash
# liveness — a set-but-invalid token would otherwise sail to §4's opaque code-10000. This reads the value
# from the ENV *inside* node (never argv — a curl -H "Bearer $VAR" would be ps-visible), so nothing leaks.
node -e 'fetch("https://api.cloudflare.com/client/v4/user/tokens/verify",{headers:{Authorization:"Bearer "+(process.env.CLOUDFLARE_API_TOKEN||"")}}).then(r=>r.json()).then(j=>{console.log(j.success?"token valid":"token INVALID/expired/revoked — "+JSON.stringify(j.errors));process.exit(j.success?0:1)}).catch(e=>{console.log("verify call failed: "+e.message);process.exit(1)})'
```
> → Live State: AUTH_MODE; TOKEN_SCOPE; TOKEN_NAME (`ephemera-wrangler-<host>`, the dashboard name — never the value).

## 4. Acceptance verify  ✔

> Runs in the shell that holds the credential: the agent's shell in `oauth-login` mode (wrangler's config
> is on disk) **or** with hand-off (a); the human's shell (hand-off (b)) for an env-only `api-token`.

```bash
npx wrangler whoami    # expect: an Account ID table always; the account email too in oauth mode or when the token carries User Details:Read (api-token+Workers-only may print "Unable to retrieve email" beside a valid Account ID — still a PASS)
```
> → Live State: ACCOUNT_ID, status: live. Every other `*.cloudflare.md` plan may now assume
> `authed-wrangler(TOKEN_SCOPE)` — and asserts *sufficiency* with its own first real call.

## Update (idempotent reconcile)  🟡

- OAuth session expired → re-run §3's `npx wrangler login`.
- Token rotated/expired → re-run the §3 token walk; the old TOKEN_NAME is revoked in §Teardown style.
- Re-running the whole plan is safe: every step observes first.

## Teardown  💥

> **Branch on AUTH_MODE — `logout` only clears the oauth session.** An exported `CLOUDFLARE_API_TOKEN`
> keeps authenticating after `logout`; api-token mode is undone by *revoking* the token and *unsetting the
> env var in the shell that holds it* (the human's shell, in hand-off (b)) — the agent cannot unset a var
> in a shell it doesn't own.

```bash
# oauth-login: drop the local session (no-op / harmless in api-token mode)
npx wrangler logout
```
> **api-token:** revoke in Dashboard → My Profile → API Tokens (⏳ human; print the path, wait), then, in
> your own terminal, `unset CLOUDFLARE_API_TOKEN`. **Account deletion** is manual and out of scope here —
> it requires the human in the dashboard and removing zones first; this plan never deletes an account it
> may not own alone.

```bash
# ✔ teardown verify — unpiped; assert on the exit/"not authenticated" string (no pipe to mask the grade)
out="$(npx wrangler whoami 2>&1)"
case "$out" in
  *"not authenticated"*|*"not logged in"*|*"Unable to authenticate"*|*"You are not authenticated"*)
     echo "gone: no active session" ;;
  *) echo "STILL AUTHENTICATED — in api-token mode an exported token authenticates past logout; unset CLOUDFLARE_API_TOKEN in the shell that holds it"; exit 1 ;;
esac
```
> → Live State: status: gone; clear AUTH_MODE/TOKEN_SCOPE/TOKEN_NAME (ACCOUNT_ID may persist — the account
> is the human's, not this plan's creation to unmake).

## Deliberately not included

- **Adding a domain/zone** — that's `domain.cloudflare.md` (this plan Provides what it Requires).
- **Paid plans, R2 billing, Workers Paid** — onboarding stays $0; upgrades are their plans' concern.
- **Global API Key** — legacy and account-wide; the `TOKEN_SCOPE`-scoped token (§3) is the least-privilege
  path — least privilege *for the fleet-need*, not one blanket template for everything.
- **Storing the token in a plan or dotfile** — Live State records names only; values live in the
  human's secret store or the shell session.
