# Ephemera — Static web on CloudFront + private S3 (OAC) — SPA or multipage

> **Self-executing Markdown.** This file is the source of truth for *intent*. The cloud is the
> source of truth for *state*. An agent reads this top-to-bottom, makes the world match it,
> and writes back what actually happened into **Live State** below.

---

## 🤖 Director prompt — read this first, every run

You are the **Director**. You execute this plan against AWS. Your operating contract:

1. **Read Intent, then Live State.** Live State is a *cache* of what was realized. The cloud is
   truth — when they disagree, believe the cloud and correct Live State.
2. **Observe before you act.** Every step says how to check "does it already look like this?"
   Run that first. Act only on the delta. Re-running this plan must be safe.
3. **Advance one verified step at a time.** Run a step → run its `verify` → only then move on.
   Never skip ahead with an unverified frontier behind you.
4. **Stop at every 🔴 and 💥.** Print what you are about to do and *wait for a human "go"*.
   Never auto-run a 🔴 (billable/global/slow-to-reverse) or 💥 (destructive) step.
5. **Write back.** After any mutation, record realized IDs in **Live State**. After any `verify`,
   record PASS/FAIL + the observed value. This file is the audit trail.
6. **⏳ steps are async.** CloudFront deploy/disable is ~15–20 min — longer than a single shell
   will sit. Run the waiter as a **background task** and resume when it signals done.
7. **Teardown is resumable.** It is *reconcile-toward-absence*: observe the cloud, figure out where
   you are in the state machine, act, repeat. A crash mid-teardown is fine — the next run re-observes.

```
Legend
🟢 create      · reversible by teardown
🟡 config      · mutating, idempotent on re-run
🔴 GATE        · billable/global or slow-to-reverse — HUMAN GO REQUIRED
💥 destructive · irreversible — HUMAN GO REQUIRED (teardown only)
⏳ wait        · async; completion is not immediate — background it
✔ verify       · read-only assertion: "does it look like this?"
```

---

## Intent

Serve a **static website** over HTTPS from a **private** S3 bucket fronted by CloudFront, using
**Origin Access Control** (OAC, not legacy OAI) and immutable-asset caching. No public bucket, no S3
website endpoint. **`SITE_TYPE` decides the routing:** `spa` returns the app shell for unknown paths
(client-side routing — 403/404 → `/index.html`, served `200`); `static` serves real files per route,
returns a real `404` (→ `/404.html`) for misses, and resolves directory paths to their index document.
The serving hostname is a **Provisioning Input**: the default `none` branch uses the free
`*.cloudfront.net` name (no ACM cert, no Route53); an `apex` or `subdomain` domain adds an ACM cert
(us-east-1) and — unless DNS is external — a Route53 alias record.

---

## Provisioning Inputs

> The Director resolves these **once**, up front, before any cloud mutation — walk the rows top-down,
> confirm each with the human (accept the **default** on silence), then write `resolved_inputs` into Live
> State. Every option is a closed enum (bar the free-text domain / ARN), so the resource graph is a pure
> function of the answers: same answers ⇒ same plan. `DOMAIN_MODE=none` reproduces the original
> free-hostname behavior exactly.

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Site type | `spa` / `static` | `spa` | `SITE_TYPE` | §3 `CustomErrorResponses` (app-shell fallback vs real 404 + index resolution) and §6 acceptance |
| 2 | Custom domain? | `none` / `apex` / `subdomain` | `none` | `DOMAIN_MODE` | whether §2b (cert), the dist `Aliases`, and §4b (DNS) run 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` | Route53 record + cert-validation: auto-create vs emit→🔴 pause→verify |
| 5 | TLS cert | `create` / `reuse:<acm-arn>` | `create` | `CERT_MODE` | request+DNS-validate a new ACM cert vs attach an existing one |
| 6 | *(apex only)* also serve `www`? | `yes` / `no` | `no` | `WWW` | adds a `www.` SAN on the cert + a `www.` alias record |
| 7 | Cost guardrail (budget alarm)? | `on` / `off` | `on` | `BUDGET_ALARM` | §7 — an AWS Budget that emails at a spend threshold (near-free; the denial-of-wallet detector) |
| 8 | Edge protection (WAF)? | `none` / `rate-limit` / `waf-managed` | `none` | `EDGE_PROTECTION` | §2d — a WAFv2 web ACL on the distribution; **billable** prevention, opt-in |

> **Denial-of-wallet note.** A looping bot pulling terabytes off CloudFront is a *billing* attack, not a
> break-in — and CloudFront has **no hard spend cap**. So the cheap, default-on `BUDGET_ALARM` is
> **detection** (it emails you fast); `EDGE_PROTECTION` is **prevention** (`rate-limit` = a WAF rate-based
> rule that blocks an IP over a threshold — the direct fix; `waf-managed` adds AWS Managed Rules). WAF is
> billable (web ACL + per-rule + per-million-requests; Bot Control more) — *check current pricing at apply*.

**Determinism note:** apex *cannot* be a CNAME — it always resolves to a Route53 **alias A/AAAA** (or, for
external DNS, an ALIAS/ANAME at your provider). A CNAME-only external provider can't host an apex; the
plan emits the target and stops.

**Composition — Requires:** `delegated-zone(DOMAIN_NAME)` *(when `DNS_MODE=managed`)*. Satisfied by any
registration — e.g. [`domain.aws.md`](./domain.aws.md), which **Provides** it — or any pre-existing
delegated zone. The Director *discovers* it by observing the cloud (`§2b` does `list-hosted-zones-by-name`);
a found zone auto-resolves `DNS_MODE=managed` + `HOSTED_ZONE_ID`, so you aren't re-asked what you already
registered. No upstream zone and not external → the Director points you at `domain.aws.md`. Composition is
through the cloud.

---

## Live State

> The Director writes here. `—` means "not yet realized / unknown." Treat as a cache of the cloud.

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown complete (resumable Janitor) — re-entry found bucket already gone (idempotent)
last_verified: teardown verify — 0 distributions, 0 OACs, bucket GONE

resolved_inputs:                  # last run was the none-branch (free *.cloudfront.net hostname)
  site_type:   spa                # spa | static (multipage)
  domain_mode: none               # none | apex | subdomain
  domain_name: —                  # required iff domain_mode != none
  dns_mode:    managed            # managed | external
  cert_mode:   create             # create | reuse:<acm-arn>
  www:         no                 # yes | no (apex only)
  budget_alarm:   on              # on | off  (default on — near-free denial-of-wallet detector)
  edge_protection: none           # none | rate-limit | waf-managed  (WAF is billable; opt-in)
```

| key           | value                                              |
|---------------|----------------------------------------------------|
| AWS_REGION    | `us-west-2`                                         |
| ACCOUNT_ID    | `<AWS_ACCOUNT_ID>`                                      |
| BUCKET        | `mdinfra-spa-test-<AWS_ACCOUNT_ID>-origin`              |
| DISCOVERY_KEY | `mdinfra:spa-test`  *(distribution Comment)*        |
| OAC_ID        | — (deleted)                                        |
| DIST_ID       | — (deleted)                                        |
| DIST_DOMAIN   | — (deleted)                                        |
| DIST_ARN      | — (deleted)                                        |
| DOMAIN_NAME   | — (none-branch)                                    |
| CERT_ARN      | — (none-branch; ACM us-east-1)                     |
| HOSTED_ZONE_ID| — (none-branch; Route53)                           |

**Verify ledger** (most recent run):

| ✔ check                          | expected                          | observed | result |
|----------------------------------|-----------------------------------|----------|--------|
| bucket public-access-block       | all four `true`                   | all true | ✅ PASS |
| bucket policy SourceArn          | this distribution's ARN           | matches  | ✅ PASS |
| distribution status             | `Deployed` + `Enabled=true`       | Deployed/true | ✅ PASS |
| `GET /`                          | `200` + body marker `EPHEMERA-OK`| 200 + marker  | ✅ PASS |
| `GET /some/deep/route`           | `200` (SPA fallback)              | 200 + marker  | ✅ PASS |
| direct S3 object URL             | `403` (origin is private)         | 403           | ✅ PASS |

---

## 0. Variables

```bash
export AWS_REGION="us-west-2"
export ACCOUNT_ID="<AWS_ACCOUNT_ID>"
export BUCKET="mdinfra-spa-test-${ACCOUNT_ID}-origin"
export DISCOVERY_KEY="mdinfra:spa-test"     # distribution Comment — the Janitor's lookup key
export SITE_DIR="./site"
# Realized later — the Director rehydrates these from Live State or re-derives from the cloud:
export OAC_ID="" DIST_ID="" DIST_DOMAIN="" DIST_ARN=""

# Resolved from Provisioning Inputs (none-branch = original free-hostname behavior):
export SITE_TYPE="spa"         # spa | static (multipage)
export BUDGET_ALARM="on"       # on | off — §7 budget alarm (near-free)
export BUDGET_LIMIT_USD="50"   # monthly threshold that triggers the alert
export ALARM_EMAIL="you@example.com"   # where the budget alert lands
export EDGE_PROTECTION="none"  # none | rate-limit | waf-managed — §2d WAF (billable)
export WEB_ACL_ARN=""          # realized in §2d when EDGE_PROTECTION != none
export DOMAIN_MODE="none"      # none | apex | subdomain
export DOMAIN_NAME=""          # e.g. example.com or app.example.com
export DNS_MODE="managed"      # managed | external
export CERT_MODE="create"      # create | reuse:<acm-arn>
export WWW="no"                # yes | no (apex only)
export CERT_REGION="us-east-1" # ACM certs for CloudFront MUST live here, regardless of $AWS_REGION
export CF_ALIAS_ZONE="Z2FDTNDATAQYW2"  # CloudFront's fixed hosted-zone id for Route53 alias targets (global constant)
export CERT_ARN="" HOSTED_ZONE_ID=""   # realized later
```

Managed AWS-owned policy IDs (stable):

| Purpose | ID |
|---|---|
| Cache policy `CachingOptimized` | `658327ea-f89d-4fab-a63d-7e88639e58f6` |
| Response headers `SecurityHeadersPolicy` | `67f7725c-6f97-4210-82d7-5512b31e9d03` |

---

## Dependency frontier (why order matters)

```
bucket ─> lockdown ─> versioning ─> encryption ─> tags ─┐
                                                         │
OAC ─────────────────────────────────────────────┐      │
                                                  ▼      ▼
                                          DISTRIBUTION ──┴─> bucket-policy (needs DIST_ID)
                                                  │
                                                  ⏳ wait deployed ─> deploy content ─> ✔ acceptance
```

Non-negotiable edge: **bucket-policy comes after the distribution exists** — it pins the
distribution ARN in the `SourceArn` condition (the confused-deputy guard).

**Custom-domain overlay** (only when `DOMAIN_MODE≠none`):

```
ACM cert request ─> DNS validation ─> ⏳ ISSUED ─┐
  (managed: auto-CNAME in zone / external: emit→🔴 pause)
                                                  ▼
                          DISTRIBUTION (Aliases + ACM ViewerCertificate)   [needs cert ISSUED]
                                                  │
                        ⏳ deployed ─> Route53 alias A/AAAA (managed)        [needs DIST_DOMAIN]
                                      / emit record→🔴 pause→verify (external)
```

Two more non-negotiable edges: **the cert must be `ISSUED` before the distribution can reference it**, and
**the alias record needs the distribution's domain** (records come after the dist exists).

---

## 1. Private S3 origin bucket  🟢🟡

```bash
# observe: does it already look like this?
if aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
  echo "bucket exists — skip create, ensure config below is applied (all idempotent)"
else
  # 🟢 us-west-2 needs LocationConstraint. (us-east-1 is the ONE region that rejects it.)
  aws s3api create-bucket --bucket "$BUCKET" --region "$AWS_REGION" \
    --create-bucket-configuration LocationConstraint="$AWS_REGION"
fi

# 🟡 lock it down — all four switches on (this is what makes "private + OAC" honest)
aws s3api put-public-access-block --bucket "$BUCKET" \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# 🟡 versioning — cheap rollback insurance
aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Enabled

# 🟡 default encryption (SSE-S3)
aws s3api put-bucket-encryption --bucket "$BUCKET" --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'

# 🟡 tag-as-truth — the registry the Janitor falls back on if Live State is lost
aws s3api put-bucket-tagging --bucket "$BUCKET" \
  --tagging 'TagSet=[{Key=Project,Value=mdinfra},{Key=Deployment,Value=spa-test}]'
```

```bash
# ✔ verify — all four must be true
aws s3api get-public-access-block --bucket "$BUCKET" \
  --query 'PublicAccessBlockConfiguration' --output json
```
> **→ write Live State:** `BUCKET` confirmed; verify row "bucket public-access-block".

---

## 2. Origin Access Control  🟢

```bash
# observe: reuse an OAC with our name if it already exists
OAC_ID="$(aws cloudfront list-origin-access-controls \
  --query "OriginAccessControlList.Items[?Name=='${BUCKET}-oac'].Id | [0]" --output text)"

if [ "$OAC_ID" = "None" ] || [ -z "$OAC_ID" ]; then
  OAC_ID="$(aws cloudfront create-origin-access-control --origin-access-control-config '{
    "Name":"'"$BUCKET"'-oac",
    "Description":"OAC for Ephemera SPA test",
    "OriginAccessControlOriginType":"s3",
    "SigningBehavior":"always",
    "SigningProtocol":"sigv4"
  }' --query 'OriginAccessControl.Id' --output text)"
fi
echo "OAC_ID=$OAC_ID"
```
> **→ write Live State:** `OAC_ID`.

---

## 2b. TLS certificate — ACM (custom domain only)  🔴 GATE

> Skipped entirely when `DOMAIN_MODE=none`. ACM certs for CloudFront **must** be in `us-east-1`
> (`$CERT_REGION`) regardless of `$AWS_REGION`. Requesting a public cert is the 🔴.

```bash
if [ "$DOMAIN_MODE" != "none" ]; then
  # --- managed DNS: resolve the hosted zone now (needed for validation AND the §4b alias record) ---
  if [ "$DNS_MODE" = "managed" ]; then
    # longest-suffix match: app.example.com is normally served by the example.com. zone
    HOSTED_ZONE_ID="$(aws route53 list-hosted-zones-by-name \
      --query "HostedZones[?ends_with('${DOMAIN_NAME}.', Name)] | sort_by(@, &length(Name))[-1].Id" \
      --output text 2>/dev/null | sed 's#/hostedzone/##')"
    { [ -z "$HOSTED_ZONE_ID" ] || [ "$HOSTED_ZONE_ID" = "None" ]; } && \
      echo "no Route53 hosted zone covers ${DOMAIN_NAME} — register/delegate it first (see domain.aws.md → Provides delegated-zone), or use DNS_MODE=external"
    echo "HOSTED_ZONE_ID=$HOSTED_ZONE_ID"
  fi

  case "$CERT_MODE" in
    reuse:*)
      CERT_ARN="${CERT_MODE#reuse:}"
      # ✔ assert: ISSUED, in us-east-1, covers the name (+ www if WWW=yes)
      aws acm describe-certificate --region "$CERT_REGION" --certificate-arn "$CERT_ARN" \
        --query 'Certificate.{Status:Status,Domain:DomainName,SANs:SubjectAlternativeNames}' --output json
      ;;
    *)  # create
      # observe: reuse a cert we already requested for this exact name
      CERT_ARN="$(aws acm list-certificates --region "$CERT_REGION" \
        --query "CertificateSummaryList[?DomainName=='${DOMAIN_NAME}'].CertificateArn | [0]" --output text)"
      if [ "$CERT_ARN" = "None" ] || [ -z "$CERT_ARN" ]; then
        SAN_ARGS=""
        [ "$DOMAIN_MODE" = "apex" ] && [ "$WWW" = "yes" ] && SAN_ARGS="--subject-alternative-names www.${DOMAIN_NAME}"
        # 🔴 request a public, DNS-validated cert
        CERT_ARN="$(aws acm request-certificate --region "$CERT_REGION" \
          --domain-name "$DOMAIN_NAME" $SAN_ARGS --validation-method DNS \
          --query CertificateArn --output text)"
      fi
      ;;
  esac
  echo "CERT_ARN=$CERT_ARN"
fi
```
> **→ write Live State:** `CERT_ARN`, `HOSTED_ZONE_ID`.

```bash
# Place the DNS validation record, then wait for ISSUED (create-mode only; a reused cert is already ISSUED).
if [ "$DOMAIN_MODE" != "none" ] && [ "${CERT_MODE%%:*}" = "create" ]; then
  # the CNAME ACM wants (re-read until ACM populates it — a beat after request):
  read VNAME VVALUE < <(aws acm describe-certificate --region "$CERT_REGION" --certificate-arn "$CERT_ARN" \
    --query 'Certificate.DomainValidationOptions[0].ResourceRecord.[Name,Value]' --output text)

  if [ "$DNS_MODE" = "managed" ]; then
    # 🟡 auto-create the validation CNAME in our zone (idempotent UPSERT)
    aws route53 change-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" --change-batch "{
      \"Changes\":[{\"Action\":\"UPSERT\",\"ResourceRecordSet\":{
        \"Name\":\"$VNAME\",\"Type\":\"CNAME\",\"TTL\":300,
        \"ResourceRecords\":[{\"Value\":\"$VVALUE\"}]}}]}"
  else
    # 🔴 external DNS — EMIT and PAUSE for the human to place it elsewhere
    echo "Create this validation record at your DNS provider, then reply when placed:"
    echo "  ${VNAME}  CNAME  ${VVALUE}"
    # → STOP here for human 'placed'. On resume, continue to the wait below.
  fi

  # ⏳ wait for the cert to validate (background it if it lingers)
  aws acm wait certificate-validated --region "$CERT_REGION" --certificate-arn "$CERT_ARN"
fi
```

```bash
# ✔ verify — cert must be ISSUED in us-east-1
[ "$DOMAIN_MODE" = "none" ] || aws acm describe-certificate --region "$CERT_REGION" \
  --certificate-arn "$CERT_ARN" --query 'Certificate.Status' --output text   # expect: ISSUED
```
> **→ write Live State:** verify row "ACM cert ISSUED".

---

## 2d. Edge protection — WAFv2 web ACL  🔴 GATE  *(skipped when `EDGE_PROTECTION=none`)*

> 🔴 **Billable.** For CloudFront the web ACL is **scope `CLOUDFRONT`, region `us-east-1`**, and attaches
> by putting its ARN in the distribution's `WebACLId` (§3) — not via `associate-web-acl` (that's for
> regional resources). `rate-limit` = a single rate-based rule (blocks an IP over the limit — the direct
> denial-of-wallet fix); `waf-managed` adds AWS Managed Rule groups.
>
> **Cost caveat — the cure has its own bill.** AWS WAF is billed **per-million-requests inspected** (plus
> per-ACL + per-rule monthly), so a high-volume flood charges the *WAF* too — the mitigation carries a
> variable cost that scales with the attack. Keep the rate limit aggressive, the `waf-managed` rule count
> lean, and lean on the §7 budget alarm. This per-request WAF billing is itself a **portability
> difference**: on the Cloudflare binding the equivalent (WAF / Bot Fight) is **plan-flat**, so protection
> cost is bounded — see `web.cloudflare.md`'s portability ledger. *Check current WAF pricing at apply.*

```bash
if [ "$EDGE_PROTECTION" != "none" ]; then
  RULES='[{ "Name":"rate-limit","Priority":0,"Action":{"Block":{}},
    "Statement":{"RateBasedStatement":{"Limit":2000,"AggregateKeyType":"IP"}},
    "VisibilityConfig":{"SampledRequestsEnabled":true,"CloudWatchMetricsEnabled":true,"MetricName":"rate-limit"}}]'
  if [ "$EDGE_PROTECTION" = "waf-managed" ]; then
    RULES='[{ "Name":"rate-limit","Priority":0,"Action":{"Block":{}},
      "Statement":{"RateBasedStatement":{"Limit":2000,"AggregateKeyType":"IP"}},
      "VisibilityConfig":{"SampledRequestsEnabled":true,"CloudWatchMetricsEnabled":true,"MetricName":"rate-limit"}},
    { "Name":"AWSManagedCommon","Priority":1,"OverrideAction":{"None":{}},
      "Statement":{"ManagedRuleGroupStatement":{"VendorName":"AWS","Name":"AWSManagedRulesCommonRuleSet"}},
      "VisibilityConfig":{"SampledRequestsEnabled":true,"CloudWatchMetricsEnabled":true,"MetricName":"common"}},
    { "Name":"AWSIPReputation","Priority":2,"OverrideAction":{"None":{}},
      "Statement":{"ManagedRuleGroupStatement":{"VendorName":"AWS","Name":"AWSManagedRulesAmazonIpReputationList"}},
      "VisibilityConfig":{"SampledRequestsEnabled":true,"CloudWatchMetricsEnabled":true,"MetricName":"iprep"}}]'
  fi
  # observe-before-act: reuse our web ACL by name if it already exists (idempotent)
  WEB_ACL_ARN="$(aws wafv2 list-web-acls --scope CLOUDFRONT --region us-east-1 \
    --query "WebACLs[?Name=='ephemera-web-acl'].ARN | [0]" --output text)"
  if [ "$WEB_ACL_ARN" = "None" ] || [ -z "$WEB_ACL_ARN" ]; then
    WEB_ACL_ARN="$(aws wafv2 create-web-acl --name ephemera-web-acl --scope CLOUDFRONT --region us-east-1 \
      --default-action Allow={} --rules "$RULES" \
      --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=ephemera-web-acl \
      --query 'Summary.ARN' --output text)"
  fi
fi
```
```bash
# ✔ verify
[ "$EDGE_PROTECTION" = "none" ] || aws wafv2 list-web-acls --scope CLOUDFRONT --region us-east-1 \
  --query "WebACLs[?Name=='ephemera-web-acl'].ARN | [0]" --output text   # the ARN
```
> **→ write Live State:** `WEB_ACL_ARN`.

---

## 3. CloudFront distribution  🔴 GATE

> 🔴 **STOP. Human go required.** This creates global, billable edge infra and takes ~15–20 min to
> deploy (and a comparable wait to disable/delete). This is the one step you put a human in front of.

The not-found behavior branches on `SITE_TYPE`. **`spa`:** 403/404 → `/index.html` (200) makes
client-side routing work — a deep link to `/dashboard/42` 403s at S3 (object absent), CloudFront rewrites
to the app shell, the router takes over. **`static`:** misses return a real `404` (→ `/404.html`), and an
index-rewrite CloudFront Function (built below) resolves directory paths (`/blog/` → `/blog/index.html`),
since the OAC REST origin — unlike an S3 website origin — does not serve index documents itself.

```bash
# observe: don't create a second one — match by our discovery key (Comment)
DIST_ID="$(aws cloudfront list-distributions \
  --query "DistributionList.Items[?Comment=='${DISCOVERY_KEY}'].Id | [0]" --output text)"

# Branch the cert + aliases on the resolved inputs (none-branch = original default-cert behavior).
if [ "$DOMAIN_MODE" = "none" ]; then
  ALIASES_JSON='{ "Quantity": 0 }'
  VIEWERCERT_JSON='{ "CloudFrontDefaultCertificate": true }'
else
  if [ "$DOMAIN_MODE" = "apex" ] && [ "$WWW" = "yes" ]; then
    ALIASES_JSON='{ "Quantity": 2, "Items": ["'"$DOMAIN_NAME"'","www.'"$DOMAIN_NAME"'"] }'
  else
    ALIASES_JSON='{ "Quantity": 1, "Items": ["'"$DOMAIN_NAME"'"] }'
  fi
  VIEWERCERT_JSON='{ "ACMCertificateArn": "'"$CERT_ARN"'", "SSLSupportMethod": "sni-only", "MinimumProtocolVersion": "TLSv1.2_2021" }'
fi

# Branch not-found behavior + index resolution on SITE_TYPE.
if [ "$SITE_TYPE" = "static" ]; then
  # multipage: real 404; build (idempotently) an index-rewrite function the distribution will associate.
  INDEX_FN_ARN="$(aws cloudfront describe-function --name ephemera-index-rewrite \
    --query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text 2>/dev/null)"
  if [ -z "$INDEX_FN_ARN" ] || [ "$INDEX_FN_ARN" = "None" ]; then
    cat > /tmp/pf-index.js <<'JS'
function handler(event) {
  var r = event.request, u = r.uri;
  if (u.endsWith('/')) r.uri = u + 'index.html';                       // /blog/ → /blog/index.html
  else if (!u.split('/').pop().includes('.')) r.uri = u + '/index.html'; // /blog → /blog/index.html
  return r;
}
JS
    INDEX_FN_ARN="$(aws cloudfront create-function --name ephemera-index-rewrite \
      --function-config Comment='index resolution',Runtime='cloudfront-js-2.0' \
      --function-code fileb:///tmp/pf-index.js \
      --query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)"
    aws cloudfront publish-function --name ephemera-index-rewrite \
      --if-match "$(aws cloudfront describe-function --name ephemera-index-rewrite --query 'ETag' --output text)" >/dev/null
  fi
  CER_JSON='{ "Quantity": 2, "Items": [
    { "ErrorCode": 403, "ResponseCode": "404", "ResponsePagePath": "/404.html", "ErrorCachingMinTTL": 10 },
    { "ErrorCode": 404, "ResponseCode": "404", "ResponsePagePath": "/404.html", "ErrorCachingMinTTL": 10 } ]}'
  FUNC_ASSOC_JSON='{ "Quantity": 1, "Items": [{ "EventType": "viewer-request", "FunctionARN": "'"$INDEX_FN_ARN"'" }] }'
else
  # spa: unknown paths return the app shell at 200 (client-side routing takes over)
  CER_JSON='{ "Quantity": 2, "Items": [
    { "ErrorCode": 403, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 10 },
    { "ErrorCode": 404, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 10 } ]}'
  FUNC_ASSOC_JSON='{ "Quantity": 0 }'
fi

if [ "$DIST_ID" = "None" ] || [ -z "$DIST_ID" ]; then
  cat > /tmp/pf-dist.json <<JSON
{
  "CallerReference": "ephemera-spa-$(date +%Y%m%d-%H%M%S)",
  "Aliases": ${ALIASES_JSON},
  "DefaultRootObject": "index.html",
  "Origins": { "Quantity": 1, "Items": [{
    "Id": "s3-spa-origin",
    "DomainName": "${BUCKET}.s3.${AWS_REGION}.amazonaws.com",
    "OriginAccessControlId": "${OAC_ID}",
    "S3OriginConfig": { "OriginAccessIdentity": "" },
    "ConnectionAttempts": 3, "ConnectionTimeout": 10,
    "OriginShield": { "Enabled": false }
  }]},
  "DefaultCacheBehavior": {
    "TargetOriginId": "s3-spa-origin",
    "ViewerProtocolPolicy": "redirect-to-https",
    "Compress": true,
    "AllowedMethods": { "Quantity": 2, "Items": ["GET","HEAD"],
      "CachedMethods": { "Quantity": 2, "Items": ["GET","HEAD"] } },
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "ResponseHeadersPolicyId": "67f7725c-6f97-4210-82d7-5512b31e9d03",
    "FunctionAssociations": ${FUNC_ASSOC_JSON}
  },
  "CustomErrorResponses": ${CER_JSON},
  "Comment": "${DISCOVERY_KEY}",
  "Enabled": true,
  "HttpVersion": "http2and3",
  "WebACLId": "${WEB_ACL_ARN}",
  "ViewerCertificate": ${VIEWERCERT_JSON},
  "PriceClass": "PriceClass_100"
}
JSON
  DIST_JSON="$(aws cloudfront create-distribution --distribution-config file:///tmp/pf-dist.json)"
  DIST_ID="$(echo "$DIST_JSON"     | jq -r '.Distribution.Id')"
  DIST_DOMAIN="$(echo "$DIST_JSON" | jq -r '.Distribution.DomainName')"
  DIST_ARN="$(echo "$DIST_JSON"    | jq -r '.Distribution.ARN')"
  # tag-as-truth on the distribution too
  aws cloudfront tag-resource --resource "$DIST_ARN" \
    --tags 'Items=[{Key=Project,Value=mdinfra},{Key=Deployment,Value=spa-test}]'
else
  DIST_DOMAIN="$(aws cloudfront get-distribution --id "$DIST_ID" --query 'Distribution.DomainName' --output text)"
  DIST_ARN="$(aws cloudfront get-distribution --id "$DIST_ID" --query 'Distribution.ARN' --output text)"

  # 🟡 reconcile an existing distribution toward the resolved inputs (e.g. none → custom domain).
  if [ "$DOMAIN_MODE" != "none" ]; then
    CUR_ALIAS="$(aws cloudfront get-distribution --id "$DIST_ID" \
      --query 'Distribution.DistributionConfig.Aliases.Items[0]' --output text)"
    if [ "$CUR_ALIAS" != "$DOMAIN_NAME" ]; then
      # re-fetch the rotating ETag, patch Aliases + ViewerCertificate, push back with --if-match
      aws cloudfront get-distribution-config --id "$DIST_ID" > /tmp/pf-dc.json
      ETAG="$(jq -r '.ETag' /tmp/pf-dc.json)"
      jq ".DistributionConfig.Aliases = ${ALIASES_JSON} | .DistributionConfig.ViewerCertificate = ${VIEWERCERT_JSON} | .DistributionConfig" \
        /tmp/pf-dc.json > /tmp/pf-dc-new.json
      aws cloudfront update-distribution --id "$DIST_ID" --if-match "$ETAG" \
        --distribution-config file:///tmp/pf-dc-new.json >/dev/null
      echo "distribution updated to match resolved inputs — ⏳ wait deployed again"
    fi
  fi
fi
echo "DIST_ID=$DIST_ID  DIST_DOMAIN=$DIST_DOMAIN"
```
> **→ write Live State:** `DIST_ID`, `DIST_DOMAIN`, `DIST_ARN`; `status: creating`.

```bash
# ⏳ WAIT — background this; CloudFront first-deploy is ~15–20 min (exceeds a single shell).
aws cloudfront wait distribution-deployed --id "$DIST_ID"
```

```bash
# ✔ verify
aws cloudfront get-distribution --id "$DIST_ID" \
  --query 'Distribution.{Status:Status,Enabled:DistributionConfig.Enabled}' --output json
# expect: {"Status":"Deployed","Enabled":true}
```

---

## 4. Bucket policy — *now* that the distribution exists  🟡

```bash
# 🟡 grant ONLY the CloudFront service principal, scoped to THIS distribution's ARN.
#    The SourceArn condition is the confused-deputy guard.
aws s3api put-bucket-policy --bucket "$BUCKET" --policy '{
  "Version":"2012-10-17",
  "Statement":[{
    "Sid":"AllowCloudFrontOAC",
    "Effect":"Allow",
    "Principal":{"Service":"cloudfront.amazonaws.com"},
    "Action":"s3:GetObject",
    "Resource":"<ARN>'"$BUCKET"'/*",
    "Condition":{"StringEquals":{
      "AWS:SourceArn":"<ARN>'"$ACCOUNT_ID"':distribution/'"$DIST_ID"'"
    }}
  }]
}'
```

```bash
# ✔ verify — the SourceArn must equal this distribution
aws s3api get-bucket-policy --bucket "$BUCKET" --query Policy --output text \
  | jq -r '.Statement[0].Condition.StringEquals."AWS:SourceArn"'
# expect: <ARN><DIST_ID>
```

---

## 4b. Custom-domain DNS — Route53 alias (custom domain only)  🟡 / 🔴

> Skipped when `DOMAIN_MODE=none`. **Managed** auto-creates the record; **external** emits it and pauses.
> Apex *cannot* be a CNAME — it always uses a Route53 **alias A/AAAA** to the distribution (CloudFront's
> fixed alias zone is `$CF_ALIAS_ZONE`). Subdomains use the same alias form for uniformity.

```bash
# helper: emit the A+AAAA alias change-batch entries for one record name
pf_alias_pair () {  # $1 = record name
  cat <<JSON
{ "Action":"UPSERT","ResourceRecordSet":{ "Name":"$1","Type":"A",
    "AliasTarget":{"HostedZoneId":"${CF_ALIAS_ZONE}","DNSName":"${DIST_DOMAIN}","EvaluateTargetHealth":false}}},
{ "Action":"UPSERT","ResourceRecordSet":{ "Name":"$1","Type":"AAAA",
    "AliasTarget":{"HostedZoneId":"${CF_ALIAS_ZONE}","DNSName":"${DIST_DOMAIN}","EvaluateTargetHealth":false}}}
JSON
}

if [ "$DOMAIN_MODE" != "none" ] && [ "$DNS_MODE" = "managed" ]; then
  CHANGES="$(pf_alias_pair "$DOMAIN_NAME")"
  [ "$DOMAIN_MODE" = "apex" ] && [ "$WWW" = "yes" ] && CHANGES="${CHANGES},$(pf_alias_pair "www.${DOMAIN_NAME}")"
  aws route53 change-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" \
    --change-batch "{\"Changes\":[${CHANGES}]}"

elif [ "$DOMAIN_MODE" != "none" ] && [ "$DNS_MODE" = "external" ]; then
  # 🔴 EMIT the record(s) for the human's own DNS, then PAUSE for "placed".
  if [ "$DOMAIN_MODE" = "apex" ]; then
    echo "Apex needs ALIAS/ANAME (a plain CNAME at the zone apex is invalid DNS):"
    echo "  ${DOMAIN_NAME}.  ALIAS/ANAME  ${DIST_DOMAIN}."
    echo "If your provider is CNAME-only it cannot host an apex here — stop, or switch to a subdomain / managed Route53."
  else
    echo "Create at your DNS provider:"
    echo "  ${DOMAIN_NAME}.  CNAME  ${DIST_DOMAIN}."
  fi
  # → STOP for human 'placed'. On resume, run §6's custom-domain acceptance (bounded retry;
  #   still propagating → record 'pending' in Live State and let a later `verify` confirm).
fi
```
> **→ write Live State:** DNS record(s) created/emitted; `status: live` once §6 passes.

---

## 5. Deploy content (two-pass cache discipline)  🟢

```bash
# 🟢 Pass 1: everything except index.html — content-hashed assets, cache forever
aws s3 sync "$SITE_DIR" "s3://$BUCKET" --delete --exclude "index.html" \
  --cache-control "public,max-age=31536000,immutable"

# 🟡 Pass 2: index.html — never cache (it's the pointer to the hashed bundles; stale = broken app)
aws s3 cp "$SITE_DIR/index.html" "s3://$BUCKET/index.html" \
  --cache-control "no-cache,no-store,must-revalidate" --content-type "text/html"

# bust the shell so the new index is served immediately
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/index.html" "/"
```

---

## 6. Go live — acceptance verify  ✔

```bash
echo "Live at: https://${DIST_DOMAIN}/"

# ✔ root → 200 + marker
curl -s -o /dev/null -w 'root_http=%{http_code}\n' "https://${DIST_DOMAIN}/"
curl -s "https://${DIST_DOMAIN}/" | grep -o 'EPHEMERA-OK' && echo "marker=found"

# ✔ deep link — branches on SITE_TYPE
if [ "$SITE_TYPE" = "static" ]; then
  # multipage: an unknown path is a real 404; a directory path resolves to its index (200)
  curl -s -o /dev/null -w 'missing_http=%{http_code}\n'  "https://${DIST_DOMAIN}/no-such-page"   # expect 404
  curl -s -o /dev/null -w 'dirindex_http=%{http_code}\n' "https://${DIST_DOMAIN}/"               # index resolution (200)
else
  # spa: any deep link returns the app shell (200) — proves 404→index.html
  curl -s -o /dev/null -w 'deeplink_http=%{http_code}\n' "https://${DIST_DOMAIN}/some/deep/route"  # expect 200
fi

# ✔ direct S3 object → 403 (proves the origin is private)
curl -s -o /dev/null -w 's3_direct_http=%{http_code}\n' \
  "https://${BUCKET}.s3.${AWS_REGION}.amazonaws.com/index.html"

# ✔ custom-domain acceptance (only when DOMAIN_MODE != none)
if [ "$DOMAIN_MODE" != "none" ]; then
  echo "Custom domain: https://${DOMAIN_NAME}/"
  curl -s -o /dev/null -w 'domain_root=%{http_code}\n'     "https://${DOMAIN_NAME}/"
  curl -s "https://${DOMAIN_NAME}/" | grep -o 'EPHEMERA-OK' && echo "domain_marker=found"
  curl -s -o /dev/null -w 'domain_deeplink=%{http_code}\n' "https://${DOMAIN_NAME}/some/deep/route"
  # the served TLS cert must cover the domain (catches a mis-wired ViewerCertificate)
  echo | openssl s_client -connect "${DOMAIN_NAME}:443" -servername "${DOMAIN_NAME}" 2>/dev/null \
    | openssl x509 -noout -subject 2>/dev/null
fi
```
> **→ write Live State:** `status: live`, fill the verify ledger, set `last_verified`.

---

## 7. Cost guardrail — budget alarm  🟡  *(skipped when `BUDGET_ALARM=off`; gates nothing — can run first)*

> The near-free **denial-of-wallet detector**: an AWS Budget that emails `ALARM_EMAIL` when actual or
> forecast spend crosses `BUDGET_LIMIT_USD`. It won't *stop* a runaway (AWS has no hard cap — that's §2d's
> job), but it turns a silent terabyte into an alert on day one. First two budgets are free.

```bash
if [ "$BUDGET_ALARM" = "on" ]; then
  cat > /tmp/pf-budget.json <<JSON
{ "BudgetName": "ephemera-web-${ACCOUNT_ID}", "BudgetLimit": { "Amount": "${BUDGET_LIMIT_USD}", "Unit": "USD" },
  "TimeUnit": "MONTHLY", "BudgetType": "COST" }
JSON
  cat > /tmp/pf-budget-notify.json <<JSON
[ { "Notification": { "NotificationType": "ACTUAL", "ComparisonOperator": "GREATER_THAN", "Threshold": 80, "ThresholdType": "PERCENTAGE" },
    "Subscribers": [ { "SubscriptionType": "EMAIL", "Address": "${ALARM_EMAIL}" } ] },
  { "Notification": { "NotificationType": "FORECASTED", "ComparisonOperator": "GREATER_THAN", "Threshold": 100, "ThresholdType": "PERCENTAGE" },
    "Subscribers": [ { "SubscriptionType": "EMAIL", "Address": "${ALARM_EMAIL}" } ] } ]
JSON
  aws budgets create-budget --account-id "$ACCOUNT_ID" \
    --budget file:///tmp/pf-budget.json \
    --notifications-with-subscribers file:///tmp/pf-budget-notify.json 2>/dev/null \
  || echo "budget already exists (idempotent) — or check the create error"
fi
```
```bash
# ✔ verify
[ "$BUDGET_ALARM" = "off" ] || aws budgets describe-budget --account-id "$ACCOUNT_ID" \
  --budget-name "ephemera-web-${ACCOUNT_ID}" --query 'Budget.BudgetLimit.Amount' --output text   # the limit
```
> **→ write Live State:** verify row "budget alarm @ \$${BUDGET_LIMIT_USD}".

---

## ✔ Reconcile / drift check (run anytime after go-live)

"Does it still look like this?" Re-run §1 verify, §4 verify, §3 verify, and §6 — plus, when
`DOMAIN_MODE≠none`, §2b verify (cert still `ISSUED`) and the §6 custom-domain checks (alias resolves,
served cert covers the domain). Any FAIL is drift — the Director re-applies the corresponding
create/config step (all idempotent) and re-verifies.

---

## Teardown — resumable Janitor  💥

> 💥 **Human go required.** Reverse of the DAG. This is *reconcile-toward-absence*: the Janitor
> **observes the cloud first**, places itself in the state machine, acts, and repeats. Safe to crash
> and re-run — re-entry just re-observes. The two AWS traps it bakes in: **re-fetch the ETag before
> every mutating CloudFront call** (it changes each update), and **delete only works from
> `Deployed`+`Disabled`** (disable-then-immediately-delete fails).

```
Janitor state machine (distribution, found by Comment="mdinfra:spa-test"):
  Enabled=true                  → re-fetch ETag → disable → ⏳ wait deployed → (loop)
  Enabled=false, Status=InProgress → ⏳ wait deployed → (loop)
  Enabled=false, Status=Deployed   → re-fetch ETag → 💥 delete-distribution
  distribution absent           → delete OAC (needs dist gone) → empty + delete bucket → done
```

```bash
# Custom-domain DNS — remove first (managed): delete each alias record verbatim by reading it back.
if [ "$DOMAIN_MODE" != "none" ] && [ "$DNS_MODE" = "managed" ] && [ -n "$HOSTED_ZONE_ID" ]; then
  for NAME in "$DOMAIN_NAME" $( [ "$WWW" = "yes" ] && echo "www.${DOMAIN_NAME}" ); do
    for TYPE in A AAAA; do
      RRS="$(aws route53 list-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" \
        --query "ResourceRecordSets[?Name=='${NAME}.' && Type=='${TYPE}'] | [0]" --output json)"
      if [ "$RRS" != "null" ] && [ -n "$RRS" ]; then
        aws route53 change-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" \
          --change-batch "{\"Changes\":[{\"Action\":\"DELETE\",\"ResourceRecordSet\":${RRS}}]}" >/dev/null
        echo "deleted ${TYPE} ${NAME}"
      fi
    done
  done
elif [ "$DOMAIN_MODE" != "none" ] && [ "$DNS_MODE" = "external" ]; then
  echo "Remove these from your external DNS: ${DOMAIN_NAME} (and www. if used) → the distribution."
fi

# OBSERVE
DIST_ID="$(aws cloudfront list-distributions \
  --query "DistributionList.Items[?Comment=='${DISCOVERY_KEY}'].Id | [0]" --output text)"

if [ "$DIST_ID" != "None" ] && [ -n "$DIST_ID" ]; then
  read STATUS ENABLED < <(aws cloudfront get-distribution --id "$DIST_ID" \
    --query 'Distribution.[Status,DistributionConfig.Enabled]' --output text)
  echo "dist $DIST_ID status=$STATUS enabled=$ENABLED"

  if [ "$ENABLED" = "True" ]; then
    # 🔴 DISABLE — fetch config+ETag, flip Enabled, push back with --if-match
    aws cloudfront get-distribution-config --id "$DIST_ID" > /tmp/pf-dc.json
    ETAG="$(jq -r '.ETag' /tmp/pf-dc.json)"
    jq '.DistributionConfig.Enabled=false | .DistributionConfig' /tmp/pf-dc.json > /tmp/pf-dc-off.json
    aws cloudfront update-distribution --id "$DIST_ID" --if-match "$ETAG" \
      --distribution-config file:///tmp/pf-dc-off.json >/dev/null
    echo "disable requested — now ⏳ wait"
  fi

  # ⏳ WAIT (background this — ~15 min) until Deployed+Disabled, then delete
  aws cloudfront wait distribution-deployed --id "$DIST_ID"

  # 💥 DELETE — re-fetch a fresh ETag first
  ETAG2="$(aws cloudfront get-distribution-config --id "$DIST_ID" --query ETag --output text)"
  aws cloudfront delete-distribution --id "$DIST_ID" --if-match "$ETAG2"
  echo "distribution deleted"
fi

# OAC — deletable only once no distribution references it
OAC_ID="$(aws cloudfront list-origin-access-controls \
  --query "OriginAccessControlList.Items[?Name=='${BUCKET}-oac'].Id | [0]" --output text)"
if [ "$OAC_ID" != "None" ] && [ -n "$OAC_ID" ]; then
  OAC_ETAG="$(aws cloudfront get-origin-access-control --id "$OAC_ID" --query ETag --output text)"
  aws cloudfront delete-origin-access-control --id "$OAC_ID" --if-match "$OAC_ETAG"
  echo "OAC deleted"
fi

# index-rewrite function (static sites only) — deletable once no distribution references it; absent for spa
FN_ETAG="$(aws cloudfront describe-function --name ephemera-index-rewrite --query ETag --output text 2>/dev/null)"
if [ -n "$FN_ETAG" ] && [ "$FN_ETAG" != "None" ]; then
  aws cloudfront delete-function --name ephemera-index-rewrite --if-match "$FN_ETAG" && echo "index-rewrite function deleted"
fi

# WAFv2 web ACL (only if EDGE_PROTECTION was used) — deletable once no distribution references it
WACL_ARN="$(aws wafv2 list-web-acls --scope CLOUDFRONT --region us-east-1 \
  --query "WebACLs[?Name=='ephemera-web-acl'].ARN | [0]" --output text)"
if [ "$WACL_ARN" != "None" ] && [ -n "$WACL_ARN" ]; then
  WACL_ID="$(aws wafv2 list-web-acls --scope CLOUDFRONT --region us-east-1 \
    --query "WebACLs[?Name=='ephemera-web-acl'].Id | [0]" --output text)"
  WACL_LOCK="$(aws wafv2 get-web-acl --name ephemera-web-acl --scope CLOUDFRONT --region us-east-1 --id "$WACL_ID" --query LockToken --output text)"
  aws wafv2 delete-web-acl --name ephemera-web-acl --scope CLOUDFRONT --region us-east-1 --id "$WACL_ID" --lock-token "$WACL_LOCK" && echo "web ACL deleted"
fi

# Budget — orthogonal; remove if this plan created it
aws budgets delete-budget --account-id "$ACCOUNT_ID" --budget-name "ephemera-web-${ACCOUNT_ID}" 2>/dev/null \
  && echo "budget deleted" || true

# 💥 BUCKET — empty (incl. versions + delete-markers) then delete
if aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
  aws s3 rm "s3://$BUCKET" --recursive
  # purge non-current versions + delete markers
  VERSIONS="$(aws s3api list-object-versions --bucket "$BUCKET" \
    --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' --output json 2>/dev/null)"
  if [ -n "$VERSIONS" ] && [ "$(echo "$VERSIONS" | jq '.Objects | length')" != "0" ]; then
    aws s3api delete-objects --bucket "$BUCKET" --delete "$VERSIONS" >/dev/null
  fi
  MARKERS="$(aws s3api list-object-versions --bucket "$BUCKET" \
    --query '{Objects: DeleteMarkers[].{Key:Key,VersionId:VersionId}}' --output json 2>/dev/null)"
  if [ -n "$MARKERS" ] && [ "$(echo "$MARKERS" | jq '.Objects | length')" != "0" ]; then
    aws s3api delete-objects --bucket "$BUCKET" --delete "$MARKERS" >/dev/null
  fi
  aws s3api delete-bucket --bucket "$BUCKET"
  echo "bucket deleted"
fi

# ACM cert — delete ONLY if we minted it (CERT_MODE=create); a reused cert is never deleted.
# delete-certificate fails while still associated with a distribution → this runs AFTER the dist is gone.
if [ "$DOMAIN_MODE" != "none" ] && [ "${CERT_MODE%%:*}" = "create" ] && [ -n "$CERT_ARN" ]; then
  aws acm delete-certificate --region "$CERT_REGION" --certificate-arn "$CERT_ARN" \
    && echo "cert deleted" \
    || echo "cert still associated or already gone — re-run after the distribution is fully deleted"
fi
```
> **→ write Live State:** `status: gone`; reset `OAC_ID`/`DIST_ID`/`DIST_DOMAIN`/`DIST_ARN`/`CERT_ARN`/`HOSTED_ZONE_ID` to `—`.

```bash
# ✔ verify teardown — all three should report "gone"
aws s3api head-bucket --bucket "$BUCKET" 2>&1 | grep -q . && echo "bucket: GONE" || echo "bucket: still present"
aws cloudfront list-distributions --query "DistributionList.Items[?Comment=='${DISCOVERY_KEY}'] | length(@)"   # expect 0
aws cloudfront list-origin-access-controls --query "OriginAccessControlList.Items[?Name=='${BUCKET}-oac'] | length(@)"  # expect 0
# custom-domain leftover (only meaningful when a domain was used + we minted the cert)
if [ "$DOMAIN_MODE" != "none" ] && [ "${CERT_MODE%%:*}" = "create" ] && [ -n "$CERT_ARN" ]; then
  aws acm describe-certificate --region "$CERT_REGION" --certificate-arn "$CERT_ARN" >/dev/null 2>&1 \
    && echo "cert: still present" || echo "cert: GONE"
fi
```

---

## Deliberately *not* in this plan (named, so the omission is a decision)

- **`www → apex` 301 redirect** — `WWW=yes` aliases `www` to the same distribution; a true 301 needs a
  CloudFront Function or a redirect bucket. Deferred.
- **Apex on a CNAME-only external DNS provider** — unsupported: an apex requires ALIAS/ANAME (or managed
  Route53 alias records). The plan emits the target and stops.
- **WAF** — a `WebACLId` on the distribution. Add for real traffic.
- **CloudFront access logs** — second bucket + `Logging` block. Add for audit.
- **CloudFront Function** for redirect/rewrite — cleaner than error-response routing if the router needs it.
