# Deploying to benstull.art — Cloudflare Pages + R2

Publishes the simulator as a fully-static site: the frontend on **Cloudflare
Pages** (`benstull.art`, app at `/human-experience-simulator`, apex redirects to
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.

Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
Plan:   `docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`.

This project is **exempt from the flotilla/PPE/§9 pipeline** (operator-set) — it
deploys directly to Cloudflare. Safety/hygiene rules still hold.

## Repeatable deploy (after the one-time setup below)

Once the bucket/CORS/custom-domains + `wrangler login` exist (one-time, §1–3), every
later deploy is one command (`deploy/cloudflare/deploy.sh`, or the Make targets):

```bash
make deploy          # frontend only: build + Pages deploy (fast — most deploys)
make deploy-media    # also re-sync all media to R2 (~2 min, parallel) — when media changed
./deploy/cloudflare/deploy.sh --media-only   # only the R2 media sync, skip Pages
```

The script rebuilds `dist/`, (optionally) parallel-uploads `dist-media/` to R2, and
deploys Pages — printing the live URL. It checks `wrangler` auth first and fails
loudly. Override defaults via env: `BUCKET`, `PROJECT`, `MEDIA_BASE`, `APP_PATH`,
`BRANCH`, `JOBS`. The sections below document the one-time setup the script assumes.

## Prerequisites (operator gestures — need Cloudflare auth)

`wrangler` is not installed in the dev box; the deploy runs from a machine with
Cloudflare credentials.

```bash
npm i -g wrangler
wrangler login
```

`benstull.art` is already a Cloudflare zone (no nameserver migration).

## 1. Build the artifacts

From the repo root (run as a MODULE — `tools/http.py` shadows stdlib `http` if run
as a script):

```bash
.venv/bin/python -m tools.build_static
```

Produces:
- `dist/` — Pages output: `dist/human-experience-simulator/` (frontend + baked
  `clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
  root `dist/_redirects` (apex → app path, and no-slash → slashed).
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
  (clip bases + 154 transition morphs + 5 per-scale audio). No master/mezzanine
  sources.

## 2. R2 — bucket, CORS, custom domain, media

Bucket name: **`human-experience-simulator-media`**. The default `wrangler login`
OAuth token already has R2 + Pages + zone scopes (no separate API token needed).

```bash
wrangler r2 bucket create human-experience-simulator-media

# CORS — load-bearing (the WebGL dream shader taints without it). The wrangler CLI
# wants its own schema, NOT the S3-style array; use the *.wrangler.json file:
wrangler r2 bucket cors set human-experience-simulator-media --file deploy/cloudflare/r2-cors.wrangler.json
#   (deploy/cloudflare/r2-cors.json is the equivalent S3-style policy, for the
#    dashboard / S3 API; r2-cors.wrangler.json is the same rule in wrangler's
#    `{ "rules": [ { "allowed": {...} } ] }` form.)

# Custom domain — wrangler CAN do this (no dashboard needed); needs the zone id.
ZONE=$(curl -s -H "Authorization: Bearer $(grep '^oauth_token' ~/.wrangler/config/default.toml | cut -d'"' -f2)" \
  "https://api.cloudflare.com/client/v4/zones?name=benstull.art" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
wrangler r2 bucket domain add human-experience-simulator-media \
  --domain static.benstull.art --zone-id "$ZONE" --min-tls 1.2
```

**Media upload — there is NO `wrangler r2 bucket sync`.** Upload per-file with
`wrangler r2 object put ... --remote`, setting content-type + immutable cache. The
URLs are content-hash-versioned (`?v=<hash>`), so `immutable` is safe — a re-baked
clip gets a new hash → new URL → cache busts. ~604 files / ~2.3 GB, sequential:

```bash
WR=$(command -v wrangler); BUCKET=human-experience-simulator-media
find dist-media -type f | while read -r f; do
  key="${f#dist-media/}"
  case "${f##*.}" in mp4) ct=video/mp4;; mp3) ct=audio/mpeg;; json) ct=application/json;; *) ct=application/octet-stream;; esac
  "$WR" r2 object put "$BUCKET/$key" --file "$f" --remote \
    --content-type "$ct" --cache-control "public, max-age=31536000, immutable"
done
```

(If you have `rclone` + an R2 S3 API token, `rclone copy dist-media <remote>:bucket`
is much faster than the loop.)

If you change the media base, rebuild with `--media-base https://<host>/` so
`config.js` matches.

## 3. Pages — deploy + domain + redirect

```bash
wrangler pages project create human-experience-simulator --production-branch main
wrangler pages deploy dist --project-name human-experience-simulator --branch main
```

Then **in the dashboard** (Workers & Pages → project → Custom domains) — there is
no wrangler CLI for Pages custom domains:
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
  serves `522` until the cert/routing provisions.)
- The app serves at `benstull.art/human-experience-simulator/`; the shipped
  `dist/_redirects` sends `/` and the no-slash form to it.

## 4. Verify before/after going live

```bash
# CORS + Range on the media origin (after step 2)
curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
  https://static.benstull.art/<some>/base.mp4 | grep -iE "access-control-allow-origin|content-range|accept-ranges"
```

Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-experience-simulator/`. Turn the **Feel** knob to max
and confirm the dream effect renders (no `SecurityError` in the console — that
would mean CORS is misconfigured and the GL texture is tainted). The local
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
cross-origin path before you ship.
