diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index a36e28a..8fd5b40 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -17,6 +17,7 @@ permissions: jobs: verify: runs-on: ubuntu-latest + timeout-minutes: 15 defaults: run: working-directory: frontend @@ -41,3 +42,7 @@ jobs: run: npm run test:posthog - name: Build production app run: npm run build + - name: Install browser for production checks + run: node node_modules/playwright-core/cli.js install --with-deps chromium + - name: Verify production page and API boundaries + run: npm run test:browser diff --git a/README.md b/README.md index 73ff981..47be5f0 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,49 @@ # CodesWhat website -Private source for the public CodesWhat org site at codeswhat.com. The -Next.js app lives in `frontend/`; everything deployable is under that -directory. - -## What this repo is (and isn't) - -This is permanently private infrastructure, not a community project. That's -why there's no LICENSE, CONTRIBUTING, SECURITY policy, or CODEOWNERS here: -the site's source isn't published, only the rendered site is. The tier -decision and rationale live in the private ops repo -(`decisions/repository-tiers.md`). - -## Working here - -- Flow: feature branches PR into `dev`, then `dev` promotes to `main`. -- CI: `website.yml` runs the website contract checks. -- Deploys: Vercel, but git-integration deploys are broken for this repo (a - private repo in a GitHub org can't deploy on the Hobby plan). Production - deploys happen from a local checkout with - `npx vercel deploy --prod --scope codeswhat`. -- Analytics: PostHog via the shared "CodesWhat Public Websites" project, - proxied through `e.codeswhat.com`, cookieless. - -## More docs - -- `docs/README.md` - development guide -- `frontend/README-VERCEL.md` - deploy specifics -- `ROADMAP.md` - planned sections and site direction +Public source for [codeswhat.com](https://codeswhat.com), the CodesWhat project +and consulting site. The deployed Next.js application lives in `frontend/`. + +## Development + +Use Node.js 24. From `frontend/`, install the locked dependencies with `npm ci` +and start the development server with `npm run dev`. + +Existing checks are `npm run lint`, `npm run type-check`, `npm run test:scene` +and `npm run test:posthog`. Run `npm run build`, then `npm run test:browser` +to check the production page and invalid newsletter input. The browser check +uses the existing Playwright dependency with installed Chromium or Chrome; +CI installs its matching Chromium build. It uses a temporary local server +and blocks external requests. +The root Lefthook configuration runs staged Biome checks and typechecking at +commit time. Keep those checks enabled. + +Changes go through a feature PR into `dev`, followed by a reviewed promotion +from `dev` to `main`. Preserve the same tested content through promotion. +The `Website contracts` workflow checks pull requests and the integration and +production branches. + +## Deployment and verification + +Vercel's Git integration deploys `main` to production with `frontend/` as the +project root. Verify the deployment's source commit and the actual page after +promotion. A successful build or pageview event does not prove that the page's +client components initialized. + +The Content Security Policy uses a fresh request nonce for Next's generated +scripts and the initial theme script. HTML renders per request; immutable +JavaScript and static assets keep their existing caching. Keep the request and +response policies consistent, and verify a real theme toggle and mounted scene +when changing scripts, headers or rendering. + +Analytics uses the shared CodesWhat Public Websites PostHog project through +`e.codeswhat.com`, with the existing cookieless sanitizer. Preview and local +development should leave its production-only environment values unset. +Newsletter delivery uses server-side EmailOctopus credentials. See the +[deployment guide](frontend/README-VERCEL.md) for configuration. + +## Repository boundaries + +Internal plans and operational scratch belong in ignored `.planning/`, and +`.claude/` stays ignored to protect nested agent worktrees. Neither is a build input. +Don't commit credentials or local environment files. Existing public source +does not imply that private planning is safe to publish here. diff --git a/frontend/README-VERCEL.md b/frontend/README-VERCEL.md index 7c270d3..17e647c 100644 --- a/frontend/README-VERCEL.md +++ b/frontend/README-VERCEL.md @@ -1,27 +1,40 @@ # Vercel Deployment Guide -## Quick Deploy +## Production deployment -[](https://vercel.com/new/clone?repository-url=https://github.com/codeswhat/website) +The existing Vercel project deploys `CodesWhat/codeswhat.com` from `main`, with +`frontend/` as its root directory and Node.js 24. Feature changes merge into +`dev`, then a reviewed promotion advances `main`. Git integration is working; +a local CLI deployment is not the routine production path. -## Manual Deployment +After promotion, confirm the production deployment references the intended +commit. Open the canonical domain and verify the theme toggle changes theme +and the client scene mounts. Build success and analytics events alone do not +establish that client initialization worked. -1. Install Vercel CLI (optional): +## Rendering and security headers - ```bash - npm i -g vercel - ``` +The Next proxy sets a fresh script nonce on both the forwarded request policy +and the response policy. Next uses that nonce for generated inline scripts; +the root layout applies it to the theme-init script. HTML renders per request +so nonce values are not reused through static prerendering. Static JS/assets +retain the caching rules in `vercel.json`. -2. Deploy: - ```bash - vercel - ``` +Keep the CSP's allowed third-party origins narrow. A fixed hash for only the +theme script does not permit Next's inline Flight scripts and breaks hydration. +The production browser regression verifies actual interaction and blocked +unapproved scripts. The remaining response headers and API cache rules live +in `vercel.json`. ## Environment Variables Add these in your Vercel dashboard under Settings → Environment Variables: -### Required Variables +### Site and newsletter configuration + +The site metadata variables are optional overrides for the defaults in +`lib/site-config.ts`. EmailOctopus credentials are required for newsletter +delivery and stay server-side. | Variable | Description | Example | | ------------------------------ | ------------------------ | -------------------------------- | @@ -53,17 +66,17 @@ unset in Preview and Development so those deployments emit no analytics: The `vercel.json` file includes: -- ✅ Security headers -- ✅ API route configuration -- ✅ Function timeouts -- ✅ Caching rules +- Additional security headers +- API route configuration +- Function timeouts +- Static-asset caching rules ## Features Configured - **Security Headers**: XSS protection, frame options, content type sniffing prevention - **API Caching**: Disabled for `/api/*` routes - **Function Duration**: 10 seconds max for email signup -- **Sitemap**: Rewrite rule ready for dynamic sitemap +- **Sitemap**: Next's sitemap route supplies the public page list ## Monitoring @@ -81,7 +94,7 @@ The `vercel.json` file includes: ### Build Failures - Check build logs in Vercel dashboard -- Ensure Node.js version matches locally (20.9.0+) +- Ensure Node.js version matches locally (24.x) - Verify all dependencies are in package.json ### Email Signup Not Working diff --git a/frontend/app/api/subscribe/route.ts b/frontend/app/api/subscribe/route.ts index 0f57d09..307c2af 100644 --- a/frontend/app/api/subscribe/route.ts +++ b/frontend/app/api/subscribe/route.ts @@ -44,14 +44,18 @@ export async function POST(request: Request) { } // Parse request body - let body: { email?: string }; + let body: unknown; try { body = await request.json(); } catch { return NextResponse.json({ error: "Invalid request format" }, { status: 400 }); } - const { email } = body; + if (body === null || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid request format" }, { status: 400 }); + } + + const email = "email" in body ? body.email : undefined; // Input validation if (!email || typeof email !== "string") { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 04d6430..2d8844e 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import { headers } from "next/headers"; import { ThemedToaster } from "@/components/site/ThemedToaster"; import { BASE_URL, OG_IMAGE, SITE_CONFIG } from "@/lib/site-config"; import { THEME_INIT_SCRIPT } from "@/lib/theme-init-script"; @@ -67,16 +68,21 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const nonce = (await headers()).get("x-nonce") ?? undefined; return (
- + {children} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index f70aeed..764396a 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,9 +1,4 @@ -import { createHash } from "node:crypto"; import type { NextConfig } from "next"; -import { THEME_INIT_SCRIPT } from "./lib/theme-init-script"; - -const posthogProxy = "https://e.codeswhat.com"; -const themeScriptHash = `sha256-${createHash("sha256").update(THEME_INIT_SCRIPT).digest("base64")}`; const nextConfig: NextConfig = { async redirects() { @@ -24,31 +19,6 @@ const nextConfig: NextConfig = { }, ]; }, - async headers() { - return [ - { - source: "/(.*)", - headers: [ - { - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - `script-src 'self' '${themeScriptHash}' ${posthogProxy}`, - "style-src 'self' 'unsafe-inline'", - `connect-src 'self' ${posthogProxy}`, - "img-src 'self' data: blob:", - "font-src 'self'", - "worker-src 'self' blob:", - "object-src 'none'", - "base-uri 'self'", - "form-action 'self'", - "frame-ancestors 'none'", - ].join("; "), - }, - ], - }, - ]; - }, }; export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index 421ae9b..5c487e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "test:scene": "node --test test/scene.test.mjs", "test:posthog": "node --experimental-strip-types --test test/posthog.test.ts test/posthog-source.test.mjs", "test:posthog:browser": "node --test test/posthog-browser.test.mjs", + "test:browser": "node --test test/site-browser.test.mjs", "og": "node scripts/generate-og.mjs", "coin": "node scripts/capture-assets.mjs --subject coin", "balloon": "node scripts/capture-assets.mjs --subject balloon", diff --git a/frontend/proxy.ts b/frontend/proxy.ts new file mode 100644 index 0000000..c48a83b --- /dev/null +++ b/frontend/proxy.ts @@ -0,0 +1,37 @@ +import { randomBytes } from "node:crypto"; +import { type NextRequest, NextResponse } from "next/server"; + +const posthogProxy = "https://e.codeswhat.com"; + +export function proxy(request: NextRequest) { + const nonce = randomBytes(16).toString("base64"); + const developmentScriptPolicy = process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""; + const contentSecurityPolicy = [ + "default-src 'self'", + `script-src 'self' 'nonce-${nonce}' ${posthogProxy}${developmentScriptPolicy}`, + "style-src 'self' 'unsafe-inline'", + `connect-src 'self' ${posthogProxy}`, + "img-src 'self' data: blob:", + "font-src 'self'", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join("; "); + + // Next reads the request policy while rendering its inline Flight scripts. + // Replace caller headers so only this request's generated nonce is trusted. + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-nonce", nonce); + requestHeaders.set("Content-Security-Policy", contentSecurityPolicy); + const response = NextResponse.next({ request: { headers: requestHeaders } }); + response.headers.set("Content-Security-Policy", contentSecurityPolicy); + return response; +} + +export const config = { + matcher: [ + "/((?!api/|_next/|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$|.*\\.(?:png|svg|webp|ico|woff2?)$).*)", + ], +}; diff --git a/frontend/test/posthog-browser.test.mjs b/frontend/test/posthog-browser.test.mjs index ead35da..3c02e7b 100644 --- a/frontend/test/posthog-browser.test.mjs +++ b/frontend/test/posthog-browser.test.mjs @@ -15,5 +15,5 @@ test("production browser response keeps PostHog on the exact proxy", { assert.match(csp, /connect-src[^;]*https:\/\/e\.codeswhat\.com/); assert.doesNotMatch(csp, /\*\.posthog\.com|https:\/\/(app|us)\.posthog\.com/); assert.doesNotMatch(body, /query-secret=should-not-leave/); - assert.match(body, /