diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e6c500f..8d80262 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,6 +3,7 @@ version: 2 # updates, when enabled in the repository settings, do not follow this schedule. updates: - package-ecosystem: npm + target-branch: dev directory: / schedule: interval: monthly @@ -38,6 +39,7 @@ updates: update-types: - version-update:semver-major - package-ecosystem: nuget + target-branch: dev # Dependabot expands ProCoderJi.slnx here, covering the API and test projects # together so shared .NET packages can move in one reviewed pull request. directory: /backend @@ -56,6 +58,7 @@ updates: - minor - patch - package-ecosystem: github-actions + target-branch: dev directory: / schedule: interval: monthly diff --git a/.github/scripts/branch-policy.test.mjs b/.github/scripts/branch-policy.test.mjs new file mode 100644 index 0000000..1ed88bf --- /dev/null +++ b/.github/scripts/branch-policy.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { branchPolicy } from "./check-branch-policy.mjs"; + +const repository = "procoderji-com/procoderji"; +const check = (base, head, sourceRepository = repository) => branchPolicy({ base, head, sourceRepository, repository }); + +test("contributors and bots can send changes to dev", () => { + assert.equal(check("dev", "feat/student-experience", "contributor/procoderji"), null); + assert.equal(check("dev", "dependabot/npm_and_yarn/example"), null); +}); + +test("main accepts only the canonical dev branch", () => { + assert.equal(check("main", "dev"), null); + assert.ok(check("main", "feat/student-experience")); + assert.ok(check("main", "dependabot/npm_and_yarn/example")); + assert.ok(check("main", "dev", "contributor/procoderji")); +}); + +test("unknown or absent targets do not silently pass", () => { + assert.ok(check("release/1.0", "dev")); + assert.ok(check(undefined, undefined)); + assert.ok(branchPolicy({ base: "main", head: "dev" })); +}); diff --git a/.github/scripts/check-branch-policy.mjs b/.github/scripts/check-branch-policy.mjs new file mode 100644 index 0000000..e2b468a --- /dev/null +++ b/.github/scripts/check-branch-policy.mjs @@ -0,0 +1,26 @@ +import { pathToFileURL } from "node:url"; + +export function branchPolicy({ base, head, sourceRepository, repository }) { + if (!base || !head || !sourceRepository || !repository) return "Pull-request branch metadata is incomplete."; + if (base === "dev") return null; + if (base === "main" && head === "dev" && sourceRepository === repository) return null; + if (base === "main") { + return "Production PRs must promote this repository's dev branch. Send feature and dependency PRs to dev first."; + } + return "Contributions target dev; production promotions target main."; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const failure = branchPolicy({ + base: process.env.PR_BASE, + head: process.env.PR_HEAD, + sourceRepository: process.env.PR_SOURCE_REPOSITORY, + repository: process.env.TARGET_REPOSITORY, + }); + if (failure) { + console.error(failure); + process.exitCode = 1; + } else { + console.log("Branch policy passed."); + } +} diff --git a/.github/workflows/backend-containers.yml b/.github/workflows/backend-containers.yml index 6433440..c414780 100644 --- a/.github/workflows/backend-containers.yml +++ b/.github/workflows/backend-containers.yml @@ -12,7 +12,7 @@ on: - 'vercel.backend.json' - '.github/workflows/backend-containers.yml' push: - branches: [main] + branches: [dev, main] paths: - 'backend/**' - 'shared/**' diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 0000000..4e01a3d --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,30 @@ +name: Branch policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + +jobs: + branch-policy: + name: Branch policy + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check out proposed policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Test branch policy + run: node --test .github/scripts/branch-policy.test.mjs + + - name: Enforce contribution and promotion targets + env: + PR_BASE: ${{ github.event.pull_request.base.ref }} + PR_HEAD: ${{ github.event.pull_request.head.ref }} + PR_SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + TARGET_REPOSITORY: ${{ github.repository }} + run: node .github/scripts/check-branch-policy.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 536ef43..fe3e5ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main] + branches: [dev, main] workflow_dispatch: permissions: @@ -11,7 +11,8 @@ permissions: concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Cancel stale PR checks, but never interrupt a push run during a deployment. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: verify: @@ -148,3 +149,43 @@ jobs: ConnectionStrings__Platform: 'Host=localhost;Port=5432;Database=procoderji_test;Username=postgres;Password=postgres' PublicOrigin: http://localhost:3000 run: dotnet run --project backend/src/ProCoderJi.Api --configuration Release --no-build --no-launch-profile -- --migrate + + deploy: + name: Deploy checked branch + permissions: + contents: read + deployments: write + needs: [verify, site-quality, backend] + if: github.event_name == 'push' && github.repository == 'procoderji-com/procoderji' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: + name: ${{ github.ref_name == 'main' && 'production' || 'staging' }} + url: ${{ vars.PUBLIC_ORIGIN }} + concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: false + steps: + - name: Check out checked commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: 24 + + - name: Install deployment CLI + run: npm install --global vercel@65.14.0 + + - name: Deploy backend, verify storage, then deploy frontend + env: + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + VERCEL_FRONTEND_PROJECT_ID: ${{ vars.VERCEL_FRONTEND_PROJECT_ID }} + VERCEL_BACKEND_PROJECT_ID: ${{ vars.VERCEL_BACKEND_PROJECT_ID }} + PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }} + BACKEND_ORIGIN: ${{ vars.BACKEND_ORIGIN }} + VERCEL_FRONTEND_TOKEN: ${{ secrets.VERCEL_FRONTEND_TOKEN }} + VERCEL_BACKEND_TOKEN: ${{ secrets.VERCEL_BACKEND_TOKEN }} + run: node scripts/deploy-vercel.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc5a1a9..b803c65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,13 +53,13 @@ fork as `origin`, and continue from the same checkout. ## Start a branch -Update your local `main` before starting a new change: +Update your local `dev` before starting a new change: ```bash -git switch main git fetch upstream -git merge --ff-only upstream/main -git push origin main +git switch dev +git merge --ff-only upstream/dev +git push origin dev git switch -c feat/describe-the-change ``` @@ -120,11 +120,16 @@ git commit -m "Describe the change" git push -u origin ``` -Open a pull request from your fork branch to `procoderji-com/procoderji:main`. Fill in the +Open a pull request from your fork branch to `procoderji-com/procoderji:dev`. Fill in the template, link a related issue when one exists, explain the resulting behavior, and list the checks you ran. Include screenshots for visible UI changes and sources or permissions for learning content. +`dev` is the integration/staging branch; `main` is production. Feature and dependency changes +go through `dev`. Maintainers promote the canonical `dev` branch to `main` after staging checks; +contributors do not send feature branches directly to `main`. See the +[branch and deployment workflow](docs/branching-and-releases.md). + Respond to review feedback and keep the branch up to date when needed. Maintainers review student value, correctness, accessibility, and maintenance cost before merging. A proposed feature may need a smaller scope or further discussion. diff --git a/docs/README.md b/docs/README.md index 6d82303..2f372d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ synchronization is a separate capability; paid billing is future work. | Import a portfolio from a text template | [Markdown import](portfolio-markdown.md), [template](../public/templates/portfolio.md) | | Work on portfolio fields, policy, publication, or storage | [Shared rules](../shared/portfolio/README.md), [Portfolio API](portfolio-api.md) | | Configure production hosting and the site domain | [Deployment](deployment.md) | +| Contribute to dev, test staging and promote a production release | [Branches and releases](branching-and-releases.md) | | Run the API and choose a database with Docker | [Containers](containers.md) | | Configure the selected hosted PostgreSQL database | [Neon setup](neon.md) | | Report a vulnerability privately | [Security policy](../SECURITY.md) | diff --git a/docs/branching-and-releases.md b/docs/branching-and-releases.md new file mode 100644 index 0000000..afe0cac --- /dev/null +++ b/docs/branching-and-releases.md @@ -0,0 +1,90 @@ +# Branches and deployments + +ProCoderJii uses two long-lived branches: + +| Branch | Purpose | Deployment | +| --- | --- | --- | +| `dev` | Default contribution branch; reviewed features and dependency updates integrate here. | Staging, after CI passes. | +| `main` | Reviewed production releases. | Production, after CI passes. | + +The flow is `feature/* -> PR -> dev -> staging checks -> PR -> main`. There is no permanent +`release` branch. Version tags can identify releases without creating another integration branch. + +## Contributions + +Create a feature branch from current `dev` in your fork and open the PR against canonical `dev`. +Both branches reject deletion and force pushes and require PR review and passing checks. +The Branch policy check rejects a feature/fork PR directly into `main`; only this repository's +`dev` branch can be promoted. A fork branch named `dev` is not a production promotion. + +Commits and contribution PRs use the contributor account. For the project owner's two-account +workflow, contributions use `Coding-Professional`; reviews and merges use `procoder-ji`. + +## Promote a release + +1. Make sure `dev` includes current `main`. If production has advanced independently, open a + `main -> dev` synchronization PR and run checks before testing staging again. +2. Wait for the exact `dev` commit's CI and staging deployment to pass. Exercise the changed + student journey on staging with sample data. Check migrations, account/portfolio privacy, + mobile and keyboard behavior when affected. +3. Open a PR from **canonical `dev` to `main`**, describing the behavior being released and + the staging checks. Review as `procoder-ji` and satisfy all required checks. +4. Use a **merge commit** for this promotion, preserving ancestry between the long-lived + branches. Squash merges remain suitable for individual feature PRs into `dev`. +5. Production deploys after the new `main` commit passes CI. Confirm the deployment summary, + health/session checks, canonical redirects and affected public routes. + +Do not deploy unreviewed feature commits to production. Urgent fixes follow the same route; +there is no permanent bypass branch. Database schema changes remain deliberate release +operations: test the migrations on staging and apply approved production migrations with the +direct connection before rolling out code that requires them. The runtime keeps migration on +startup disabled. Never use a production database to run browser fixtures. + +## Deployment environments + +Each GitHub environment has branch restrictions and separate project-scoped deployment tokens. +The CI deployment job runs only on canonical `dev`/`main` pushes, after frontend, browser/SEO +and backend checks pass. PRs and fork workflows receive no deployment credentials. + +| Environment | Allowed branch | Frontend project | Backend project | +| --- | --- | --- | --- | +| `staging` | `dev` | `procoderji-dev` | `procoderji-api-dev` | +| `production` | `main` | `procoderji` | `procoderji-api` | + +Environment variables: `VERCEL_ORG_ID`, `VERCEL_FRONTEND_PROJECT_ID`, +`VERCEL_BACKEND_PROJECT_ID`, `PUBLIC_ORIGIN`, and `BACKEND_ORIGIN`. +Environment secrets: `VERCEL_FRONTEND_TOKEN` and `VERCEL_BACKEND_TOKEN`. Scope each token to +its project, give it an expiration date and rotate it before expiry. Tokens are never printed +or committed. The script passes them directly to the CLI child process without a shell or +command echo; it never dumps provider output into the workflow logs. +Database and Google credentials stay in the appropriate Vercel project's secret settings. + +Staging uses a schema-only Neon branch and a fresh `procoderji_dev` database, with a separate +runtime role. Production student records and Data Protection keys are not copied into it. +Use a separate Google OAuth client with the staging frontend callback. Until configured, +staging supports guest practice and editor previews while account saving is unavailable. + +Set `DEPLOYMENT_ENVIRONMENT=staging` in the staging frontend. Staging and Vercel previews send +`noindex, nofollow` and disallow crawling; a dedicated staging project is still a production +*build*, so `NODE_ENV` alone is not an environment boundary. Indexing directives are not access +control. Use sample data and configure hosting access protection when private QA is needed. + +The deployment script copies tracked source only. It prepares the backend's service configuration +in an isolated directory, deploys/verifies the API first, then deploys the frontend. The live +summary records the checked source commit and both origins. A failure stops the rollout; inspect +the relevant Vercel project and restore its previous deployment if a rollback is needed. + +Disable a previous independent Git deployment connection after this pipeline has been verified; +otherwise a second deployment path can publish before CI finishes. The canonical GitHub Actions +pipeline does not require granting Vercel's GitHub integration access to an organization. + +## Operator recovery + +For a manual staging check from a trusted checkout, set the staging project IDs and origins in +the current process, authenticate Vercel CLI, and run +`node scripts/deploy-vercel.mjs --local-staging`. This mode is unavailable in GitHub Actions and +cannot select production. Source files and the operator's existing auth remain separate. + +For a production incident, use the hosting dashboard's rollback to the previous verified +deployment, then send the fix through `dev`. Do not delete student records or blindly reverse a +schema migration as part of an application rollback. diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 197b481..f14e1e3 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -19,7 +19,8 @@ These steps do not need repeated owner permission. Existing decisions, including backend and selected Neon PostgreSQL deployment, remain valid. A person implementing that approved scope should continue through a reviewable change and its relevant checks. -Use the pull-request workflow for changes to `main`, and satisfy the repository's active review +Use feature PRs into `dev`, then a reviewed promotion PR from canonical `dev` into `main`. +Follow [branching and releases](branching-and-releases.md), and satisfy the active review and status-check rules before merging. Maintainers can merge routine changes within their authorised scope once the relevant checks and review are complete. Production rollout and private-credential access follow the separately agreed operating scope. diff --git a/next.config.ts b/next.config.ts index be9615d..4d83f47 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { isNonProductionDeployment } from "./src/lib/deployment.ts"; /** * Conservative response headers. No Content-Security-Policy yet: the site loads @@ -16,7 +17,10 @@ const nextConfig: NextConfig = { // Do not announce the framework in an "X-Powered-By: Next.js" response header. poweredByHeader: false, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + const headers = isNonProductionDeployment(process.env.DEPLOYMENT_ENVIRONMENT, process.env.VERCEL_ENV) + ? [...securityHeaders, { key: "X-Robots-Tag", value: "noindex, nofollow" }] + : securityHeaders; + return [{ source: "/:path*", headers }]; }, // The old plain /courses/... reader duplicated the dashboard (same URL structure, no styling). // Send visitors and any old links to the real pages instead of serving duplicate content. diff --git a/scripts/deploy-vercel.mjs b/scripts/deploy-vercel.mjs new file mode 100644 index 0000000..f225ab5 --- /dev/null +++ b/scripts/deploy-vercel.mjs @@ -0,0 +1,137 @@ +// Deployment credentials stay in environment secrets, never in source or logs. +// PR jobs never execute this script with deployment credentials. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const localStaging = process.argv.includes("--local-staging"); +const branch = localStaging ? "dev" : process.env.GITHUB_REF_NAME; +const temporary = []; +const env = { ...process.env, DEBUG: "" }; +const cli = process.env.VERCEL_CLI; +const command = cli?.endsWith(".js") ? process.execPath : cli || "vercel"; +const prefix = cli?.endsWith(".js") ? [cli] : []; + +function requireValue(key) { + const value = process.env[key]; + if (!value?.trim()) throw new Error(`Missing deployment setting: ${key}`); + return value; +} + +function origin(key) { + const value = requireValue(key); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") { + throw new Error(`Expected an HTTPS origin for ${key}`); + } + return url.origin; +} + +function workspace() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "procoderji-deploy-")); + temporary.push(directory); + return directory; +} + +function vercel(args, cwd, project, token) { + const result = spawnSync(command, [ + ...prefix, ...args, + ...(token ? ["--token", token] : []), + ], { + cwd, env: { ...env, VERCEL_ORG_ID: requireValue("VERCEL_ORG_ID"), VERCEL_PROJECT_ID: project }, + encoding: "utf8", windowsHide: true, timeout: 12 * 60 * 1000, + maxBuffer: 8 * 1024 * 1024, + }); + let data; + try { data = JSON.parse(result.stdout); } catch { /* Never dump raw provider logs. */ } + if (result.status !== 0 || !data) throw new Error("Vercel deployment failed. Inspect its build in the project dashboard."); + return data; +} + +function authentication(tokenKey) { + if (localStaging && !process.env[tokenKey]) return undefined; + // Native CLI 65 requires its explicit token option; no shell or command logging is used. + return requireValue(tokenKey); +} + +function deploySource() { + const result = spawnSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", windowsHide: true }); + if (result.status !== 0) throw new Error("Cannot enumerate tracked deployment source."); + const directory = workspace(); + for (const file of result.stdout.split("\0").filter(Boolean)) { + const source = path.resolve(root, file); + if (!source.startsWith(root + path.sep) || fs.lstatSync(source).isSymbolicLink()) { + throw new Error("Deployment source must contain ordinary repository files."); + } + const target = path.join(directory, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + } + return directory; +} + +async function check(url, inspect) { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(30_000) }); + if (await inspect(response)) return; + } catch { /* Cold starts can need one retry. */ } + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error("A deployed endpoint failed its release check."); +} + +async function main() { + if (localStaging && process.env.GITHUB_ACTIONS === "true") throw new Error("Local staging mode is unavailable in Actions."); + if (!["dev", "main"].includes(branch)) throw new Error("Only dev and main have deployment environments."); + if (!localStaging && (process.env.GITHUB_EVENT_NAME !== "push" || process.env.GITHUB_REPOSITORY !== "procoderji-com/procoderji")) { + throw new Error("Only checked pushes to the canonical repository can deploy."); + } + const publicOrigin = origin("PUBLIC_ORIGIN"); + const backendOrigin = origin("BACKEND_ORIGIN"); + if (branch === "main" && publicOrigin !== "https://procoderji.com") throw new Error("Unexpected production origin."); + if (branch === "dev" && publicOrigin === "https://procoderji.com") throw new Error("Staging must have its own origin."); + const sha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true }).stdout.trim(); + if (!/^[a-f0-9]{40}$/.test(sha) || (!localStaging && sha !== process.env.GITHUB_SHA)) throw new Error("The checkout must match the checked commit."); + const backendProject = requireValue("VERCEL_BACKEND_PROJECT_ID"); + const frontendProject = requireValue("VERCEL_FRONTEND_PROJECT_ID"); + if (backendProject === frontendProject) throw new Error("Backend and frontend projects must be separate."); + + const backendSource = deploySource(); + const frontendSource = deploySource(); + fs.copyFileSync(path.join(backendSource, "vercel.backend.json"), path.join(backendSource, "vercel.json")); + const deployArgs = ["deploy", "--prod", "--yes", "--json", "--meta", `sourceCommit=${sha}`, "--meta", `sourceBranch=${branch}`]; + const backend = vercel([...deployArgs, "--project", backendProject], backendSource, backendProject, authentication("VERCEL_BACKEND_TOKEN")); + if (backend.status === "error" || backend.deployment?.readyState !== "READY") throw new Error("Backend deployment did not become ready."); + await check(`${backendOrigin}/api/platform/health`, async r => r.ok && (await r.json()).status === "ok"); + await check(`${backendOrigin}/api/platform/auth/session`, async r => { + if (!r.ok) return false; + const session = await r.json(); + return !!session.csrfToken && (branch !== "main" || session.configured === true); + }); + console.log("Backend is ready; database-backed session check passed."); + + const frontend = vercel([...deployArgs, "--project", frontendProject], frontendSource, frontendProject, authentication("VERCEL_FRONTEND_TOKEN")); + if (frontend.status === "error" || frontend.deployment?.readyState !== "READY") throw new Error("Frontend deployment did not become ready."); + await check(publicOrigin, async r => r.ok && (branch === "main" || r.headers.get("x-robots-tag")?.includes("noindex"))); + console.log(`Frontend is ready: ${publicOrigin}`); + const summary = `### ${branch === "main" ? "Production" : "Staging"} deployment\n\nCommit: \`${sha}\`\n\n- Frontend: ${publicOrigin}\n- Backend: ${backendOrigin}\n- Health and database-backed session checks passed.\n`; + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : "Deployment failed."); + process.exitCode = 1; +} finally { + const parent = fs.realpathSync(os.tmpdir()); + for (const directory of temporary) { + const resolved = path.resolve(directory); + if (path.dirname(resolved) === parent && path.basename(resolved).startsWith("procoderji-deploy-")) { + fs.rmSync(resolved, { recursive: true, force: true }); + } + } +} diff --git a/src/app/api/platform/[...path]/route.ts b/src/app/api/platform/[...path]/route.ts index 40c6737..b6de8c8 100644 --- a/src/app/api/platform/[...path]/route.ts +++ b/src/app/api/platform/[...path]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { isBackendPath, parseBackendOrigin } from "@/features/account/config"; +import { canonicalGoogleChallenge, isBackendPath, parseBackendOrigin } from "@/features/account/config"; import { revalidateTag } from "next/cache"; import { portfolioCacheTag } from "@/features/portfolio/public-model"; import { validateUsername } from "@/features/portfolio/validation"; @@ -90,6 +90,13 @@ async function proxy( signal: AbortSignal.timeout(20_000), }, ); + if (path === "auth/google" && request.method === "GET" && upstream.status === 302) { + const canonical = canonicalGoogleChallenge(request.url, upstream.headers.get("location")); + if (canonical) { + // Restart on the callback host before forwarding a host-only correlation cookie. + return new Response(null, { status: 307, headers: { ...privateHeaders, Location: canonical } }); + } + } const responseHeaders = new Headers(privateHeaders); for (const name of ["content-type", "location", "retry-after"]) { const value = upstream.headers.get(name); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ad9f801..55a3cd1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import { getSiteUrl } from "@/lib/site"; import { OG_IMAGE, SITE_DESCRIPTION, SITE_NAME } from "@/lib/seo"; import { MotionProvider } from "@/components/motion-provider"; +import { isNonProductionDeployment } from "@/lib/deployment"; const siteUrl = getSiteUrl(); @@ -19,6 +20,9 @@ export const metadata: Metadata = { images: [OG_IMAGE], }, twitter: { card: "summary_large_image", images: [OG_IMAGE.url] }, + ...(isNonProductionDeployment(process.env.DEPLOYMENT_ENVIRONMENT, process.env.VERCEL_ENV) + ? { robots: { index: false, follow: false } } + : {}), }; export default function RootLayout({ @@ -36,6 +40,11 @@ export default function RootLayout({ > Skip to main content + {isNonProductionDeployment(process.env.DEPLOYMENT_ENVIRONMENT, process.env.VERCEL_ENV) && ( +
+ Staging preview. Use sample data; accounts and saved work here are separate from production. +
+ )} {/* Background Layer */}
diff --git a/src/app/robots.ts b/src/app/robots.ts index 69d9894..2a35ca2 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,7 +1,11 @@ import type { MetadataRoute } from "next"; import { getSiteUrl } from "@/lib/site"; +import { isNonProductionDeployment } from "@/lib/deployment"; export default function robots(): MetadataRoute.Robots { + if (isNonProductionDeployment(process.env.DEPLOYMENT_ENVIRONMENT, process.env.VERCEL_ENV)) { + return { rules: { userAgent: "*", disallow: "/" } }; + } const siteUrl = getSiteUrl(); return { rules: { userAgent: "*", allow: "/" }, diff --git a/src/features/account/config.test.ts b/src/features/account/config.test.ts index 5dd6bbd..5ce389a 100644 --- a/src/features/account/config.test.ts +++ b/src/features/account/config.test.ts @@ -1,6 +1,31 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { BACKEND_PATHS, isBackendPath, parseBackendOrigin } from "./config.ts"; +import { BACKEND_PATHS, canonicalGoogleChallenge, isBackendPath, parseBackendOrigin } from "./config.ts"; + +const googleChallenge = (callback: string) => `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({ redirect_uri: callback, state: "private-state" })}`; + +test("OAuth starts on its callback host before setting a host-only correlation cookie", () => { + const location = googleChallenge("https://procoderji.com/api/platform/signin-google"); + const request = "https://www.procoderji.com/api/platform/auth/google?returnTo=%2Flearn%2Fportfolio&code=discard-me"; + assert.equal(canonicalGoogleChallenge(request, location), "https://procoderji.com/api/platform/auth/google?returnTo=%2Flearn%2Fportfolio"); + assert.equal(canonicalGoogleChallenge("https://procoderji.com/api/platform/auth/google", location), null); +}); + +test("staging aliases restart on the staging callback host without leaking OAuth state", () => { + const location = googleChallenge("https://procoderji-dev.vercel.app/api/platform/signin-google"); + const redirect = canonicalGoogleChallenge("https://unique-preview.vercel.app/api/platform/auth/google", location); + assert.equal(redirect, "https://procoderji-dev.vercel.app/api/platform/auth/google"); + assert.ok(!redirect.includes("private-state")); +}); + +test("malformed or unrelated upstream redirects cannot become canonical login redirects", () => { + const request = "https://preview.example.test/api/platform/auth/google"; + for (const location of [null, "not a URL", "https://unrelated.example.test/", googleChallenge("javascript:alert(1)"), + googleChallenge("https://user:secret@example.test/api/platform/signin-google"), googleChallenge("https://example.test/wrong-path"), + googleChallenge("https://example.test/api/platform/signin-google?secret=hidden"), googleChallenge("http://example.test/api/platform/signin-google")]) { + assert.equal(canonicalGoogleChallenge(request, location), null); + } +}); test("backend proxy accepts explicit root origins for local and hosted .NET services", () => { assert.equal( diff --git a/src/features/account/config.ts b/src/features/account/config.ts index 9c92a55..4f5c3c4 100644 --- a/src/features/account/config.ts +++ b/src/features/account/config.ts @@ -18,6 +18,26 @@ export function parseBackendOrigin(value: string | undefined): string | null { } } +/** The authorization location must come from the trusted .NET upstream, never browser input. */ +export function canonicalGoogleChallenge(requestUrl: string, authorizationLocation: string | null): string | null { + if (!authorizationLocation) return null; + try { + const request = new URL(requestUrl); + const authorization = new URL(authorizationLocation); + if (authorization.origin !== "https://accounts.google.com" || authorization.username || authorization.password) return null; + const callback = new URL(authorization.searchParams.get("redirect_uri") ?? ""); + const localHttp = callback.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(callback.hostname); + if ((callback.protocol !== "https:" && !localHttp) || callback.username || callback.password || + callback.pathname !== "/api/platform/signin-google" || callback.search || callback.hash || callback.origin === request.origin) return null; + const destination = new URL("/api/platform/auth/google", callback.origin); + const returnTo = request.searchParams.get("returnTo"); + if (returnTo !== null) destination.searchParams.set("returnTo", returnTo); + return destination.href; + } catch { + return null; + } +} + export const BACKEND_PATHS = new Set([ "auth/session", "auth/google", diff --git a/src/features/portfolio/markdown.test.ts b/src/features/portfolio/markdown.test.ts index eb4cfbf..59dbad6 100644 --- a/src/features/portfolio/markdown.test.ts +++ b/src/features/portfolio/markdown.test.ts @@ -9,7 +9,7 @@ import { } from "./markdown.ts"; import type { PortfolioData } from "./types.ts"; -const template = fs.readFileSync("public/templates/portfolio.md", "utf8"); +const template = fs.readFileSync("public/templates/portfolio.md", "utf8").replace(/\r\n/g, "\n"); const example: PortfolioData = { name: "Riya Student", headline: "Computer science student building useful web applications", diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 7e51fb3..d035424 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -17,6 +17,18 @@ test("the expected baseline headers are present", () => { assert.match(map["Permissions-Policy"], /camera=\(\)/); }); +test("staging sends noindex headers while retaining security headers", async () => { + const previous = process.env.DEPLOYMENT_ENVIRONMENT; + try { + process.env.DEPLOYMENT_ENVIRONMENT = "staging"; + const rules = (await nextConfig.headers?.()) ?? []; + assert.deepEqual(rules[0].headers, [...securityHeaders, { key: "X-Robots-Tag", value: "noindex, nofollow" }]); + } finally { + if (previous === undefined) delete process.env.DEPLOYMENT_ENVIRONMENT; + else process.env.DEPLOYMENT_ENVIRONMENT = previous; + } +}); + test("the framework is not advertised in an X-Powered-By header", () => { assert.equal(nextConfig.poweredByHeader, false); }); diff --git a/src/lib/deployment.test.ts b/src/lib/deployment.test.ts new file mode 100644 index 0000000..6015839 --- /dev/null +++ b/src/lib/deployment.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isNonProductionDeployment } from "./deployment.ts"; + +test("staging and previews remain non-indexable even when using production builds", () => { + assert.equal(isNonProductionDeployment("staging", "production"), true); + assert.equal(isNonProductionDeployment(undefined, "preview"), true); + assert.equal(isNonProductionDeployment("production", "production"), false); + assert.equal(isNonProductionDeployment(undefined, undefined), false); +}); diff --git a/src/lib/deployment.ts b/src/lib/deployment.ts new file mode 100644 index 0000000..22140c8 --- /dev/null +++ b/src/lib/deployment.ts @@ -0,0 +1,7 @@ +/** Operator-controlled deployment context; never infer it from a visitor's Host header. */ +export function isNonProductionDeployment( + environment: string | undefined, + vercelEnvironment: string | undefined, +): boolean { + return environment === "staging" || vercelEnvironment === "preview"; +}