You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

213 lines
7.4 KiB
Markdown

# 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 (`<slug>/<path>` 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 = "<client id from the GitHub OAuth App above>"
ADMIN_GITHUB_LOGIN = "<your GitHub login — auto-promoted to admin on first 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=<slug>`) as an
approved user returns `201` and the page is listed at `GET /api/pages`.
- [ ] `GET /<slug>/` 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 /<slug>/` 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 |