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