# Ephemera — Domain registration (Cloudflare Registrar)

> Self-executing Markdown. Same **intent** as [`domain.aws.md`](./domain.aws.md) — *end with a registered
> domain whose DNS zone is live, ready for consumer plans* — but a very different **binding**. The cloud is
> the source of truth for state; this file is intent + write-back ledger + audit.
>
> **Provides:** `cf-zone(DOMAIN_NAME)` — an active Cloudflare zone for the domain. Consumers like
> [`web.cloudflare.md`](./web.cloudflare.md) (`custom_domain: true`) **Require** it and discover it by
> observing the cloud (zone `status: active`), never by reading this file.

---

## 🤖 Director prompt

Same contract as `domain.aws.md`: observe before acting; verify each step; write realized values back into
Live State; stop at 🔴/💥 for human go. **The defining difference of this binding:** Cloudflare Registrar
has **no register/transfer API** — the purchase happens in the dashboard. So the central step is not an
API call you gate; it is an **out-of-band hand-off** you emit and pause on. The API only *reads* the
registrar and *manages the zone* around that human action.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
        ✉ out-of-band · a human action in the Cloudflare dashboard that no API exposes
```

## Intent

Register (or transfer in) a domain via **Cloudflare Registrar** — notable for **at-cost pricing** (registry
+ ICANN fee, no markup) — and end with its **zone active on Cloudflare**, so consumer plans attach
routes/records to it. The honest shape of this binding:

- Cloudflare Registrar **requires the zone to be on Cloudflare first** (the classic transfer model); fresh
  registrations are also completed in the dashboard.
- The **Registrar API is read + limited-update only** (`GET`/`PUT` auto-renew, lock) — there is **no
  `register`**. So this plan automates the zone and the verification *around* a dashboard purchase.

**The portability inversion (see the ledger at the end):** on SPA serving, Cloudflare collapses 5 AWS
resources to one line. On *registration*, the clouds trade seats — AWS is the fully API-gated purchase;
Cloudflare is the manual one. Same intent, opposite automation surface.

## Provisioning Inputs

> Same questions as [`domain.aws.md`](./domain.aws.md). Several resolve to **dashboard / no-op** here,
> because the registrar is UI-driven — that's a binding fact, recorded so it's a decision not a surprise.

| # | Question | Options (closed enum) | Default | Sets | Gates (Cloudflare binding) |
|---|----------|-----------------------|---------|------|----------------------------|
| 1 | Domain name | text — an apex, e.g. `example.com` | — | `DOMAIN_NAME` | the whole plan |
| 2 | Registration term (years) | `1` … `10` | `1` | `YEARS` | **dashboard** — set during the UI purchase |
| 3 | Auto-renew? | `on` / `off` | `on` | `AUTO_RENEW` | set in UI; later togglable via the Registrar `PUT` API |
| 4 | WHOIS privacy? | `on` / `off` | `on` | `PRIVACY` | **no-op** — Cloudflare provides WHOIS redaction free, always |

## Live State

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

resolved_inputs:
  domain_name: —
  years:       1
  auto_renew:  on            # on | off
  privacy:     on            # on | off (no-op on CF — always redacted)
```

| key               | value |
|-------------------|-------|
| CF_ACCOUNT_ID     | — |
| DOMAIN_NAME       | — |
| ZONE_ID           | — |
| ZONE_STATUS       | — *(pending | active)* |
| NAMESERVERS       | — *(the two CF nameservers to set at the current registrar, pre-transfer)* |
| EXPIRES_ON        | — |

| ✔ check                | expected             | observed | result |
|------------------------|----------------------|----------|--------|
| zone status            | `active`             | —        | —      |
| registrar owns domain  | present in registrar | —        | —      |

## 0. Variables

```bash
export CF_API="https://api.cloudflare.com/client/v4"
export CF_ACCOUNT_ID=""        # the Cloudflare account that will hold the domain
# Resolved from Provisioning Inputs:
export DOMAIN_NAME=""          # e.g. example.com
export AUTO_RENEW="on"         # on | off (togglable via Registrar PUT after purchase)
# NOTE: auth via the ambient CLOUDFLARE_API_TOKEN (Bearer). Never echo the token.
auth () { printf 'Authorization: Bearer %s' "$CLOUDFLARE_API_TOKEN"; }
export ZONE_ID="" ZONE_STATUS=""
```

## Dependency frontier

```
zone on Cloudflare (active) ─┐                 (Registrar needs the zone first)
                             ▼
            ✉ dashboard: Register/Transfer DOMAIN_NAME  (THE purchase — no API)
                             │
        ✔ registrar shows domain ─> ✔ zone active ─> Provides cf-zone(DOMAIN_NAME)
```

The one hard edge unique to this binding: **the purchase is not an API call.** Everything the Director can
do is *before* it (stage the zone) and *after* it (verify), bridged by an ✉ hand-off.

## 1. Zone on Cloudflare  🟡 (prerequisite)

> Cloudflare Registrar can only hold a domain whose **zone is already on Cloudflare**. Observe; create if
> missing; then the human points the *current* registrar's nameservers at Cloudflare and waits for active.

```bash
# observe: does an active zone already exist?
ZONE_JSON="$(curl -s "$CF_API/zones?name=${DOMAIN_NAME}" -H "$(auth)")"
ZONE_ID="$(echo "$ZONE_JSON"     | jq -r '.result[0].id // empty')"
ZONE_STATUS="$(echo "$ZONE_JSON" | jq -r '.result[0].status // "absent"')"
echo "ZONE_ID=$ZONE_ID ZONE_STATUS=$ZONE_STATUS"

if [ -z "$ZONE_ID" ]; then
  # 🟡 create the zone (full setup) — returns the two CF nameservers to set at the current registrar
  CREATE="$(curl -s -X POST "$CF_API/zones" -H "$(auth)" -H 'Content-Type: application/json' \
    --data "{\"name\":\"${DOMAIN_NAME}\",\"account\":{\"id\":\"${CF_ACCOUNT_ID}\"},\"type\":\"full\"}")"
  ZONE_ID="$(echo "$CREATE" | jq -r '.result.id')"
  echo "Set these nameservers at your CURRENT registrar, then wait for the zone to go active:"
  echo "$CREATE" | jq -r '.result.name_servers[]'
fi
```
> **→ write Live State:** `ZONE_ID`, `NAMESERVERS`, `ZONE_STATUS`; `status: zone-pending` until active.

```bash
# ✔ verify — zone must reach 'active' before the registrar will take the domain
curl -s "$CF_API/zones/${ZONE_ID}" -H "$(auth)" | jq -r '.result.status'   # expect: active
```

## 2. Register / transfer in the dashboard  ✉ 🔴 — *the purchase (no API)*

> 🔴 **Human go — in the Cloudflare dashboard.** There is **no register/transfer API**. Emit the exact
> steps, then PAUSE until the human confirms the registrar shows the domain. Like AWS, this is billable and
> a commitment — but here you cannot script it even if you wanted to.

```bash
cat <<TXT
In the Cloudflare dashboard (the API cannot do this):
  Account Home → Domain Registration → Register Domains  (or Transfer Domains, if it's registered elsewhere)
  • Select / enter:   ${DOMAIN_NAME}
  • Term:             ${YEARS:-1} year(s)
  • Pricing:          at-cost (registry + ICANN fee, no markup) — complete payment in the UI
  • Privacy:          automatic & free (WHOIS redacted) — nothing to set
Reply when the dashboard's Registrar list shows ${DOMAIN_NAME}.
TXT
# → 🔴 PAUSE for the human. On resume, verify below.
```
> **→ write Live State:** `status: awaiting-purchase` until confirmed.

## 3. Verify registrar + zone — the Provides contract  ✔

```bash
# the Registrar API is read-only here: confirm the account now owns the registration
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/registrar/domains/${DOMAIN_NAME}" -H "$(auth)" \
  | jq '{registered:.success, auto_renew:.result.auto_renew, expires:.result.expires_at}'

# and the zone is active → DNS is Cloudflare-managed → consumers (custom_domain) just work
curl -s "$CF_API/zones/${ZONE_ID}" -H "$(auth)" | jq -r '.result.status'   # expect: active
```
> **→ write Live State:** `EXPIRES_ON`, `ZONE_STATUS: active`; verify rows; `status: live`.
> **Provides `cf-zone(DOMAIN_NAME)`** — consumer plans may now discover and use it.

## Teardown — the asymmetry  💥

> 💥 **Human go — and the same hard truth as AWS:** you cannot un-buy a domain. The reversible part is
> auto-renew (one API call); the registration itself lapses at term end. Letting go of the *zone* is also
> reversible, but breaks DNS immediately.

```bash
# 1. Stop future spend: disable auto-renew via the Registrar PUT API (the one thing the API CAN do).
[ "$AUTO_RENEW" = "off" ] || curl -s -X PUT \
  "$CF_API/accounts/${CF_ACCOUNT_ID}/registrar/domains/${DOMAIN_NAME}" \
  -H "$(auth)" -H 'Content-Type: application/json' --data '{"auto_renew":false}' \
  | jq -r '.result.auto_renew'   # expect: false

# 2. (optional) delete the zone — ONLY if no consumer plan needs it; breaks delegation at once.
#    curl -s -X DELETE "$CF_API/zones/${ZONE_ID}" -H "$(auth)"
```
> **→ write Live State:** `status: lapsing` (auto-renew off; registration persists until expiry). Only
> `gone` if the registration is actually released — never claim it while CF still lists the domain.

```bash
# ✔ verify teardown — auto-renew is off
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/registrar/domains/${DOMAIN_NAME}" -H "$(auth)" \
  | jq -r '.result.auto_renew'   # expect: false
```

---

## Portability ledger — same intent, two bindings (the inversion)

| | AWS (`domain.aws.md`) | Cloudflare (`domain.cloudflare.md`) |
|---|---|---|
| The purchase | `register-domain` — **one gated API call** | dashboard only — **no register API**, an ✉ hand-off |
| Pricing | retail + markup | **at-cost** (no markup) |
| WHOIS privacy | a flag on `register-domain` | automatic & free (no-op input) |
| Zone + delegation | auto-created & delegated by the purchase | **zone must exist on CF first**, then registrar takes it |
| Plan-able end-to-end? | yes, up to the 🔴 gate | no — the core step is out-of-band |
| ICANN email verify | `get-contact-reachability-status` (API-observable) | handled in the dashboard flow |
| Teardown | disable auto-renew (API) · `delete-domain` where supported | disable auto-renew (API) · rest manual |

> Cross-reference: this is the exact opposite of the **SPA** ledger, where Cloudflare's `custom_domain:
> true` collapses the AWS ACM + alias-record apparatus to one line. Whichever cloud is "easy" depends
> entirely on *which* intent — the value of holding both bindings is seeing that, not picking a winner.

## Deliberately not included

- **Scripting the purchase** — impossible: no Cloudflare Registrar register/transfer API exists. The plan
  emits the dashboard steps and pauses; that is the binding's ceiling, not an authoring shortcut.
- **Moving an existing registrar's NS automatically** — setting Cloudflare's nameservers at your *current*
  registrar (pre-transfer) is a step at that registrar, outside this account's API.
- **DNSSEC, registrar lock changes, redirect rules** — separate intents.
- **Refund/cancellation** — does not exist for registrations; see Teardown's asymmetry.
