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
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 |
|