# Ephemera — Static web on Cloudflare (Workers Static Assets) — SPA or multipage

> Self-executing Markdown. Same **intent** as [`web.aws.md`](./web.aws.md); different **binding**.
> The cloud is the source of truth for state; this file is intent + write-back ledger + audit.

---

## 🤖 Director prompt

Same contract as `web.aws.md`: observe before acting; verify each step; stop at 🔴/💥 for human go;
write realized values back into Live State. Cloudflare deploys are seconds, not minutes, and reverse
with one command — so there are no ⏳ async waits and the only gate is the outward-facing deploy.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

Serve a **static website** over HTTPS from a global CDN, with the origin never publicly browsable as a
bucket. **`SITE_TYPE` decides the routing:** `spa` returns the app shell for unknown paths (client-side
routing); `static` serves real files and returns a real 404 for misses. **Identical to the AWS plan's
intent** — only the binding differs.

**Shared acceptance contract** (the same test both bindings must pass):
1. `GET /` → `200` + body marker `${CONTENT_MARKER}` (default `EPHEMERA-OK`)
2. deep link — `spa`: `GET /some/deep/route` → `200` (app-shell fallback); `static`: a real path → `200`, a miss → `404`
3. HTTPS enforced

> **Note on "private origin":** the AWS plan also asserts "direct S3 URL → 403." On Cloudflare there
> is *no separate origin to lock down* — assets live on Cloudflare's edge, served only through the
> Worker/CDN. The private-origin requirement is satisfied by *architecture*, so there's nothing to
> assert. That absence is the portability insight, not a gap.

## Provisioning Inputs

> Same table as [`web.aws.md`](./web.aws.md) — the **inputs are identical across bindings**; only how each
> is realized differs. The Director resolves them once, writes `resolved_inputs` into Live State, and
> branches the steps below. `DOMAIN_MODE=none` reproduces the original `*.workers.dev` behavior.

| # | Question | Options (closed enum) | Default | Sets | Gates (Cloudflare binding) |
|---|----------|-----------------------|---------|------|----------------------------|
| 1 | Site type | `spa` / `static` | `spa` | `SITE_TYPE` | `not_found_handling` in §1 (`single-page-application` vs `404-page`) and §3 acceptance |
| 2 | Custom domain? | `none` / `apex` / `subdomain` | `none` | `DOMAIN_MODE` | whether a `custom_domain` route is added at all |
| 3 | Domain name | text — `example.com` / `app.example.com` | — | `DOMAIN_NAME` | required iff `DOMAIN_MODE≠none` |
| 4 | Manage DNS here? | `managed` / `external` | `managed` | `DNS_MODE` | **managed** = zone on Cloudflare (cert + DNS automatic); **external** = documented limitation (§1b) |
| 5 | TLS cert | `create` / `reuse:<arn>` | `create` | `CERT_MODE` | **no-op when managed** — Cloudflare issues + renews the edge cert automatically |
| 6 | *(apex only)* also serve `www`? | `yes` / `no` | `no` | `WWW` | adds a second `custom_domain` route for `www.` |
| 7 | Bot protection? | `bot-fight` / `super` / `off` | `bot-fight` | `BOT_PROTECTION` | §1c — zone-level bot mitigation (applies to the **custom-domain zone**) |

> **Denial-of-wallet note.** The exact incident that mauls a CloudFront bill — a looping bot pulling
> terabytes — is **structurally far cheaper here**: Cloudflare's CDN egress is **$0**, so a bot loop costs
> bandwidth = nothing (at most Worker *requests* on a paid plan). `BOT_PROTECTION` adds defense in depth on
> the **custom-domain zone**: `bot-fight` = Bot Fight Mode (free); `super` = Super Bot Fight Mode (Pro+,
> paid). The free `*.workers.dev` hostname is already behind Cloudflare's edge; zone bot-mode applies once
> a `custom_domain` on a CF zone exists.

**Binding asymmetry (the portability insight):** on AWS apex-vs-subdomain costs a whole alias-record branch
and `CERT_MODE` drives an ACM request; on Cloudflare a custom domain is **one line** (`custom_domain:
true`) and CF auto-provisions both the cert and the (apex-flattened) DNS record — so `CERT_MODE` is moot
and apex/subdomain is transparent. The catch: this needs the **zone on Cloudflare**. `DNS_MODE=external`
(zone elsewhere) can't get a true Worker custom domain without moving the zone — see §1b.

**Composition — Requires:** `cf-zone(DOMAIN_NAME)` *(when `DNS_MODE=managed`)* — an active Cloudflare zone.
Satisfied by [`domain.cloudflare.md`](./domain.cloudflare.md), which **Provides** it, or any zone already on
Cloudflare. The Director discovers it by observing the cloud (zone `status: active`); absent → it points you
at `domain.cloudflare.md` or the external path in §1b. Through the cloud.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown — `wrangler delete` removed the worker
last_verified: 2026-06-24 cloud re-check — Worker absent (API 10007), edge 404; `--dry-run` config valid; teardown DNS-skip branch confirmed (DOMAIN_MODE=none ⇒ no route/cert/DNS removal attempted)

resolved_inputs:              # last run was the none-branch (free *.workers.dev hostname)
  site_type:   spa            # spa | static (multipage)
  bot_protection: bot-fight   # bot-fight | super | off  (default bot-fight, free)
  domain_mode: none           # none | apex | subdomain
  domain_name: —              # required iff domain_mode != none
  dns_mode:    managed        # managed | external  (managed = zone on Cloudflare)
  cert_mode:   create         # create | reuse:<arn>  (no-op on CF managed)
  www:         no             # yes | no (apex only)
```

| key         | value |
|-------------|-------|
| ACCOUNT_ID  | `<CF_ACCOUNT_ID>` |
| WORKER_NAME | `mdinfra-spa-test` |
| URL         | `https://mdinfra-spa-test.<worker>.workers.dev` |
| VERSION_ID  | `fcf4479e-97cd-4e49-b4a8-07220f9ee3f7` |
| DOMAIN_NAME | — (none-branch) |

| ✔ check                  | expected                          | observed | result |
|--------------------------|-----------------------------------|----------|--------|
| `GET /`                  | `200` + `EPHEMERA-OK`            | 200 + marker | ✅ PASS |
| `GET /some/deep/route`   | `200` (SPA fallback)              | 200 + marker | ✅ PASS |
| HTTPS                    | served over TLS                   | HTTP/2 TLS   | ✅ PASS |

## 0. Variables

```bash
# Deployment identity — the template serves ANY site now, not just the bundled demo:
export WORKER_NAME="mdinfra-spa-test"        # Worker/deploy name (was baked into wrangler.jsonc)
export ASSETS_DIR="./site"                   # directory of files to serve (was hardcoded ./site)
export CONTENT_MARKER="EPHEMERA-OK"          # a string present in the served page → §3 acceptance assertion
export CONFIG="$(mktemp -d)/wrangler.jsonc"  # GENERATED in §1 from resolved inputs (config = pure fn of inputs)
# NOTE: this machine wraps `wrangler` in a shell function that pulls a keychain token via a helper
# not present non-interactively. Use `command wrangler` to bypass it and use the ambient CLOUDFLARE_API_TOKEN.

# Resolved from Provisioning Inputs (none-branch = original *.workers.dev behavior):
export SITE_TYPE="spa"         # spa | static (multipage)
export BOT_PROTECTION="bot-fight"  # bot-fight | super | off  (§1c — zone-level)
export DOMAIN_MODE="none"      # none | apex | subdomain
export DOMAIN_NAME=""          # e.g. example.com or app.example.com
export DNS_MODE="managed"      # managed | external  (managed = zone on Cloudflare)
export CERT_MODE="create"      # create | reuse:<arn>  (no-op on CF managed — cert is automatic)
export WWW="no"                # yes | no (apex only)
export CF_ZONE_ID=""           # discovered in §1a from DOMAIN_NAME's apex (when DOMAIN_MODE != none)
```

## Dependency frontier

Essentially none — one config file, one deploy. **That collapse is the whole point of the binding
comparison.** No bucket→OAC→distribution→policy ordering, no ARNs to thread, no chicken-and-egg.

## 1. Project config — generated from resolved inputs  🟢

The config *is* the infrastructure, and it's a **pure function of the resolved inputs** (`WORKER_NAME`,
`ASSETS_DIR`, `SITE_TYPE` → `not_found_handling`, and the custom-domain route). Assets-only Worker, no
`main`, no Worker code.

```bash
NFH=$([ "$SITE_TYPE" = "static" ] && echo "404-page" || echo "single-page-application")
ROUTES=""
if [ "$DOMAIN_MODE" != "none" ] && [ "$DNS_MODE" = "managed" ]; then
  ROUTES=",\"routes\":[{\"pattern\":\"${DOMAIN_NAME}\",\"custom_domain\":true}"
  [ "$WWW" = "yes" ] && ROUTES="${ROUTES},{\"pattern\":\"www.${DOMAIN_NAME}\",\"custom_domain\":true}"
  ROUTES="${ROUTES}]"
fi
cat > "$CONFIG" <<JSON
{ "name": "${WORKER_NAME}", "compatibility_date": "2026-06-01",
  "assets": { "directory": "${ASSETS_DIR}", "not_found_handling": "${NFH}" }${ROUTES} }
JSON
command wrangler deploy --config "$CONFIG" --dry-run 2>&1 | tail -5   # ✔ generated config valid
```
> The bundled `wrangler.jsonc` is now just a demo example — the plan generates its own config so it serves
> any `ASSETS_DIR` under any `WORKER_NAME`. (Dogfood finding: the template was fit to the demo's `./site`.)

## 1a. Discover the custom-domain zone  ✔  *(skipped when `DOMAIN_MODE=none`)*

> Fixes the gap dogfooding exposed: `CF_ZONE_ID` (used by §1c) must be **discovered from the domain's
> apex**, not assumed. For `ephemera.daystra.com` the apex is `daystra.com`.

```bash
if [ "$DOMAIN_MODE" != "none" ] && [ "$DNS_MODE" = "managed" ]; then
  APEX="$(echo "$DOMAIN_NAME" | awk -F. '{print $(NF-1)"."$NF}')"
  CF_ZONE_ID="$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=${APEX}" \
    -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
    | python3 -c 'import sys,json;r=json.load(sys.stdin)["result"];print(r[0]["id"] if r else "")')"
  echo "zone for ${APEX}: ${CF_ZONE_ID:-NOT FOUND — the zone must be on Cloudflare}"
fi
```
> → Live State: CF_ZONE_ID.

## 1b. Custom domain — Cloudflare route (custom domain only)  🟡 / 🔴

> Skipped when `DOMAIN_MODE=none`. A Worker custom domain is **one line** — Cloudflare issues the edge
> cert and creates the proxied DNS record automatically, *provided the zone is active on this Cloudflare
> account*. apex vs subdomain is transparent (CF flattens an apex CNAME natively).

**`DNS_MODE=managed`** (zone on Cloudflare) — the `custom_domain` route is **generated in §1** from
`DOMAIN_NAME` (+ `www` when `WWW=yes`); the §2 deploy then provisions the cert + DNS record automatically.
Nothing to hand-edit.

```bash
# ✔ the generated config already carries the route
[ "$DOMAIN_MODE" = "none" ] || { grep -q custom_domain "$CONFIG" && echo "route present in generated config"; }
```

**`DNS_MODE=external`** (zone NOT on Cloudflare) — a true Worker custom domain is unavailable without the
zone on Cloudflare. 🔴 Choose one, then record the decision in Live State:
- **Move the zone to Cloudflare** → becomes the `managed` path above (cert + DNS automatic).
- **CNAME a subdomain** at your provider: `app.example.com CNAME <WORKER>.workers.dev.` — reachable, but
  **no managed cert for the custom name** (the served cert is `*.workers.dev`).
- **apex + external** — same ALIAS/ANAME caveat as the AWS binding; a CNAME-only provider can't host apex.

## 1c. Bot protection — zone-level  🟡 / 🔴  *(skipped when `BOT_PROTECTION=off` or no CF custom-domain zone)*

> Applies to the **custom-domain zone** (`CF_ZONE_ID`, discovered from `domain.cloudflare.md`). `bot-fight`
> = Bot Fight Mode (free, all plans); `super` = Super Bot Fight Mode (🔴 **Pro+, paid**). The free
> `*.workers.dev` hostname is already behind Cloudflare's edge, so this is meaningful once a `custom_domain`
> on a CF zone exists.

```bash
if [ "$BOT_PROTECTION" != "off" ] && [ "$DOMAIN_MODE" != "none" ] && [ "$DNS_MODE" = "managed" ]; then
  if [ "$BOT_PROTECTION" = "bot-fight" ]; then
    BODY='{"fight_mode": true}'                                  # free Bot Fight Mode
  else
    BODY='{"sbfm_definitely_automated": "block", "sbfm_likely_automated": "managed_challenge", "optimize_wordpress": false}'  # Super BFM (Pro+)
  fi
  curl -fsS -X PUT "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/bot_management" \
    -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -H "Content-Type: application/json" --data "$BODY"
fi
```
```bash
# ✔ verify
[ "$BOT_PROTECTION" = "off" ] || [ "$DOMAIN_MODE" = "none" ] || \
  curl -fsS "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/bot_management" \
    -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"])'
```
> **→ write Live State:** verify row "bot protection = ${BOT_PROTECTION}".

## 2. Deploy  🟡 (outward-facing — light gate)

> 🟡 Publishes a public `*.workers.dev` URL. Free tier, deploys in seconds, reverses with one
> `wrangler delete`. Show the command, then deploy.

```bash
command wrangler deploy --config "$CONFIG"
# → write Live State: URL, VERSION_ID; status: live
```

## 3. Acceptance verify  ✔

```bash
URL="https://mdinfra-spa-test.<subdomain>.workers.dev"   # realized in §2
curl -s -o /dev/null -w 'root=%{http_code}\n'  "$URL/"
curl -s "$URL/" | grep -o "$CONTENT_MARKER"
if [ "$SITE_TYPE" = "static" ]; then
  curl -s -o /dev/null -w 'missing=%{http_code}\n' "$URL/no-such-page"   # expect 404
else
  curl -s -o /dev/null -w 'deep=%{http_code}\n'    "$URL/some/deep/route" # expect 200 (app shell)
fi

# ✔ custom-domain acceptance (only when DOMAIN_MODE != none; managed CF custom domain serves a real cert)
if [ "$DOMAIN_MODE" != "none" ]; then
  curl -s -o /dev/null -w 'domain_root=%{http_code}\n' "https://${DOMAIN_NAME}/"
  curl -s "https://${DOMAIN_NAME}/" | grep -o "$CONTENT_MARKER"
  curl -s -o /dev/null -w 'domain_deep=%{http_code}\n' "https://${DOMAIN_NAME}/some/deep/route"
fi
```
> → write Live State: status: live; fill verify rows.

## Teardown  💥 (one command — contrast the CloudFront dance)

> 💥 Human go. No disable→⏳wait→delete state machine: a Worker deletes immediately.

```bash
command wrangler delete --config "$CONFIG"
# Removing the Worker also removes any custom_domain route + its CF-managed cert and DNS record (managed).
# DNS_MODE=external: tell the human to remove their CNAME at the external provider.
```
```bash
# ✔ verify teardown — expect the worker absent
command wrangler deployments list --config "$CONFIG" 2>&1 | tail -3   # errors / empty = gone
```
> → write Live State: status: gone.

---

## Portability ledger — same intent, two bindings

| | AWS (`web.aws.md`) | Cloudflare (`web.cloudflare.md`) |
|---|---|---|
| Resources to reach the intent | 5 (bucket, public-access-block, OAC, distribution, bucket-policy) | 1 (a Worker w/ static assets) |
| "Private origin" handling | explicit: OAC + bucket-policy + `SourceArn` confused-deputy guard | n/a — edge unifies storage+CDN, nothing to lock |
| Not-found routing (`SITE_TYPE`) | `CustomErrorResponses` (+ a CloudFront Function for `static` index resolution) | one config line: `not_found_handling` (`single-page-application` ↔ `404-page`) |
| Time to first byte live | ~15–20 min (distribution deploy ⏳) | seconds |
| Teardown | disable → ⏳ wait ~15 min → delete → OAC → empty versioned bucket → delete | one `wrangler delete` |
| Egress cost | per-GB (the cost-A/B headline) | $0 |
| Per-asset cache headers | two-pass `s3 cp --cache-control` (immutable vs no-cache) | CF default; immutable needs a small Worker |
| Custom domain | ACM cert (us-east-1) + dist `Aliases` + Route53 **alias A/AAAA** | one line: `custom_domain: true` — cert + DNS automatic |
| apex vs subdomain | apex can't CNAME → dedicated alias-record branch | transparent (CF apex flattening) |
| `CERT_MODE` input | request/validate, or reuse an ACM ARN | no-op when managed (cert automatic) |
| External DNS | any ALIAS/ANAME provider (emit → 🔴 pause → verify) | custom domain needs the zone **on** CF; external = documented limitation |
| Denial-of-wallet (looping bot) | **egress billed + no hard spend cap** → budget alarm (detect) + WAF rate-limit (prevent) | **$0 egress** caps the blast radius structurally; Bot Fight Mode (free) on top |
| Cost of the *mitigation* itself | AWS WAF billed **per-million-requests inspected** (+ per-ACL/per-rule) — under a flood **the cure accrues its own variable bill** ("cure worse than the disease") | WAF / Bot Fight **plan-flat** — protection cost is bounded, no per-request WAF charge |

## Deliberately not included

- **Custom domain for a zone NOT on Cloudflare** — a true Worker custom domain (managed cert) needs the
  zone on Cloudflare. External-zone options (CNAME to `*.workers.dev`, or move the zone) are in §1b;
  Cloudflare-for-SaaS custom hostnames (managed cert for an external zone) are out of scope.
- **apex on a CNAME-only external provider** — unsupported (needs ALIAS/ANAME), same as the AWS binding.
- **Per-asset immutable cache headers** — would add a tiny `main` Worker with a cache-control override
  (see the Cloudflare static-assets cache pattern). Omitted to keep the binding assets-only.
- **WAF / Turnstile / Access** — Cloudflare equivalents of the AWS "deliberately not included" list.
