From 37090dd96f867033ab1e440636fd659e604bfb52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:49:15 +0000 Subject: [PATCH] Rework agent context: gotcha-first CLAUDE.md, progressive disclosure via skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies Anthropic's context engineering guidance for Claude 5 generation models to this repo's agent context. CLAUDE.md drops from 222 lines to 90 and now carries only what an agent cannot learn by reading the repo. Removed: the directory tree (duplicated and stale against README), the naming-conventions table, the step-by-step new-page walkthrough, the per-module testing strategies, and architecture prose restating what the code already shows. Also removed two inaccurate lint claims (`--max-warnings 0` is not a Biome flag; "no unsafe writes" described nothing). Added in its place, verified against the source: the two JSX runtimes and the absence of hydration, the mock-before-import ordering service tests depend on, why the test runner is a script rather than `bun test`, the central security-header and CSP wiring, `APP_URL` port sensitivity in CSRF origin checks, production-only asset fingerprinting, and the Biome and tsconfig exclusions. Overconstrained rules give way to judgement where no failure mode backed them — "use code comments sparingly" becomes "match the surrounding code's comment density, naming, and idiom". The absolutes that encode real environment failures (the dev server on port 3000, the test scripts) stay. Procedural detail moves into three progressively-disclosed skills, each split across reference files so only the relevant layer loads: adding-a-feature page.md, api-endpoint.md writing-tests controllers.md, services.md, client.md verifying-changes .gitignore previously excluded all of .claude, so the skills would not have been shared. It now tracks .claude/skills/ and keeps local settings ignored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GXCNyaHcipkSVcLzHDERmA --- .claude/skills/adding-a-feature/SKILL.md | 35 +++ .../references/api-endpoint.md | 60 ++++ .../adding-a-feature/references/page.md | 90 ++++++ .claude/skills/verifying-changes/SKILL.md | 59 ++++ .claude/skills/writing-tests/SKILL.md | 28 ++ .../skills/writing-tests/references/client.md | 62 ++++ .../writing-tests/references/controllers.md | 78 +++++ .../writing-tests/references/services.md | 66 +++++ .gitignore | 4 +- CLAUDE.md | 266 +++++------------- README.md | 12 +- 11 files changed, 565 insertions(+), 195 deletions(-) create mode 100644 .claude/skills/adding-a-feature/SKILL.md create mode 100644 .claude/skills/adding-a-feature/references/api-endpoint.md create mode 100644 .claude/skills/adding-a-feature/references/page.md create mode 100644 .claude/skills/verifying-changes/SKILL.md create mode 100644 .claude/skills/writing-tests/SKILL.md create mode 100644 .claude/skills/writing-tests/references/client.md create mode 100644 .claude/skills/writing-tests/references/controllers.md create mode 100644 .claude/skills/writing-tests/references/services.md diff --git a/.claude/skills/adding-a-feature/SKILL.md b/.claude/skills/adding-a-feature/SKILL.md new file mode 100644 index 0000000..1d7b45b --- /dev/null +++ b/.claude/skills/adding-a-feature/SKILL.md @@ -0,0 +1,35 @@ +--- +name: adding-a-feature +description: How a new page, API endpoint, or admin route is wired through this repo's layers, and which files must change together. Use when adding or removing a route, page, or endpoint, when a new route 404s or its client script never runs, or when scaffolding a new service. +--- + +# Adding a feature + +The layering is strict, and it's what makes the codebase testable: + +- **Routes** map a URL to a controller method. Nothing else. +- **Controllers** orchestrate — call services, then render a template or return JSON. +- **Services** own all business logic and data access. Controllers never query the database. +- **Templates** are pure presentation, receiving fully resolved data as props. + +Data is fetched before render, so templates never need loading states. + +Types are the contract between layers: export a type from the service alongside its functions and +import that same type in the controller and template. Don't redeclare the shape at each layer — +if a template's props drift from the service's return type, that's the bug. + +Wiring is spread across several files and a missed one fails quietly. Follow the checklist for +what you're adding: + +- New page → `references/page.md` +- New API endpoint → `references/api-endpoint.md` + +Both end the same way: add the co-located test (see the `writing-tests` skill) and run +`bun run check` and `bun run test` (see `verifying-changes`). + +## Migrations + +New tables go in `src/server/database/migrations/` as `NNN_snake_case.ts`. Create one with +`bun run migrate:create`. They apply automatically on server start and at the top of the test +run — a failed migration means the server won't boot. Add any new table to `cleanupTestData` in +`src/server/test-utils/helpers.ts` or tests will bleed into each other. diff --git a/.claude/skills/adding-a-feature/references/api-endpoint.md b/.claude/skills/adding-a-feature/references/api-endpoint.md new file mode 100644 index 0000000..2963bd4 --- /dev/null +++ b/.claude/skills/adding-a-feature/references/api-endpoint.md @@ -0,0 +1,60 @@ +# Adding an API endpoint + +Same flow as a page, without the template or client layers. `src/server/controllers/api/projects.ts` +is the full CRUD example. + +## 1. Service — `src/server/services/.ts` + +Export functions and their types. If a view route already needs this logic, share the service +rather than having one route call the other over HTTP — routes must not fetch routes. + +## 2. Controller — `src/server/controllers/api/.ts` + +Return JSON with `Response.json()`; handle the error cases explicitly. + +```ts +export const examplesApi = { + async index() { + return Response.json({ examples: await getExamples() }); + }, + async show(req: BunRequest<"/api/examples/:id">) { + const example = await getExample(Number(req.params.id)); + if (!example) return Response.json({ error: "Not found" }, { status: 404 }); + return Response.json({ example }); + }, +}; +``` + +## 3. Barrel — `src/server/controllers/api/index.ts` + +API controllers take an `Api` suffix so they don't collide with the app controller for the same +resource: + +```ts +export { examplesApi } from "./examples"; +``` + +## 4. Route — `src/server/routes/api.ts` + +```ts +"/api/examples": createRouteHandler({ GET: examplesApi.index, POST: examplesApi.create }), +"/api/examples/:id": createRouteHandler({ + GET: examplesApi.show, + PUT: examplesApi.update, + DELETE: examplesApi.destroy, +}), +``` + +`createRouteHandler` returns 405 for any method not listed. + +## 5. Test — `src/server/controllers/api/.test.ts` + +See the `writing-tests` skill. + +## State-changing endpoints + +`csrfProtection` validates the request `Origin` against `APP_URL` and expects the token in the +`CSRF_HEADER_NAME` header for AJAX callers (form posts send it in the body instead). An endpoint +called from a browser needs it; one called by an external client needs a deliberate decision about +authentication instead. `src/server/middleware/rate-limit.ts` is available for anything +abuse-prone. diff --git a/.claude/skills/adding-a-feature/references/page.md b/.claude/skills/adding-a-feature/references/page.md new file mode 100644 index 0000000..b07415b --- /dev/null +++ b/.claude/skills/adding-a-feature/references/page.md @@ -0,0 +1,90 @@ +# Adding a page + +Worked example: a `/dashboard` page. Read `src/server/controllers/app/projects.tsx` and +`src/server/templates/projects.tsx` alongside this — they're the fullest example in the repo +(list, create, delete, auth, flash messages, a Preact island). + +## 1. Service — `src/server/services/dashboard.ts` + +Only if the page needs data. Export the functions and the types together; the type is what the +controller and template both import. + +## 2. Template — `src/server/templates/dashboard.tsx` + +Takes fully resolved data as props, wrapped in the layout: + +```tsx + +``` + +`name` sets `data-page` on ``, which is what dispatches the client script in step 6. Any +form that POSTs needs ``. + +This compiles with React's JSX runtime and renders once on the server — it never hydrates. Don't +reach for `useState` here. + +## 3. Controller — `src/server/controllers/app/dashboard.tsx` + +```tsx +export const dashboard = { + async index(req: BunRequest) { + const data = await getDashboardData(); + return render(); + }, +}; +``` + +`render()` and `redirect()` come from `src/server/utils/response.ts`. Don't set security headers — +they're applied centrally. + +## 4. Barrel — `src/server/controllers/app/index.ts` + +```ts +export { dashboard } from "./dashboard"; +``` + +## 5. Route — `src/server/routes/app.tsx` + +Single method: + +```ts +"/dashboard": dashboard.index, +``` + +Multiple methods, or anything that must reject others with a 405: + +```ts +"/dashboard": createRouteHandler({ GET: dashboard.index, POST: dashboard.create }), +``` + +Route params are typed through the handler — `projects.destroy<"/projects/:id/delete">` in +`app.tsx` is the pattern to copy. + +## 6. Client script — `src/client/pages/dashboard.ts` + +Export `init()`, then register it in `src/client/main.ts`: + +```ts +import { init as initDashboard } from "@client/pages/dashboard"; +registerPage("dashboard", { init: initDashboard }); +``` + +Skipping the `registerPage` call is the quiet failure: the script builds, ships, and never runs. +The registered name must equal the `name` prop from step 2. + +Export `cleanup()` too if the script adds listeners outside its own subtree. + +## 7. Page CSS — `src/client/pages/dashboard.css` + +Add `@import "./pages/dashboard.css";` to `src/client/style.css`. It is not picked up otherwise. + +## 8. Test — `src/server/controllers/app/dashboard.test.ts` + +See the `writing-tests` skill. + +## Removing a page + +The same list in reverse — template, controller, barrel export, route, nav link +(`src/server/components/nav.tsx`), client script, `registerPage` call in `main.ts`, the CSS file, +its `@import` in `style.css`, and the tests. `START_PROMPT.md` §5 lists exactly this for the stack +page and is a good checklist to mirror. diff --git a/.claude/skills/verifying-changes/SKILL.md b/.claude/skills/verifying-changes/SKILL.md new file mode 100644 index 0000000..5f86adb --- /dev/null +++ b/.claude/skills/verifying-changes/SKILL.md @@ -0,0 +1,59 @@ +--- +name: verifying-changes +description: How to lint, typecheck, and test this repo before finishing work. Use when you have edited files under src/ and need to confirm the change is sound, when a test or lint command is behaving unexpectedly, or when deciding which command to run for a targeted check. +--- + +# Verifying changes + +Run these through the `package.json` scripts. They set env vars and apply migrations that the +raw `bun` commands do not. + +## The two commands + +```bash +bun run check # biome lint + tsc --noEmit +bun run test # migrations, then every *.test.ts file +``` + +`bun run check` is fast and should pass before you consider a change done. `bun run test` is the +behavioural gate. The pre-commit hook runs `bun run build && bun run check`, so a lint or type +error blocks the commit. + +## Targeted runs + +```bash +bun run test:file src/server/services/project.test.ts # one file, migrations first +bun run lint:write # apply Biome's safe fixes +bun run typecheck # types only +``` + +`bun run test:file` takes a path or a directory. Prefer it over `bun test ` while iterating. + +## Why not `bun test` directly + +`bun run test` executes `src/server/test-utils/run-tests.ts`, which: + +1. Applies migrations against the test database first — `bun test` alone runs against whatever + schema happens to be there. +2. Spawns one process per test file, so a module mock or a mutated global in one file can't leak + into the next. +3. Pins `SESSION_COOKIE_NAME=session_id`. Tests hardcode that cookie name; a custom value in your + `.env` otherwise leaks in and fails auth tests for reasons that look unrelated. +4. Kills any file that exceeds 60s (`TEST_FILE_TIMEOUT_MS`) and reports it as failed rather than + hanging the run. + +## Reading failures + +- **`DATABASE_URL is required for tests`** — `.env.test` is missing or unloaded. It needs a + separate database from development; see `START_PROMPT.md` §1. +- **A file reported as `TIMED OUT`** — usually an unclosed SQL connection. Service tests need + `await connection.end()` in `afterAll`. +- **Auth or session assertions failing across many files** — check for `SESSION_COOKIE_NAME` in + your `.env`, and that you ran the script rather than `bun test`. +- **Type errors in `email-providers/resend.ts`** — that file is excluded in `tsconfig.json`, so + `bun run check` will not catch regressions there. + +## Browser checks + +For user-visible changes, confirm in the browser with the `/browse` skill against +http://localhost:3000. The dev server is already running in another tab — don't start one. diff --git a/.claude/skills/writing-tests/SKILL.md b/.claude/skills/writing-tests/SKILL.md new file mode 100644 index 0000000..1126fd2 --- /dev/null +++ b/.claude/skills/writing-tests/SKILL.md @@ -0,0 +1,28 @@ +--- +name: writing-tests +description: Testing patterns for this repo — which layer gets mocked, how service tests reach PostgreSQL, and how client tests get a DOM. Use when adding or changing a *.test.ts / *.test.tsx file, when a new module needs test coverage, or when an existing test fails in a way that looks like a setup problem. +--- + +# Writing tests + +Tests are co-located: `home.test.ts` sits next to `home.tsx`. Test user-visible behaviour rather +than implementation, and cover both guest and authenticated paths for anything auth-aware. + +The mocking boundary is the same everywhere: **mock the service layer, exercise everything above +it for real.** Controllers are tested against real `Response` objects and real rendered HTML, not +against assertions that a render function was called. + +Pick the reference for the layer you're working in: + +| Layer | Reference | +|---|---| +| `controllers/api/`, `controllers/app/`, `controllers/admin/` | `references/controllers.md` | +| `services/`, `middleware/` | `references/services.md` | +| `src/client/**` | `references/client.md` | + +`src/server/test-utils/` holds the shared kit: `helpers.ts` (`cleanupTestData`, `seedTestData`, +`randomEmail`), `setup.ts` (`createMockRequest`, `expectJsonResponse`), `factories.ts`, and +`bun-request.ts` for building `BunRequest` values with route params. + +Run everything with `bun run test` — see the `verifying-changes` skill for why the raw `bun test` +command misbehaves here. diff --git a/.claude/skills/writing-tests/references/client.md b/.claude/skills/writing-tests/references/client.md new file mode 100644 index 0000000..d3f8f49 --- /dev/null +++ b/.claude/skills/writing-tests/references/client.md @@ -0,0 +1,62 @@ +# Client tests + +DOM globals come from happy-dom, preloaded for every test file via `bunfig.toml` +(`src/client/test-utils/setup.ts`). You don't register it yourself. + +## Page scripts + +Build a fixture matching the server-rendered HTML, call `init()`, assert on the DOM. + +```ts +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +describe("projects page", () => { + beforeEach(() => { + document.body.innerHTML = ` +
Test Project
+ `; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("filters rows", async () => { + const { init } = await import("./projects"); + init(); + // ...assert + }); +}); +``` + +Import the page module **dynamically inside the test**. A top-level import is cached across +tests in the same file, so `init()` would run against stale module state. + +The fixture has to match what the server actually renders — the same ids, classes, and +`data-` attributes the script queries. If you change the template, change the fixture. + +## Preact islands + +Render into a container and assert on the output: + +```ts +/** @jsxImportSource preact */ +import { render } from "preact"; + +const container = document.createElement("div"); +document.body.appendChild(container); +render(, container); +expect(container.textContent).toContain("Test"); +``` + +The `/** @jsxImportSource preact */` pragma on line 1 is required — without it the file compiles +against React's runtime and the render fails. + +Islands here reach outside their own tree (`ProjectSearch` toggles rows in the server-rendered +table by id), so the fixture usually needs that surrounding markup in `document.body` too. + +## Page registration + +Pages are wired in `src/client/main.ts` with `registerPage(name, { init })`, and dispatched from +`document.body.dataset.page` — set by the `name` prop on ``. A page script that isn't +registered never runs, and no test will tell you. diff --git a/.claude/skills/writing-tests/references/controllers.md b/.claude/skills/writing-tests/references/controllers.md new file mode 100644 index 0000000..89a843d --- /dev/null +++ b/.claude/skills/writing-tests/references/controllers.md @@ -0,0 +1,78 @@ +# Controller tests + +Mock the services the controller imports; assert on the real `Response` it returns. + +## Shape + +```ts +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { SQL } from "bun"; +import type { Project } from "../../services/project"; +import { createBunRequest } from "../../test-utils/bun-request"; +import { cleanupTestData } from "../../test-utils/helpers"; + +const connection = new SQL(process.env.DATABASE_URL as string); + +// Mocks must run before the module under test is imported. +mock.module("../../services/database", () => ({ get db() { return connection; } })); + +const mockGetProjects = mock(async (): Promise => []); +mock.module("../../services/project", () => ({ getProjects: mockGetProjects })); + +import { projects } from "./projects"; // deliberately below the mocks + +afterAll(async () => { + await connection.end(); + mock.restore(); +}); +``` + +Controllers that touch sessions, CSRF, or auth still need a live connection even though the +domain service is mocked — sessions are stored in PostgreSQL. Clear each mock in `beforeEach` +with `mockClear()`. + +## Building requests + +`createBunRequest(url, init, params)` from `test-utils/bun-request.ts` returns a `BunRequest` +with `params` and a working `cookies` API. Use `findSetCookie(req, name)` / +`getSetCookieHeaders(req)` to assert on cookies the controller set. + +`createMockRequest(url, method, body)` from `test-utils/setup.ts` is the lighter option when the +handler needs neither params nor cookies. + +## API controllers + +Assert the HTTP contract directly: + +```ts +const res = await examplesApi.index(createMockRequest("http://localhost/api/examples")); +expect(res.status).toBe(200); +await expectJsonResponse(res, { examples: [] }); +``` + +Cover the error paths — bad input, missing resource, unauthorised — not just the happy one. + +## View controllers + +Render the response body and assert on the HTML: + +```ts +const res = await projects.index(createBunRequest("http://localhost/projects")); +expect(res.status).toBe(200); +expect(await res.text()).toContain("Test Project"); +``` + +For redirects, assert the status and the `Location` header rather than the body: + +```ts +expect(res.status).toBe(303); +expect(res.headers.get("Location")).toBe("/login"); +``` + +Test the guest and the authenticated render of anything auth-aware — build a session with +`createAuthenticatedSession` / `createGuestSession` and pass the cookie through the request. + +## Fixtures + +`test-utils/factories.ts` has `createMockProject` and `createMockVisitorStats`, both taking an +overrides object. Add new factories there rather than hand-rolling shapes in each test. diff --git a/.claude/skills/writing-tests/references/services.md b/.claude/skills/writing-tests/references/services.md new file mode 100644 index 0000000..58cb12b --- /dev/null +++ b/.claude/skills/writing-tests/references/services.md @@ -0,0 +1,66 @@ +# Service and middleware tests + +Services run against a real PostgreSQL database from `.env.test` — real SQL, real constraints, no +query mocking. + +## Shape + +```ts +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { SQL } from "bun"; +import { cleanupTestData, seedTestData } from "../test-utils/helpers"; + +if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL is required for tests"); +} +const connection = new SQL(process.env.DATABASE_URL); + +mock.module("./database", () => ({ + get db() { return connection; }, +})); + +import { db } from "./database"; +import { createProject, getProjects } from "./project"; + +describe("Project service", () => { + beforeEach(async () => { + await cleanupTestData(db); + }); + + afterAll(async () => { + await connection.end(); + mock.restore(); + }); +}); +``` + +Three things this shape is load-bearing on: + +- **The mock precedes the imports.** `mock.module` has to run before the service module is + evaluated, so the service imports sit below executable code. That is intentional; leave it. +- **The getter.** `get db()` defers resolution so the mock survives module caching. +- **`await connection.end()` in `afterAll`.** Without it the file hangs and the runner kills it + at the 60s timeout. + +## Isolation + +`cleanupTestData(db)` truncates `user_tokens`, `sessions`, `users`, and `project`, and restarts +`project_id_seq`. Call it in `beforeEach`, not `afterEach` — a failed test then leaves its rows +behind for inspection. Extend that helper when you add a table rather than truncating inline. + +`seedTestData(db)` inserts three known projects. `randomEmail()` gives a collision-free address +for user fixtures. + +## What to cover + +Full CRUD against real SQL, plus the cases the database enforces and TypeScript can't: unique +violations, foreign-key cascades, null columns, ordering guarantees. + +## Middleware + +Middleware in `src/server/middleware/` returns `Response | null` — a `Response` means "stop, this +is the answer", `null` means "carry on". Assert both branches. CSRF and auth middleware read +sessions from PostgreSQL, so they need the same live connection setup as services. + +`csrfProtection` validates the request `Origin` against `APP_URL`, so requests built in tests need +a matching `Origin` header or an explicit `expectedOrigin` option. diff --git a/.gitignore b/.gitignore index b53d8d5..640ddda 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ coverage .env .env.test docs -.claude +.claude/* +!.claude/skills/ +.claude/settings.local.json .worktrees .gstack \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 6598305..f180128 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,221 +1,103 @@ -# CLAUDE.md - Development Context & Guidelines +# CLAUDE.md -This file contains essential context and guidelines for Claude instances working on this project. +Billet is a server-rendered full-stack TypeScript app on Bun + PostgreSQL. Requests flow +route → controller → service → template. `README.md` has the directory tree; the code is the +spec for everything else. This file is for the things you can't learn by reading the repo. -**Key workflow reminders:** +## Working agreements -- Never try to run the local dev server. The Human is always running it in another tab on port 3000. -- Always test your work with the /browse skill to confirm it works as expected. -- This project uses JSX for it's template engine but it is NOT a React (client) project. -- When trying different approaches for a given problem, always go back and remove or refactor. -- Use code comments sparingly. Save them for when the extra context is really needed. +- The dev server is already running on port 3000 in another tab — don't start one, and don't + add a second. `bun run dev` here will fail on the port or fight the watcher. +- Run test and lint suites through the `package.json` scripts (`bun run test`, `bun run check`). + Invoking `bun test` directly skips migrations and leaks your `.env` into the run — see the + `verifying-changes` skill. +- Write code that reads like the surrounding code: match its comment density, naming, and idiom. +- When you try several approaches to a problem, delete the ones you abandoned before you finish. +- Check work in the browser with the `/browse` skill when the change is user-visible. -## General codebase notes +## Gotchas -### Code Quality Standards +### Two JSX runtimes, no hydration -**STRICT LINTING ENFORCED:** +Server templates and `src/server/components/` compile with React's runtime (`jsxImportSource: react` +in `tsconfig.json`) and render once through `renderToString()` in `src/server/utils/response.ts`. +None of it hydrates — there is no React on the client, and `useState` in a server component does +nothing. -ALWAYS check for TS errors and linting issues before finishing a work loop (`bun run check`) +Client interactivity is Preact islands. A client component opts in per-file with a +`/** @jsxImportSource preact */` pragma on line 1, and the page script mounts it with +`render()` from `preact`. Preact is marked `--external` in the build scripts and resolved at +runtime from the import map in `src/server/components/layouts.tsx`, so the version pinned there +must stay in step with `package.json`. -- **Zero warnings allowed** (`--max-warnings 0`) -- **No "any" types allowed** (`noExplicitAny: error`) -- **No console statements allowed** (`noConsole: error`) — use `log` from `src/server/services/logger.ts` instead -- **No unsafe writes** +No Web Components. Shadow DOM and custom-element lifecycles need browser infrastructure to test; +pure functions and Preact islands are both testable under `bun:test`. -### Package Management +### Service tests mock the DB module before importing the service -- Uses `bun` as the package manager (not npm, pnpm or yarn) -- Lock file: `bun.lock` +`src/server/services/*.test.ts` call `mock.module("./database", ...)` and *then* import the +service under test. The imports sit below executable code on purpose — that ordering is what +makes the mock take effect. Don't tidy it. -## Architecture Decisions +### The test runner is a script, not `bun test` -### Code Quality Tools +`bun run test` runs `src/server/test-utils/run-tests.ts`, which applies migrations first, spawns +one process per test file (isolation + a per-file timeout), and pins `SESSION_COOKIE_NAME=session_id` +because tests hardcode that cookie. A custom `SESSION_COOKIE_NAME` in your `.env` breaks auth tests +if it leaks in. -- **Biome**: Code linting for TypeScript -- **Prettier**: Code formatting with consistent style -- **TypeScript**: Strict mode enabled for type safety +`bunfig.toml` preloads `src/client/test-utils/setup.ts` for *every* test file. It registers +happy-dom globals and then restores Bun's native `Request`/`Response`/`FormData` — server tests +depend on that restore. -### No Web Components +### Security headers and CSP are centralised -Shadow DOM and custom element lifecycles can't be tested without browser-level infrastructure (happy-dom, Puppeteer, etc.). Prefer pure functions for logic and Preact islands for client-side interactivity — both are testable with `bun:test`. +Every response gets its headers from `secureRoutes` / `handleGuarded` in +`src/server/utils/security-headers.ts`. Controllers set only content-specific headers. -### Testing Strategies by Module Type +The CSP script allowlist is `'self' 'unsafe-inline' https://unpkg.com https://esm.sh`. Any new +third-party script needs the CSP entry, an SRI `integrity` hash, and ideally a `preconnect` in +`layouts.tsx` — otherwise it is silently blocked in the browser but passes every test. -ALWAYS run test suites via the `package.json` *test scripts* so the env vars are correct. -NEVER try to roll your own lint or test commands. +### Env is validated at boot and `APP_URL` is load-bearing -**API Controllers** (`src/server/controllers/api/*.test.ts`): -- Mock service layer dependencies only -- Test actual HTTP Response objects (status codes, headers, JSON content) -- Focus on request/response handling and error scenarios +`validateEnv()` exits the process on a missing var, then migrations run before `Bun.serve()` — a +failed migration means no server. `src/server/services/database.ts` throws at import time without +`DATABASE_URL`, so anything that imports it (directly or not) needs the env set. -**View Controllers** (`src/server/controllers/app/*.test.ts`): -- Mock service layer dependencies only -- Test actual HTML output using `renderToString()` -- Verify specific content appears in rendered HTML -- Test redirect responses with actual status codes and Location headers +`APP_URL` must include the port. CSRF origin validation compares the request `Origin` against it +exactly, so `http://localhost` vs `http://localhost:3000` rejects every form post with a 403. -**Services** (`src/server/services/*.test.ts`): -- Use real PostgreSQL database for testing with .env.test configuration -- Test complete CRUD operations with actual SQL queries -- Use table truncation and cleanup for test isolation +### Assets are only fingerprinted in production -**Test Utilities** (`src/server/test-utils/*.ts`): -- Unit test adapters and helper functions directly -- Focus on input/output transformations -- Test edge cases and error handling +`initAssets()` and `getAssetUrl()` no-op unless `NODE_ENV=production`, so asset URLs differ between +dev and prod. In production the files must already exist in `dist/assets` or startup throws. -**Client Scripts** (`src/client/**/*.test.ts`): -- Use happy-dom for DOM globals (auto-loaded via bunfig.toml prelude) -- Set up DOM fixtures matching server-rendered HTML in `beforeEach` -- Clean up with `document.body.innerHTML = ""` in `afterEach` -- Call `init()` and assert DOM state changes -- For Preact components, render into a container and assert output -- Use dynamic imports for page init functions to get fresh module context (avoid top-level imports with module caching) +### Linting -### Best Practices +Biome runs with `recommended` on and `noConsole: error` — use `log` from +`src/server/services/logger.ts` in server code. Test files, `logger.ts`, the database CLI/seed +scripts, and `test-utils/bootstrap.ts` have targeted overrides in `biome.json`; add an override +there rather than sprinkling ignore comments. -- **Test user interactions**: Focus on user behavior rather than implementation -- **Authenticated contexts**: Test components with guest and logged-in users -- **Error scenarios**: Test error handling and edge cases +`tsconfig.json` excludes `src/server/services/email-providers/resend.ts` from typechecking — +changes to it are not covered by `bun run check`. -## Architecture Patterns - -### Server-side Rendering Flow - -- Data fetched synchronously in route handlers is always available before template renders -- No need for loading states when data is fetched server-side before rendering -- Routes return Response objects with proper headers, not JSX elements directly -- Templates receive fully resolved data as props +### Naming that isn't inferable -### Server Startup +App controllers export a plain name (`home`, `projects`); API controllers use an `Api` suffix +(`examplesApi`, `statsApi`) so both can be barrel-exported when they share a resource name. -- Migrations run automatically on startup (`await runMigrations()` before `Bun.serve()`) -- If a migration fails, the server won't start (fail-safe) -- No need to run migrations manually before starting the server +## Skills -### Logging +Detail lives in skills so it loads only when it's relevant: -- Use `log.info(category, message)`, `log.warn(...)`, `log.error(...)` from `src/server/services/logger.ts` -- Never use `console.*` directly in server code — Biome enforces `noConsole: error` -- CLI scripts (`cli.ts`, `bootstrap.ts`) and test files are exempt from this rule -- Output format: `[LEVEL] [category] message` — goes to stdout/stderr for platform capture - -### Service Layer Abstraction - -- Business logic should live in `/src/server/services/` directory -- Services provide single source of truth for data operations -- Services should be pure functions when possible for easier testing -- Services can be shared across both API and view routes -- Example: `analytics.ts` service for visitor stats and analytics - -### Type Safety Across Layers - -- Export types from service modules alongside functions -- Import and use service types in templates for consistency -- Avoid duplicating type definitions across files -- Maintain type safety from service → route → template -- Example: `VisitorStats` type exported from analytics service - -## Routing Structure - -### Route Organization - -- Separate API routes (`/src/server/routes/api.ts`) and view routes (`/src/server/routes/app.tsx`) -- Routes use Bun's native `routes: {}` configuration for better performance -- API routes return JSON responses using `Response.json()` -- View routes render HTML using `renderToString()` wrapped in Response objects -- Both route types can share services for business logic - -### Route Handler Patterns - -- API routes: `(req) => Response | Promise` -- View routes: `(req) => Response` (after fetching data from services) -- Avoid circular dependencies between routes (don't fetch API routes from view routes) -- Use services to share logic between different route types - -## Project Structure - -### Directory Layout - -``` -src/ -├── client/ # Browser-side code -│ ├── main.ts # Entry point — routes to page init functions -│ ├── style.css # Global styles (CSS entry point) -│ ├── components/ # Reusable client components (CSS) -│ │ ├── nav.css -│ │ └── layout.css -│ └── pages/ # Page-specific JS & CSS (co-located) -│ ├── home.ts / home.css -│ ├── about.ts / about.css -│ └── contact.ts / contact.css -│ -├── server/ # Server-side code (Bun/TypeScript) -│ ├── main.ts # Server entry point -│ ├── routes/ -│ │ ├── app.tsx # View route map -│ │ └── api.ts # API route map -│ ├── controllers/ # Route handlers (grouped by domain) -│ │ ├── app/ # View controllers — return HTML -│ │ ├── api/ # API controllers — return JSON -│ │ └── auth/ # Auth controllers — login/logout flows -│ ├── templates/ # Full-page JSX templates -│ ├── components/ # Reusable server JSX components -│ ├── services/ # Business logic & data access -│ ├── middleware/ # HTTP middleware (auth, CSRF) -│ ├── utils/ # Shared utilities (response, crypto, etc.) -│ ├── database/ -│ │ ├── cli.ts / migrate.ts # Migration tooling -│ │ └── migrations/ # Numbered migration files -│ └── test-utils/ # Test infrastructure (setup, factories, helpers) -│ -└── types/ # Global TypeScript type declarations -``` - -### Naming Conventions - -| What | Convention | Example | -|------|-----------|---------| -| Files & directories | kebab-case | `route-handler.ts`, `test-utils/` | -| JSX component exports | PascalCase | `Home`, `Layout`, `CsrfField` | -| Controller namespace exports | camelCase | `home`, `examplesApi`, `login` | -| Service functions | camelCase | `getExamples`, `createCsrfToken` | -| Type exports | PascalCase | `Example`, `VisitorStats`, `AuthContext` | -| Migrations | `NNN_snake_case.ts` | `001_initial_setup.ts` | -| Test files | Co-located `.test.ts` | `home.test.ts` next to `home.tsx` | - -**Controller barrel export naming:** App controllers export plain names (`home`, `about`). API controllers use an `Api` suffix (`examplesApi`, `statsApi`) to disambiguate when both domains share a resource name. - -### How Files Connect (Adding a New Page) - -To add a new page called "dashboard", create files in this order: - -1. **Service** (if it needs data): `src/server/services/dashboard.ts` - - Export functions and types -2. **Template**: `src/server/templates/dashboard.tsx` - - Import types from service, accept data as props - - Wrap in `` -3. **Controller**: `src/server/controllers/app/dashboard.tsx` - - Import service functions and template - - Fetch data, pass to template via `render()` - - Export as `const dashboard = { index(req) { ... } }` -4. **Barrel export**: Add `export { dashboard } from "./dashboard"` to `controllers/app/index.ts` -5. **Route**: Add `"/dashboard": dashboard.index` to `routes/app.tsx` -6. **Client JS** (if interactive): `src/client/pages/dashboard.ts` - - Export an `init()` function - - Register it in `src/client/main.ts` -7. **Client CSS** (if page-specific styles): `src/client/pages/dashboard.css` -8. **Test**: `src/server/controllers/app/dashboard.test.ts` - -For an **API endpoint**, the flow is similar but skips templates: -1. Service → 2. Controller in `controllers/api/` → 3. Barrel export with `Api` suffix → 4. Route in `routes/api.ts` → 5. Test - -### Key Patterns - -- **Routes** are thin — they only map URL paths to controller methods -- **Controllers** orchestrate: fetch from services, then render templates or return JSON -- **Services** own all business logic and data access — controllers never query the DB directly -- **Templates** are pure presentation — they receive fully resolved data as props -- **Client scripts** use `data-page` on `` for page routing (set by the `Layout` component's `name` prop) +- `adding-a-feature` — wiring a new page, API endpoint, or admin route through every layer +- `writing-tests` — the testing pattern for each module type +- `verifying-changes` — how to run checks and tests, and what the failures mean + +## Runbooks + +`runbooks/` holds the operational standards this project is held to — `SECURITY.md`, `PRIVACY.md`, +`ACCESSIBILITY.md`, `SEO.md`, `EMAIL.md`, `CI.md`. Read the relevant one before changing headers, +cookies, metadata, or email delivery. diff --git a/README.md b/README.md index c6e183c..fbf384a 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,17 @@ Run the full suite: `bun run test` The "designed for AI agents" tagline is the reason Billet exists, so here's what that means in practice. -### CLAUDE.md — the agent's guide +### CLAUDE.md and skills — the agent's guide -The repo includes a 200-line [`CLAUDE.md`](CLAUDE.md) that serves as an onboarding document for AI coding agents. It covers the full architecture: directory layout, naming conventions, routing patterns, service layer design, testing strategies by module type, and a step-by-step walkthrough for adding a new page. When an agent opens this project, it knows where everything goes and how everything connects — before writing a single line of code. +The repo ships a deliberately short [`CLAUDE.md`](CLAUDE.md) plus a set of skills in `.claude/skills/`. `CLAUDE.md` covers only what an agent can't learn by reading the repo — the gotchas: two JSX runtimes with no hydration, why service tests mock the database module before importing, why `bun test` isn't the test command, where security headers actually come from. Everything procedural lives in skills that load on demand: + +| Skill | Loads when | +|---|---| +| `adding-a-feature` | Wiring a page, endpoint, or migration through every layer | +| `writing-tests` | Adding or fixing a test — one reference per module type | +| `verifying-changes` | Running lint, typecheck, and the test suite, and reading their failures | + +This split follows Anthropic's [context engineering guidance for Claude 5 models](https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models): keep the always-loaded context small and specific to your codebase, and use progressive disclosure for the rest. ### Why this architecture works for agents