Micro static-site host running entirely on Cloudflare: a Rust->wasm Worker (workers-rs 0.7 + axum 0.8) serving user-uploaded .html/.zip content from R2 at pages.elijah.run/<slug>/, with D1 for users/ sessions/page metadata and GitHub OAuth for login. Admins approve uploaders, delete pages, and can mark pages/users trusted. Security design (docs/plans/2026-07-12-pages-design.md): hosted content is served with a CSP sandbox (opaque origin — no cookies or storage, cannot make credentialed /api calls) unless admin-trusted; strict slug grammar + reserved names; streaming zip validation (traversal, bombs, size/entry/depth limits); SHA-256-hashed session tokens in __Host- cookies; Origin checks on all mutations; upload quotas and size caps. Includes OpenTofu infra (R2, D1, DNS; wrangler owns the Worker deploy), an embedded single-file management UI, deployment guide, and 148 native unit tests + 18 doctests (clippy -D warnings clean on native and wasm32). Deployed and verified in production. Squash of 15 commits from pages-v0: each ticket implemented by a Claude Sonnet agent, reviewed by a Claude Opus agent, tracked with beans (pages-lxjz..pages-shqc). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago | |
|---|---|---|
| .. | ||
| .gitignore | 1 week ago | |
| README.md | 1 week ago | |
| d1.tf | 1 week ago | |
| dns.tf | 1 week ago | |
| providers.tf | 1 week ago | |
| r2.tf | 1 week ago | |
| rate-limits.tf | 1 week ago | |
| variables.tf | 1 week ago | |
README.md
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:
cloudflare_api_token = "..."
cloudflare_account_id = "..."
cloudflare_zone_id = "..."
1. Prerequisites
Cloudflare account
- An active Cloudflare account with the
elijah.runzone. - 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.runoverview 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:
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Application name:
pages.elijah.run(or similar). - Homepage URL:
https://pages.elijah.run. - Authorization callback URL:
https://pages.elijah.run/auth/callback— must matchBASE_URL+/auth/callbackexactly, since the Worker validates GitHub's redirect against this. - Click Register application.
- Copy the generated Client ID — this becomes
GITHUB_CLIENT_IDin../wrangler.toml. - Click Generate a new client secret and copy it immediately (it's
only shown once) — this becomes the
GITHUB_CLIENT_SECRETWorker 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:
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):
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:
tofu output d1_database_id
3. Update wrangler.toml
From the pages/ project root (not infra/):
-
Replace the placeholder
database_idin[[d1_databases]]with the real ID fromtofu output d1_database_id. -
Uncomment the
[[routes]]block now that the DNS record exists:[[routes]] pattern = "pages.elijah.run/*" zone_name = "elijah.run"
4. Apply the D1 schema
From pages/ (schema lives at migrations/schema.sql):
# 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
# 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]:
[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/:
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_LOGINshowsis_admin/can_uploadtrue viaGET /api/me. - Uploading a small
.htmlfile (POST /api/pages?name=<slug>) as an approved user returns201and the page is listed atGET /api/pages. GET /<slug>/serves the uploaded content withContent-Security-Policy: sandbox …andX-Content-Type-Options: nosniffresponse headers.- Deleting the page (
DELETE /api/pages/:slug) as the owner or an admin removes it from both the page list and R2 (a subsequentGET /<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 |