diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bbb2528..a0a9cf0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,7 +24,7 @@ on: description: "Run hosted smoke checks after deploy" required: true type: boolean - default: false + default: true permissions: contents: read @@ -82,12 +82,16 @@ jobs: worker_origin="${{ vars.FORAGE_PRODUCTION_WORKER_ORIGIN }}" pages_branch="${{ vars.FORAGE_PRODUCTION_PAGES_BRANCH }}" default_pages_branch="main" + smoke_expect_production="true" + web_smoke_mode="public" ;; staging) web_origin="${{ vars.FORAGE_STAGING_WEB_ORIGIN }}" worker_origin="${{ vars.FORAGE_STAGING_WORKER_ORIGIN }}" pages_branch="${{ vars.FORAGE_STAGING_PAGES_BRANCH }}" default_pages_branch="staging" + smoke_expect_production="false" + web_smoke_mode="access-protected" ;; *) echo "::error::Unsupported deployment environment: $environment_name" @@ -114,6 +118,8 @@ jobs: echo "web_origin=$web_origin" echo "worker_origin=$worker_origin" echo "pages_branch=$pages_branch" + echo "smoke_expect_production=$smoke_expect_production" + echo "web_smoke_mode=$web_smoke_mode" } >> "$GITHUB_OUTPUT" - name: Check infra formatting @@ -145,4 +151,6 @@ jobs: env: FORAGE_WEB_ORIGIN: ${{ steps.resolve.outputs.web_origin }} FORAGE_WORKER_ORIGIN: ${{ steps.resolve.outputs.worker_origin }} + FORAGE_SMOKE_EXPECT_PRODUCTION: ${{ steps.resolve.outputs.smoke_expect_production }} + FORAGE_WEB_SMOKE_MODE: ${{ steps.resolve.outputs.web_smoke_mode }} run: pnpm smoke:hosted diff --git a/docs/17-ci-and-quality-gates.md b/docs/17-ci-and-quality-gates.md index 7383440..68b7160 100644 --- a/docs/17-ci-and-quality-gates.md +++ b/docs/17-ci-and-quality-gates.md @@ -48,7 +48,8 @@ Current Worker API contract coverage: Hosted smoke command: - Script: `scripts/check-hosted-smoke.mjs` - Command: `FORAGE_WEB_ORIGIN=https://forage.example.com FORAGE_WORKER_ORIGIN=https://api.forage.example.com pnpm smoke:hosted` -- Purpose: verify deployed Worker health, Worker CORS/preflight, Pages headers, CSP Worker origin, and basic rendered app HTML +- Purpose: verify deployed Worker health, Worker CORS/preflight, unauthenticated session shape, OAuth start redirect/PKCE setup, Pages headers, CSP Worker origin, and basic rendered app HTML +- Staging can use `FORAGE_WEB_SMOKE_MODE=access-protected` when the web hostname is behind Cloudflare Access. - This is not part of the default CI gate because it requires live hosted domains. Local developer hooks: diff --git a/docs/21-hosting-ui-setup.md b/docs/21-hosting-ui-setup.md index cdab1a8..ff2ce14 100644 --- a/docs/21-hosting-ui-setup.md +++ b/docs/21-hosting-ui-setup.md @@ -207,7 +207,17 @@ FORAGE_WORKER_ORIGIN=https://api.forage.example.com \ pnpm smoke:hosted ``` -For staging, use the staging web and API origins and set `FORAGE_SMOKE_EXPECT_PRODUCTION=false`. This script verifies Worker health, CORS, preflight headers, Pages security headers, CSP Worker origin, and basic app HTML. +For staging, use the staging web and API origins, set `FORAGE_SMOKE_EXPECT_PRODUCTION=false`, and set `FORAGE_WEB_SMOKE_MODE=access-protected` when Cloudflare Access protects the staging web hostname: + +```sh +FORAGE_WEB_ORIGIN=https://forage-staging.shrimpworks.dev \ +FORAGE_WORKER_ORIGIN=https://api-staging.forage.shrimpworks.dev \ +FORAGE_SMOKE_EXPECT_PRODUCTION=false \ +FORAGE_WEB_SMOKE_MODE=access-protected \ +pnpm smoke:hosted +``` + +This script verifies Worker health, CORS, unauthenticated session shape, GitHub OAuth start redirect/PKCE setup, preflight headers, and either public Pages HTML/security headers or Cloudflare Access protection depending on `FORAGE_WEB_SMOKE_MODE`. Verify Worker health: - `GET https://api.forage.example.com/api/health` diff --git a/docs/23-deployment-automation.md b/docs/23-deployment-automation.md index 0c23899..904f102 100644 --- a/docs/23-deployment-automation.md +++ b/docs/23-deployment-automation.md @@ -10,7 +10,7 @@ Forage deploys through `.github/workflows/deploy.yml`. The workflow is manual-on - `environment`: `staging` or `production` - `deploy_worker`: deploy `apps/worker` with Wrangler - `deploy_pages`: deploy `apps/web/dist` to Cloudflare Pages with Wrangler direct upload -- `run_hosted_smoke`: run `pnpm smoke:hosted` after deployment +- `run_hosted_smoke`: run `pnpm smoke:hosted` after deployment, enabled by default ## GitHub Repository Secrets @@ -77,10 +77,11 @@ Hosted smoke: FORAGE_WEB_ORIGIN=https://staging.forage.example.com \ FORAGE_WORKER_ORIGIN=https://api-staging.forage.example.com \ FORAGE_SMOKE_EXPECT_PRODUCTION=false \ +FORAGE_WEB_SMOKE_MODE=access-protected \ pnpm smoke:hosted ``` -Set `FORAGE_SMOKE_EXPECT_PRODUCTION=false` for staging because staging Worker config intentionally exposes non-secret setup diagnostics. Leave it unset for production so the smoke check verifies that production config hides those diagnostics. +The deploy workflow sets `FORAGE_SMOKE_EXPECT_PRODUCTION=false` and `FORAGE_WEB_SMOKE_MODE=access-protected` automatically for staging because staging Worker config intentionally exposes non-secret setup diagnostics and the staging web hostname is behind Cloudflare Access. For production, the workflow sets `FORAGE_SMOKE_EXPECT_PRODUCTION=true` and `FORAGE_WEB_SMOKE_MODE=public` so the smoke check verifies public Pages HTML/security headers and confirms production config hides diagnostics. When using Cloudflare Pages custom branch domains, the staging branch name should match the OpenTofu environment key. For example, the `staging` environment maps to `staging..pages.dev` and can be connected to a custom hostname such as `forage-staging.example.com`. diff --git a/scripts/check-hosted-smoke.mjs b/scripts/check-hosted-smoke.mjs index 25bc36a..c76c2a6 100644 --- a/scripts/check-hosted-smoke.mjs +++ b/scripts/check-hosted-smoke.mjs @@ -9,6 +9,8 @@ const untrustedOrigin = normalizeOrigin( process.env.FORAGE_UNTRUSTED_ORIGIN ?? "https://forage-smoke.invalid", ); const expectProductionConfig = parseBooleanEnv(process.env.FORAGE_SMOKE_EXPECT_PRODUCTION, true); +const webSmokeMode = process.env.FORAGE_WEB_SMOKE_MODE ?? "public"; +const skipOAuthStart = parseBooleanEnv(process.env.FORAGE_SMOKE_SKIP_OAUTH_START, false); const failures = []; @@ -21,10 +23,17 @@ Set: Optional: FORAGE_UNTRUSTED_ORIGIN=https://untrusted.example.com + FORAGE_WEB_SMOKE_MODE=public + FORAGE_SMOKE_SKIP_OAUTH_START=false `); process.exit(1); } +if (!["public", "access-protected"].includes(webSmokeMode)) { + console.error("FORAGE_WEB_SMOKE_MODE must be public or access-protected."); + process.exit(1); +} + const webIsHttps = webOrigin.startsWith("https://"); const workerIsHttps = workerOrigin.startsWith("https://"); @@ -81,6 +90,78 @@ if (rejectedConfig.response.headers.has("access-control-allow-origin")) { failures.push("Worker config returned Access-Control-Allow-Origin for an untrusted origin."); } +const session = await fetchJson(`${workerOrigin}/api/session`, { + label: "Worker unauthenticated session", + headers: { + Origin: webOrigin, + }, +}); +assertHeader( + session.response, + "access-control-allow-origin", + webOrigin, + "Worker unauthenticated session", +); +assertHeader( + session.response, + "access-control-allow-credentials", + /^true$/i, + "Worker unauthenticated session", +); +if (session.payload?.authenticated !== false) { + failures.push("Worker unauthenticated session response must report authenticated: false."); +} + +if (!skipOAuthStart) { + const authStart = await fetch(`${workerOrigin}/auth/github`, { + redirect: "manual", + headers: { + Origin: webOrigin, + }, + }); + if (authStart.status !== 302) { + failures.push(`GitHub OAuth start returned ${authStart.status}; expected 302.`); + } + assertHeader( + authStart, + "location", + /^https:\/\/github\.com\/login\/oauth\/authorize\?/i, + "GitHub OAuth start", + ); + assertHeader(authStart, "set-cookie", /forage_oauth_state=/i, "GitHub OAuth start"); + + const authLocation = authStart.headers.get("location"); + if (authLocation) { + const authUrl = new URL(authLocation); + const redirectUri = authUrl.searchParams.get("redirect_uri"); + if (redirectUri !== `${workerOrigin}/auth/github/callback`) { + failures.push( + `GitHub OAuth start redirect_uri was ${redirectUri}; expected ${workerOrigin}/auth/github/callback.`, + ); + } + if (authUrl.searchParams.get("code_challenge_method") !== "S256") { + failures.push("GitHub OAuth start is missing PKCE code_challenge_method=S256."); + } + if (!authUrl.searchParams.get("code_challenge")) { + failures.push("GitHub OAuth start is missing PKCE code_challenge."); + } + if (!authUrl.searchParams.get("state")) { + failures.push("GitHub OAuth start is missing state."); + } + } + + const oauthCookie = authStart.headers.get("set-cookie") ?? ""; + if (!/HttpOnly/i.test(oauthCookie)) { + failures.push("GitHub OAuth start cookie is missing HttpOnly."); + } + if (!/SameSite=Lax/i.test(oauthCookie)) { + failures.push("GitHub OAuth start cookie is missing SameSite=Lax."); + } + if (workerIsHttps && !/Secure/i.test(oauthCookie)) { + failures.push("GitHub OAuth start cookie is missing Secure."); + } +} + const preflight = await fetch(`${workerOrigin}/api/settings`, { method: "OPTIONS", headers: { @@ -100,28 +181,46 @@ assertHeader( "Worker settings preflight", ); -const webResponse = await fetch(webOrigin); -if (!webResponse.ok) { - failures.push(`Web app returned ${webResponse.status}; expected 2xx.`); -} -const webHtml = await webResponse.text(); -if (!webHtml.includes('id="forage-app"')) { - failures.push("Web app HTML is missing the Forage app root."); -} -if (!webHtml.includes("Starred repos, ready to sort through.")) { - failures.push("Web app HTML is missing the expected heading."); -} -if (!webHtml.includes(`connect-src 'self' ${workerOrigin}`)) { - failures.push("Web app CSP meta tag does not include the configured Worker origin."); -} -assertHeader(webResponse, "x-content-type-options", /^nosniff$/i, "Web app"); -assertHeader(webResponse, "x-frame-options", /^DENY$/i, "Web app"); -assertHeader(webResponse, "referrer-policy", /^strict-origin-when-cross-origin$/i, "Web app"); -assertHeader(webResponse, "permissions-policy", /camera=\(\)/i, "Web app"); -assertHeader(webResponse, "content-security-policy", /frame-ancestors 'none'/i, "Web app"); +if (webSmokeMode === "public") { + const webResponse = await fetch(webOrigin); + if (!webResponse.ok) { + failures.push(`Web app returned ${webResponse.status}; expected 2xx.`); + } + const webHtml = await webResponse.text(); + if (!webHtml.includes('id="forage-app"')) { + failures.push("Web app HTML is missing the Forage app root."); + } + if (!webHtml.includes("Starred repos, ready to sort through.")) { + failures.push("Web app HTML is missing the expected heading."); + } + if (!webHtml.includes(`connect-src 'self' ${workerOrigin}`)) { + failures.push("Web app CSP meta tag does not include the configured Worker origin."); + } + assertHeader(webResponse, "x-content-type-options", /^nosniff$/i, "Web app"); + assertHeader(webResponse, "x-frame-options", /^DENY$/i, "Web app"); + assertHeader(webResponse, "referrer-policy", /^strict-origin-when-cross-origin$/i, "Web app"); + assertHeader(webResponse, "permissions-policy", /camera=\(\)/i, "Web app"); + assertHeader(webResponse, "content-security-policy", /frame-ancestors 'none'/i, "Web app"); + + if (webIsHttps) { + assertHeader(webResponse, "strict-transport-security", /max-age=/i, "Web app"); + } +} else { + const webResponse = await fetch(webOrigin, { redirect: "manual" }); + const location = webResponse.headers.get("location") ?? ""; + const body = await webResponse.text().catch(() => ""); + const accessRedirect = + [301, 302, 303, 307, 308].includes(webResponse.status) && + (/cloudflareaccess\.com/i.test(location) || /\/cdn-cgi\/access/i.test(location)); + const accessDenied = + [401, 403].includes(webResponse.status) && + (/cloudflare access/i.test(body) || /\/cdn-cgi\/access/i.test(body)); -if (webIsHttps) { - assertHeader(webResponse, "strict-transport-security", /max-age=/i, "Web app"); + if (!accessRedirect && !accessDenied) { + failures.push( + `Access-protected web returned ${webResponse.status}; expected Cloudflare Access redirect or denial.`, + ); + } } if (workerIsHttps) { assertHeader(health.response, "strict-transport-security", /max-age=/i, "Worker health", {