From d9c4830cf9bf9975bc32e4ec246ed3413b954a5c Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 13 Jul 2026 17:14:22 -0700 Subject: [PATCH] feat(pages): static-site host at pages.elijah.run (v0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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 --- README.md | 1 + pages/.beans.yml | 6 + ...pages-static-site-host-on-cloudflare-v0.md | 15 + ...ages-adl9--page-content-serving-from-r2.md | 27 + ...fra-deployment-docs-project-docs-polish.md | 27 + ...-embedded-management-ui-staticindexhtml.md | 26 + .../pages-jgc4--d1-schema-and-query-layer.md | 25 + .../pages-kns6--pages-and-users-json-api.md | 29 + ...caffold-worker-crate-hello-world-builds.md | 25 + ...-modules-slug-mime-upload-validation-he.md | 32 + ...d-per-user-trust-opt-out-of-csp-sandbox.md | 39 + ...--github-oauth-sessions-auth-middleware.md | 27 + pages/.dev.vars.example | 17 + pages/.gitignore | 9 + pages/CLAUDE.md | 64 + pages/Cargo.lock | 1118 +++++++++++++++++ pages/Cargo.toml | 54 + pages/README.md | 71 ++ pages/docs/ARCHITECTURE.md | 41 + pages/docs/PLANNING.md | 48 + pages/docs/plans/2026-07-12-pages-design.md | 283 +++++ pages/infra/.gitignore | 5 + pages/infra/README.md | 212 ++++ pages/infra/d1.tf | 16 + pages/infra/dns.tf | 19 + pages/infra/providers.tf | 20 + pages/infra/r2.tf | 8 + pages/infra/rate-limits.tf | 69 + pages/infra/variables.tf | 23 + pages/justfile | 30 + pages/migrations/0002-trust.sql | 29 + pages/migrations/schema.sql | 82 ++ pages/src/auth.rs | 636 ++++++++++ pages/src/core/headers.rs | 261 ++++ pages/src/core/mime.rs | 171 +++ pages/src/core/mod.rs | 22 + pages/src/core/origin.rs | 144 +++ pages/src/core/serve_path.rs | 308 +++++ pages/src/core/slug.rs | 390 ++++++ pages/src/core/upload.rs | 864 +++++++++++++ pages/src/db.rs | 669 ++++++++++ pages/src/lib.rs | 55 + pages/src/routes/api.rs | 595 +++++++++ pages/src/routes/auth.rs | 358 ++++++ pages/src/routes/mod.rs | 227 ++++ pages/src/routes/serve.rs | 241 ++++ pages/src/tests.rs | 13 + pages/static/index.html | 938 ++++++++++++++ pages/wrangler.toml | 42 + 49 files changed, 8431 insertions(+) create mode 100644 pages/.beans.yml create mode 100644 pages/.beans/pages-77lk--pages-static-site-host-on-cloudflare-v0.md create mode 100644 pages/.beans/pages-adl9--page-content-serving-from-r2.md create mode 100644 pages/.beans/pages-ihlp--opentofu-infra-deployment-docs-project-docs-polish.md create mode 100644 pages/.beans/pages-jaqz--embedded-management-ui-staticindexhtml.md create mode 100644 pages/.beans/pages-jgc4--d1-schema-and-query-layer.md create mode 100644 pages/.beans/pages-kns6--pages-and-users-json-api.md create mode 100644 pages/.beans/pages-lxjz--scaffold-worker-crate-hello-world-builds.md create mode 100644 pages/.beans/pages-saom--core-policy-modules-slug-mime-upload-validation-he.md create mode 100644 pages/.beans/pages-shqc--per-page-and-per-user-trust-opt-out-of-csp-sandbox.md create mode 100644 pages/.beans/pages-zd46--github-oauth-sessions-auth-middleware.md create mode 100644 pages/.dev.vars.example create mode 100644 pages/.gitignore create mode 100644 pages/CLAUDE.md create mode 100644 pages/Cargo.lock create mode 100644 pages/Cargo.toml create mode 100644 pages/README.md create mode 100644 pages/docs/ARCHITECTURE.md create mode 100644 pages/docs/PLANNING.md create mode 100644 pages/docs/plans/2026-07-12-pages-design.md create mode 100644 pages/infra/.gitignore create mode 100644 pages/infra/README.md create mode 100644 pages/infra/d1.tf create mode 100644 pages/infra/dns.tf create mode 100644 pages/infra/providers.tf create mode 100644 pages/infra/r2.tf create mode 100644 pages/infra/rate-limits.tf create mode 100644 pages/infra/variables.tf create mode 100644 pages/justfile create mode 100644 pages/migrations/0002-trust.sql create mode 100644 pages/migrations/schema.sql create mode 100644 pages/src/auth.rs create mode 100644 pages/src/core/headers.rs create mode 100644 pages/src/core/mime.rs create mode 100644 pages/src/core/mod.rs create mode 100644 pages/src/core/origin.rs create mode 100644 pages/src/core/serve_path.rs create mode 100644 pages/src/core/slug.rs create mode 100644 pages/src/core/upload.rs create mode 100644 pages/src/db.rs create mode 100644 pages/src/lib.rs create mode 100644 pages/src/routes/api.rs create mode 100644 pages/src/routes/auth.rs create mode 100644 pages/src/routes/mod.rs create mode 100644 pages/src/routes/serve.rs create mode 100644 pages/src/tests.rs create mode 100644 pages/static/index.html create mode 100644 pages/wrangler.toml diff --git a/README.md b/README.md index faa2c7e..c4424ac 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A mono-repo of CLI tools and self-contained HTTP web services built in Rust, tar | Service | Description | |---|---| | `common` | Shared library crate — types, utilities, and definitions used across services | +| `pages` | Micro static-site host at `pages.elijah.run` — upload an `.html`/`.zip`, served sandboxed at `//` (Cloudflare Workers + R2 + D1, Rust→wasm, GitHub OAuth) | _Services will be listed here as they are added._ diff --git a/pages/.beans.yml b/pages/.beans.yml new file mode 100644 index 0000000..7cd3b1e --- /dev/null +++ b/pages/.beans.yml @@ -0,0 +1,6 @@ +beans: + path: .beans + prefix: pages- + id_length: 4 + default_status: todo + default_type: task diff --git a/pages/.beans/pages-77lk--pages-static-site-host-on-cloudflare-v0.md b/pages/.beans/pages-77lk--pages-static-site-host-on-cloudflare-v0.md new file mode 100644 index 0000000..9db1e43 --- /dev/null +++ b/pages/.beans/pages-77lk--pages-static-site-host-on-cloudflare-v0.md @@ -0,0 +1,15 @@ +--- +# pages-77lk +title: 'pages: static-site host on Cloudflare (v0)' +status: completed +type: epic +priority: normal +created_at: 2026-07-13T02:07:28Z +updated_at: 2026-07-13T22:38:04Z +--- + +Root epic for the v0 implementation of pages.elijah.run. Design + security analysis: docs/plans/2026-07-12-pages-design.md. All work happens on branch pages-v0, one commit per child bean. + +## Summary of Changes + +All 8 child beans completed on branch pages-v0, one reviewed commit each (Sonnet implementation agents, Opus review agents, review findings applied before every commit). Final state: 142 native unit tests + 17 doctests, clippy -D warnings clean on native and wasm32, tofu validate clean. Deployment is phase 2 — follow infra/README.md. diff --git a/pages/.beans/pages-adl9--page-content-serving-from-r2.md b/pages/.beans/pages-adl9--page-content-serving-from-r2.md new file mode 100644 index 0000000..0d685e3 --- /dev/null +++ b/pages/.beans/pages-adl9--page-content-serving-from-r2.md @@ -0,0 +1,27 @@ +--- +# pages-adl9 +title: Page content serving from R2 +status: completed +type: feature +priority: normal +created_at: 2026-07-13T02:09:09Z +updated_at: 2026-07-13T17:11:23Z +parent: pages-77lk +blocked_by: + - pages-kns6 +--- + +Implement design doc §4.7. src/routes/serve.rs (wasm-only): GET /:slug -> 301 /:slug/; GET /:slug/ -> R2 slug/index.html; GET /:slug/*path -> R2 slug/path, on miss retry slug/path/index.html, else minimal 404 HTML page. Invalid/reserved slugs 404. Every user-content response gets headers from core::headers: CSP sandbox (no allow-same-origin), X-Content-Type-Options nosniff, Referrer-Policy no-referrer, Cache-Control public max-age=300, Access-Control-Allow-Origin *, Content-Type from core::mime by extension. Route registration must not shadow /, /api, /auth. Path normalization logic (join, index fallback candidates) in core as pure function with native tests. + +- [x] serve.rs handlers + router wiring (fallback route) +- [x] pure path-resolution helper in core + native tests +- [x] all §4.7 headers on every response incl. 404s for slugs +- [x] full validation suite passes + +## Summary of Changes + +R2 page serving by a Sonnet agent; Opus review APPROVE (no blockers, nits were doc-only and applied). +- src/core/serve_path.rs: pure candidate resolver (exact -> dir-index fallback) with traversal/length/shape rejection, 19 native tests. +- src/routes/serve.rs: /{slug} 301, /{slug}/ index, /{slug}/{*rest} assets; single apply_user_content_headers choke point puts CSP sandbox, nosniff, no-referrer, cache-control, ACAO * on every 200/301/404/500. +- core/headers.rs gained the ACAO constant. +Route precedence verified against matchit semantics; reserved slugs are a second 404 layer. 142 unit tests + 17 doctests green. diff --git a/pages/.beans/pages-ihlp--opentofu-infra-deployment-docs-project-docs-polish.md b/pages/.beans/pages-ihlp--opentofu-infra-deployment-docs-project-docs-polish.md new file mode 100644 index 0000000..9895306 --- /dev/null +++ b/pages/.beans/pages-ihlp--opentofu-infra-deployment-docs-project-docs-polish.md @@ -0,0 +1,27 @@ +--- +# pages-ihlp +title: OpenTofu infra, deployment docs, project docs polish +status: completed +type: task +priority: normal +created_at: 2026-07-13T02:09:09Z +updated_at: 2026-07-13T22:38:04Z +parent: pages-77lk +blocked_by: + - pages-jaqz +--- + +infra/ per design §3/§4.4 mirroring ../quotesdb/infra style: providers.tf (cloudflare provider, variables for account_id/zone_id/api_token), r2.tf (pages bucket), d1.tf (pages database), dns.tf (pages.elijah.run record for worker custom domain or route), rate-limits.tf (rule on POST /api/* and /auth/*), every resource/data block commented. infra/README.md: apply order, wrangler deploy step, wrangler secret put GITHUB_CLIENT_SECRET, D1 schema apply command, GitHub OAuth app setup (callback URL). Update wrangler.toml with route/custom-domain + real binding placeholders and comments. tofu fmt + tofu validate (best effort if provider download blocked — say so). Update docs/PLANNING.md work log + README as needed. + +- [x] infra/*.tf with comments +- [x] infra/README.md deployment guide +- [x] wrangler.toml finalized +- [x] tofu fmt/validate attempted, result recorded +- [x] PLANNING.md updated + +## Summary of Changes + +OpenTofu infra + deployment docs by a Sonnet agent; Opus review APPROVE with 2 should-fixes applied: +- infra/{providers,variables,r2,d1,dns,rate-limits}.tf (cloudflare ~> 4, AAAA 100:: placeholder + wrangler-owned route, http_ratelimit ruleset), infra/README.md end-to-end deploy guide, infra/.gitignore. +- Fixes: zone-entrypoint ruleset guidance corrected (one http_ratelimit ruleset per zone on every plan — merging with quotesdb's is mandatory when both apply); unused var.domain removed. +- tofu fmt -check + tofu validate pass; wrangler.toml left with placeholders pending real tofu apply. diff --git a/pages/.beans/pages-jaqz--embedded-management-ui-staticindexhtml.md b/pages/.beans/pages-jaqz--embedded-management-ui-staticindexhtml.md new file mode 100644 index 0000000..8a43186 --- /dev/null +++ b/pages/.beans/pages-jaqz--embedded-management-ui-staticindexhtml.md @@ -0,0 +1,26 @@ +--- +# pages-jaqz +title: Embedded management UI (static/index.html) +status: completed +type: feature +priority: normal +created_at: 2026-07-13T02:09:09Z +updated_at: 2026-07-13T17:22:22Z +parent: pages-77lk +blocked_by: + - pages-adl9 +--- + +Single-file management UI per design §4.1/§5, served at GET / via include_str!. Vanilla HTML+CSS+JS inline, no external resources (would break under our own CSP? — the / route is NOT sandboxed; still keep it dependency-free). Features: fetch /api/pages and render list with links; fetch /api/me — show Login with GitHub (/auth/login) or user + Logout (POST /auth/logout); upload card (only when can_upload): file input + drag-drop for .html/.zip, name field defaulting to filename minus extension (mirror core::slug::default_slug_from_filename rules in JS), submit via fetch POST /api/pages?name=... with file as body and its Content-Type (application/zip or text/html), show success with link, 409 -> inline 'name taken' error keeping form state, 403 -> 'not authorized' warning; delete buttons on own pages (confirm()), admins see all-page delete + Users panel (list, can_upload checkboxes -> PATCH). Clean minimal styling, dark-mode friendly via prefers-color-scheme. Serve / with standard security headers (nosniff, no CSP sandbox, a strict same-origin CSP allowing inline script/style is fine). + +- [x] static/index.html complete UI +- [x] GET / route with headers +- [x] name defaulting + error paths match API contract +- [x] full validation suite passes + +## Summary of Changes + +Single-file management UI (static/index.html, inline CSS/JS, no external resources) served at GET / via include_str!, by a Sonnet agent; Opus review APPROVE (no XSS, contract-faithful, CSP verified). +- List/login/logout, drag-drop upload with slug defaulting mirroring core::slug, 201/409/403/401/other handling, owner/admin delete, lazy admin users panel. +- Strict non-sandbox CSP on / (inline allowed, connect/img/frame-ancestors locked down) + nosniff/no-referrer/no-store. +- Post-review polish: client-side 10 MiB pre-check, dead el() attrs branch removed. diff --git a/pages/.beans/pages-jgc4--d1-schema-and-query-layer.md b/pages/.beans/pages-jgc4--d1-schema-and-query-layer.md new file mode 100644 index 0000000..4eb99cc --- /dev/null +++ b/pages/.beans/pages-jgc4--d1-schema-and-query-layer.md @@ -0,0 +1,25 @@ +--- +# pages-jgc4 +title: D1 schema and query layer +status: completed +type: feature +priority: normal +created_at: 2026-07-13T02:09:08Z +updated_at: 2026-07-13T02:57:07Z +parent: pages-77lk +blocked_by: + - pages-saom +--- + +migrations/schema.sql exactly per design doc §4.2 (users, sessions, pages). src/db.rs (wasm-only): thin typed wrappers over worker d1 — upsert_user_on_login(github_id, login, avatar_url, admin_login_match) handling ADMIN_GITHUB_LOGIN auto-promotion (design §2.4), get_user_by_session(token_hash) with expiry check, create_session/delete_session, list_pages (join owner login), get_page, count_pages_for_user, insert_page (surface PK conflict as typed NameTaken error), delete_page, list_users, set_can_upload. Row structs with serde. + +- [x] migrations/schema.sql +- [x] src/db.rs typed query layer +- [x] wasm32 check + clippy pass; native suite unaffected + +## Summary of Changes + +migrations/schema.sql (idempotent, indexed) + src/db.rs typed D1 query layer (wasm-only), by a Sonnet agent. Opus review: REQUEST-CHANGES then fixed: +- BLOCKER: session expiry compare now normalizes both sides via datetime() (lexicographic ISO-T/Z trap removed); create_session param/doc contract fixed. +- NIT: NameTaken detection tightened to 'unique' matches only (real FK on pages.owner_id would have misclassified as 409). +Validation green on both targets; native suite unaffected (72 tests + 4 doctests). diff --git a/pages/.beans/pages-kns6--pages-and-users-json-api.md b/pages/.beans/pages-kns6--pages-and-users-json-api.md new file mode 100644 index 0000000..f7f9f4e --- /dev/null +++ b/pages/.beans/pages-kns6--pages-and-users-json-api.md @@ -0,0 +1,29 @@ +--- +# pages-kns6 +title: Pages and users JSON API +status: completed +type: feature +priority: normal +created_at: 2026-07-13T02:09:09Z +updated_at: 2026-07-13T16:47:43Z +parent: pages-77lk +blocked_by: + - pages-zd46 +--- + +Implement design doc §4.1 API rows + §4.6 upload pipeline. src/routes/api.rs (wasm-only): GET /api/me; GET /api/pages (public); POST /api/pages?name= — auth (401), can_upload (403), Origin check vs BASE_URL (403), slug validation/reserved (400) via core, quota 50 pages/user (403), Content-Length + buffered size cap 10 MiB (413), core::upload::build_manifest by Content-Type text/html vs application/zip (400 on other types), D1 insert first (409 NameTaken), then R2 puts under slug/ with best-effort rollback on failure (500); DELETE /api/pages/:slug — owner or admin, delete R2 prefix (list+delete loop) then D1 row; GET /api/users + PATCH /api/users/:id {can_upload} — admin only. All errors JSON {"error": "..."} with correct status. Wire into router. Keep handlers thin; any new pure logic goes in core with native tests. + +- [x] /api/me, GET /api/pages +- [x] POST /api/pages full pipeline in §4.6 order +- [x] DELETE /api/pages/:slug +- [x] admin user endpoints +- [x] Origin-check helper + native tests where pure +- [x] full validation suite passes + +## Summary of Changes + +Full JSON API by a Sonnet agent; Opus review APPROVE with fixes applied: +- src/routes/api.rs: /api/me, GET/POST /api/pages (§4.6 pipeline: auth, can_upload, Origin, slug, quota, size caps, manifest, D1-first slug reservation, R2 puts with rollback), DELETE with paginated R2 prefix cleanup, admin /api/users GET/PATCH. +- src/core/origin.rs: origin_allowed + can_delete pure helpers (12 tests + 2 doctests). +- Review fixes: DefaultBodyLimit::max(10 MiB) instead of disable() (memory bounded at body-collection time); DELETE Origin check hoisted before DB lookups per §4.6. +122 unit tests + 16 doctests green; both targets clippy-clean. diff --git a/pages/.beans/pages-lxjz--scaffold-worker-crate-hello-world-builds.md b/pages/.beans/pages-lxjz--scaffold-worker-crate-hello-world-builds.md new file mode 100644 index 0000000..6ca5d87 --- /dev/null +++ b/pages/.beans/pages-lxjz--scaffold-worker-crate-hello-world-builds.md @@ -0,0 +1,25 @@ +--- +# pages-lxjz +title: Scaffold worker crate (hello world builds) +status: completed +type: task +priority: normal +created_at: 2026-07-13T02:07:52Z +updated_at: 2026-07-13T02:18:40Z +parent: pages-77lk +--- + +Create the pages crate skeleton per design doc §4.5 and quotesdb patterns: Cargo.toml (cdylib+rlib, worker 0.7 with d1+http features, axum 0.8 no-default-features json+query, tower-service, http, serde/serde_json, thiserror, zip no-default-features deflate, getrandom wasm_js — mirror ../quotesdb/Cargo.toml target-gating), src/lib.rs with wasm-only #[worker::event(fetch)] returning a placeholder response via an axum Router, wrangler.toml (name=pages, worker-build, D1 binding DB, R2 binding PAGES, vars BASE_URL/GITHUB_CLIENT_ID/ADMIN_GITHUB_LOGIN), .dev.vars.example, release profile size opts. + +- [x] Cargo.toml with target-gated deps and release profile +- [x] src/lib.rs hello-world axum router behind cfg(wasm32) +- [x] wrangler.toml + .dev.vars.example +- [x] cargo check --target wasm32-unknown-unknown passes +- [x] cargo check + clippy (both targets) + test pass + +## Summary of Changes + +Crate skeleton implemented by a Sonnet agent, reviewed by an Opus agent (verdict: APPROVE). +- Cargo.toml (target-gated deps mirroring quotesdb), src/lib.rs wasm-only axum fetch glue, src/tests.rs, wrangler.toml (DB/PAGES bindings, vars), .dev.vars.example. +- Review fixes applied: dropped dead duplicate getrandom 0.3 dep and corrected its comment; added commented [[routes]] placeholder for pages.elijah.run to wrangler.toml. +- Validation green on both targets (fmt, check, clippy -D warnings, test). diff --git a/pages/.beans/pages-saom--core-policy-modules-slug-mime-upload-validation-he.md b/pages/.beans/pages-saom--core-policy-modules-slug-mime-upload-validation-he.md new file mode 100644 index 0000000..5f8e10c --- /dev/null +++ b/pages/.beans/pages-saom--core-policy-modules-slug-mime-upload-validation-he.md @@ -0,0 +1,32 @@ +--- +# pages-saom +title: 'Core policy modules: slug, mime, upload validation, headers' +status: completed +type: feature +priority: normal +created_at: 2026-07-13T02:09:08Z +updated_at: 2026-07-13T02:42:56Z +parent: pages-77lk +blocked_by: + - pages-lxjz +--- + +Implement src/core/ per design doc §2.2, §2.3, §2.6, §4.5 — pure Rust, no worker deps, compiles + unit-tested natively. + +slug.rs: validate_slug() enforcing ^[a-z0-9][a-z0-9-]{0,62}$ after lowercasing, reserved-name list from design §2.3, and default_slug_from_filename() (my-project.zip -> my-project, lowercased, invalid chars -> hyphen, collapse repeats/trim). +mime.rs: extension->MIME allowlist (html, htm, css, js, mjs, json, txt, md, png, jpg, jpeg, gif, svg, webp, avif, ico, woff, woff2, ttf, otf, wasm, map, xml, webmanifest, mp3, mp4, webm, pdf); unknown -> application/octet-stream. +upload.rs: UploadLimits (10 MiB compressed, 25 MiB uncompressed total, 5 MiB per file, 300 entries, path len 180, depth 10) and build_manifest(kind, bytes) -> Result)>, UploadError>. Html kind -> single index.html entry. Zip kind: use zip crate, reject path traversal (.. segments, absolute, backslash), reject duplicates, skip directories, enforce limits while decompressing (count streamed bytes, abort on breach), require root index.html. Typed UploadError with user-facing Display messages. +headers.rs: constants for CSP sandbox value (design §2.1), nosniff, referrer-policy, cache-control. + +- [x] slug.rs + tests (valid/invalid/reserved/default-name derivation) +- [x] mime.rs + tests +- [x] upload.rs + tests (traversal, bomb via high-ratio zip, dup entries, missing index.html, happy paths html+zip) using zip writer in tests to build fixtures +- [x] headers.rs + tests +- [x] full validation suite passes (both targets) + +## Summary of Changes + +src/core/ implemented by a Sonnet agent, adversarial Opus review (verdict: APPROVE, no bypasses found). +- slug.rs (grammar + 17 reserved names + default_slug_from_filename), mime.rs (allocation-free allowlist), upload.rs (streaming zip validation: traversal, bombs, limits; html manifest), headers.rs (CSP sandbox etc.). +- 72 native unit tests + 4 doctests incl. crafted-zip attack fixtures and accept-at-limit boundary cases (added post-review). +- Design doc §2.2 wording reconciled (path length is bytes). diff --git a/pages/.beans/pages-shqc--per-page-and-per-user-trust-opt-out-of-csp-sandbox.md b/pages/.beans/pages-shqc--per-page-and-per-user-trust-opt-out-of-csp-sandbox.md new file mode 100644 index 0000000..282f749 --- /dev/null +++ b/pages/.beans/pages-shqc--per-page-and-per-user-trust-opt-out-of-csp-sandbox.md @@ -0,0 +1,39 @@ +--- +# pages-shqc +title: 'Per-page and per-user trust: opt out of CSP sandbox' +status: completed +type: feature +priority: normal +created_at: 2026-07-13T23:44:29Z +updated_at: 2026-07-13T23:58:27Z +--- + +Allow admin-designated trusted pages/users whose content is served WITHOUT the CSP sandbox (design §2.1), so local-first apps can use localStorage. Effective trust = pages.trusted OR users.trusted (owner), computed at serve time via D1 join. + +Scope: +- migrations: add users.trusted + pages.trusted (INTEGER NOT NULL DEFAULT 0); base schema.sql updated for fresh installs + incremental 0002 migration for live DB +- db.rs: trusted fields on User/PageRow, get_page joins owner trust, set_page_trusted, set_user_trusted +- api.rs: PATCH /api/pages/{slug} {trusted} (admin+Origin), PATCH /api/users/{id} accepts {can_upload?, trusted?} +- serve.rs: D1 lookup first (404 for unknown slug before R2), conditional sandbox header +- core: pure helper for conditional header set + tests +- UI: admin trust toggles (users panel + per-page), trusted badge +- docs: design §2.1 addendum — trusted content can act as any visitor incl. admins; trust only content you authored + +- [x] schema + migration +- [x] db.rs trust support +- [x] api endpoints +- [x] serve-time conditional sandbox +- [x] core helper + tests +- [x] UI toggles/badge +- [x] docs updated +- [x] full validation suite green + +## Summary of Changes + +Per-page + per-user trust escape hatch by a Sonnet agent; Opus production-rigor review: APPROVE, no blockers. +- Effective trust = pages.trusted OR owner users.trusted, computed via live join at serve time; only the 200 content path can drop the sandbox — 301/404/500 and all error branches stay sandboxed (fail-safe verified). +- Trust settable only by admins (Origin-checked PATCH /api/pages/{slug} + extended PATCH /api/users/{id}); login upsert can never touch it. +- migrations/0002-trust.sql for the live DB (must run BEFORE deploy — old columns missing → 500s, review-confirmed fail-safe but an outage). +- UI: trusted badges + admin toggles; docs §2.1 addendum with the explicit trusted-content warning. +- Review hygiene fix: tfplan* gitignored and removed from the tree. +148 unit tests + 18 doctests green; both targets clippy-clean. diff --git a/pages/.beans/pages-zd46--github-oauth-sessions-auth-middleware.md b/pages/.beans/pages-zd46--github-oauth-sessions-auth-middleware.md new file mode 100644 index 0000000..0108a01 --- /dev/null +++ b/pages/.beans/pages-zd46--github-oauth-sessions-auth-middleware.md @@ -0,0 +1,27 @@ +--- +# pages-zd46 +title: GitHub OAuth, sessions, auth middleware +status: completed +type: feature +priority: normal +created_at: 2026-07-13T02:09:08Z +updated_at: 2026-07-13T16:29:44Z +parent: pages-77lk +blocked_by: + - pages-jgc4 +--- + +Implement design doc §2.4 + §4.1 auth rows. src/auth.rs: pure helpers compiled on both targets — random 32-byte hex token gen (getrandom), sha256 hex (add sha2 crate, works on wasm), cookie header building/parsing (__Host-session HttpOnly Secure SameSite=Lax Path=/ Max-Age 30d; state cookie 10min), GitHub authorize URL builder — all native-tested. src/routes/auth.rs (wasm-only): GET /auth/login (state cookie + redirect), GET /auth/callback (verify state, exchange code via worker Fetch to github.com with Accept: application/json + User-Agent header, fetch /user, upsert user via db.rs, create session, redirect /), POST /auth/logout (delete session, clear cookie). Request extension/helper current_user(env, req) for other routes. Wire routes into lib.rs router. + +- [x] auth.rs pure helpers + native tests (cookie parse/build, authorize URL, token/hash) +- [x] routes/auth.rs OAuth flow +- [x] current_user session resolution helper +- [x] full validation suite passes + +## Summary of Changes + +GitHub OAuth + sessions by a Sonnet agent (resumed once after a session-limit interruption; #[worker::send] fixed the axum Send bound). Opus security review: APPROVE. +- src/auth.rs: token gen (getrandom), sha256_hex, __Host- cookie build/parse (zero-alloc), authorize URL with hand-rolled percent-encoding, hand-implemented civil_from_days expiry formatting — 38 new unit tests + 9 doctests. +- src/routes/{mod,auth}.rs: login/callback/logout, state-cookie CSRF binding, hashed session storage, generic 502s (no secret leakage), current_user helper. AppState (Db + Config) via with_state. +- Review fix applied: admin login comparison now eq_ignore_ascii_case (GitHub logins are case-insensitive). +110 unit tests + 14 doctests green; both targets clippy-clean. diff --git a/pages/.dev.vars.example b/pages/.dev.vars.example new file mode 100644 index 0000000..7524985 --- /dev/null +++ b/pages/.dev.vars.example @@ -0,0 +1,17 @@ +# Example local dev vars for the pages Worker. +# Copy to `.dev.vars` (git-ignored) and fill in real values for local +# `wrangler dev` runs. Never commit the real `.dev.vars` file. + +# Public base URL used for OAuth redirects and the Origin check on mutating +# API routes. Local dev typically uses http://localhost:8787. +BASE_URL=http://localhost:8787 + +# GitHub OAuth App client ID (public). +GITHUB_CLIENT_ID= + +# GitHub OAuth App client secret. In production this is a wrangler secret +# (`wrangler secret put GITHUB_CLIENT_SECRET`); for local dev it goes here. +GITHUB_CLIENT_SECRET= + +# GitHub login that is auto-promoted to admin on first login. +ADMIN_GITHUB_LOGIN= diff --git a/pages/.gitignore b/pages/.gitignore new file mode 100644 index 0000000..54c61c2 --- /dev/null +++ b/pages/.gitignore @@ -0,0 +1,9 @@ +/target +/build +.env +.dev.vars +.wrangler/ +infra/.terraform/ +infra/*.tfstate* +infra/.terraform.lock.hcl +infra/tfplan* diff --git a/pages/CLAUDE.md b/pages/CLAUDE.md new file mode 100644 index 0000000..85f80d8 --- /dev/null +++ b/pages/CLAUDE.md @@ -0,0 +1,64 @@ +@../CLAUDE.md + +# CLAUDE.md — pages + +`pages` is a micro static-site host: upload an `.html` or `.zip`, get it +served at `pages.elijah.run//`. One Cloudflare Worker (Rust → wasm via +workers-rs + axum), an R2 bucket for content, a D1 database for +users/sessions/page metadata, GitHub OAuth for login. + +**Design reference (read first):** `docs/plans/2026-07-12-pages-design.md` + + +Run all commands from `pages/` (or `pages/infra/` for OpenTofu). + + + +Beans are scoped to this directory — run `beans` commands from `pages/`. + + + + +## Deviations from repo defaults (deliberate, see design doc §3) + +- **No SQLx** — SQLx cannot run on Cloudflare Workers; D1 access uses the + `worker` crate bindings directly. +- **No Yew/Trunk frontend** — v0 ships a single `static/index.html` + (inline CSS/JS) embedded in the Worker via `include_str!`. +- **Worker deploys via `wrangler deploy`**, not OpenTofu (Terraform provider + can't upload module Workers with wasm). OpenTofu owns R2/D1/DNS/rate + limits in `infra/`. + + + + + +## Validation (run from `pages/`, in order) + +```sh +cargo fmt +cargo check --target wasm32-unknown-unknown # worker code is wasm-only +cargo check # native (pure logic modules) +cargo clippy --all-targets -- -D warnings +cargo clippy --target wasm32-unknown-unknown -- -D warnings +cargo test # native unit tests +``` + +For infra changes (from `pages/infra/`): `tofu fmt` and `tofu validate`. + + + + + +## wasm / workers-rs notes + +- Follow the `../quotesdb` pattern for the `#[worker::event(fetch)]` + axum + glue (`quotesdb/src/lib.rs` bottom) and for `Cargo.toml` target-gated + dependencies (getrandom `wasm_js`, worker `http` feature, tower-service). +- Keep all policy logic (slug/zip/MIME/cookie parsing) in pure modules that + compile natively so `cargo test` covers them; wasm-only code + (`cfg(target_arch = "wasm32")`) should be thin glue. +- Workers wasm is single-threaded: `unsafe impl Send/Sync` on Arc-wrapped + state wrappers is the accepted pattern (as in quotesdb). + + diff --git a/pages/Cargo.lock b/pages/Cargo.lock new file mode 100644 index 0000000..8222108 --- /dev/null +++ b/pages/Cargo.lock @@ -0,0 +1,1118 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "js-sys", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pages" +version = "0.1.0" +dependencies = [ + "axum", + "getrandom", + "http", + "serde", + "serde_json", + "sha2", + "thiserror", + "tower-service", + "worker", + "zip", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "worker" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/pages/Cargo.toml b/pages/Cargo.toml new file mode 100644 index 0000000..9eee284 --- /dev/null +++ b/pages/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "pages" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +# Shared dependencies: compile for both native host and wasm32-unknown-unknown. +[dependencies] +# Serialisation for API request/response payloads and D1 row (de)serialisation. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Error types with Display/std::error::Error derive — works on both native and wasm32. +thiserror = "2" +# Zip reading for upload validation (core::upload); deflate is the only +# compression method we need to support. Pure Rust, compiles on wasm32. +zip = { version = "2", default-features = false, features = ["deflate"] } +# SHA-256 for session token hashing (auth); pure Rust, compiles on wasm32. +sha2 = "0.10" +# Entropy for session/state token generation (auth.rs). Declared unconditionally +# (no features) so `generate_token` compiles and is unit-testable on the native +# host target too; the wasm_js feature required for wasm32-unknown-unknown is +# layered on in the wasm-only dependency table below — Cargo unifies the two +# declarations of the same crate/version, adding the feature only on that +# target. +getrandom = "0.4" + +# WASM-only dependencies (Workers entry point). +# workers-rs, getrandom/wasm_js, and the axum Workers glue are wasm32-specific. +[target.'cfg(target_arch = "wasm32")'.dependencies] +# Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc. +# The `http` feature makes the fetch handler accept/return standard http types, +# enabling direct Axum router integration without manual request conversion. +worker = { version = "0.7", features = ["d1", "http"] } +# Axum HTTP framework for the WASM Workers entry point — minimal feature set +# (no full tokio runtime needed; json and query features required for handlers). +axum = { version = "0.8", default-features = false, features = ["json", "query"] } +# Tower service trait — required by the WASM entry point to call the Axum router. +tower-service = "0.3" +# http crate — re-exported by axum and worker; needed explicitly so the return +# type `http::Response` resolves in the fetch event handler. +http = "1" +# Enables the wasm_js feature of the unconditional `getrandom` dependency +# above (see its comment) — routes entropy to crypto.getRandomValues() via +# the Web Crypto API, required for wasm32-unknown-unknown. +getrandom = { version = "0.4", features = ["wasm_js"] } + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 diff --git a/pages/README.md b/pages/README.md new file mode 100644 index 0000000..8830201 --- /dev/null +++ b/pages/README.md @@ -0,0 +1,71 @@ +# pages + +A micro static-site host at `pages.elijah.run`, running entirely on +Cloudflare (Workers + R2 + D1) with the Worker written in Rust compiled to +WebAssembly. + +## What it does + +- Browse all live pages at `pages.elijah.run`. +- Log in with GitHub, upload a single `.html` file or a `.zip` of static + assets (drag & drop), pick a name, and the content is served at + `pages.elijah.run//`. +- Admins approve which users may upload, and can delete any page. + +Hosted pages are intended to be **client-side-only single-page apps**. They +are served with a sandboxing Content-Security-Policy (opaque origin): no +cookies, no localStorage — see `docs/plans/2026-07-12-pages-design.md` §2.1. +An admin can mark a specific page, or all of a user's pages, as **trusted** +to opt them out of that sandbox (e.g. for local-first apps that need +`localStorage`) — see the "Trust escape hatch" addendum in §2.1 for the +exact semantics and the security trade-off that comes with it. + +## How it works + +One Cloudflare Worker routes everything: + +- `/` — embedded management UI (list, login, upload, admin) +- `/auth/*` — GitHub OAuth + session cookies (sessions in D1, hashed) +- `/api/*` — JSON API (list/upload/delete pages, manage users) +- `//*` — page content served from R2 with strict security headers + +Uploads are validated hard: strict slug grammar + reserved names, zip +path-traversal rejection, decompression limits (10 MiB compressed, +25 MiB uncompressed, 300 files). See the design doc for the full security +analysis. + +## Run locally + +```sh +# from pages/ +cp .dev.vars.example .dev.vars # fill in a GitHub OAuth app's id/secret +wrangler d1 execute pages --local --file migrations/schema.sql +wrangler dev # builds via worker-build and serves locally +``` + +## Test + +```sh +# from pages/ +cargo fmt +cargo check --target wasm32-unknown-unknown +cargo clippy --all-targets -- -D warnings +cargo test +``` + +## Deploy + +Infrastructure (R2 bucket, D1 database, DNS, rate limits) is OpenTofu in +`infra/`; the Worker itself deploys with `wrangler deploy` (see +`infra/README.md`). Secrets via `wrangler secret put GITHUB_CLIENT_SECRET`. + +## License + +Dual-licensed under [Apache-2.0](../LICENSE-APACHE) and +[MIT](../LICENSE-MIT), like the rest of this repository. + +## Disclaimer + +This software was written with Claude Code. Design, review and orchestration +by Claude Fable 5 (`claude-fable-5`); implementation by Claude Sonnet +agents with Claude Opus review agents. diff --git a/pages/docs/ARCHITECTURE.md b/pages/docs/ARCHITECTURE.md new file mode 100644 index 0000000..18daa1f --- /dev/null +++ b/pages/docs/ARCHITECTURE.md @@ -0,0 +1,41 @@ +# pages — Architecture + +Full design + security analysis: `plans/2026-07-12-pages-design.md`. + +## Components + +``` + ┌─────────────────────────────────────────┐ + browser ──────────▶ │ Cloudflare Worker (Rust → wasm, axum) │ + │ │ + GET / │ routes/mod.rs router construction │ + GET /auth/* │ routes/auth.rs GitHub OAuth, sessions │──▶ github.com (token exchange) + * /api/* │ routes/api.rs pages + users JSON API │──▶ D1 `DB` (users/sessions/pages) + GET //* │ routes/serve.rs page content serving │──▶ R2 `PAGES` (/) + │ │ + │ core/ pure policy logic (native- │ + │ tested): slug, mime, upload │ + │ validation, security headers │ + └─────────────────────────────────────────┘ +``` + +## Component interactions + +- **core/** has no Cloudflare dependencies; it compiles natively and holds + every security-relevant decision (slug grammar, reserved names, zip + limits, MIME allowlist, CSP header values). Unit tests live here. +- **db.rs** (wasm-only) wraps D1 queries; **routes/** (wasm-only) are thin + axum handlers that call core + db + R2. +- **auth.rs** builds OAuth URLs and cookie/session token logic (pure parts + native-tested); the GitHub HTTP calls are wasm-only. +- **static/index.html** is the whole management UI, embedded at compile time + with `include_str!` and served at `/`. + +## Trust boundaries + +1. **Uploaded content** is untrusted: validated in core/upload.rs, stored in + R2, and always served with a sandboxing CSP (opaque origin) so it can + never make credentialed calls to `/api`. +2. **Browser → API**: session cookie (`__Host-`, HttpOnly, SameSite=Lax, + hashed in D1) + Origin check on mutating routes. +3. **Worker → GitHub**: client secret only ever used server-side. diff --git a/pages/docs/PLANNING.md b/pages/docs/PLANNING.md new file mode 100644 index 0000000..d16c956 --- /dev/null +++ b/pages/docs/PLANNING.md @@ -0,0 +1,48 @@ +# pages — Planning + +## Phases + +| Phase | Content | Status | +|---|---|---| +| 0 | Security analysis + design (`plans/2026-07-12-pages-design.md`) | done | +| 1 | v0 implementation on branch `pages-v0` (beans below) | done | +| 2 | Deploy + manual QA against real Cloudflare account | deployed; manual QA (login/upload/delete) pending | +| 3 | v1 candidates: per-page subdomains, page overwrite, Yew UI, OpenAPI | not started | + +## v0 tickets (beans, serial) + +1. scaffold — crate skeleton, wrangler.toml, hello-world worker builds +2. core — slug/mime/upload/header policy modules + unit tests +3. db — D1 schema + query layer +4. auth — GitHub OAuth, sessions, auth middleware +5. api — pages + users JSON API +6. serve — page content serving from R2 +7. frontend — embedded management UI +8. infra — OpenTofu + deployment docs + +## Work log + +- **2026-07-12** — Project initialized. Security analysis and v0 design + written; beans created; implementation pipeline started (Sonnet + implementation agents, Opus review agents, one commit per ticket). +- **2026-07-13** — infra ticket (pages-ihlp) done: `infra/*.tf` (R2 bucket, + D1 database, DNS placeholder record, rate limiting ruleset) mirroring + `../quotesdb/infra` style, `infra/README.md` deployment guide, `tofu fmt` + + `tofu validate` passing. +- **2026-07-13** — v0 implementation complete: all 8 beans done on + `pages-v0`, one reviewed commit per ticket (Sonnet implementation, Opus + review). 142 unit tests + 17 doctests; both-target clippy clean. Next: + phase 2 (real deploy + manual QA per `infra/README.md`). +- **2026-07-13** — Deployed. tofu applied R2 + D1 + DNS (rate-limit ruleset + deliberately NOT applied — quotesdb's live http_ratelimit ruleset owns + the zone's single slot; merge required if wanted). Remote schema applied; + GITHUB_CLIENT_SECRET secret set; route pages.elijah.run/* live. Smoke + tests pass (/, /api/me, /auth/login 302, reserved + unknown slugs 404 + with sandbox CSP). Pending manual QA: GitHub login as admin, upload, + view, delete. +- **2026-07-13** — pages-shqc: per-page/per-user "trusted" escape hatch from + the CSP sandbox (`pages.trusted OR owner's users.trusted`, computed at + serve time). `migrations/0002-trust.sql` added for the live DB (not yet + applied remotely — manual step); schema.sql, db.rs, api.rs, serve.rs, + core/headers.rs, and the admin UI all updated; 148 unit + 18 doctests, + both-target clippy clean. diff --git a/pages/docs/plans/2026-07-12-pages-design.md b/pages/docs/plans/2026-07-12-pages-design.md new file mode 100644 index 0000000..a5947ed --- /dev/null +++ b/pages/docs/plans/2026-07-12-pages-design.md @@ -0,0 +1,283 @@ +# pages.elijah.run — Design & Security Analysis + +**Date:** 2026-07-12 +**Status:** Approved for v0 implementation (branch `pages-v0`) + +## 1. What it is + +A micro static-site host running entirely on Cloudflare: + +- Anyone can browse `pages.elijah.run` and see all live pages. +- Authenticated + authorized users upload a single `.html` file or a `.zip` + of static assets; the content is served at `pages.elijah.run//`. +- Admins can delete any page and grant/revoke upload permission for users. +- Auth is GitHub OAuth (free, no infrastructure of our own). + +Components: one Cloudflare Worker (Rust → wasm via workers-rs), one R2 bucket +(page content), one D1 database (users, sessions, page metadata). + +## 2. Security analysis + +### 2.1 Hosted pages share the origin with the management app (critical) + +User-uploaded HTML served from `pages.elijah.run//` is **same-origin** +with the upload/admin UI at `pages.elijah.run/`. Untrusted JS on the same +origin could otherwise make credentialed `fetch("/api/…")` calls (delete +pages, admin actions) — the session cookie would be attached automatically. + +**Mitigations (layered):** + +1. **CSP sandbox — primary.** Every response serving user content carries + `Content-Security-Policy: sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock` + — critically **without** `allow-same-origin`. The browser gives the + document an *opaque origin*: no cookies are sent on its requests, no + localStorage/IndexedDB, no same-origin DOM access. The session cookie is + therefore never attached to requests initiated by hosted pages. +2. **HttpOnly session cookie** — not readable from JS even if sandbox were + bypassed. +3. **Origin check on all mutating API routes** — `POST`/`PATCH`/`DELETE` + require an `Origin` header equal to the configured `BASE_URL`. Sandboxed + documents send `Origin: null`. + +**Accepted residual risks:** hosted pages can still phish (render a fake +login form), display arbitrary content under the elijah.run name, and cannot +use cookies/localStorage themselves (documented limitation of hosting here). +A stronger isolation (per-page subdomains, `.pages.elijah.run`) is the +v1 upgrade path if these limits bite. + +**Trust escape hatch (v0.1, pages-shqc).** Some hosted pages are legitimately +local-first apps (a habit tracker, a notes app) that need `localStorage` to +be useful at all — the CSP sandbox above denies that categorically. An admin +can mark either a specific page (`pages.trusted`) or a user +(`users.trusted`) as *trusted*; a page's **effective trust** is +`pages.trusted OR users.trusted`, computed at serve time via a D1 +join (`src/db.rs::PageRow::effective_trust`). A trusted page is served with +every header above *except* `Content-Security-Policy` — no sandbox, so the +document keeps its real `pages.elijah.run` origin. + +**This is a deliberate, explicit downgrade of the §2.1 mitigation for that +one page**, not a separate isolation mechanism: a trusted page runs +same-origin with the management app and therefore *can* act as any visitor +of `pages.elijah.run`, including a logged-in admin — it can issue +credentialed `fetch("/api/…")` calls (delete pages, change permissions, +grant/revoke trust on other pages) using whatever session cookie happens to +be present in the browser that loads it. Trusting a page is therefore +equivalent to giving its author the same access as anyone who ever visits +that page while signed in. **Only content authored by trusted humans should +ever be marked trusted** — never grant it to satisfy a feature request from +an unknown or unvetted uploader. Marking a *user* trusted (rather than a +single page) extends this to every page they upload, present and future, +so it should be granted even more sparingly than a single-page grant. + +### 2.2 Malicious archives + +- **Path traversal:** zip entry names like `../../x`, absolute paths (`/x`, + `C:\x`), backslash separators, or `..` segments anywhere → reject upload. +- **Zip bombs:** enforce **before/while extracting**: max compressed upload + 10 MiB (checked via `Content-Length` and while buffering), max total + uncompressed 25 MiB, max 5 MiB per file, max 300 entries, max path length + 180 bytes, max depth 10. Counting is done on decompressed bytes as they + stream out, aborting on breach — never trust zip headers alone. +- **Weird entries:** directories are skipped, symlinks don't exist for us + (we never touch a filesystem — entries go straight to R2), duplicate entry + names → reject, nested zips are stored as opaque files, never recursed. + +### 2.3 Slug (page name) abuse + +- Strict slug grammar: `^[a-z0-9][a-z0-9-]{0,62}$` (lowercase alphanumerics + and hyphens, no leading hyphen, ≤ 63 chars). Uploaded names are + lower-cased and validated, never "cleaned up" silently beyond that. +- **Reserved names** rejected to avoid route collisions and confusion: + `api`, `auth`, `admin`, `assets`, `static`, `login`, `logout`, `oauth`, + `favicon.ico`, `robots.txt`, `sitemap.xml`, `index.html`, `www`, `health`, + `status`, `well-known`, `cdn-cgi` (Cloudflare-internal path prefix). +- Uniqueness enforced by the D1 primary key; duplicate → HTTP 409 with a + JSON error so the UI can prompt for a new name. + +### 2.4 Authentication & sessions + +- **GitHub OAuth (authorization-code flow).** `state` is 32 random bytes, + stored in a short-lived (10 min) `HttpOnly` cookie and compared at the + callback — CSRF protection for the OAuth flow itself. +- **Sessions:** 32 random bytes (hex) issued as `__Host-session` cookie with + `HttpOnly; Secure; SameSite=Lax; Path=/`; only the **SHA-256 hash** is + stored in D1 (a D1 read leak doesn't yield usable tokens). 30-day expiry, + checked server-side; logout deletes the row. +- **CSRF on the API:** `SameSite=Lax` (cross-site POSTs don't carry the + cookie) + the Origin check from §2.1. +- **Authorization model:** `users.can_upload` gates uploads (default *off* — + new OAuth users can log in but not publish until an admin approves); + `users.is_admin` gates delete-any-page and user management; page owners + can delete their own pages. Admin bootstrap: the `ADMIN_GITHUB_LOGIN` + Worker var — a user logging in with that GitHub login is auto-promoted. + +### 2.5 Resource abuse / denial-of-wallet + +- Upload size caps (§2.2) and a **per-user page quota** (default 50). +- Cloudflare rate-limiting rule on `POST /api/*` and `/auth/*` (infra). +- R2/D1 are pay-per-use with generous free tiers; caps above keep worst-case + storage bounded (50 pages × 25 MiB per user, and uploads are gated by an + admin-approved allowlist anyway). + +### 2.6 Content-type handling + +- MIME types derived **only from file extension** via an allowlist map; + unknown extensions are served as `application/octet-stream`. +- `X-Content-Type-Options: nosniff` on every response. +- Only `.html`/`.htm` are ever served as `text/html`. + +### 2.7 Secrets + +- `GITHUB_CLIENT_SECRET`, `SESSION_SECRET` (reserved for future signed + values) via `wrangler secret put`; never in the repo. Local dev uses + `.dev.vars` / `.env` (git-ignored). + +## 3. Implementation issues identified + +| Issue | Resolution | +|---|---| +| **SQLx doesn't run on Workers** (repo default data pattern) | Use `worker` crate D1 bindings directly; documented deviation in `pages/CLAUDE.md`. | +| **workers-rs JS types are `!Send`**, axum wants `Send` handlers | Follow the proven `quotesdb` pattern: build the axum `Router` per-request inside `#[event(fetch)]`, share state via `Arc` with `unsafe impl Send + Sync` (sound: Workers wasm is single-threaded). | +| **Multipart parsing in wasm is fiddly** | Upload is a raw request body: `POST /api/pages?name=` with `Content-Type: application/zip` or `text/html`. The browser `fetch(file)` does this natively. | +| **Workers free tier: 10 ms CPU/request** | Deflate of a 10 MiB zip can exceed this. Limits are conservative; if uploads hit CPU kills, the $5/mo paid tier (30 s CPU) is the fallback. Serving is I/O-bound and unaffected. | +| **Entropy on wasm32** | `getrandom` with the `wasm_js` feature (same versions/pattern as `quotesdb`, which builds without extra RUSTFLAGS). | +| **Repo default frontend is Yew/Trunk** | v0 ships a single static `index.html` (inline CSS/JS) embedded with `include_str!` — one file, no extra build pipeline. Yew rewrite is possible later without API changes. Documented deviation. | +| **Native testability of a wasm-only worker** | Crate is `cdylib + rlib`; all policy logic (slugs, zip validation, MIME, cookies/auth helpers) lives in pure modules compiled and unit-tested natively; the wasm-only glue (`cfg(target_arch = "wasm32")`) stays thin. | +| **Deploying module Workers via Terraform provider** | Same as quotesdb: `wrangler deploy` owns the script + routes; OpenTofu owns R2 bucket, D1 database, DNS, rate limits. | + +## 4. Design + +### 4.1 HTTP surface + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/` | – | Management UI (embedded static HTML) | +| GET | `/auth/login` | – | Redirect to GitHub OAuth (sets state cookie) | +| GET | `/auth/callback` | – | Exchange code, upsert user, create session | +| POST | `/auth/logout` | session | Delete session, clear cookie | +| GET | `/api/me` | session | Current user (login, can_upload, is_admin) | +| GET | `/api/pages` | – | List pages (slug, owner login, created_at, size) | +| POST | `/api/pages?name=` | can_upload | Raw body upload (html or zip). 201 / 400 / 401 / 403 / 409 / 413 | +| DELETE | `/api/pages/:slug` | owner or admin | Delete page (D1 row + R2 objects) | +| GET | `/api/users` | admin | List users | +| PATCH | `/api/users/:id` | admin | Set `can_upload` and/or `trusted` (JSON body `{"can_upload"?: bool, "trusted"?: bool}`, at least one required — pages-shqc) | +| PATCH | `/api/pages/:slug` | admin | Set a page's own `trusted` flag (JSON body `{"trusted": bool}` — pages-shqc) | +| GET | `/:slug` | – | 301 → `/:slug/` | +| GET | `/:slug/` | – | Serve `index.html` of the page | +| GET | `/:slug/*path` | – | Serve asset; directory-style paths fall back to `/index.html`; 404 JSON-less page otherwise | + +Single-file `.html` uploads are stored as `/index.html`. + +### 4.2 D1 schema + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + github_id INTEGER NOT NULL UNIQUE, + login TEXT NOT NULL, + avatar_url TEXT, + can_upload INTEGER NOT NULL DEFAULT 0, + is_admin INTEGER NOT NULL DEFAULT 0, + trusted INTEGER NOT NULL DEFAULT 0, -- pages-shqc, §2.1 addendum + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the cookie token + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE pages ( + slug TEXT PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id), + file_count INTEGER NOT NULL, + total_bytes INTEGER NOT NULL, + trusted INTEGER NOT NULL DEFAULT 0, -- pages-shqc, §2.1 addendum + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +`trusted` on both tables was added by pages-shqc (§2.1 addendum) after v0 +shipped — `migrations/0002-trust.sql` is the incremental `ALTER TABLE` for +the already-live production database; `migrations/schema.sql` above already +has both columns for fresh installs. + +### 4.3 R2 layout + +`/` (e.g. `my-project/index.html`, +`my-project/assets/app.js`). Deleting a page lists by prefix `/` and +deletes all objects. + +### 4.4 Bindings & configuration (wrangler.toml) + +- D1 binding `DB`, R2 binding `PAGES`. +- Vars: `BASE_URL` (`https://pages.elijah.run`), `GITHUB_CLIENT_ID`, + `ADMIN_GITHUB_LOGIN`. +- Secrets: `GITHUB_CLIENT_SECRET`. + +### 4.5 Crate layout (single crate `pages`, cdylib + rlib) + +``` +src/ +├── lib.rs # module wiring + #[event(fetch)] (wasm-only) +├── core/ +│ ├── mod.rs +│ ├── slug.rs # slug validation + reserved names [native tests] +│ ├── mime.rs # extension → MIME allowlist [native tests] +│ ├── upload.rs # zip/html validation & manifest building [native tests] +│ └── headers.rs # security header constants (CSP sandbox…)[native tests] +├── auth.rs # OAuth URLs, state/session token & cookie helpers +│ # (pure parts native-tested; GitHub calls wasm-only) +├── db.rs # D1 queries (wasm-only) +├── routes/ # axum handlers (wasm-only) +│ ├── mod.rs # router construction +│ ├── auth.rs +│ ├── api.rs +│ └── serve.rs # page serving from R2 +└── tests.rs # unit test module wiring +static/index.html # management UI (embedded via include_str!) +migrations/schema.sql +infra/*.tf +``` + +### 4.6 Upload pipeline + +1. Auth middleware resolves session → user; reject 401/403 (`can_upload`). +2. Origin check (mutating request). +3. Validate `?name=` slug (grammar + reserved + quota) → 400/409. +4. Enforce `Content-Length` ≤ 10 MiB, buffer body with a hard cap → 413. +5. `Content-Type: text/html` → manifest of one file `index.html`. + `application/zip` → extract with the §2.2 limits → manifest of + `(path, bytes)`; must contain a root `index.html` (else 400). +6. Insert D1 `pages` row (PK conflict → 409, nothing written to R2 yet). +7. `put` each file to R2 under `/…`; on failure, best-effort cleanup + of written objects and the D1 row → 500. + +### 4.7 Serving pipeline + +`GET /:slug[/*path]` → reserved/invalid slug → 404. Otherwise a D1 +`get_page` lookup happens **before** any R2 access (pages-shqc): unknown +slug → 404 without touching R2; a known page's `effective_trust` (§2.1 +addendum) is read once and used for every response built from that point on. +Then R2 `get` on `/`; on miss try +`<…>/index.html`. Response headers: correct MIME, +`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, +`Cache-Control: public, max-age=300`, and `Access-Control-Allow-Origin: *` +(assets must load for opaque-origin documents) on **every** page response; +the §2.1 CSP sandbox is added on top of those four **unless** the page is +effectively trusted (§2.1 addendum), in which case it's omitted entirely. +The redirect (`/:slug` → `/:slug/`) and the shared 404 body always carry the +sandbox unconditionally — no D1 lookup happens before either of those two +short-circuit paths. See `src/core/headers.rs::user_content_headers` (pure, +natively-tested) and `src/routes/serve.rs` (the wasm-only caller). + +## 5. v0 scope cuts (explicit) + +- No page overwrite/update: delete + re-upload. +- No per-page subdomains (see §2.1 residual risk). +- No OpenAPI spec generation (repo TODO is unresolved; endpoints documented + here and in README). +- No Yew frontend; single embedded HTML file. +- Admin UI is minimal (user list with checkbox, delete buttons on pages). diff --git a/pages/infra/.gitignore b/pages/infra/.gitignore new file mode 100644 index 0000000..5df4c15 --- /dev/null +++ b/pages/infra/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfstate +*.tfstate.backup +.terraform.lock.hcl +*.tfvars diff --git a/pages/infra/README.md b/pages/infra/README.md new file mode 100644 index 0000000..ff3fbc3 --- /dev/null +++ b/pages/infra/README.md @@ -0,0 +1,212 @@ +# 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 | diff --git a/pages/infra/d1.tf b/pages/infra/d1.tf new file mode 100644 index 0000000..cc8b6e1 --- /dev/null +++ b/pages/infra/d1.tf @@ -0,0 +1,16 @@ +# Cloudflare D1 database backing the pages users/sessions/page-metadata +# tables (schema in ../migrations/schema.sql). SQLite-compatible; bound to +# the Worker via the "DB" binding name in wrangler.toml. +# NOTE: D1 must be created before the Worker script can bind to it +# (chicken-and-egg). On first apply: tofu apply -target=cloudflare_d1_database.pages +resource "cloudflare_d1_database" "pages" { + account_id = var.cloudflare_account_id + name = "pages" +} + +# Export the D1 database ID so wrangler.toml's [[d1_databases]] placeholder +# can be replaced with the real ID, and for use in wrangler migration commands. +output "d1_database_id" { + description = "D1 database ID — use to update wrangler.toml [[d1_databases]] database_id, and with: wrangler d1 execute pages --file ../migrations/schema.sql --remote" + value = cloudflare_d1_database.pages.id +} diff --git a/pages/infra/dns.tf b/pages/infra/dns.tf new file mode 100644 index 0000000..e8e7c37 --- /dev/null +++ b/pages/infra/dns.tf @@ -0,0 +1,19 @@ +# DNS record for pages.elijah.run. +# +# Unlike ../quotesdb (a Cloudflare Pages project fronted by a CNAME to its +# *.pages.dev subdomain), pages.elijah.run is served by a Worker route +# declared in ../wrangler.toml ([[routes]] pattern = "pages.elijah.run/*", +# see pages/CLAUDE.md "Deviations from repo defaults"). A Worker route only +# intercepts traffic for a hostname that already resolves through Cloudflare's +# proxy — it does not need a real origin IP, since the Worker itself +# generates the response. The conventional placeholder is a proxied AAAA +# record pointing at the documented "black hole" address 100::/64 +# (RFC 6666 discard prefix); Cloudflare never actually connects to it because +# the orange-clouded record is only there to make the hostname proxied. +resource "cloudflare_record" "pages" { + zone_id = var.cloudflare_zone_id + name = "pages" + type = "AAAA" + content = "100::" + proxied = true +} diff --git a/pages/infra/providers.tf b/pages/infra/providers.tf new file mode 100644 index 0000000..e8f496d --- /dev/null +++ b/pages/infra/providers.tf @@ -0,0 +1,20 @@ +# Terraform block: local state (terraform.tfstate is gitignored, no remote +# backend needed for this project) and the Cloudflare provider version pin. +# Pinned to the same major version as ../quotesdb/infra for consistency +# across the mono-repo. +terraform { + required_providers { + # Cloudflare provider for R2, D1, DNS, and rate limiting. + cloudflare = { + source = "registry.terraform.io/cloudflare/cloudflare" + version = "~> 4" + } + } +} + +# Cloudflare provider configuration. +# Authentication uses an API token passed via var.cloudflare_api_token. +# Never hardcode credentials here — use TF_VAR_* env vars or a gitignored .tfvars file. +provider "cloudflare" { + api_token = var.cloudflare_api_token +} diff --git a/pages/infra/r2.tf b/pages/infra/r2.tf new file mode 100644 index 0000000..2d8ed8f --- /dev/null +++ b/pages/infra/r2.tf @@ -0,0 +1,8 @@ +# R2 bucket that stores all hosted page content. +# Objects are keyed "/" (e.g. "my-project/index.html", +# "my-project/assets/app.js") — see docs/plans/2026-07-12-pages-design.md §4.3. +# Bound to the Worker via the "PAGES" binding name in wrangler.toml. +resource "cloudflare_r2_bucket" "pages" { + account_id = var.cloudflare_account_id + name = "pages" +} diff --git a/pages/infra/rate-limits.tf b/pages/infra/rate-limits.tf new file mode 100644 index 0000000..b1c3c66 --- /dev/null +++ b/pages/infra/rate-limits.tf @@ -0,0 +1,69 @@ +# Cloudflare WAF rate limiting rules for the pages Worker, per +# docs/plans/2026-07-12-pages-design.md §2.5 (denial-of-wallet mitigation). +# Uses the http_ratelimit phase of cloudflare_ruleset, mirroring +# ../quotesdb/infra/rate-limits.tf. +# +# NOTE on thresholds: the design doc's targets (uploads ~10/min, auth +# ~20/min per IP) are minute-based, but the Cloudflare Free plan only allows +# a 10-second window ("period" must be 10) for http_ratelimit rules, so the +# figures below are converted to 10s buckets and rounded to the nearest +# sensible integer rather than being an exact /min conversion. +# +# NOTE on the zone entrypoint limit: Cloudflare allows only ONE zone-level +# ruleset per phase, on every plan — the http_ratelimit entrypoint for +# elijah.run is a single resource. quotesdb already declares such a ruleset +# (../quotesdb/infra/rate-limits.tf) on this shared zone, so applying this +# file's ruleset alongside quotesdb's will clobber it (last apply wins), +# regardless of plan. A paid plan only raises the number of rules allowed +# INSIDE the one ruleset (Free additionally caps it at 1 rule with 10s +# periods). Before applying both projects' limits, merge the two rules +# blocks below into the zone's single shared cloudflare_ruleset (wherever +# it ends up living); this file is written standalone so it works verbatim +# when quotesdb's ruleset is not applied. See infra/README.md. +resource "cloudflare_ruleset" "pages_rate_limits" { + # Scoped to the elijah.run zone that hosts pages.elijah.run. + zone_id = var.cloudflare_zone_id + name = "pages rate limits" + description = "Per-IP rate limiting for mutating pages endpoints (uploads, auth)" + kind = "zone" + phase = "http_ratelimit" + + rules { + # Uploads: POST /api/pages?name=. Free plan restrictions apply + # (see quotesdb's note): expression fields are limited to Path and + # Verified Bot only (no Method, no regex — "contains" instead of a + # path prefix match), characteristics are IP + colo only, and period + # must be 10. This also catches GET /api/pages (list) since Method + # isn't available to filter on Free — acceptable since list is cheap + # and idempotent; the design's 10/min target for the abusive path + # (upload) still bounds worst-case write volume. + # ~10/min => ~1.7/10s, rounded up to 2/10s. + description = "Limit /api/pages requests to 2 per IP per 10 seconds" + expression = "http.request.uri.path contains \"/api/pages\"" + action = "block" + ratelimit { + # cf.colo.id is required alongside ip.src (rate limiting is processed + # per-colocation). + characteristics = ["ip.src", "cf.colo.id"] + period = 10 + requests_per_period = 2 + mitigation_timeout = 10 + } + } + + rules { + # Auth: GET /auth/login, GET /auth/callback, POST /auth/logout. + # ~20/min => ~3.3/10s, rounded up to 4/10s (slightly more permissive + # than uploads since login/logout round-trips are more frequent under + # normal use than page uploads). + description = "Limit /auth/ requests to 4 per IP per 10 seconds" + expression = "http.request.uri.path contains \"/auth/\"" + action = "block" + ratelimit { + characteristics = ["ip.src", "cf.colo.id"] + period = 10 + requests_per_period = 4 + mitigation_timeout = 10 + } + } +} diff --git a/pages/infra/variables.tf b/pages/infra/variables.tf new file mode 100644 index 0000000..40b0585 --- /dev/null +++ b/pages/infra/variables.tf @@ -0,0 +1,23 @@ +# Cloudflare API token — required for all provider operations. +# Set via: export TF_VAR_cloudflare_api_token="..." +variable "cloudflare_api_token" { + description = "Cloudflare API token with Workers, D1, R2, and DNS edit permissions." + type = string + sensitive = true +} + +# Cloudflare account ID — required for R2 and D1 resources. +# Set via: export TF_VAR_cloudflare_account_id="..." +variable "cloudflare_account_id" { + description = "Cloudflare account ID where pages resources are provisioned." + type = string +} + +# Cloudflare zone ID for elijah.run — required for the DNS record and rate +# limiting ruleset. +# Set via: export TF_VAR_cloudflare_zone_id="..." +variable "cloudflare_zone_id" { + description = "Cloudflare zone ID for the elijah.run domain." + type = string +} + diff --git a/pages/justfile b/pages/justfile new file mode 100644 index 0000000..0ec4340 --- /dev/null +++ b/pages/justfile @@ -0,0 +1,30 @@ +# Provision Cloudflare resources (R2 bucket, D1 database, DNS record, +# rate limits) from infra/. Reads credentials from infra/terraform.tfvars. +tofu-apply: + cd infra && tofu init -input=false && tofu apply + +# Apply schema migrations to the remote (production) D1 database. +# schema.sql is idempotent (CREATE ... IF NOT EXISTS), safe to re-run. +migrate: + wrangler d1 execute pages --remote --file migrations/schema.sql -y + +# Apply schema migrations to the local D1 database used by `wrangler dev`. +migrate-local: + wrangler d1 execute pages --local --file migrations/schema.sql -y + +# Build (via worker-build, configured in wrangler.toml) and deploy the Worker. +deploy: + wrangler deploy + +# Run the Worker locally (uses .dev.vars for vars/secrets). +dev: + wrangler dev + +# Full validation suite (see CLAUDE.md). +validate: + cargo fmt + cargo check --target wasm32-unknown-unknown + cargo check + cargo clippy --all-targets -- -D warnings + cargo clippy --target wasm32-unknown-unknown -- -D warnings + cargo test diff --git a/pages/migrations/0002-trust.sql b/pages/migrations/0002-trust.sql new file mode 100644 index 0000000..28ecc03 --- /dev/null +++ b/pages/migrations/0002-trust.sql @@ -0,0 +1,29 @@ +-- pages.elijah.run — incremental migration: users.trusted + pages.trusted +-- +-- pages-shqc: adds the admin-granted "trust" escape hatch (design §2.1 +-- addendum) — trusted pages/users are served WITHOUT the CSP sandbox so +-- local-first apps (e.g. a habit tracker) can use localStorage. Effective +-- trust for a page is `pages.trusted OR users.trusted`, computed +-- at serve time (src/db.rs::PageRow::effective_trust). +-- +-- `migrations/schema.sql` already has both columns for fresh installs +-- (`CREATE TABLE IF NOT EXISTS` there is a no-op against a live DB that +-- already has the tables). This file is for the *existing* production D1 +-- database, which needs the columns added in place. +-- +-- SQLite has no `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so re-running +-- this file against a database that has already applied it will fail with +-- an error like `duplicate column name: trusted` on each statement below — +-- that is expected and safe to ignore (same precedent as quotesdb's +-- `ALTER_QUOTES_ADD_HIDDEN` migration, src/db/migrations.rs). Do NOT run +-- this more than once per database; check first if unsure +-- (`PRAGMA table_info(users)` / `PRAGMA table_info(pages)`). +-- +-- Apply with (NOT run automatically by this ticket — remote apply is a +-- separate, manual, human-triggered step): +-- wrangler d1 execute --file=migrations/0002-trust.sql (remote) +-- wrangler d1 execute --local --file=migrations/0002-trust.sql (local dev) + +ALTER TABLE users ADD COLUMN trusted INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE pages ADD COLUMN trusted INTEGER NOT NULL DEFAULT 0; diff --git a/pages/migrations/schema.sql b/pages/migrations/schema.sql new file mode 100644 index 0000000..247912c --- /dev/null +++ b/pages/migrations/schema.sql @@ -0,0 +1,82 @@ +-- pages.elijah.run — D1 schema +-- +-- Mirrors docs/plans/2026-07-12-pages-design.md §4.2 exactly. All statements +-- use `IF NOT EXISTS` so this file can be re-applied safely (e.g. re-running +-- `wrangler d1 execute` against a database that already has these objects). +-- +-- Apply with: +-- wrangler d1 execute --file=migrations/schema.sql (remote) +-- wrangler d1 execute --local --file=migrations/schema.sql (local dev) + +-- Registered users, upserted on every successful GitHub OAuth login +-- (src/db.rs::Db::upsert_user_on_login). `github_id` is the stable GitHub +-- account id (not the login, which can change) and is the natural +-- conflict key for the upsert. `can_upload` and `is_admin` are booleans +-- stored as INTEGER 0/1 (SQLite/D1 has no native boolean type) — see the +-- `UserRow` -> `User` conversion in src/db.rs for how these are turned +-- into real `bool`s on read. Both default to 0: new OAuth users can log +-- in but cannot publish until an admin grants `can_upload` (design §2.4). +-- `ADMIN_GITHUB_LOGIN` logins are auto-promoted (both flags forced to 1) +-- on every login by the upsert, independent of this default. `trusted` is a +-- separate, admin-only-granted flag (pages-shqc): it is never touched by +-- admin auto-promotion or by any login upsert — see the "Trust escape +-- hatch" addendum in docs/plans/2026-07-12-pages-design.md §2.1. A user's +-- `trusted` flag contributes to a page's *effective* trust (alongside the +-- page's own `trusted` flag below) via `p.trusted OR u.trusted`. +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + github_id INTEGER NOT NULL UNIQUE, + login TEXT NOT NULL, + avatar_url TEXT, + can_upload INTEGER NOT NULL DEFAULT 0, + is_admin INTEGER NOT NULL DEFAULT 0, + trusted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Server-side session records. The cookie sent to the browser holds the raw +-- session token; only its SHA-256 hex hash is ever persisted here, so a D1 +-- read leak does not yield a usable session token (design §2.4). +-- `ON DELETE CASCADE` means deleting a user row (none currently does this in +-- v0, but future admin tooling might) also removes their sessions. +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the cookie token + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- One row per published page. `slug` is the primary key, so a duplicate +-- upload name surfaces as a UNIQUE/PK constraint violation, which src/db.rs +-- maps to a typed `DbError::NameTaken` (HTTP 409 at the routes layer, +-- design §2.3). `file_count`/`total_bytes` are denormalized summaries of the +-- R2 objects stored under the `/` prefix (design §4.3), populated at +-- upload time so the page list and per-user quota check (design §2.5) don't +-- need to touch R2. `trusted` is an admin-only-granted flag (pages-shqc, +-- design §2.1 addendum): a page's *effective* trust is +-- `pages.trusted OR users.trusted`, computed at serve time — +-- see src/db.rs's `PageRow::effective_trust`. +CREATE TABLE IF NOT EXISTS pages ( + slug TEXT PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id), + file_count INTEGER NOT NULL, + total_bytes INTEGER NOT NULL, + trusted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Per-user page quota (design §2.5) is enforced by counting +-- `pages` rows WHERE owner_id = ? before an upload; without this index that +-- scan is a full table scan on every upload. +CREATE INDEX IF NOT EXISTS idx_pages_owner_id ON pages(owner_id); + +-- get_user_by_session filters `WHERE expires_at > datetime('now')` on every +-- authenticated request; this index keeps that lookup (and any future +-- expired-session cleanup job) from scanning the whole table. +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- Not required by any current query pattern (session lookups go through +-- `token_hash`, the primary key) but cheap to maintain and speeds up the +-- `ON DELETE CASCADE` scan when a user row is deleted, plus any future +-- "list my sessions" / "log out everywhere" feature keyed on user_id. +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); diff --git a/pages/src/auth.rs b/pages/src/auth.rs new file mode 100644 index 0000000..d3b7bf7 --- /dev/null +++ b/pages/src/auth.rs @@ -0,0 +1,636 @@ +//! Pure OAuth/session helpers — token generation, hashing, cookie +//! building/parsing, the GitHub authorize URL, and epoch→UTC formatting for +//! session expiry. +//! +//! Everything in this module is plain Rust with no dependency on `worker` or +//! `axum`, so it compiles for both the native host target and +//! `wasm32-unknown-unknown` and is unit-tested natively (`cargo test`). See +//! `docs/plans/2026-07-12-pages-design.md` §2.4 (auth design) and §2.1 (why +//! the session cookie is `HttpOnly`). The wasm-only glue that actually talks +//! to GitHub and D1 lives in `routes::auth`. + +// ── Cookie names & lifetimes ───────────────────────────────────────────── + +/// Name of the session cookie. +/// +/// The `__Host-` prefix is a browser-enforced guarantee: a cookie with this +/// prefix is only accepted if it also sets `Secure`, has no `Domain` +/// attribute, and `Path=/` — i.e. it can only ever have been set by this +/// exact origin. That closes off a class of subdomain/cross-origin cookie +/// injection attacks (design §2.4). +pub const SESSION_COOKIE_NAME: &str = "__Host-session"; + +/// Name of the short-lived OAuth CSRF-protection cookie (design §2.4). +pub const STATE_COOKIE_NAME: &str = "__Host-oauth-state"; + +/// Session cookie lifetime: 30 days, in seconds (design §2.4). +pub const SESSION_MAX_AGE_SECS: u64 = 30 * 24 * 60 * 60; + +/// OAuth `state` cookie lifetime: 10 minutes, in seconds (design §2.4) — long +/// enough to complete the GitHub authorize redirect round trip, short enough +/// that a stale, unused state cookie is not a long-lived CSRF token. +pub const STATE_MAX_AGE_SECS: u64 = 10 * 60; + +// ── Token generation & hashing ─────────────────────────────────────────── + +/// Generates a fresh random token: 32 bytes of entropy, hex-encoded to a +/// 64-character lowercase string. +/// +/// Used for both the OAuth `state` value and the raw session token (design +/// §2.4). Backed by [`getrandom::fill`], which draws from the OS CSPRNG on +/// native targets and `crypto.getRandomValues()` (via the `wasm_js` feature) +/// on `wasm32-unknown-unknown` — the same entropy source used throughout the +/// Workers ecosystem (see `Cargo.toml`). +/// +/// # Panics +/// +/// Panics if the platform's entropy source is unavailable. This is treated +/// as an unrecoverable environment failure (there is no safe fallback for +/// session/CSRF token generation) rather than a `Result` callers might be +/// tempted to paper over. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::generate_token; +/// let token = generate_token(); +/// assert_eq!(token.len(), 64); +/// assert!(token.chars().all(|c| c.is_ascii_hexdigit())); +/// ``` +pub fn generate_token() -> String { + let mut buf = [0u8; 32]; + getrandom::fill(&mut buf).expect("getrandom: no secure entropy source available"); + hex_encode(&buf) +} + +/// Hex-encodes a byte slice as a lowercase string (two characters per byte). +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + // `write!` to a `String` never fails. + let _ = write!(out, "{b:02x}"); + } + out +} + +/// Computes the SHA-256 digest of `input`, hex-encoded (lowercase, 64 +/// characters). +/// +/// Used to store only a hash of the session token in D1 (design §2.4) — a +/// database read leak doesn't yield a usable session, since sessions are +/// looked up by the hash and the raw token never touches storage. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::sha256_hex; +/// assert_eq!( +/// sha256_hex(""), +/// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +/// ); +/// ``` +pub fn sha256_hex(input: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + hex_encode(&hasher.finalize()) +} + +// ── Cookie building ─────────────────────────────────────────────────────── + +/// Common cookie attribute suffix shared by every cookie this module issues: +/// site-wide path, HTTPS-only, inaccessible to JS, and not sent on +/// cross-site requests (design §2.1, §2.4). +fn attrs(max_age_secs: u64) -> String { + format!("Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}") +} + +/// Builds the `Set-Cookie` value for a new session, valid for `max_age_secs` +/// seconds. +/// +/// Callers pass [`SESSION_MAX_AGE_SECS`] in production; the parameter exists +/// so tests can use short-lived values without waiting. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::session_cookie; +/// let c = session_cookie("abc123", 2592000); +/// assert!(c.starts_with("__Host-session=abc123;")); +/// assert!(c.contains("HttpOnly")); +/// assert!(c.contains("Secure")); +/// assert!(c.contains("SameSite=Lax")); +/// assert!(c.contains("Path=/")); +/// assert!(c.contains("Max-Age=2592000")); +/// ``` +pub fn session_cookie(token: &str, max_age_secs: u64) -> String { + format!("{SESSION_COOKIE_NAME}={token}; {}", attrs(max_age_secs)) +} + +/// Builds the `Set-Cookie` value that deletes the session cookie +/// (`Max-Age=0`), for logout. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::clear_session_cookie; +/// assert!(clear_session_cookie().contains("Max-Age=0")); +/// ``` +pub fn clear_session_cookie() -> String { + format!("{SESSION_COOKIE_NAME}=; {}", attrs(0)) +} + +/// Builds the `Set-Cookie` value for the short-lived OAuth `state` cookie +/// (design §2.4), fixed at [`STATE_MAX_AGE_SECS`]. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::state_cookie; +/// let c = state_cookie("xyz"); +/// assert!(c.starts_with("__Host-oauth-state=xyz;")); +/// assert!(c.contains("Max-Age=600")); +/// ``` +pub fn state_cookie(state: &str) -> String { + format!("{STATE_COOKIE_NAME}={state}; {}", attrs(STATE_MAX_AGE_SECS)) +} + +/// Builds the `Set-Cookie` value that deletes the OAuth `state` cookie +/// (`Max-Age=0`) — sent on every `/auth/callback` response, success or +/// failure, so a state cookie never outlives a single OAuth round trip. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::clear_state_cookie; +/// assert!(clear_state_cookie().contains("Max-Age=0")); +/// ``` +pub fn clear_state_cookie() -> String { + format!("{STATE_COOKIE_NAME}=; {}", attrs(0)) +} + +// ── Cookie parsing ──────────────────────────────────────────────────────── + +/// Looks up `name` in a raw `Cookie` request header value and returns its +/// value, or `None` if not present. +/// +/// Robust to the spacing variations real browsers send (`Cookie: a=1; +/// b=2` vs. `a=1;b=2` vs. extra spaces), and to `name` being a prefix of a +/// different cookie's name (e.g. looking up `session` does not match a +/// cookie literally named `session2`). Returns a borrow of the input — no +/// allocation. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::cookie_value; +/// assert_eq!(cookie_value("a=1; b=2", "b"), Some("2")); +/// assert_eq!(cookie_value("a=1;b=2", "b"), Some("2")); +/// assert_eq!(cookie_value("a=1; b=2", "c"), None); +/// assert_eq!(cookie_value("session=tok; session2=other", "session"), Some("tok")); +/// ``` +pub fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> { + for part in cookie_header.split(';') { + let part = part.trim(); + if let Some(rest) = part.strip_prefix(name) { + if let Some(value) = rest.strip_prefix('=') { + return Some(value); + } + } + } + None +} + +// ── GitHub authorize URL ───────────────────────────────────────────────── + +/// Percent-encodes `input` for safe inclusion in a URL query component. +/// +/// Unreserved characters (`ALPHA` / `DIGIT` / `-` `.` `_` `~`, RFC 3986 +/// §2.3) pass through unchanged; every other byte is encoded as `%XX` +/// (uppercase hex). Hand-rolled rather than pulling in the `url` crate — the +/// only values ever encoded here are a handful of query parameters +/// (`client_id`, a `pages.elijah.run/...` redirect URI, and a hex token), +/// so a full URL-parsing crate is unnecessary weight for a wasm binary. +fn percent_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push(hex_digit(b >> 4)); + out.push(hex_digit(b & 0x0f)); + } + } + } + out +} + +/// Uppercase hex digit for a nibble (`0..=15`), for [`percent_encode`]. +fn hex_digit(nibble: u8) -> char { + match nibble { + 0..=9 => (b'0' + nibble) as char, + _ => (b'A' + (nibble - 10)) as char, + } +} + +/// Builds the GitHub OAuth authorization URL to redirect the browser to for +/// `GET /auth/login` (design §4.1). +/// +/// `client_id` and `redirect_uri` come from Worker configuration +/// (`GITHUB_CLIENT_ID` var, `BASE_URL` + `/auth/callback` — design §4.4); +/// `state` is a freshly generated [`generate_token`] value, also stored in +/// the `state` cookie ([`state_cookie`]) for the callback to verify. +/// +/// # Examples +/// +/// ``` +/// use pages::auth::github_authorize_url; +/// let url = github_authorize_url( +/// "abc123", +/// "https://pages.elijah.run/auth/callback", +/// "deadbeef", +/// ); +/// assert!(url.starts_with("https://github.com/login/oauth/authorize?")); +/// assert!(url.contains("client_id=abc123")); +/// assert!(url.contains("redirect_uri=https%3A%2F%2Fpages.elijah.run%2Fauth%2Fcallback")); +/// assert!(url.contains("state=deadbeef")); +/// ``` +pub fn github_authorize_url(client_id: &str, redirect_uri: &str, state: &str) -> String { + format!( + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}", + percent_encode(client_id), + percent_encode(redirect_uri), + percent_encode(state), + ) +} + +// ── Epoch → UTC formatting ──────────────────────────────────────────────── + +/// Formats `epoch_secs` (seconds since the Unix epoch, UTC) as +/// `YYYY-MM-DD HH:MM:SS` — a format SQLite's `datetime()` function parses +/// directly (see `db::Db::create_session`). +/// +/// The date part is computed by the well-known Howard Hinnant +/// `civil_from_days` algorithm (proleptic Gregorian calendar, correct for +/// all years including leap years, valid for the full `i64` day range) — +/// hand-rolled rather than pulling in `chrono`/`time`, which are heavier +/// than this crate wants on a wasm binary for a single conversion (design +/// says "no chrono/time" — see `Cargo.toml` comments and `docs/plans/…` +/// §4.5). +/// +/// # Examples +/// +/// ``` +/// use pages::auth::format_expiry; +/// assert_eq!(format_expiry(0), "1970-01-01 00:00:00"); +/// assert_eq!(format_expiry(1752300000), "2025-07-12 06:00:00"); +/// assert_eq!(format_expiry(951782400), "2000-02-29 00:00:00"); // leap year +/// ``` +pub fn format_expiry(epoch_secs: i64) -> String { + let days = epoch_secs.div_euclid(86_400); + let secs_of_day = epoch_secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + let hour = secs_of_day / 3600; + let minute = (secs_of_day % 3600) / 60; + let second = secs_of_day % 60; + format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}") +} + +/// Convenience wrapper combining "now" (as an epoch-seconds value the caller +/// obtains from `worker::Date::now()` on the wasm side, kept out of this +/// pure module) with [`SESSION_MAX_AGE_SECS`] to produce the `expires_at` +/// string for [`db::Db::create_session`](crate::db::Db::create_session). +/// +/// # Examples +/// +/// ``` +/// use pages::auth::session_expiry_from_now; +/// let expiry = session_expiry_from_now(0); +/// assert_eq!(expiry, "1970-01-31 00:00:00"); // 0 + 30 days +/// ``` +pub fn session_expiry_from_now(now_epoch_secs: i64) -> String { + format_expiry(now_epoch_secs + SESSION_MAX_AGE_SECS as i64) +} + +/// Converts a day count since the Unix epoch (1970-01-01 = day `0`) to a +/// proleptic-Gregorian `(year, month, day)` triple. +/// +/// This is Howard Hinnant's `civil_from_days` algorithm +/// (), +/// reproduced here rather than pulled from a dependency — it is ~15 lines, +/// well-known, and the only date computation this crate needs. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + // ── generate_token ─────────────────────────────────────────────── + + #[test] + fn token_is_64_hex_chars() { + let t = generate_token(); + assert_eq!(t.len(), 64); + assert!(t.chars().all(|c| c.is_ascii_hexdigit())); + assert!(t.chars().all(|c| !c.is_ascii_uppercase())); + } + + #[test] + fn tokens_are_unique() { + let tokens: HashSet = (0..50).map(|_| generate_token()).collect(); + assert_eq!(tokens.len(), 50, "expected 50 unique tokens in 50 draws"); + } + + // ── sha256_hex ─────────────────────────────────────────────────── + + #[test] + fn sha256_known_vector_empty() { + assert_eq!( + sha256_hex(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn sha256_known_vector_abc() { + assert_eq!( + sha256_hex("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn sha256_is_deterministic() { + assert_eq!(sha256_hex("hello world"), sha256_hex("hello world")); + } + + #[test] + fn sha256_differs_for_different_input() { + assert_ne!(sha256_hex("a"), sha256_hex("b")); + } + + // ── session_cookie / clear_session_cookie ─────────────────────── + + #[test] + fn session_cookie_has_host_prefix_and_token() { + let c = session_cookie("mytoken", SESSION_MAX_AGE_SECS); + assert!(c.starts_with("__Host-session=mytoken;")); + } + + #[test] + fn session_cookie_has_required_attributes() { + let c = session_cookie("t", 123); + assert!(c.contains("Path=/")); + assert!(c.contains("Secure")); + assert!(c.contains("HttpOnly")); + assert!(c.contains("SameSite=Lax")); + assert!(c.contains("Max-Age=123")); + } + + #[test] + fn session_cookie_max_age_is_30_days() { + assert_eq!(SESSION_MAX_AGE_SECS, 30 * 24 * 60 * 60); + } + + #[test] + fn clear_session_cookie_zeroes_max_age_and_keeps_attrs() { + let c = clear_session_cookie(); + assert!(c.starts_with("__Host-session=;")); + assert!(c.contains("Max-Age=0")); + assert!(c.contains("HttpOnly")); + assert!(c.contains("Secure")); + } + + // ── state_cookie / clear_state_cookie ─────────────────────────── + + #[test] + fn state_cookie_has_host_prefix_and_value() { + let c = state_cookie("abc"); + assert!(c.starts_with("__Host-oauth-state=abc;")); + } + + #[test] + fn state_cookie_max_age_is_600() { + assert_eq!(STATE_MAX_AGE_SECS, 600); + assert!(state_cookie("x").contains("Max-Age=600")); + } + + #[test] + fn state_cookie_has_required_attributes() { + let c = state_cookie("x"); + assert!(c.contains("Path=/")); + assert!(c.contains("Secure")); + assert!(c.contains("HttpOnly")); + assert!(c.contains("SameSite=Lax")); + } + + #[test] + fn clear_state_cookie_zeroes_max_age() { + let c = clear_state_cookie(); + assert!(c.starts_with("__Host-oauth-state=;")); + assert!(c.contains("Max-Age=0")); + } + + // ── cookie_value ───────────────────────────────────────────────── + + #[test] + fn cookie_value_finds_middle_cookie() { + assert_eq!(cookie_value("a=1; b=2; c=3", "b"), Some("2")); + } + + #[test] + fn cookie_value_finds_first_and_last() { + assert_eq!(cookie_value("a=1; b=2; c=3", "a"), Some("1")); + assert_eq!(cookie_value("a=1; b=2; c=3", "c"), Some("3")); + } + + #[test] + fn cookie_value_missing_returns_none() { + assert_eq!(cookie_value("a=1; b=2", "z"), None); + } + + #[test] + fn cookie_value_empty_header_returns_none() { + assert_eq!(cookie_value("", "a"), None); + } + + #[test] + fn cookie_value_no_extra_spacing() { + assert_eq!(cookie_value("a=1;b=2;c=3", "b"), Some("2")); + } + + #[test] + fn cookie_value_extra_spacing() { + assert_eq!(cookie_value("a=1; b=2 ; c=3", "b"), Some("2")); + } + + #[test] + fn cookie_value_does_not_match_name_prefix() { + // Looking up "session" must not match a cookie literally named + // "session2". + assert_eq!(cookie_value("session2=nope", "session"), None); + } + + #[test] + fn cookie_value_prefers_exact_match_over_prefix_cookie() { + assert_eq!( + cookie_value("session=tok; session2=other", "session"), + Some("tok") + ); + } + + #[test] + fn cookie_value_real_host_prefixed_cookie() { + let header = format!("{SESSION_COOKIE_NAME}=abcdef123456; other=x"); + assert_eq!( + cookie_value(&header, SESSION_COOKIE_NAME), + Some("abcdef123456") + ); + } + + #[test] + fn cookie_value_handles_value_containing_equals() { + // Cookie values can legally contain '=' (e.g. base64); only the + // first '=' after the name should split name from value. + assert_eq!(cookie_value("a=b=c", "a"), Some("b=c")); + } + + // ── github_authorize_url ───────────────────────────────────────── + + #[test] + fn authorize_url_has_expected_base_and_params() { + let url = github_authorize_url( + "client123", + "https://pages.elijah.run/auth/callback", + "statetoken", + ); + assert!(url.starts_with("https://github.com/login/oauth/authorize?")); + assert!(url.contains("client_id=client123")); + assert!(url.contains("state=statetoken")); + } + + #[test] + fn authorize_url_percent_encodes_redirect_uri() { + let url = github_authorize_url("id", "https://pages.elijah.run/auth/callback", "s"); + assert!(url.contains("redirect_uri=https%3A%2F%2Fpages.elijah.run%2Fauth%2Fcallback")); + // The raw, un-encoded redirect_uri must not appear verbatim as a + // query value (it does appear as a substring of the encoded form's + // decoded pieces, so check the literal unescaped "://" is absent). + assert!(!url.contains("redirect_uri=https://")); + } + + #[test] + fn authorize_url_encodes_reserved_characters() { + let url = github_authorize_url("a b", "http://x/y?z=1", "s t&u"); + assert!(!url.contains(' '), "spaces must be percent-encoded"); + assert!(url.contains("a%20b")); + assert!(url.contains("s%20t%26u")); + } + + #[test] + fn authorize_url_leaves_unreserved_chars_unescaped() { + let url = github_authorize_url("abc-123_ABC.~", "https://x/y", "s"); + assert!(url.contains("client_id=abc-123_ABC.~")); + } + + // ── format_expiry ──────────────────────────────────────────────── + + #[test] + fn format_expiry_unix_epoch() { + assert_eq!(format_expiry(0), "1970-01-01 00:00:00"); + } + + #[test] + fn format_expiry_known_2025_date() { + assert_eq!(format_expiry(1_752_300_000), "2025-07-12 06:00:00"); + } + + #[test] + fn format_expiry_y2k_start() { + assert_eq!(format_expiry(946_684_800), "2000-01-01 00:00:00"); + } + + #[test] + fn format_expiry_second_before_y2k() { + assert_eq!(format_expiry(946_684_799), "1999-12-31 23:59:59"); + } + + #[test] + fn format_expiry_leap_year_feb_29_2000() { + // 2000 is divisible by 400, so it *is* a leap year despite being a + // century year (the case the naive "divisible by 4" rule gets + // wrong) — a good check that civil_from_days handles the + // Gregorian century-leap-year exception correctly. + assert_eq!(format_expiry(951_782_400), "2000-02-29 00:00:00"); + } + + #[test] + fn format_expiry_non_leap_century_year_1900_would_skip_feb_29() { + // 1900 is divisible by 4 but not by 400 — not a leap year. Going + // from 1900-02-28 forward one day must land on 1900-03-01, not + // 1900-02-29. Compute epoch seconds for 1900-02-28 00:00:00 UTC + // relative to the 2000-01-01 anchor: there are 36524 days between + // 1900-01-01 and 2000-01-01 (Gregorian, excluding the 1900 leap + // day), so use the delta directly via format_expiry's own days-only + // math by comparing two consecutive-day outputs instead of a + // hardcoded absolute epoch (kept simple and self-checking). + let day_len = 86_400; + // 2000-01-01 00:00:00 UTC epoch (known good from format_expiry_y2k_start). + let y2k = 946_684_800i64; + // 1900-01-01 is exactly 36524 days before 2000-01-01 in the proleptic + // Gregorian calendar (no Feb 29 1900). + let epoch_1900_01_01 = y2k - 36_524 * day_len; + let epoch_1900_02_28 = epoch_1900_01_01 + 58 * day_len; // Jan has 31 days: 31 + 27 = 58 days to Feb 28 + assert_eq!(format_expiry(epoch_1900_02_28), "1900-02-28 00:00:00"); + assert_eq!( + format_expiry(epoch_1900_02_28 + day_len), + "1900-03-01 00:00:00" + ); + } + + #[test] + fn format_expiry_leap_day_rolls_to_march_1() { + // 2000-02-29 00:00:00 + 1 day = 2000-03-01 00:00:00. + assert_eq!(format_expiry(951_782_400 + 86_400), "2000-03-01 00:00:00"); + } + + #[test] + fn format_expiry_mid_year_known_date() { + assert_eq!(format_expiry(1_577_836_800), "2020-01-01 00:00:00"); + } + + // ── session_expiry_from_now ────────────────────────────────────── + + #[test] + fn session_expiry_adds_30_days() { + assert_eq!(session_expiry_from_now(0), "1970-01-31 00:00:00"); + } + + #[test] + fn session_expiry_matches_format_expiry_plus_constant() { + let now = 1_752_300_000i64; + assert_eq!( + session_expiry_from_now(now), + format_expiry(now + SESSION_MAX_AGE_SECS as i64) + ); + } +} diff --git a/pages/src/core/headers.rs b/pages/src/core/headers.rs new file mode 100644 index 0000000..d163b2e --- /dev/null +++ b/pages/src/core/headers.rs @@ -0,0 +1,261 @@ +//! Security header constants applied to every hosted-page response. +//! +//! See `docs/plans/2026-07-12-pages-design.md` §2.1 and §4.7. These values +//! are plain `&str` constants rather than `http::HeaderValue` so this module +//! stays dependency-free and usable from both native tests and the wasm-only +//! response-building code in `routes::serve`. + +/// `Content-Security-Policy` value applied to **every** response that +/// serves user-uploaded content. +/// +/// This is the primary defense described in design §2.1: hosted pages are +/// served from the *same origin* as the management app +/// (`pages.elijah.run//` vs. `pages.elijah.run/`), so without +/// isolation, malicious JS in an uploaded page could issue credentialed +/// `fetch("/api/…")` calls — deleting pages, or worse, using the viewer's +/// own session cookie. +/// +/// The `sandbox` directive forces the document into an **opaque origin**: +/// the browser treats it as if it came from a unique, unguessable origin +/// unrelated to `pages.elijah.run`. An opaque origin cannot read +/// `document.cookie`, has no access to `localStorage`/`IndexedDB`, and — +/// critically — the browser does not attach `pages.elijah.run` cookies +/// (including the `HttpOnly` session cookie) to any request it initiates, +/// because opaque origins are cross-origin with respect to the real +/// `pages.elijah.run` origin for cookie-sending purposes. So even a direct +/// `fetch("https://pages.elijah.run/api/pages/evil", { method: "DELETE" })` +/// from inside the hosted page goes out unauthenticated and is rejected by +/// session auth. +/// +/// `allow-same-origin` is deliberately **excluded** — including it would +/// give the document back its real origin identity (and therefore its +/// cookies), defeating the whole mitigation. The other tokens +/// (`allow-scripts`, `allow-forms`, `allow-modals`, `allow-popups`, +/// `allow-pointer-lock`) restore the interactivity a static site legitimately +/// needs (JS execution, forms, `alert`/`confirm`, `window.open`, pointer +/// lock) without restoring origin identity. +pub const CSP_SANDBOX: &str = + "sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"; + +/// `X-Content-Type-Options` value applied to every response. +/// +/// Prevents browsers from MIME-sniffing a response body into a different +/// content type than the one we declare — pairs with [`mime_for_path`](super::mime::mime_for_path) +/// serving strictly extension-derived, allowlisted MIME types. +pub const NOSNIFF: &str = "nosniff"; + +/// `Referrer-Policy` value applied to every hosted-page response. +/// +/// Hosted pages can contain arbitrary/untrusted content; never leak the +/// referring `pages.elijah.run//…` URL (which could itself be +/// sensitive, e.g. reveal an unlisted page slug) to third-party +/// destinations a hosted page links out to. +pub const REFERRER_POLICY: &str = "no-referrer"; + +/// `Cache-Control` value applied to served page content (HTML and assets). +/// +/// Short, public cache: content changes only on delete+re-upload (v0 has no +/// in-place update, per design §5), so a five-minute edge/browser cache is +/// safe and reduces R2 read volume without risking long-lived staleness. +pub const USER_CONTENT_CACHE_CONTROL: &str = "public, max-age=300"; + +/// HTTP header name for [`CSP_SANDBOX`]. +pub const HEADER_CONTENT_SECURITY_POLICY: &str = "Content-Security-Policy"; + +/// HTTP header name for [`NOSNIFF`]. +pub const HEADER_X_CONTENT_TYPE_OPTIONS: &str = "X-Content-Type-Options"; + +/// HTTP header name for [`REFERRER_POLICY`]. +pub const HEADER_REFERRER_POLICY: &str = "Referrer-Policy"; + +/// HTTP header name for [`USER_CONTENT_CACHE_CONTROL`]. +pub const HEADER_CACHE_CONTROL: &str = "Cache-Control"; + +/// `Access-Control-Allow-Origin` value applied to every hosted-page response +/// (design §4.7). +/// +/// The CSP `sandbox` directive ([`CSP_SANDBOX`]) gives hosted documents an +/// opaque origin, so a page's own assets (e.g. `fetch()`ing a sibling JSON +/// file, or a ` + + diff --git a/pages/wrangler.toml b/pages/wrangler.toml new file mode 100644 index 0000000..5c85c1f --- /dev/null +++ b/pages/wrangler.toml @@ -0,0 +1,42 @@ +# Wrangler configuration for the pages Worker. +# Used by `wrangler deploy` — the Cloudflare Terraform provider v4 does not +# support uploading ES module workers that import wasm files, so wrangler +# handles the multipart module bundle upload (see pages/CLAUDE.md +# "Deviations from repo defaults"). OpenTofu (infra/) owns the R2 bucket, +# D1 database, DNS, and rate limits. + +name = "pages" +main = "build/index.js" +compatibility_date = "2026-07-12" + +[build] +command = "worker-build --release" + +# Route: maps all of pages.elijah.run to this worker. The DNS record it +# rides on is created by the OpenTofu config in infra/ (proxied AAAA 100::). +[[routes]] +pattern = "pages.elijah.run/*" +zone_name = "elijah.run" + +# D1 database binding — referenced in workers-rs code as `env.d1("DB")`. +[[d1_databases]] +binding = "DB" +database_name = "pages" +# ID output by the OpenTofu D1 resource in infra/ (tofu output d1_database_id). +database_id = "6c5cd319-18d6-4ec8-bfa3-64479aef12ed" + +# R2 bucket binding — referenced in workers-rs code as `env.bucket("PAGES")`. +[[r2_buckets]] +binding = "PAGES" +bucket_name = "pages" + +[vars] +BASE_URL = "https://pages.elijah.run" +# GitHub OAuth App client ID (public). Set the real value before deploying. +GITHUB_CLIENT_ID = "Ov23liQFioTbWqqfUPYi" +# GitHub login that is auto-promoted to admin on first login. Set the real value before deploying. +ADMIN_GITHUB_LOGIN = "pop" + +# GITHUB_CLIENT_SECRET is a wrangler secret, not a var — set it with: +# wrangler secret put GITHUB_CLIENT_SECRET +# Never store it in this file or commit it to the repo.