From 2149ced5987b443a72296a0c264007fece421dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:22:55 +0000 Subject: [PATCH] fix(infra): least-privilege deploy tokens, HTTPS and security headers Deploys and Terraform no longer share one broad Cloudflare token that any branch could read: - deploy.yml builds without secrets (pnpm install --ignore-scripts) and uploads site/dist; production deploys only download it and run a pinned wrangler with a Pages-only token from the main-only `production` environment. - PR previews deploy from the new deploy-nightly.yml (workflow_run, so main's copy of the workflow runs) with the Pages-only token from the main-only `nightly` environment. - infra-plan.yml uses a read-only Cloudflare token and read-only state keys with -lock=false; infra-apply.yml runs in `production` behind a job-level main guard instead of a removable step. - Least-privilege permissions everywhere, actions pinned to commit SHAs, Dependabot for github-actions, and CODEOWNERS gets a `*` pattern. The router Worker redirects http:// and www. to https://pywire.dev, always proxies to Pages over HTTPS and rewrites any pages.dev Location, which fixes http://pywire.dev/install redirecting to pywire-landing.pages.dev and the http://pywire.dev/docs/ redirect loop. It reads the Pages hostnames from Terraform bindings and adds HSTS, nosniff, Referrer-Policy, Permissions-Policy, framing rules and a CSP (report-only for the docs tutorial beyond frame-ancestors/object-src/base-uri). Terraform turns on always_use_https, routes www to the router, and protects both Pages projects with prevent_destroy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018u8icLkZGUvTWLJ6Faa89E --- .github/CODEOWNERS | 2 +- .github/dependabot.yml | 14 ++ .github/workflows/ci.yml | 21 ++- .github/workflows/deploy-nightly.yml | 64 +++++++ .github/workflows/deploy.yml | 72 +++++--- .github/workflows/infra-apply.yml | 23 +-- .github/workflows/infra-plan.yml | 32 ++-- infra/README.md | 130 +++++++++++--- infra/main.tf | 55 +++++- tests/worker.test.mjs | 184 +++++++++++++++++++ worker/src/index.js | 254 ++++++++++++++++++--------- 11 files changed, 685 insertions(+), 166 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/deploy-nightly.yml create mode 100644 tests/worker.test.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 01b3244..48c8b35 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -@pywire/maintainers \ No newline at end of file +* @pywire/maintainers diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..183967a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + # Workflow actions are pinned to commit SHAs; this keeps the pins (and their + # `# vX.Y.Z` comments) current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + commit-message: + prefix: chore + include: scope diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c25638..51bd50a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,26 +5,31 @@ on: branches: - main +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest name: Build Check steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10 - name: Install dependencies working-directory: site - run: pnpm install + run: pnpm install --frozen-lockfile --ignore-scripts - name: Build working-directory: site @@ -34,12 +39,14 @@ jobs: runs-on: ubuntu-latest name: Installer Tests steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - - name: Test install.sh + - name: Test installer and router Worker run: node --test 'tests/*.test.mjs' diff --git a/.github/workflows/deploy-nightly.yml b/.github/workflows/deploy-nightly.yml new file mode 100644 index 0000000..cf76f60 --- /dev/null +++ b/.github/workflows/deploy-nightly.yml @@ -0,0 +1,64 @@ +name: Deploy Nightly + +# Puts a PR's landing-site build on nightly.pywire.dev. workflow_run always +# runs this file as it is on main, so a PR can change what gets built but not +# what this job does with the token: it only downloads the artifact that +# deploy.yml built (without secrets) and uploads it to the nightly branch. +on: + workflow_run: + workflows: [Deploy Landing Site] + types: [completed] + +permissions: {} + +jobs: + deploy-nightly: + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_repository.full_name == github.repository + && (github.event.workflow_run.event == 'pull_request' + || (github.event.workflow_run.event == 'workflow_dispatch' + && github.event.workflow_run.head_branch != 'main')) + # Strictly one deploy at a time: a running deploy is never cancelled, and + # a newer one waits for it (GitHub keeps only the newest waiting job). + concurrency: + group: deploy-landing-nightly + cancel-in-progress: false + runs-on: ubuntu-latest + # Restricted to main in the repo settings (workflow_run runs on main) and + # holds the Pages-only CLOUDFLARE_PAGES_TOKEN. + environment: + name: nightly + url: https://nightly.pywire.dev + permissions: + actions: read # the build artifact lives on the triggering run + name: Deploy Nightly + steps: + - name: Find build + id: build + # Drafts skip the build, so there is nothing to deploy. + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" \ + --jq '[.artifacts[] | select(.name == "landing-dist" and (.expired | not))] | length') + echo "found=$([ "$count" -gt 0 ] && echo true || echo false)" >>"$GITHUB_OUTPUT" + + - name: Download build + if: steps.build.outputs.found == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: landing-dist + path: dist + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Deploy to Cloudflare Pages + if: steps.build.outputs.found == 'true' + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + wranglerVersion: '4.145.0' + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist --project-name=pywire-landing --branch=nightly diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 29f9e9d..a7555e1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,12 @@ name: Deploy Landing Site # main deploys production. Nightly (nightly.pywire.dev) only takes PRs that are # ready for review: drafts and bare branch pushes never deploy. Mark a PR # ready, or push to one that already is, to put it on nightly. +# +# Building runs PR code and third-party install scripts, so it holds no +# secrets: it uploads site/dist as an artifact. Deploying only downloads that +# artifact and uploads it to Pages, in a job whose environment only main can +# use. PR builds deploy from deploy-nightly.yml (workflow_run), which runs +# main's copy of that workflow, never the PR's. on: workflow_dispatch: {} push: @@ -14,55 +20,79 @@ on: paths: - 'site/**' +permissions: {} + jobs: - deploy-landing: - # PRs: ready for review only, and only from this repo (forks get no secrets). + build: + # PRs: ready for review only, and only from this repo. if: >- github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) - # Strictly one deploy per target at a time: a running deploy is never - # cancelled, and a newer one waits for it (GitHub keeps only the newest - # waiting job, so an older queued deploy is dropped in its favour). Job - # level, so skipped draft runs never join the queue. - concurrency: - group: deploy-landing-${{ github.ref == 'refs/heads/main' && 'main' || 'nightly' }} - cancel-in-progress: false runs-on: ubuntu-latest permissions: contents: read - deployments: write - name: Deploy Landing Site - env: - TARGET_BRANCH: ${{ github.ref == 'refs/heads/main' && 'main' || 'nightly' }} + name: Build Landing Site steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Deploy the PR's own head, not GitHub's merge preview. ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10 - name: Install dependencies working-directory: site - run: pnpm install + run: pnpm install --frozen-lockfile --ignore-scripts - name: Build working-directory: site run: pnpm run build + - name: Upload build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: landing-dist + path: site/dist + if-no-files-found: error + retention-days: 3 + + deploy-production: + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + # Strictly one deploy at a time: a running deploy is never cancelled, and + # a newer one waits for it (GitHub keeps only the newest waiting job). + concurrency: + group: deploy-landing-main + cancel-in-progress: false + runs-on: ubuntu-latest + # The environment is restricted to main in the repo settings and holds + # CLOUDFLARE_PAGES_TOKEN, so no other branch's workflow can read it. + environment: + name: production + url: https://pywire.dev + permissions: {} + name: Deploy Landing Site + steps: + - name: Download build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: landing-dist + path: dist + - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v4 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + wranglerVersion: '4.145.0' + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - gitHubToken: ${{ secrets.GITHUB_TOKEN }} - command: pages deploy site/dist --project-name=pywire-landing --branch=${{ env.TARGET_BRANCH }} + command: pages deploy dist --project-name=pywire-landing --branch=main diff --git a/.github/workflows/infra-apply.yml b/.github/workflows/infra-apply.yml index 14b2f24..f151869 100644 --- a/.github/workflows/infra-apply.yml +++ b/.github/workflows/infra-apply.yml @@ -3,8 +3,7 @@ name: Infra apply on: workflow_dispatch: {} -permissions: - contents: read +permissions: {} concurrency: group: terraform @@ -12,8 +11,16 @@ concurrency: jobs: apply: + # The real guard is the production environment: it is restricted to main + # in the repo settings and holds the write credentials, so a dispatch from + # any other branch (which runs that branch's copy of this file) gets + # nothing. The job-level `if` just skips such runs cleanly. + if: github.ref == 'refs/heads/main' + environment: production runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read defaults: run: working-directory: infra @@ -25,15 +32,11 @@ jobs: TF_VAR_maintainer_emails: ${{ secrets.MAINTAINER_EMAILS }} TF_IN_AUTOMATION: "true" steps: - - uses: actions/checkout@v7 - - - name: Main branch only - if: github.ref != 'refs/heads/main' - run: | - echo "::error::Infra apply only runs on main" - exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: hashicorp/setup-terraform@v4.0.1 + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: terraform_version: 1.16.4 terraform_wrapper: false diff --git a/.github/workflows/infra-plan.yml b/.github/workflows/infra-plan.yml index c76fc29..fc710fc 100644 --- a/.github/workflows/infra-plan.yml +++ b/.github/workflows/infra-plan.yml @@ -1,5 +1,10 @@ name: Infra plan +# Plans only ever read. Every run (PRs, main, the weekly drift check) uses a +# read-only Cloudflare token and read-only state-bucket keys, and skips the +# state lock (-lock=false) because taking it is a write. PR runs execute the +# PR's own Terraform, so they must never see a credential that can change +# anything; applying is infra-apply.yml's job, in the production environment. on: pull_request: paths: ["infra/**", "worker/**"] @@ -9,10 +14,7 @@ on: schedule: - cron: "0 8 * * 1" # weekly drift check -permissions: - contents: read - pull-requests: write # plan comments - issues: write # drift issues +permissions: {} concurrency: group: terraform @@ -22,20 +24,26 @@ jobs: plan: runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + pull-requests: write # plan comments + issues: write # drift issues defaults: run: working-directory: infra env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_READONLY_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_READONLY_SECRET_ACCESS_KEY }} + TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_READONLY_TOKEN }} TF_VAR_forwarding_rules: ${{ secrets.EMAIL_FORWARDING_RULES }} TF_VAR_maintainer_emails: ${{ secrets.MAINTAINER_EMAILS }} TF_IN_AUTOMATION: "true" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: hashicorp/setup-terraform@v4.0.1 + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: terraform_version: 1.16.4 terraform_wrapper: false @@ -50,7 +58,7 @@ jobs: echo "exitcode=$code" >>"$GITHUB_OUTPUT" exit 0 fi - terraform plan -input=false -no-color -detailed-exitcode -lock-timeout=120s >plan.txt 2>&1 + terraform plan -input=false -no-color -detailed-exitcode -lock=false >plan.txt 2>&1 code=$? # The plan is posted publicly (PR comments, drift issues) — scrub emails. sed -i -E 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/[email]/g' plan.txt @@ -60,7 +68,7 @@ jobs: - name: Comment plan on PR # A non-empty plan on a PR is expected — comment it, never fail the PR. if: github.event_name == 'pull_request' - uses: actions/github-script@v9.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const code = Number('${{ steps.plan.outputs.exitcode }}'); @@ -109,7 +117,7 @@ jobs: - name: Open drift issue (scheduled check) if: github.event_name == 'schedule' && steps.plan.outputs.exitcode != '0' - uses: actions/github-script@v9.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const title = 'Terraform drift on main'; diff --git a/infra/README.md b/infra/README.md index 6d7c37b..8513fc3 100644 --- a/infra/README.md +++ b/infra/README.md @@ -11,8 +11,11 @@ brew install hashicorp/tap/terraform ## Daily flow - PRs touching `infra/` or `worker/` get a **plan comment** (never fails the PR). -- Pushing to `main` with drift fails the **Infra plan** job → run **Infra apply** - (Actions → Infra apply → Run workflow) to converge. + Plans use read-only credentials and `-lock=false` (see + [Credentials and environments](#credentials-and-environments)). +- Pushing to `main` reports unapplied changes as a notice (only a plan error + fails) → run **Infra apply** (Actions → Infra apply → Run workflow) to + converge. - A weekly check opens a "Terraform drift on main" issue if live state diverges. ## Local runs @@ -32,32 +35,85 @@ terraform init terraform plan # reads TF_VARs from terraform.tfvars ``` -## Secrets (repo settings) +## Credentials and environments + +Every Cloudflare credential is scoped to one job. Anything a pull request can +run (its build, its Terraform, its copy of a workflow) only ever sees +read-only credentials; write credentials live in environments that only +`main` can use. + +| Workflow | Job | Environment | Cloudflare credential | +|---|---|---|---| +| `ci.yml` | build, tests | — | none | +| `deploy.yml` | `build` (runs `pnpm install`/PR code) | — | none; uploads `site/dist` as an artifact | +| `deploy.yml` | `deploy-production` (push to `main`) | `production` | `CLOUDFLARE_PAGES_TOKEN` | +| `deploy-nightly.yml` | `deploy-nightly` (`workflow_run` after a PR build) | `nightly` | `CLOUDFLARE_PAGES_TOKEN` | +| `infra-plan.yml` | `plan` (PRs, `main`, weekly) | — | `CLOUDFLARE_READONLY_TOKEN`, read-only R2 keys | +| `infra-apply.yml` | `apply` (manual, `main`) | `production` | `CLOUDFLARE_API_TOKEN`, read/write R2 keys | + +Deploy jobs never install dependencies from the repo: they download the +artifact the secret-free build job produced and run a pinned `wrangler`. +`deploy-nightly.yml` is triggered by `workflow_run`, which always runs the +file as it is on `main`, so a PR can change what is built for nightly but not +what the deploy job does with the token. + +### Environments (Settings → Environments) + +| Environment | Deployment branches | Secrets | +|---|---|---| +| `production` | Selected branches: `main` only (optionally add required reviewers) | `CLOUDFLARE_PAGES_TOKEN`, `CLOUDFLARE_API_TOKEN`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | +| `nightly` | Selected branches: `main` only (`workflow_run` runs on `main`) | `CLOUDFLARE_PAGES_TOKEN` | + +The branch restriction is what keeps the write tokens away from other +branches: a workflow on any other branch (a PR, or a manual dispatch of a +modified `infra-apply.yml`) cannot enter the environment, so it never gets +its secrets. Don't also keep these names as repository secrets — a repository +secret is readable from every branch. + +### Repository secrets (Settings → Secrets and variables → Actions) | Secret | Purpose | |---|---| -| `CLOUDFLARE_API_TOKEN` | provider auth — see [Token permissions](#token-permissions) for the exact grant list | -| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | state backend (Object Read & Write, scoped to `pywire-tfstate` only) | +| `CLOUDFLARE_ACCOUNT_ID` | account for `wrangler pages deploy` (not sensitive; it is also in `infra.auto.tfvars`) | +| `CLOUDFLARE_READONLY_TOKEN` | provider auth for `terraform plan` — the read-only grant list below | +| `R2_READONLY_ACCESS_KEY_ID` / `R2_READONLY_SECRET_ACCESS_KEY` | state backend for plans (Object Read only, scoped to `pywire-tfstate`) | | `EMAIL_FORWARDING_RULES` / `MAINTAINER_EMAILS` | private tfvars values for CI | -## Token permissions +`R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` (Object Read & Write, scoped to +`pywire-tfstate` only) are `production` environment secrets. -The `CLOUDFLARE_API_TOKEN` must be an **account token** (verify: passes -`/accounts/{id}/tokens/verify`, fails `/user/tokens/verify`). Current picker -names (2026-09) — dashboard says *Write*, older docs say *Edit*: +## Token permissions -| Scope | Permission | -|---|---| -| Account | Pages: Write | -| Account | Workers R2 Storage: Write | -| Account | Workers Scripts: Write | -| Account | Email Routing Addresses: Write | -| Account | Account Rulesets: Write | -| Zone (pywire.dev) | Email Routing Rules: Write | -| Zone (pywire.dev) | Workers Routes: Write | -| Zone (pywire.dev) | DNS: Write | -| Zone (pywire.dev) | Zone WAF: Write | -| Zone (pywire.dev) | Zone Settings: **Read AND Write** | +All three are **account tokens** (verify: passes +`/accounts/{id}/tokens/verify`, fails `/user/tokens/verify`), restricted to +this account and, for zone permissions, to the `pywire.dev` zone. Current +picker names (2026-09) — dashboard says *Write*, older docs say *Edit*. + +**`CLOUDFLARE_PAGES_TOKEN`** (site deploys): Account → Cloudflare Pages: +Write, nothing else. Cloudflare cannot scope it to one project or branch, +so it can still publish any Pages project in the account; that is why it +only lives in environments restricted to `main`. + +**`CLOUDFLARE_API_TOKEN`** (`terraform apply`) and +**`CLOUDFLARE_READONLY_TOKEN`** (`terraform plan`) have the same grants, at +*Write* and *Read* respectively: + +| Scope | `CLOUDFLARE_API_TOKEN` | `CLOUDFLARE_READONLY_TOKEN` | +|---|---|---| +| Account | Pages: Write | Pages: Read | +| Account | Workers R2 Storage: Write | Workers R2 Storage: Read | +| Account | Workers Scripts: Write | Workers Scripts: Read | +| Account | Email Routing Addresses: Write | Email Routing Addresses: Read | +| Account | Account Rulesets: Write | Account Rulesets: Read | +| Zone (pywire.dev) | Email Routing Rules: Write | Email Routing Rules: Read | +| Zone (pywire.dev) | Workers Routes: Write | Workers Routes: Read | +| Zone (pywire.dev) | DNS: Write | DNS: Read | +| Zone (pywire.dev) | Zone WAF: Write | Zone WAF: Read | +| Zone (pywire.dev) | Zone Settings: **Read AND Write** | Zone Settings: Read | + +Before relying on a new read-only token, run `terraform plan -lock=false` +locally with it (and the read-only R2 keys): the plan must succeed and match +a plan made with the write token. Wrinkles found the hard way: @@ -82,11 +138,35 @@ Wrinkles found the hard way: - **Project recreation drops custom domains AND their DNS record** (Pages auto-deletes the zone CNAME when the project is destroyed, 2026-09 live lesson). Both `cloudflare_pages_domain` resources and the docs CNAME are - now terraform-managed; after any `terraform apply -replace` of a Pages - project, re-check the domain attach and CNAME, then dispatch the site - deploy. Also: Pages refuses to delete a project with too many deployments — - prune them via the API first (`accounts/.../pages/projects/

/deployments`). + now terraform-managed, and both Pages projects carry + `prevent_destroy = true`, so a plan that would destroy one fails instead. + To recreate one on purpose, drop `prevent_destroy` in the same PR, then + after `terraform apply -replace` re-check the domain attach and CNAME, + dispatch the site deploy, and restore `prevent_destroy`. Also: Pages + refuses to delete a project with too many deployments — prune them via the + API first (`accounts/.../pages/projects/

/deployments`). - **Provider upgrades past 5.16 are blocked**: 5.24+ cannot read this state's `email_routing_settings` (new `support_subaddress` field vs old state objects). To upgrade: `terraform state rm` the four email-routing resources, re-import them with the new provider, then bump the lock. +- **The router gets the Pages hostnames from Terraform.** `worker/src/index.js` + reads `LANDING_HOST` / `DOCS_HOST` (plain-text bindings set from the Pages + projects' `subdomain`); nightly prefixes `nightly.`. It answers 500 rather + than guess if they are missing, and rewrites any redirect naming a + `*.pages.dev` host back onto the public origin. +- **HTTPS and security headers.** `always_use_https` is on for the zone, and + the router redirects `http://` and `www.` itself, then adds HSTS + (`max-age=31536000; includeSubDomains`), `nosniff`, `Referrer-Policy`, + `Permissions-Policy`, framing rules and a CSP to everything it serves. The + docs CSP is **report-only** apart from `frame-ancestors`, `object-src` and + `base-uri`: the tutorial loads Pyodide from jsDelivr, runs WebAssembly and + blob: workers, and installs from PyPI. Walk through the tutorial with the + console open; once it reports nothing, move the report-only policy to the + enforced header in the Worker. `docs.pywire.dev` is served by Pages + directly and gets none of these headers. +- **`www.pywire.dev` DNS is not in Terraform.** Its proxied record predates + this config; the `www` Worker route makes the router redirect it to the + apex. To manage the record here, look up its id + (`GET /zones/{zone_id}/dns_records?name=www.pywire.dev`), add a + `cloudflare_dns_record "www"` matching it plus an `import` block with id + `/`, and plan. diff --git a/infra/main.tf b/infra/main.tf index 612e792..3c5d6cf 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -48,6 +48,13 @@ resource "cloudflare_pages_project" "docs" { build_command = "pnpm run build" destination_dir = "dist" } + + # Destroying a project drops its custom domain and DNS record (see + # infra/README.md) and releases its pages.dev name, which the router proxies + # to. + lifecycle { + prevent_destroy = true + } } resource "cloudflare_pages_project" "landing" { @@ -60,6 +67,10 @@ resource "cloudflare_pages_project" "landing" { build_command = "pnpm run build" destination_dir = "dist" } + + lifecycle { + prevent_destroy = true + } } # Custom domains: Pages auto-managed this CNAME while the domain was attached @@ -68,7 +79,7 @@ resource "cloudflare_pages_project" "landing" { resource "cloudflare_dns_record" "docs_cname" { zone_id = var.zone_id name = "docs" - content = "pywire-docs.pages.dev" + content = cloudflare_pages_project.docs.subdomain type = "CNAME" proxied = true ttl = 1 @@ -101,11 +112,25 @@ resource "cloudflare_workers_script" "router" { content_sha256 = filesha256("../worker/src/index.js") main_module = "index.js" - bindings = [{ - name = "CDN_BUCKET" - type = "r2_bucket" - bucket_name = cloudflare_r2_bucket.cdn.name - }] + # The router proxies to the Pages projects by these names; they live here, + # next to the projects, rather than hardcoded in the Worker. + bindings = [ + { + name = "CDN_BUCKET" + type = "r2_bucket" + bucket_name = cloudflare_r2_bucket.cdn.name + }, + { + name = "LANDING_HOST" + type = "plain_text" + text = cloudflare_pages_project.landing.subdomain + }, + { + name = "DOCS_HOST" + type = "plain_text" + text = cloudflare_pages_project.docs.subdomain + }, + ] } # --- Nightly Environment --- @@ -221,6 +246,24 @@ resource "cloudflare_workers_route" "nightly" { script = cloudflare_workers_script.router.script_name } +# www has a proxied DNS record that predates Terraform (see infra/README.md) +# and no working origin behind it (525). The router answers it with a +# redirect to the apex. +resource "cloudflare_workers_route" "www" { + zone_id = var.zone_id + pattern = "www.pywire.dev/*" + script = cloudflare_workers_script.router.script_name +} + +# HTTPS only. Cloudflare redirects http:// at the edge for every proxied +# hostname in the zone; the router also redirects, and sends HSTS +# (includeSubDomains) on its responses. +resource "cloudflare_zone_setting" "always_use_https" { + zone_id = var.zone_id + setting_id = "always_use_https" + value = "on" +} + # --- 6. Allow AI crawlers to LLM documentation files --- resource "cloudflare_ruleset" "allow_llm_crawlers" { zone_id = var.zone_id diff --git a/tests/worker.test.mjs b/tests/worker.test.mjs new file mode 100644 index 0000000..68d6051 --- /dev/null +++ b/tests/worker.test.mjs @@ -0,0 +1,184 @@ +// Tests for the pywire.dev router Worker (worker/src/index.js). The Worker only +// uses web-standard fetch/Request/Response, so Node runs it as is; the Pages +// upstream is a stubbed global fetch and R2 is a tiny in-memory fake. +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' + +import router, { LANDING_CSP, DOCS_CSP, DOCS_CSP_REPORT_ONLY, CDN_CSP } from '../worker/src/index.js' + +const ENV = { + LANDING_HOST: 'pywire-landing.pages.dev', + DOCS_HOST: 'pywire-docs.pages.dev', + CDN_BUCKET: { + async list({ prefix }) { + const keys = ['tree-sitter-pywire/tree_sitter_pywire-0.1.0-py3-none-any.whl'] + return { objects: keys.filter((k) => k.startsWith(prefix)).map((key) => ({ key })) } + }, + async get(key) { + return key === 'tree-sitter-pywire/tree_sitter_pywire-0.1.0-py3-none-any.whl' + ? { body: 'wheel', httpMetadata: { contentType: 'application/zip' } } + : null + }, + }, +} + +// Stub Pages: record upstream requests, answer via `respond`. +let upstream +let respond +const realFetch = globalThis.fetch +beforeEach(() => { + upstream = [] + respond = () => new Response('ok', { status: 200, headers: { 'Content-Type': 'text/html' } }) + globalThis.fetch = async (request) => { + upstream.push(request) + return respond(new URL(request.url)) + } +}) +afterEach(() => { + globalThis.fetch = realFetch +}) + +const get = (url, env = ENV) => router.fetch(new Request(url), env) + +function assertNoPagesDev(res) { + for (const [name, value] of res.headers) { + assert.ok(!value.includes('pages.dev'), `${name} leaks a pages.dev host: ${value}`) + } +} + +test('http is redirected to https on the same host and path, before any upstream fetch', async () => { + for (const [from, to] of [ + ['http://pywire.dev/install', 'https://pywire.dev/install'], + ['http://pywire.dev/docs/', 'https://pywire.dev/docs/'], + ['http://nightly.pywire.dev/a?b=1', 'https://nightly.pywire.dev/a?b=1'], + ]) { + const res = await get(from) + assert.equal(res.status, 301, from) + assert.equal(res.headers.get('Location'), to) + assertNoPagesDev(res) + } + assert.equal(upstream.length, 0) +}) + +test('www is redirected to the apex over https', async () => { + for (const from of ['https://www.pywire.dev/install?x=1', 'http://www.pywire.dev/install?x=1']) { + const res = await get(from) + assert.equal(res.status, 301) + assert.equal(res.headers.get('Location'), 'https://pywire.dev/install?x=1') + } +}) + +test('the landing site is proxied over https with the Pages Host header', async () => { + const res = await get('https://pywire.dev/install') + assert.equal(res.status, 200) + assert.equal(upstream.length, 1) + assert.equal(upstream[0].url, 'https://pywire-landing.pages.dev/install') + assert.equal(upstream[0].headers.get('Host'), 'pywire-landing.pages.dev') + assert.equal(upstream[0].redirect, 'manual') +}) + +test('nightly routes to the nightly branch aliases', async () => { + await get('https://nightly.pywire.dev/') + await get('https://nightly.pywire.dev/docs/guides/') + assert.deepEqual( + upstream.map((r) => r.url), + ['https://nightly.pywire-landing.pages.dev/', 'https://nightly.pywire-docs.pages.dev/guides/'], + ) +}) + +test('docs are proxied with /docs stripped', async () => { + await get('https://pywire.dev/docs') + await get('https://pywire.dev/docs/guides/forms/?q=1') + assert.deepEqual( + upstream.map((r) => r.url), + ['https://pywire-docs.pages.dev/', 'https://pywire-docs.pages.dev/guides/forms/?q=1'], + ) +}) + +test('docs trailing-slash redirects keep the /docs mount and never name pages.dev', async () => { + respond = (u) => new Response(null, { status: 308, headers: { Location: `https://pywire-docs.pages.dev${u.pathname}/` } }) + const res = await get('https://pywire.dev/docs/guides/forms') + assert.equal(res.status, 308) + assert.equal(res.headers.get('Location'), '/docs/guides/forms/') + assertNoPagesDev(res) + + respond = (u) => new Response(null, { status: 308, headers: { Location: `${u.pathname}/` } }) + const relative = await get('https://pywire.dev/docs/guides/forms') + assert.equal(relative.headers.get('Location'), '/docs/guides/forms/') +}) + +test('landing redirects to any pages.dev host are rewritten onto the public origin', async () => { + for (const host of ['pywire-landing.pages.dev', 'abc123.pywire-landing.pages.dev']) { + respond = () => new Response(null, { status: 301, headers: { Location: `https://${host}/install` } }) + const res = await get('https://pywire.dev/install') + assert.equal(res.headers.get('Location'), '/install') + assertNoPagesDev(res) + } +}) + +test('redirects to unrelated hosts pass through', async () => { + respond = () => new Response(null, { status: 302, headers: { Location: 'https://github.com/pywire' } }) + const res = await get('https://pywire.dev/somewhere') + assert.equal(res.headers.get('Location'), 'https://github.com/pywire') +}) + +test('landing responses carry the security headers', async () => { + const res = await get('https://pywire.dev/') + assert.equal(res.headers.get('Content-Security-Policy'), LANDING_CSP) + assert.match(LANDING_CSP, /frame-ancestors 'none'/) + assert.equal(res.headers.get('X-Frame-Options'), 'DENY') + assert.equal(res.headers.get('Strict-Transport-Security'), 'max-age=31536000; includeSubDomains') + assert.equal(res.headers.get('X-Content-Type-Options'), 'nosniff') + assert.equal(res.headers.get('Referrer-Policy'), 'strict-origin-when-cross-origin') + assert.match(res.headers.get('Permissions-Policy'), /camera=\(\)/) +}) + +test('the installer keeps its content type and gets the security headers', async () => { + respond = () => new Response('#!/bin/sh\n', { headers: { 'Content-Type': 'application/x-install-instructions' } }) + const res = await get('https://pywire.dev/install') + assert.equal(res.headers.get('Content-Type'), 'application/x-install-instructions') + assert.equal(res.headers.get('X-Content-Type-Options'), 'nosniff') + assert.equal(res.headers.get('Content-Security-Policy'), LANDING_CSP) + assert.equal(await res.text(), '#!/bin/sh\n') +}) + +test('docs enforce framing rules and report the full CSP only', async () => { + const res = await get('https://pywire.dev/docs/tutorial/') + assert.equal(res.headers.get('Content-Security-Policy'), DOCS_CSP) + assert.equal(res.headers.get('Content-Security-Policy-Report-Only'), DOCS_CSP_REPORT_ONLY) + assert.equal(res.headers.get('X-Frame-Options'), 'SAMEORIGIN') + // The tutorial needs WebAssembly, blob: workers and jsDelivr for Pyodide. + assert.match(DOCS_CSP_REPORT_ONLY, /'wasm-unsafe-eval'/) + assert.match(DOCS_CSP_REPORT_ONLY, /worker-src 'self' blob:/) + assert.match(DOCS_CSP_REPORT_ONLY, /https:\/\/cdn\.jsdelivr\.net/) +}) + +test('cdn serves the simple index and files with a locked-down CSP', async () => { + const index = await get('https://pywire.dev/cdn/simple/tree-sitter-pywire/') + assert.equal(index.status, 200) + assert.match(await index.text(), /href="\/cdn\/tree-sitter-pywire\/tree_sitter_pywire-0\.1\.0-py3-none-any\.whl"/) + assert.equal(index.headers.get('Access-Control-Allow-Origin'), '*') + assert.equal(index.headers.get('Content-Security-Policy'), CDN_CSP) + + const file = await get('https://pywire.dev/cdn/tree-sitter-pywire/tree_sitter_pywire-0.1.0-py3-none-any.whl') + assert.equal(file.status, 200) + assert.equal(file.headers.get('Content-Type'), 'application/zip') + assert.equal(file.headers.get('Strict-Transport-Security'), 'max-age=31536000; includeSubDomains') + + assert.equal((await get('https://pywire.dev/cdn/simple/nope/')).status, 404) + assert.equal((await get('https://pywire.dev/cdn/nope.whl')).status, 404) + assert.equal(upstream.length, 0) +}) + +test('shortcut redirects', async () => { + const res = await get('https://pywire.dev/github') + assert.equal(res.status, 302) + assert.equal(res.headers.get('Location'), 'https://github.com/pywire/pywire') + assert.equal((await get('https://pywire.dev/toString')).status, 200) // not a shortcut, proxied +}) + +test('missing host bindings fail closed instead of guessing a pages.dev name', async () => { + const res = await get('https://pywire.dev/', { CDN_BUCKET: ENV.CDN_BUCKET }) + assert.equal(res.status, 500) + assert.equal(upstream.length, 0) +}) diff --git a/worker/src/index.js b/worker/src/index.js index e300e35..d5113b3 100644 --- a/worker/src/index.js +++ b/worker/src/index.js @@ -1,104 +1,190 @@ -// Pages answers "/guides/forms" with a 308 to "/guides/forms/". It knows -// nothing of the /docs mount, so put the prefix back on same-site redirects; -// otherwise every docs URL without a trailing slash lands on a landing 404. -export function withDocsBase(response, docsTarget) { +// Router for pywire.dev and nightly.pywire.dev: the landing site, the docs +// under /docs, the /cdn package mirror, and a few shortcut redirects. The +// Pages hostnames come from Terraform as plain-text bindings (LANDING_HOST, +// DOCS_HOST) and must never reach a client, not even in a redirect. + +// Every response from this Worker. HSTS covers subdomains: docs, nightly, demo +// and www are all proxied through Cloudflare and serve HTTPS. +const BASE_HEADERS = { + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()", +}; + +// The landing site is a static Astro build. Astro inlines small scripts and +// styles, so those need 'unsafe-inline'; everything else is same-origin apart +// from remote images and the YouTube embed on the component showcase. +export const LANDING_CSP = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-src https://www.youtube-nocookie.com", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests", +].join("; "); + +// The docs run the interactive tutorial: Pyodide from jsDelivr (WebAssembly), +// Monaco and Pyodide in (blob:) workers, a service worker, micropip installs +// from /cdn and PyPI, and a same-origin preview iframe. Only the directives +// that cannot break any of that are enforced; the full policy is report-only +// until a browser session through the tutorial shows no violations. +export const DOCS_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'"; +export const DOCS_CSP_REPORT_ONLY = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net", + "worker-src 'self' blob:", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self' https://cdn.jsdelivr.net https://pypi.org https://files.pythonhosted.org", + "frame-src 'self' blob: data:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", +].join("; "); + +// /cdn serves wheels and a PEP 503 index to micropip; nothing there is meant +// to render or run in a browser tab. +export const CDN_CSP = "default-src 'none'; frame-ancestors 'none'; sandbox"; + +export const SECURITY_HEADERS = { + landing: { ...BASE_HEADERS, "Content-Security-Policy": LANDING_CSP, "X-Frame-Options": "DENY" }, + docs: { + ...BASE_HEADERS, + "Content-Security-Policy": DOCS_CSP, + "Content-Security-Policy-Report-Only": DOCS_CSP_REPORT_ONLY, + "X-Frame-Options": "SAMEORIGIN", + }, + cdn: { ...BASE_HEADERS, "Content-Security-Policy": CDN_CSP, "X-Frame-Options": "DENY" }, + redirect: BASE_HEADERS, +}; + +export function withHeaders(response, extra) { + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(extra)) headers.set(name, value); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +function redirect(location, status) { + return new Response(null, { status, headers: { ...SECURITY_HEADERS.redirect, Location: location } }); +} + +// Pages redirects name its own host: "/guides/forms" gets a 308 to +// "https://.pages.dev/guides/forms/". Send the client to the same +// path on the public origin instead, under the mount the upstream is served +// from ("/docs" for the docs, "" for the landing site). Any pages.dev host is +// rewritten, so branch aliases never leak either. +export function rewriteLocation(response, upstreamHost, mount) { const location = response.headers.get("Location"); if (response.status < 300 || response.status >= 400 || !location) return response; - const target = new URL(location, `https://${docsTarget}/`); - if (target.hostname !== docsTarget) return response; + const target = new URL(location, `https://${upstreamHost}/`); + if (target.hostname !== upstreamHost && !target.hostname.endsWith(".pages.dev")) return response; const headers = new Headers(response.headers); - headers.set("Location", `/docs${target.pathname}${target.search}${target.hash}`); + headers.set("Location", `${mount}${target.pathname}${target.search}${target.hash}`); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } -export default { - async fetch(request, env, ctx) { - const url = new URL(request.url); - const path = url.pathname; +// Forward to a Pages project, always over HTTPS: over plain HTTP, Pages +// answers with its own redirect to https://.pages.dev. +function proxy(request, host, pathname) { + const upstream = new URL(request.url); + upstream.protocol = "https:"; + upstream.hostname = host; + upstream.port = ""; + upstream.pathname = pathname; + const upstreamRequest = new Request(upstream, { + method: request.method, + headers: request.headers, + body: request.body, + redirect: "manual", + }); + // Pages routes on Host, so it must name the project, not pywire.dev. + upstreamRequest.headers.set("Host", host); + return fetch(upstreamRequest); +} - // --- 0. DETERMINE ENVIRONMENT --- - const hostname = url.hostname; - const isNightly = hostname.startsWith("nightly."); +async function serveCdn(path, env) { + if (path.startsWith("/cdn/simple/")) { + const pkgName = path.slice("/cdn/simple/".length).replace(/\/$/, ""); + if (!pkgName) return new Response("Not Found", { status: 404 }); + const listed = await env.CDN_BUCKET.list({ prefix: `${pkgName}/` }); + if (listed.objects.length === 0) + return new Response("Not Found", { status: 404, headers: { "Access-Control-Allow-Origin": "*" } }); + const links = listed.objects + .map((obj) => { + const filename = obj.key.split("/").pop(); + return `${filename}`; + }) + .join("\n"); + return new Response( + `Links for ${pkgName}` + + `

Links for ${pkgName}

\n${links}\n`, + { headers: { "Content-Type": "text/html; charset=utf-8", "Access-Control-Allow-Origin": "*" } }, + ); + } - // Define base targets based on environment - const landingTarget = isNightly ? "nightly.pywire-landing.pages.dev" : "pywire-landing.pages.dev"; - const docsTarget = isNightly ? "nightly.pywire-docs.pages.dev" : "pywire-docs.pages.dev"; + const key = path.slice("/cdn/".length); + if (!key) return new Response("Not Found", { status: 404 }); + const obj = await env.CDN_BUCKET.get(key); + if (!obj) return new Response("Not Found", { status: 404 }); + const contentType = obj.httpMetadata?.contentType ?? "application/octet-stream"; + return new Response(obj.body, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + "Access-Control-Allow-Origin": "*", + }, + }); +} - // --- 1. CDN (R2 bucket proxy + PEP 503 simple index) --- - if (path.startsWith('/cdn/simple/')) { - const pkgName = path.slice('/cdn/simple/'.length).replace(/\/$/, '') - if (!pkgName) return new Response('Not Found', { status: 404 }) - const listed = await env.CDN_BUCKET.list({ prefix: `${pkgName}/` }) - if (listed.objects.length === 0) - return new Response('Not Found', { status: 404, headers: { 'Access-Control-Allow-Origin': '*' } }) - const links = listed.objects - .map(obj => { - const filename = obj.key.split('/').pop() - return `${filename}` - }) - .join('\n') - return new Response( - `Links for ${pkgName}` + - `

Links for ${pkgName}

\n${links}\n`, - { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Access-Control-Allow-Origin': '*' } } - ) +const SHORTCUTS = { + "/github": "https://github.com/pywire/pywire", +}; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + // --- 0. CANONICAL ORIGIN: HTTPS, no www --- + if (url.protocol === "http:" || url.hostname.startsWith("www.")) { + url.protocol = "https:"; + url.port = ""; + if (url.hostname.startsWith("www.")) url.hostname = url.hostname.slice("www.".length); + return redirect(url.toString(), 301); } - if (path.startsWith('/cdn/')) { - const key = path.slice('/cdn/'.length) - if (!key) return new Response('Not Found', { status: 404 }) - const obj = await env.CDN_BUCKET.get(key) - if (!obj) return new Response('Not Found', { status: 404 }) - const contentType = obj.httpMetadata?.contentType ?? 'application/octet-stream' - return new Response(obj.body, { - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', - 'Access-Control-Allow-Origin': '*', - }, - }) + if (!env.LANDING_HOST || !env.DOCS_HOST) { + return new Response("Router misconfigured: LANDING_HOST and DOCS_HOST must be bound", { status: 500 }); } + const isNightly = url.hostname.startsWith("nightly."); + const landingHost = isNightly ? `nightly.${env.LANDING_HOST}` : env.LANDING_HOST; + const docsHost = isNightly ? `nightly.${env.DOCS_HOST}` : env.DOCS_HOST; + const path = url.pathname; - // --- 2. HANDLE SHORTCUT REDIRECTS --- - const redirects = { - // "/discord": "https://discord.gg/pywire", // Update this! - "/github": "https://github.com/pywire/pywire", - }; - if (redirects[path]) return Response.redirect(redirects[path], 302); - - // --- 3. DEFINE PROXY FUNCTION --- - // This helper strips the 'Host' header so Pages accepts the request - async function proxy(targetOrigin, pathOverride) { - const newUrl = new URL(request.url); - newUrl.hostname = targetOrigin; - - // Apply path override if provided (for stripping /docs) - if (pathOverride !== undefined) { - newUrl.pathname = pathOverride; - } - - // ⚠️ CRITICAL: Create a clean request to avoid Host header mismatch - const newRequest = new Request(newUrl, { - method: request.method, - headers: request.headers, - body: request.body, - redirect: "manual", - }); - - // Force the Host header to match the target origin - newRequest.headers.set("Host", targetOrigin); - - return fetch(newRequest); + // --- 1. CDN (R2 bucket proxy + PEP 503 simple index) --- + if (path.startsWith("/cdn/")) { + return withHeaders(await serveCdn(path, env), SECURITY_HEADERS.cdn); } - // --- 4. ROUTE TO DOCS --- + // --- 2. SHORTCUT REDIRECTS --- + if (Object.hasOwn(SHORTCUTS, path)) return redirect(SHORTCUTS[path], 302); + + // --- 3. DOCS (the origin sees "/_astro/..." or "/") --- if (path === "/docs" || path.startsWith("/docs/")) { - // Strip "/docs" so the origin sees "/_astro/..." or "/" - const newPath = path.replace(/^\/docs/, "") || "/"; - return withDocsBase(await proxy(docsTarget, newPath), docsTarget); + const upstream = await proxy(request, docsHost, path.replace(/^\/docs/, "") || "/"); + return withHeaders(rewriteLocation(upstream, docsHost, "/docs"), SECURITY_HEADERS.docs); } - // --- 5. ROUTE TO LANDING --- - return proxy(landingTarget); + // --- 4. LANDING --- + const upstream = await proxy(request, landingHost, path); + return withHeaders(rewriteLocation(upstream, landingHost, ""), SECURITY_HEADERS.landing); }, };