# pages Infrastructure OpenTofu configuration for the Cloudflare resources backing `pages.elijah.run`. Mirrors the style of `../../quotesdb/infra`. **Scope split** (see `../CLAUDE.md` "Deviations from repo defaults" and `docs/plans/2026-07-12-pages-design.md` §3): the Cloudflare Terraform provider (v4) cannot upload ES module Workers that import wasm files, so `wrangler deploy` owns the Worker script and its route. OpenTofu (this directory) owns everything else: the R2 bucket, the D1 database, the DNS record, and the rate limiting rule. ## Resources provisioned | Resource | Description | |---|---| | `cloudflare_r2_bucket.pages` | R2 bucket storing hosted page content (`/` keys) | | `cloudflare_d1_database.pages` | D1 database for users/sessions/page metadata | | `cloudflare_record.pages` | Proxied placeholder DNS record so the Worker route can intercept `pages.elijah.run/*` | | `cloudflare_ruleset.pages_rate_limits` | Per-IP rate limits on `/api/pages` (uploads) and `/auth/` | ## Required credentials | Variable | Description | |---|---| | `cloudflare_api_token` | Cloudflare API token with Workers, D1, R2, and DNS edit permissions | | `cloudflare_account_id` | Cloudflare account ID | | `cloudflare_zone_id` | Zone ID for `elijah.run` | Set these in `terraform.tfvars` (gitignored) or as `TF_VAR_*` environment variables: ```sh cloudflare_api_token = "..." cloudflare_account_id = "..." cloudflare_zone_id = "..." ``` ## 1. Prerequisites ### Cloudflare account - An active Cloudflare account with the `elijah.run` zone. - An API token (User Profile → API Tokens → Create Token) scoped to: - **Account** → Workers R2 Storage → Edit - **Account** → D1 → Edit - **Zone** → DNS → Edit (for `elijah.run`) - **Zone** → WAF (or "Ruleset") → Edit (for the rate limiting ruleset) - Note the account ID (right sidebar of the Cloudflare dashboard) and the zone ID (`elijah.run` overview page, right sidebar). ### GitHub OAuth App `pages` authenticates users via GitHub OAuth (design §2.4). Create the app **before** deploying the Worker, since its client ID/secret are Worker config: 1. GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App**. 2. **Application name**: `pages.elijah.run` (or similar). 3. **Homepage URL**: `https://pages.elijah.run`. 4. **Authorization callback URL**: `https://pages.elijah.run/auth/callback` — must match `BASE_URL` + `/auth/callback` exactly, since the Worker validates GitHub's redirect against this. 5. Click **Register application**. 6. Copy the generated **Client ID** — this becomes `GITHUB_CLIENT_ID` in `../wrangler.toml`. 7. Click **Generate a new client secret** and copy it immediately (it's only shown once) — this becomes the `GITHUB_CLIENT_SECRET` Worker secret (§5 below), never committed to the repo. If you also want a local dev flow (`wrangler dev` on `http://localhost:8787`), create a second OAuth App (GitHub doesn't allow multiple callback URLs per app in the classic OAuth App flow) with callback `http://localhost:8787/auth/callback`, and put its client ID/secret in `.dev.vars` (copy `.dev.vars.example`, git-ignored). ## 2. tofu init / plan / apply D1 must exist before the Worker script binds to it (chicken-and-egg), same as quotesdb: ```sh cd infra/ tofu init tofu apply -target=cloudflare_d1_database.pages \ -var cloudflare_api_token="..." \ -var cloudflare_account_id="..." \ -var cloudflare_zone_id="..." ``` Then apply everything else (R2 bucket, DNS record, rate limits): ```sh tofu plan \ -var cloudflare_api_token="..." \ -var cloudflare_account_id="..." \ -var cloudflare_zone_id="..." tofu apply \ -var cloudflare_api_token="..." \ -var cloudflare_account_id="..." \ -var cloudflare_zone_id="..." ``` (Omit the `-var` flags if you've set the equivalent `TF_VAR_*` environment variables or a `terraform.tfvars` file instead.) **Zone ruleset caveat**: Cloudflare allows only one zone-level `http_ratelimit` ruleset per zone, on every plan. `quotesdb` already declares one on the shared `elijah.run` zone — if both projects' rulesets are applied, the later apply clobbers the earlier one. If quotesdb's rate limits are live, merge this file's two `rules` blocks into that single shared ruleset instead of applying `cloudflare_ruleset.pages_rate_limits` standalone (a paid plan raises the rule count allowed inside the one ruleset; the Free plan caps it at 1 rule). See the comment in `rate-limits.tf`. After apply, note the `d1_database_id` output: ```sh tofu output d1_database_id ``` ## 3. Update wrangler.toml From the `pages/` project root (not `infra/`): 1. Replace the placeholder `database_id` in `[[d1_databases]]` with the real ID from `tofu output d1_database_id`. 2. Uncomment the `[[routes]]` block now that the DNS record exists: ```toml [[routes]] pattern = "pages.elijah.run/*" zone_name = "elijah.run" ``` ## 4. Apply the D1 schema From `pages/` (schema lives at `migrations/schema.sql`): ```sh # Production (remote) database wrangler d1 execute pages --remote --file migrations/schema.sql # Local dev database (used by `wrangler dev`) wrangler d1 execute pages --local --file migrations/schema.sql ``` The schema uses `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` throughout, so re-running either command against an already-migrated database is safe. ## 5. Secrets and vars ```sh # From pages/ — prompts for the value, never stored in the repo. wrangler secret put GITHUB_CLIENT_SECRET ``` In `../wrangler.toml`, set the two plaintext vars under `[vars]`: ```toml [vars] BASE_URL = "https://pages.elijah.run" GITHUB_CLIENT_ID = "" ADMIN_GITHUB_LOGIN = "" ``` ## 6. Deploy From `pages/`: ```sh worker-build --release # or let [build] in wrangler.toml run it wrangler deploy ``` ## 7. Verify - [ ] `https://pages.elijah.run/` loads the management UI. - [ ] "Log in with GitHub" completes the OAuth round trip and sets a session cookie (`__Host-session`). - [ ] The account matching `ADMIN_GITHUB_LOGIN` shows `is_admin` / `can_upload` true via `GET /api/me`. - [ ] Uploading a small `.html` file (`POST /api/pages?name=`) as an approved user returns `201` and the page is listed at `GET /api/pages`. - [ ] `GET //` serves the uploaded content with `Content-Security-Policy: sandbox …` and `X-Content-Type-Options: nosniff` response headers. - [ ] Deleting the page (`DELETE /api/pages/:slug`) as the owner or an admin removes it from both the page list and R2 (a subsequent `GET //` 404s). - [ ] Repeating an upload or auth request past the configured rate limit gets blocked (HTTP 429-equivalent Managed Challenge/block page). ## State State is stored locally in `terraform.tfstate` (gitignored). For a team setup, migrate to a remote backend (S3-compatible bucket, Terraform Cloud, etc.). ## Files | File | Purpose | |---|---| | `providers.tf` | Terraform block (provider version pin) + Cloudflare provider configuration | | `variables.tf` | Input variable declarations | | `r2.tf` | Cloudflare R2 bucket for hosted page content | | `d1.tf` | Cloudflare D1 database + `d1_database_id` output | | `dns.tf` | Placeholder proxied DNS record enabling the Worker route | | `rate-limits.tf` | Per-IP rate limiting ruleset for uploads and auth | | `.gitignore` | Ignores state, lock, and credential files |