Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cc038b9
fix(analytics): forward $raw_user_agent and $host for cookieless inge…
biggest-littlest Aug 15, 2026
985bd2a
chore(git): reconcile main before cookieless promotion
scttbnsn Aug 15, 2026
3b6d9cd
chore(config): remove the stale Cursor rules (#54)
scttbnsn Aug 16, 2026
30cdd8e
docs(readme): describe what this repo is and how it deploys (#55)
scttbnsn Aug 16, 2026
89bad5b
chore(git): reconcile main before docs promotion
scttbnsn Aug 16, 2026
d8aec70
chore(gitignore): ignore the root .vercel link and history-backup bun…
scttbnsn Aug 26, 2026
f50fadb
feat(analytics): capture $pageleave and send $pathname (#60)
scttbnsn Aug 26, 2026
1a40882
fix(seo): repair JSON-LD logo 404 and double-slash base URLs
scttbnsn Aug 28, 2026
e1f7a85
build(deps): bump next to ^16.2.11 to clear all 35 Dependabot alerts
scttbnsn Aug 28, 2026
ac19fbb
build(deps): regenerate next-env.d.ts for next 16.3
scttbnsn Aug 28, 2026
faea446
docs(roadmap): track web-analytics table coverage follow-ups (ops X37)
scttbnsn Aug 28, 2026
25e49cd
chore: ignore .claude/ with a tracked line (#59)
scttbnsn Aug 28, 2026
9025b2f
docs(roadmap): point acquisition-data item at the ops analytics standard
scttbnsn Aug 28, 2026
f4add91
chore(git): reconcile main before analytics and hardening promotion
scttbnsn Aug 28, 2026
5aade3b
docs(roadmap): pageleave ratio is structural; note the bot-detection …
scttbnsn Aug 28, 2026
b34f329
chore(config): add .planning/ to .gitignore (#62)
scttbnsn Sep 7, 2026
b620263
chore(git): reconcile main before the .planning gitignore promotion
scttbnsn Sep 7, 2026
2d3ccab
fix(analytics): preserve validated referring hostnames (#64)
scttbnsn Sep 13, 2026
16c153c
chore(git): reconcile main before the referrer reporting promotion
scttbnsn Sep 13, 2026
4493004
fix(site): restore hydration and reject invalid signup bodies (#66)
scttbnsn Sep 13, 2026
cac45c0
chore(git): reconcile main before the hydration repair promotion
scttbnsn Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ permissions:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: frontend
Expand All @@ -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
75 changes: 47 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 31 additions & 18 deletions frontend/README-VERCEL.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
# Vercel Deployment Guide

## Quick Deploy
## Production deployment

[![Deploy with Vercel](https://vercel.com/button)](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 |
| ------------------------------ | ------------------------ | -------------------------------- |
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions frontend/app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
10 changes: 8 additions & 2 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="apple-mobile-web-app-title" content={siteName} />
<script id="theme-init" dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<script
id="theme-init"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
/>
</head>
<body className={inter.className}>
{children}
Expand Down
30 changes: 0 additions & 30 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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;
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions frontend/proxy.ts
Original file line number Diff line number Diff line change
@@ -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?)$).*)",
],
};
2 changes: 1 addition & 1 deletion frontend/test/posthog-browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<script id="theme-init">/);
assert.match(body, /<script\b[^>]*\bid="theme-init"/);
});
9 changes: 4 additions & 5 deletions frontend/test/posthog-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,10 @@ test("privacy posture disables persistence, recording, autocapture, and automati
assert.match(instrumentation, /before_send:/);
});

test("CSP allows only the exact PostHog proxy and hashes the theme script", async () => {
const config = await read("next.config.ts");
assert.ok(config.includes('"https://e.codeswhat.com"'));
assert.doesNotMatch(config, /https:\/\/(app|us)\.posthog\.com/);
assert.match(config, /sha256-/);
test("CSP allows only the exact PostHog proxy", async () => {
const proxy = await read("proxy.ts");
assert.ok(proxy.includes('"https://e.codeswhat.com"'));
assert.doesNotMatch(proxy, /https:\/\/(app|us)\.posthog\.com/);
});

test("repo-owned CI runs the website contracts and production build", async () => {
Expand Down
Loading
Loading