# Chassis β€” complete documentation Generated from https://github.com/dvd90/chassis. Every page below is the verbatim source markdown, in the order the site presents it. # 🏎️ Chassis **A lightweight, decorator-driven Express + TypeScript backend starter. Clone, run, ship.** **[πŸ“– Documentation](https://dvd90.github.io/chassis/)** Β· [Getting started](https://dvd90.github.io/chassis/#getting-started) Β· [create-chassis on npm](https://www.npmjs.com/package/create-chassis) Chassis gives you NestJS-style controller ergonomics on plain Express 5 β€” in a handful of small files you can actually read. Zero configuration required: the server boots standalone, and every integration switches on only when you add its environment variable. Scaffold with a preset or pick Γ  la carte β€” a database (Mongo, Postgres, or SQLite, ORM included), an auth provider (Auth0, JWT, or Clerk), an optional Next.js front end, Sentry, an MCP server, and x402 payments β€” and the CLI ships only what you chose. ```ts export class UserController extends Routable { constructor() { super('/users'); } @route('get', '/:id') async show(req: Request) { const user = await findUser(req.params.id); if (!user) throw new AppError(ERROR_CODES.NOT_FOUND, 'User not found'); return req.resHandler.ok(user); } @protectedRoute('post', '/', [validate({ body: createUserSchema })]) async create(req: Request) { return req.resHandler.created(await createUser(req.body)); } } ``` Export the class from `src/controllers/index.ts` β€” that's the whole wiring. ## Quick start ```bash npm create chassis my-api -- --yes # zero prompts: Postgres + JWT + Sentry + Docker npm create chassis my-app -- --preset fullstack --yes # the same, plus a Next.js front end npm create chassis my-api # interactive β€” pick a preset npm create chassis my-api -- --db postgres --auth jwt --mcp # Γ  la carte npm create chassis my-api -- --bare # nothing β€” standalone build ``` Or use the template directly: ```bash git clone https://github.com/dvd90/chassis.git my-api cd my-api && npm install && npm run dev ``` That's it β€” no database, no env file, no accounts needed. Open http://localhost:8000/status. New here? Follow the **[step-by-step getting-started guide](docs/getting-started.md)** β€” zero to a tested API in ~10 minutes. ## For AI agents Every path is non-interactive: `--yes` and `--bare` never prompt, and the CLI skips prompts automatically whenever stdin isn't a TTY. One command produces a project that already typechecks, lints and tests green. - **[llms.txt](https://dvd90.github.io/chassis/llms.txt)** β€” the project, its conventions and its docs index, in one fetch - **[llms-full.txt](https://dvd90.github.io/chassis/llms-full.txt)** β€” every documentation page, concatenated - **[AGENTS.md](AGENTS.md)** β€” the conventions to follow when writing code in a Chassis project, and the definition of done Generated projects carry `AGENTS.md`, `CLAUDE.md`, `llms.txt` and an `add-resource` skill, so whichever agent opens one writes code that matches the rest of the codebase rather than fighting it. ## Features - **TypeScript 5 + Express 5** β€” strict types, async errors caught automatically - **Decorator routing** β€” `@route` / `@protectedRoute` on controller methods, controllers auto-mount - **Consistent responses** β€” `req.resHandler.ok() / .notFound() / .validation()` with structured logging - **Request correlation** β€” every request gets a `callId` (or propagates `x-call-id`), echoed in responses and logs - **Typed, validated config** β€” zod-checked environment via `src/config`; the app refuses to boot on bad config - **Zod input validation** β€” `validate({ body, query, params })` middleware with structured 400s - **Pick-your-stack scaffolder** β€” presets or Γ  la carte: database + ORM (Mongo/Postgres/SQLite), auth (Auth0/JWT/Clerk), a Next.js front end, Sentry, MCP, x402 β€” the CLI prunes everything else so `package.json` carries only what you chose - **Opt-in integrations** β€” every module enables by env var, never required - **Payment-gated routes** β€” `@paidRoute('get', '/report', '$0.01')` via the x402 protocol (opt-in) - **Optional Next.js front end** β€” `--web` adds an App Router app and makes the project an npm-workspaces monorepo (`apps/api` + `apps/web`); the auth provider you picked is wired on both sides - **MCP server** β€” expose your API to AI agents as MCP tools (`npm run mcp`, opt-in) - **Health endpoints** β€” `/healthz` (liveness) and `/readyz` (readiness, checks enabled integrations) - **Graceful shutdown** β€” drains connections and closes integrations on SIGTERM/SIGINT - **Vitest + supertest** β€” fast tests against the pure app factory, no server or DB needed - **DB-aware code generator** β€” `npm run gen user` scaffolds a controller + test wired to your ORM (Drizzle or Mongoose) - **Production Docker** β€” multi-stage build, non-root user, plus docker-compose with your database for dev - **CI + Renovate** β€” GitHub Actions verify pipeline and automated dependency updates - **AI-agent ready** β€” ships `AGENTS.md`, `CLAUDE.md`, `llms.txt`, and an `add-resource` skill so agents write code that matches the conventions (see below) ## AI-agent ready Most people scaffolding a backend today have an AI agent in the loop. Chassis is built so that agent-written code reads like hand-written code β€” because the framework gives agents rails and a verifiable finish line: - **`AGENTS.md` + `CLAUDE.md`** ship in every project β€” Claude Code, Cursor, Copilot, and Codex pick them up automatically and follow the conventions (thin controllers, `resHandler` responses, `throw AppError`, config in one place). - **One obvious place for everything** means agent output converges on the same shape a maintainer would write β€” that's what keeps it readable. - **`npm run verify`** (strict TypeScript + ESLint + tests) is a deterministic quality gate agents iterate against until green. - **`.claude/skills/add-resource`** turns "add a books resource" into one consistent, checklisted operation. - **`llms.txt`** gives doc-fetching tools a compact map of the conventions. Nothing to install β€” it's all in the scaffold. See [AGENTS.md](AGENTS.md). ## Scripts | Command | What it does | | --------------------------------- | ------------------------------------------- | | `npm run dev` | Start with hot reload (tsx watch) | | `npm test` / `npm run test:watch` | Run the vitest suite | | `npm run verify` | Typecheck + lint + test (CI runs this) | | `npm run build` / `npm start` | Compile to `dist/` and run production build | | `npm run gen ` | Generate a controller + test | | `npm run lint` / `npm run format` | ESLint / Prettier | ## Enabling integrations Copy `.env.example` to `.env`. Each integration turns on when its variables are set β€” and stays completely dormant otherwise: | Integration | Enable by setting | What you get | | ----------- | --------------------------------- | --------------------------------------------------------- | | MongoDB | `MONGODB_URI` | Mongoose connection, readiness check, graceful disconnect | | Auth0 | `AUTH0_DOMAIN` + `AUTH0_AUDIENCE` | JWT verification on every `@protectedRoute` | | Sentry | `SENTRY_DSN` | Automatic error reporting from the central error handler | Using a different IdP? Call `setAuthProvider([...yourMiddleware])` at boot and `@protectedRoute` uses it β€” see `src/core/auth.ts`. ## Project structure ``` src/ β”œβ”€β”€ config/ # zod-validated env β†’ typed config + feature flags β”œβ”€β”€ core/ # the framework: Routable, decorators, responses, errors, validation β”œβ”€β”€ middleware/ # callId correlation, dev request logging β”œβ”€β”€ integrations/ # opt-in modules: mongo, auth0, sentry β”œβ”€β”€ controllers/ # your endpoints β€” exported classes auto-mount β”œβ”€β”€ __tests__/ # vitest + supertest β”œβ”€β”€ app.ts # pure app factory (no I/O β€” trivially testable) └── server.ts # boot: integrations β†’ listen β†’ graceful shutdown ``` ## Documentation Read them at **[dvd90.github.io/chassis](https://dvd90.github.io/chassis/)** β€” searchable, one page. The source lives in [`docs/`](docs/README.md) and the site is generated from it, so the two can never disagree: - **[Getting started](docs/getting-started.md)** β€” step-by-step tutorial - **Guides** β€” [building an API](docs/guides/building-an-api.md) Β· [authentication](docs/guides/authentication.md) Β· [deployment](docs/guides/deployment.md) - **Concepts** β€” [architecture](docs/architecture.md) Β· [modules & integrations](docs/modules.md) - **Reference** β€” [configuration](docs/reference/configuration.md) Β· [core API](docs/reference/core-api.md) Β· [CLI & scripts](docs/reference/cli.md) - **[Maintainers guide](docs/maintainers.md)** β€” publishing, releases, keeping the template fresh ## Docker ```bash docker compose up --build # API + MongoDB docker build -t my-api . # production image only ``` ## License [MIT](LICENSE) # Getting started From nothing to a running, tested API. Total time: about 10 minutes. ## Prerequisites - **Node.js 20 or newer** (`node -v`) - That's it. No database, no accounts, no Docker required to start. ## Step 1 β€” Create your project **Option A β€” the scaffolder (recommended):** ```bash npm create chassis my-api ``` Answer the prompts β€” each module you decline is completely removed from the generated project (files, dependencies, config): ``` 🏎️ create-chassis β€” Express + TypeScript, ready to drive Include MongoDB (Mongoose)? (Y/n) Include Auth0 JWT auth? (Y/n) Include Sentry error reporting? (Y/n) Include Docker (Dockerfile + compose)? (Y/n) Initialize a git repository? (Y/n) Run npm install now? (Y/n) ``` Shortcuts: `--yes` accepts everything, `--bare` declines every optional module for the smallest possible starting point. **Option B β€” use the template directly:** ```bash git clone https://github.com/dvd90/chassis.git my-api cd my-api rm -rf .git && git init npm install ``` ## Step 2 β€” Run it ```bash cd my-api npm run dev ``` ``` info No integrations configured β€” running standalone info 🏎️ Chassis running on http://localhost:8000 ``` Check it's alive: ```bash curl http://localhost:8000/status # {"status":"up","uptime":2.14} ``` Note there was no configuration step. Chassis boots with zero env vars; integrations activate later, when you add theirs ([step 7](#step-7--enable-an-integration-when-you-need-it)). ## Step 3 β€” Add your first endpoint ```bash npm run gen todo ``` This creates `src/controllers/Todo.controller.ts` (a CRUD skeleton), a matching test, and wires the export into `src/controllers/index.ts`. The dev server picks it up on save: ```bash curl http://localhost:8000/todos # {"items":[]} ``` ## Step 4 β€” Understand what you're looking at Open the generated controller: ```ts export class TodoController extends Routable { constructor() { super('/todos'); // base path β€” every route below is relative to it } @route('get', '/') async index(req: Request): Promise { return req.resHandler.ok({ items: [] }); } @route('post', '/', [validate({ body: createTodoSchema })]) async create(req: Request): Promise { return req.resHandler.created({ item: req.body }); } } ``` Three ideas carry the whole framework: 1. **`@route` declares endpoints.** Export the class from `src/controllers/index.ts` and it auto-mounts. No router files. 2. **`req.resHandler` sends responses.** `ok`, `created`, `notFound`, `validation`, … β€” every response is logged with a correlation id. 3. **Just throw for errors.** `throw new AppError(ERROR_CODES.NOT_FOUND, '...')` anywhere; the central handler maps it to a clean JSON response. More depth: [Architecture](architecture.md) Β· [Core API](reference/core-api.md). ## Step 5 β€” Run the tests ```bash npm test ``` Tests hit the app factory directly with supertest β€” no port, no database, so they run in about a second. Try `npm run test:watch` while developing. ## Step 6 β€” Verify like CI does ```bash npm run verify # typecheck + lint + test ``` The pre-commit hook and the GitHub Actions workflow both run exactly this, so green locally means green in CI. ## Step 7 β€” Enable an integration (when you need it) ```bash cp .env.example .env ``` Set a variable, restart, done: | You set | You get | | ---------------------------------------------- | ------------------------------------------------------------ | | `MONGODB_URI=mongodb://localhost:27017/my-api` | Mongoose connected, `/readyz` checks it, graceful disconnect | | `AUTH0_DOMAIN` + `AUTH0_AUDIENCE` | JWT verification on every `@protectedRoute` | | `SENTRY_DSN` | Unexpected errors reported to Sentry | No MongoDB running? `docker compose up mongo` starts one (if you kept the Docker module). Full walkthroughs: [Building an API](guides/building-an-api.md) and [Authentication](guides/authentication.md). ## Step 8 β€” Build for production ```bash npm run build # compiles to dist/ npm start # runs dist/server.js ``` Or build the production Docker image β€” multi-stage, non-root, ~150 MB: ```bash docker build -t my-api . docker run -p 8000:8000 my-api ``` ## Where to go next - [Building an API](guides/building-an-api.md) β€” a real CRUD resource with a Mongoose model, validation, and tests - [Authentication](guides/authentication.md) β€” protect routes with Auth0 or your own provider - [Deployment](guides/deployment.md) β€” ship it # Building an API This guide builds a complete `books` resource: Mongoose model, validated input, proper errors, and tests. It assumes you've read [Getting started](../getting-started.md). ## 1. Start MongoDB and enable the integration ```bash docker compose up -d mongo # or any MongoDB you have around echo 'MONGODB_URI=mongodb://localhost:27017/my-api' >> .env npm run dev ``` ``` info βœ… MongoDB connected info Integrations enabled: mongo ``` `/readyz` now reports the connection state β€” try stopping Mongo and watching it flip to 503. ## 2. Define the model Create `src/models/Book.model.ts`: ```ts import { model, Schema } from 'mongoose'; export interface Book { title: string; author: string; year?: number; } const bookSchema = new Schema( { title: { type: String, required: true }, author: { type: String, required: true }, year: { type: Number } }, { timestamps: true } ); export const BookModel = model('Book', bookSchema); ``` ## 3. Define the input schema Zod schemas double as validation _and_ TypeScript types. Create `src/schemas/book.schema.ts`: ```ts import { z } from 'zod'; export const createBookSchema = z.object({ title: z.string().min(1), author: z.string().min(1), year: z.coerce.number().int().min(0).optional() }); export type CreateBookInput = z.infer; ``` ## 4. Write the controller Create `src/controllers/Book.controller.ts` (or start from `npm run gen book` and edit): ```ts import { Request, Response } from 'express'; import { AppError, ERROR_CODES, Routable, route, validate } from '../core'; import { BookModel } from '../models/Book.model'; import { createBookSchema } from '../schemas/book.schema'; export class BookController extends Routable { constructor() { super('/books'); } @route('get', '/') async index(req: Request): Promise { const books = await BookModel.find().sort({ createdAt: -1 }); return req.resHandler.ok({ items: books }); } @route('get', '/:id') async show(req: Request): Promise { const book = await BookModel.findById(req.params.id); if (!book) { throw new AppError(ERROR_CODES.NOT_FOUND, 'Book not found'); } return req.resHandler.ok(book); } @route('post', '/', [validate({ body: createBookSchema })]) async create(req: Request): Promise { const book = await BookModel.create(req.body); return req.resHandler.created(book); } @route('delete', '/:id') async destroy(req: Request): Promise { const deleted = await BookModel.findByIdAndDelete(req.params.id); if (!deleted) { throw new AppError(ERROR_CODES.NOT_FOUND, 'Book not found'); } return req.resHandler.noContent(); } } ``` Export it from `src/controllers/index.ts`: ```ts export * from './Book.controller'; ``` That's the entire wiring. Notice what you did **not** write: no router file, no try/catch (thrown errors β€” including Mongoose failures β€” land in the central error handler), no manual 400 handling (the `validate` middleware answers with a structured issue list). ## 5. Try it ```bash curl -X POST localhost:8000/books \ -H 'content-type: application/json' \ -d '{"title":"Dune","author":"Frank Herbert","year":1965}' # 201 {"_id":"...","title":"Dune",...} curl -X POST localhost:8000/books -H 'content-type: application/json' -d '{}' # 400 {"statusReason":"Validation Failed","issues":[ # {"part":"body","path":"title","message":"Required"}, # {"part":"body","path":"author","message":"Required"}],...} curl localhost:8000/books/000000000000000000000000 # 404 {"statusReason":"Not Found","message":"Book not found",...} ``` Every response carries an `x-call-id` header, and every log line carries the same id β€” grep your logs by it when debugging. ## 6. Test it Controllers are testable without a running server. For DB-backed tests, either point `MONGODB_URI` at a throwaway database, use [mongodb-memory-server](https://github.com/typegoose/mongodb-memory-server), or keep controller logic thin and unit-test your services instead. A pattern that needs no database β€” mount a throwaway controller through the factory's test seam (`extraRoutables`) β€” is demonstrated in `src/__tests__/app.test.ts`. ## 7. Keep growing - Business logic getting thick? Add `src/services/book.service.ts` and keep controllers as thin translation layers. - Need auth on the write paths? Change `@route` to `@protectedRoute` β€” see [Authentication](authentication.md). - Need query-string filtering? `validate({ query: listBooksSchema })` works the same way as `body`. # Database Chassis ships one database at a time β€” you pick it when scaffolding (`--db mongo|postgres|sqlite`, or a preset). The ORM comes with the choice: **Mongoose** for Mongo, **Drizzle** for Postgres and SQLite. Like every integration it stays off until its env var is set, so the app boots standalone in tests. ## Postgres / SQLite (Drizzle) Set the connection string: ```bash # .env DATABASE_URL=postgres://postgres:postgres@localhost:5432/chassis # postgres # or SQLITE_PATH=chassis.db # sqlite ``` `src/db//` holds the Drizzle client (`db`) and your `schema.ts`. Add tables there, or let the generator do it: ```bash npm run gen Widget ``` This appends a `widgets` table to `schema.ts` and generates a controller wired to Drizzle (`db.select().from(widgets)`, `insert().values().returning()`, …). Then create and run the migration: ```bash npx drizzle-kit generate --config src/db//drizzle.config.ts npx drizzle-kit migrate --config src/db//drizzle.config.ts ``` `/readyz` pings the database so orchestrators only route traffic once it's reachable. With Docker enabled, `docker compose up -d` starts a matching Postgres. ## MongoDB (Mongoose) ```bash # .env MONGODB_URI=mongodb://localhost:27017/chassis ``` Mongoose has no shared `db` handle β€” models register themselves on import. `npm run gen Widget` writes `src/db/mongo/widget.model.ts` and a controller using `WidgetModel`. No migration step. ## Switching or adding a database The three are parallel integrations. To swap after scaffolding, follow the [module contract](../modules.md): add `src/integrations/.ts`, an env var + `features.` flag in `src/config`, and a guarded `init` in `src/integrations/index.ts`. Prisma isn't shipped (its codegen fights the everything-installed template), but drops in the same way if you prefer it. # Authentication `@protectedRoute` marks an endpoint as requiring authentication. _Who_ verifies the request is pluggable: Chassis ships an Auth0 integration, and swapping in any other provider is one function call. Until a provider is configured, protected routes answer **501** with a message explaining exactly what to set β€” the route is never silently open. ## Option A β€” Auth0 (built in) ### 1. Create an API in Auth0 1. Auth0 Dashboard β†’ **Applications β†’ APIs β†’ Create API** 2. Pick any name; set the **Identifier** (this becomes your _audience_), e.g. `https://my-api.example.com` 3. Note your tenant domain, e.g. `my-tenant.eu.auth0.com` ### 2. Configure Chassis ```bash # .env AUTH0_DOMAIN=my-tenant.eu.auth0.com AUTH0_AUDIENCE=https://my-api.example.com ``` Restart β€” you should see `βœ… Auth0 authentication enabled`. Both variables must be set; with either missing the integration stays off. ### 3. Protect a route ```ts import { protectedRoute } from '../core'; @protectedRoute('post', '/', [validate({ body: createBookSchema })]) async create(req: Request): Promise { return req.resHandler.created(await BookModel.create(req.body)); } ``` Extra middlewares run _after_ auth, so they can trust the request. ### 4. Call it with a token Get a test token (Auth0 Dashboard β†’ your API β†’ **Test** tab, or client credentials): ```bash TOKEN=$(curl -s https://my-tenant.eu.auth0.com/oauth/token \ -H 'content-type: application/json' \ -d '{"client_id":"...","client_secret":"...","audience":"https://my-api.example.com","grant_type":"client_credentials"}' \ | jq -r .access_token) curl localhost:8000/books -H "authorization: Bearer $TOKEN" \ -X POST -H 'content-type: application/json' \ -d '{"title":"Dune","author":"Frank Herbert"}' ``` Missing/invalid tokens get a structured 401 from the central error handler. To read the token's claims in a handler, use the `auth` property that `express-oauth2-jwt-bearer` sets on the request (`req.auth?.payload.sub`, etc.). ## Option B β€” Local JWT (built in) Pick `--auth jwt` for self-issued Bearer tokens with no third party. Set a secret: ```bash # .env JWT_SECRET=a-long-random-string ``` Unlike the hosted providers, this one has no user directory behind it β€” so Chassis ships the missing half: `src/controllers/Auth.controller.ts` mints tokens that `src/integrations/jwt.ts` then verifies (HS256, via [jose](https://github.com/panva/jose)). ```bash curl localhost:8000/auth/register -H 'content-type: application/json' \ -d '{"email":"dev@example.com","password":"correct-horse-42"}' # β†’ 201 { "user": { "id": "1", "email": "dev@example.com" }, "token": "eyJ..." } curl localhost:8000/auth/login -H 'content-type: application/json' \ -d '{"email":"dev@example.com","password":"correct-horse-42"}' # β†’ 200 { "user": {...}, "token": "eyJ..." } ``` Passwords are hashed with **scrypt** from Node's `node:crypto` β€” a memory-hard KDF in the standard library, so there is no argon2/bcrypt dependency and no native build (`src/utils/password.ts`). ### Where users are stored `src/db/users.ts` resolves the store the same way integrations resolve themselves β€” by feature flag, at call time: | Configured database | Store | | ------------------- | ------------------------------------------------------------- | | SQLite / Postgres | Drizzle `users` table (`src/db//users.ts`) | | MongoDB | Mongoose `User` model (`src/db/mongo/users.ts`) | | _none_ | in-memory, seeded from `AUTH_DEV_EMAIL` / `AUTH_DEV_PASSWORD` | Pick a database and the store follows it β€” no code change. The in-memory fallback exists so `--auth jwt --db none` still boots and logs in during development; it is process-local and forgets everything on restart. Add a database before putting local JWT in front of real users. With Drizzle, generate the migration for the `users` table before first use: ```bash npx drizzle-kit generate --config src/db/sqlite/drizzle.config.ts ``` Tokens are access tokens only, valid for one hour β€” there is no refresh rotation. Clients re-authenticate when a token expires; add a refresh endpoint alongside `/auth/login` if you need sessions to outlive that without a password prompt. ## Option C β€” Clerk (built in) Pick `--auth clerk` to use [Clerk](https://clerk.com). Set the key (Clerk reads it from the env itself): ```bash # .env CLERK_SECRET_KEY=sk_test_... ``` `src/integrations/clerk.ts` registers Clerk's middleware; read the session in a handler with `getAuth(req)` from `@clerk/express`. ## Option D β€” any other provider `@protectedRoute` delegates to whatever middleware chain was registered with `setAuthProvider()` (see `src/core/auth.ts`). To use your own IdP, API keys, sessions β€” anything β€” register a chain at boot. Example: simple API-key auth. Create `src/integrations/apiKey.ts`: ```ts import { RequestHandler } from 'express'; import { setAuthProvider } from '../core/auth'; const checkApiKey: RequestHandler = (req, res, next) => { if (req.header('x-api-key') !== process.env.API_KEY) { return req.resHandler.wrongToken('Invalid API key'); } next(); }; export function initApiKeyAuth(): void { setAuthProvider([checkApiKey]); } ``` Then register it in `src/integrations/index.ts` behind a feature flag, exactly like the built-ins (the pattern is documented in [Modules](../modules.md)). Every `@protectedRoute` in the app now uses your provider β€” controllers don't change at all. ## From the browser If you scaffolded the Next.js front end (`--web`), the same three providers have a matching web half in `apps/web/auth/providers/`. All of them do one job β€” hand `apiFetch` a bearer token for the API β€” behind one function, `getAccessToken()`: | Provider | Web package | How the token is obtained | | -------- | --------------------- | ----------------------------------------------------------------------- | | `jwt` | _(none)_ | sign-in form β†’ `/api/session` β†’ API `/auth/login` β†’ **httpOnly cookie** | | `auth0` | `@auth0/nextjs-auth0` | hosted login β†’ SDK session β†’ `getAccessToken()` | | `clerk` | `@clerk/nextjs` | `` β†’ `auth().getToken()` | Two notes that cost people hours: - **Auth0 needs an audience.** `apps/web/auth/providers/auth0.shared.ts` passes `authorizationParameters.audience`. Without it Auth0 returns an ID token rather than an API access token, and every `@protectedRoute` call is rejected. It must match `AUTH0_AUDIENCE` on the API exactly. - **The local-JWT token never touches client JavaScript.** The form posts to a Next route handler, which calls the API server-side and returns the token only as an httpOnly cookie β€” so an injected script cannot read it. See [Web front end](web.md) for the full picture. ## How it works (and why 501) Route decorators run at class-definition time, before any integration has initialized. `requireAuth()` therefore resolves the provider **per request**, not at decoration time. If nothing registered a provider, the request is answered with `501 Not Implemented` and a hint β€” a loud, obvious failure instead of an accidentally-public endpoint. # Web front end `--web` adds a Next.js 15 App Router front end and turns the project into an **npm-workspaces monorepo**. Without the flag nothing changes: the project stays a single package, exactly as it is today. ```bash npm create chassis my-app -- --preset fullstack # Postgres + JWT + web npm create chassis my-app -- --web --auth clerk # Γ  la carte ``` ## Layout ``` my-app/ package.json # workspaces root: dev / build / verify / format apps/api/ # the Chassis backend β€” src, scripts, Dockerfile apps/web/ # the Next.js app docs/ README.md docker-compose.yml .github/ ``` `npm run dev` at the root starts both (API on `:8000`, web on `:3000`). `npm run verify` runs each workspace's own verify. `npm run gen ` still scaffolds a resource β€” it forwards to `apps/api`. Nothing forces you into npm workspaces beyond the root `package.json`: there is no monorepo tool, no build graph, no plugin versions to keep in step. Two apps do not need a task orchestrator; if the repo grows to the point where one earns its keep, adding it later is a `package.json` edit. ## Talking to the API `apps/web/lib/api.ts` is the whole client: ```ts const status = await apiFetch('/status'); ``` It runs **server-side only** β€” it reads the session to attach `Authorization: Bearer `, and a token must never be exposed to the browser. Call it from server components and route handlers. Point `API_URL` at the backend (`.env.local`, defaults to `http://localhost:8000`). ## Swapping auth providers Every provider lives in `apps/web/auth/providers/` and exports the same five things. The app never names one directly β€” it imports from `auth/active.ts`, which is a single re-export line: ```ts // apps/web/auth/active.ts export * from './providers/jwt'; ``` That line (and its twin in `active-middleware.ts`, kept separate so the middleware bundle stays free of React and `next/headers`) is what `create-chassis` rewrites for `--auth`. Changing provider afterwards means editing it, installing the new SDK, and swapping the env vars. The generated project keeps only the provider you chose. In the Chassis repo itself all four coexist, and `apps/web/auth/providers/conformance.ts` typechecks each one against the `AuthModule` contract β€” so a provider that drifts out of shape fails CI here rather than in someone's generated project. That file is template-only and never ships. ## Pages | Route | What it demonstrates | | ---------- | ---------------------------------------------------------------- | | `/` | server component calling the API, with the token if there is one | | `/sign-in` | the active provider's `SignInPanel` | | `/account` | protected: redirects when signed out, then makes an authed call | `/account` is guarded twice on purpose β€” middleware redirects the obvious cases, and the page re-checks. Middleware alone is never the guard. ## Docker `docker-compose.yml` builds the API from `apps/api`. The Dockerfile there uses `npm install` rather than `npm ci`, because the workspace lockfile lives at the repo root and is not part of that build context. The web app has no compose service β€” deploy it wherever you deploy Next. # MCP server There are two, and they do opposite things: | | What it exposes | | -------------------------------------------------------------- | -------------------------------------------------------- | | **`--mcp` module** (this page) | _your_ API, as tools an agent can call | | **[`chassis-mcp`](https://www.npmjs.com/package/chassis-mcp)** | Chassis itself β€” an agent calls it to _create_ a project | If you want an agent to scaffold Chassis projects for you, that's the second one; point your client at `npx -y chassis-mcp` and it gains `list_chassis_options`, `create_chassis_project` and `chassis_conventions`. The rest of this page is about the first. --- The MCP module (`--mcp`) exposes your API to AI agents as [Model Context Protocol](https://modelcontextprotocol.io) tools. It runs as a **separate stdio process** β€” the way agent clients (Claude Desktop, etc.) launch tool servers β€” not mounted in the HTTP app. That keeps the ESM-only MCP SDK out of the compiled `dist` build; it runs via `tsx`. ## Run it ```bash npm run mcp # starts the stdio server ``` Point your agent client at that command. `MCP_API_URL` (default `http://localhost:8000`) tells the server where the running API is. ## Add tools Tools live in `src/mcp/tools.ts`. The example proxies the API's health probe; add your own with `server.registerTool`: ```ts server.registerTool( 'list_widgets', { description: 'List all widgets', inputSchema: {} }, async () => { const res = await fetch(`${config.mcp.apiUrl}/widgets`); return { content: [{ type: 'text', text: await res.text() }] }; } ); ``` Give a tool inputs with a Zod raw shape as its `inputSchema` (`{ id: z.string() }`), and the handler receives typed args. Tools that call your own endpoints keep a single source of truth (the HTTP API); tools can equally call service functions directly. # Payments (x402) The x402 module (`--x402`) gates routes behind an HTTP 402 payment using the [x402 protocol](https://www.x402.org) β€” pay-per-request in stablecoins, settled by a facilitator. It mirrors auth: a `@paidRoute` decorator plus a pluggable gate, so payment verification never lives in your controllers. ## Configure ```bash # .env X402_PAY_TO=0xYourWalletAddress X402_NETWORK=base-sepolia # testnet default; use `base` for mainnet ``` With `X402_PAY_TO` set you'll see `βœ… x402 payments enabled` at boot. Unset, a `@paidRoute` answers `501` with a hint β€” never accidentally free. ## Gate a route ```ts import { paidRoute } from '../core'; @paidRoute('get', '/report', '$0.01') async report(req: Request): Promise { return req.resHandler.ok({ report: buildReport() }); } ``` A caller without a valid payment gets `402 Payment Required` with the payment details; the x402 client library on their side handles the settlement and retries. The price string (`'$0.01'`) is USDC. ## How it works `@paidRoute` uses `requirePayment(price)`, which resolves the registered gate **per request** (see `src/core/payments.ts`) β€” so decorators can run before the integration boots. The x402 integration registers a gate backed by `x402-express`'s `paymentMiddleware`, keyed to each route's path and price. Swap in any other provider by calling `setPaymentGate()` from your own integration; `@paidRoute` doesn't care who settles the payment. # Deployment ## Build once, run anywhere ```bash npm run build # TypeScript β†’ dist/ (tests excluded) npm start # node dist/server.js ``` The compiled output has no build-time dependencies β€” a production install (`npm ci --omit=dev`) plus `dist/` is a complete deployment. ## Docker The included `Dockerfile` is production-shaped out of the box: - **Multi-stage** β€” dev dependencies never reach the final image - **Non-root** β€” runs as the `node` user - **`npm ci`** β€” reproducible installs from the lockfile ```bash docker build -t my-api . docker run -p 8000:8000 --env-file .env my-api ``` ### docker-compose (local prod-like stack) ```bash docker compose up --build ``` Starts the API plus MongoDB (if you kept the Mongo module), wired together β€” the compose file sets `MONGODB_URI` to the internal hostname. ## Health probes Chassis exposes the two endpoints orchestrators expect: | Endpoint | Meaning | Use as | | -------------- | -------------------------------------------------------------------- | --------------- | | `GET /healthz` | Process is up | Liveness probe | | `GET /readyz` | Enabled integrations are ready (e.g. Mongo connected); 503 otherwise | Readiness probe | Kubernetes example: ```yaml livenessProbe: httpGet: { path: /healthz, port: 8000 } readinessProbe: httpGet: { path: /readyz, port: 8000 } ``` ## Graceful shutdown On `SIGTERM`/`SIGINT` the server stops accepting connections, drains in-flight requests, disconnects integrations, then exits (with a 10s force-exit failsafe). This is exactly what rolling deploys on Kubernetes, ECS, Fly, Railway, Render, etc. need β€” no special handling required on your side. ## PaaS notes Any platform that runs a Node server or a Dockerfile works: - **Build command:** `npm run build` Β· **Start command:** `npm start` (or just point the platform at the Dockerfile) - Set `PORT` if the platform injects its own (Chassis reads it) - Set `NODE_ENV=production` ## Production checklist - [ ] `NODE_ENV=production` β€” enables JSON logs, hides stack traces from responses - [ ] `CORS_ORIGINS=https://your-frontend.com` β€” unset means _allow all_, which you want in dev but not in prod - [ ] Secrets (`MONGODB_URI`, `SENTRY_DSN`, …) come from the platform's secret store, not a committed file β€” `.env` is gitignored, keep it that way - [ ] `SENTRY_DSN` set if you want error reporting (recommended) - [ ] Point probes at `/healthz` and `/readyz` - [ ] CI is green (`npm run verify` β€” the included GitHub Actions workflow runs it on Node 20 and 22) # Architecture Chassis is deliberately small: the "framework" is ~7 files in `src/core` that you can read in one sitting. This page explains how a request flows through it and why the pieces are shaped the way they are. ## Request lifecycle ``` request β”‚ β”œβ”€ helmet, cors security headers, CORS policy β”œβ”€ callIdMiddleware req.callId = x-call-id header or new UUID β”œβ”€ ResponseHandler.middleware req.resHandler = per-request response helper β”œβ”€ express.json() body parsing (after resHandler, so parse β”‚ errors still get structured responses) β”œβ”€ requestLogger dev only: method/path/status/duration β”‚ β”œβ”€ controller route your @route handler β”‚ └─ throws? Express 5 forwards rejected promises automatically β”‚ β”œβ”€ 404 handler structured JSON for unmatched routes └─ central error handler AppError β†’ mapped status 401/403 β†’ wrongToken 400 β†’ badRequest anything else β†’ 500 (+ Sentry if enabled) ``` ## The decorator flow 1. **Class definition time** β€” `@route('get', '/:id')` runs and pushes a `RouteDefinition` (method, path, handler name, middlewares) onto a symbol-keyed array on the controller's prototype. No router exists yet. 2. **Boot time** β€” `createApp()` iterates every class exported from `src/controllers`, instantiates it, and calls `registerToRouter(app)`. That builds a fresh `express.Router`, binds each handler to the instance, and mounts it at the controller's base path. Because decorators only record metadata, ordering problems disappear: `@protectedRoute` can be evaluated long before the auth integration initializes β€” `requireAuth()` resolves the provider per request. ## Errors Two intentional paths: - **Expected failures** β€” return them: `req.resHandler.notFound('...')`, or throw `new AppError(ERROR_CODES.CONFLICT, '...')` from anywhere (controller, service, model hook). The central handler maps it. - **Unexpected failures** β€” just let them throw. Express 5 catches rejected promises; the central handler logs the stack (with `callId`), reports to Sentry when enabled, and returns a sanitized 500 (stack traces are only included outside production). ## Why an app factory? `createApp()` performs no I/O β€” integrations boot separately in `server.ts`. Tests build the app synchronously and hit it with supertest; no port, no database, no mocks of the framework itself. The `extraRoutables` option lets tests (or plugins) mount throwaway controllers to exercise any code path β€” see `src/__tests__/app.test.ts`. ## Configuration `src/config` is the only file that touches `process.env`. The zod schema validates at import time and the process exits with a readable message on bad config. Feature flags (`config.features.*`) are derived from which variables are present β€” that's the entire opt-in mechanism. # Integrations & modules Every integration in Chassis follows the same contract, which is what makes the template safe to strip down (the `create-chassis` CLI does exactly that) and easy to extend. ## The contract An integration is: 1. **One file** in `src/integrations/.ts` exporting `init()` (plus optional `close()` / readiness helpers). 2. **One feature flag** in `src/config` derived from its env vars: `features. = Boolean(env.SOME_VAR)`. 3. **Single-line hooks** where it touches shared code β€” registration in `src/integrations/index.ts`, optional lines in the health controller, error handler, `.env.example`, and `docker-compose.yml`. Nothing else in the codebase may import an integration directly (the health controller's readiness check is the one sanctioned exception). ## Built-in integrations | Module | Env vars | Hooks | | ---------- | --------------------------------- | ---------------------------------------------------------------------- | | `mongo` | `MONGODB_URI` | connect at boot, `/readyz` check, graceful disconnect, compose service | | `postgres` | `DATABASE_URL` | Drizzle client, `/readyz` ping, graceful close, compose service | | `sqlite` | `SQLITE_PATH` | Drizzle + better-sqlite3, `/readyz` ping, graceful close | | `auth0` | `AUTH0_DOMAIN`, `AUTH0_AUDIENCE` | registers the auth provider used by `@protectedRoute` | | `jwt` | `JWT_SECRET` | registers a jose Bearer-token provider for `@protectedRoute` | | `clerk` | `CLERK_SECRET_KEY` | registers Clerk's middleware as the `@protectedRoute` provider | | `sentry` | `SENTRY_DSN` | `Sentry.init` at boot, `captureException` in the error handler | | `x402` | `X402_PAY_TO` | registers the payment gate used by `@paidRoute` | | `mcp` | _(none β€” separate stdio process)_ | `npm run mcp` server exposing the API as agent tools | | `web` | _(none β€” a separate app)_ | Next.js front end; restructures the project into a workspaces monorepo | ## Choice groups Two concerns are **pick-exactly-one** groups rather than independent toggles, because their options are mutually exclusive: - **Database** β€” `none` / `mongo` / `postgres` / `sqlite`. The ORM follows the choice (Mongoose or Drizzle). See [Database](guides/database.md). - **Auth** β€” `none` / `auth0` / `jwt` / `clerk`, all sharing the `setAuthProvider()` seam. See [Authentication](guides/authentication.md). Each variant also has a **web half** (`web/auth/providers/`) behind the equivalent front-end seam β€” one re-export line in `web/auth/active.ts`. Local JWT additionally ships the piece the hosted providers don't need: a register/login controller and a user store that follows the database choice. See [Web front end](guides/web.md). Mechanically a group variant is just a module in the `chassis:` namespace: choosing Postgres declines `mongo` and `sqlite`, which prune exactly like a declined toggle. The template ships every variant installed together; the CLI keeps only the one you pick. `@protectedRoute` (auth) and `@paidRoute` (x402) live in `src/core` and are always present β€” with no provider configured they answer `501`, never open. ## The `chassis:` markers Lines that exist only for a specific integration carry a trailing `// chassis:` (or `# chassis:`) marker: ```ts import { initMongo, closeMongo } from './mongo'; // chassis:mongo ``` The `create-chassis` CLI uses these markers to prune declined modules: it deletes the module's file, drops every marked line, and removes the dependency from `package.json` β€” leaving a project that compiles and tests green as if the module never existed. **Rule of thumb:** keep every marked construct on a single line. ## Adding your own integration 1. Create `src/integrations/redis.ts` with `initRedis()` / `closeRedis()`. 2. Add `REDIS_URL` to the schema in `src/config` and `features.redis: Boolean(env.REDIS_URL)`. 3. Register it in `src/integrations/index.ts`: `if (config.features.redis) await initRedis();` 4. Optionally add a `/readyz` check and an `.env.example` entry. Swap rather than add: to replace Auth0 with any other IdP, call `setAuthProvider([...yourMiddleware])` from your own integration β€” `@protectedRoute` doesn't know or care who verifies the token. # CLI & scripts reference ## `create-chassis` ```bash npm create chassis my-api # interactive β€” pick a preset npm create chassis my-api -- --preset lite # SQLite + JWT, no infrastructure npm create chassis my-api -- --yes # Recommended API preset, no prompts npm create chassis my-api -- --bare # nothing β€” standalone build npm create chassis my-api -- --preset fullstack # + Next.js front end npm create chassis my-api -- --db postgres --auth jwt --mcp # Γ  la carte ``` The interactive flow asks for **one preset**, then (only if you pick `Custom`) walks you through each choice. Every choice is also a flag, so nothing is interactive-only. Run `npm create chassis -- --help` for the generated list of presets, choices, and toggles. ### Presets (`--preset `) | Preset | Stack | | ----------- | ----------------------------------------------------- | | `api` | Postgres + JWT + Sentry + Docker (the default) | | `fullstack` | `api` **+ a Next.js front end** (workspaces monorepo) | | `lite` | SQLite + JWT β€” zero external infrastructure | | `minimal` | No database, no auth β€” standalone | ### Choices β€” pick exactly one of each | Group | Values | | --------------- | ---------------------------------------- | | `--db ` | `none` Β· `mongo` Β· `postgres` Β· `sqlite` | | `--auth ` | `none` Β· `auth0` Β· `jwt` Β· `clerk` | Choosing a database brings its ORM: `mongo` β†’ Mongoose, `postgres`/`sqlite` β†’ Drizzle. ### Toggles β€” independent on/off | Flag | Module | | ---------- | --------------------------------------------------------- | | `--sentry` | Sentry error reporting | | `--mcp` | MCP server exposing the API as agent tools | | `--x402` | x402 payment-gating via `@paidRoute` | | `--web` | Next.js front end β€” see [Web front end](../guides/web.md) | | `--docker` | Dockerfile + docker-compose | ### Other flags | Flag | Effect | | -------------- | ------------------------------------------------ | | `--yes`, `-y` | No prompts; use the `api` preset (or `--preset`) | | `--bare` | No prompts; the `minimal` preset, no Docker | | `--no-install` | Skip `npm install` | | `--no-git` | Skip `git init` | | _(no TTY)_ | Behaves like `--yes` β€” safe in CI | ### What it does, in order 1. Downloads the template (GitHub tarball; requires `tar` on PATH) 2. Resolves your selection (preset β†’ prompts β†’ flags, flags win) and shows a confirmation summary 3. **Prunes everything not chosen** β€” deletes each declined module's files and directories, strips every line carrying its `chassis:` marker, and removes its dependencies, devDependencies, and npm scripts from `package.json`. A dependency shared by a kept module (e.g. `drizzle-orm`, used by both Postgres and SQLite) is never removed. 4. Renames the package, resets the version, git-inits (optional), installs (optional) 5. **Makes the project the user's own** β€” rewrites the `LICENSE` copyright and removes the maintainer-only `docs/maintainers.md` The generated project passes `npm run verify` and `npm run build`, and its `package.json` carries only the dependencies the chosen modules need β€” no dead weight. For developing the CLI itself, scaffold from a local checkout instead of the network: ```bash CHASSIS_TEMPLATE=/path/to/template node cli/index.mjs /tmp/test-app --bare ``` ## `npm run gen ` Scaffolds a resource: ```bash npm run gen user # β†’ UserController at /users npm run gen BlogPost # β†’ BlogPostController at /blog-posts ``` Creates `src/controllers/.controller.ts`, a smoke test in `src/__tests__/`, and appends the export to `src/controllers/index.ts`. It is **database-aware**: it detects the installed DB and generates matching persistence code β€” - **Postgres / SQLite** β€” a controller wired to Drizzle, plus a table appended to `src/db//schema.ts`. Generate the migration with `npx drizzle-kit generate --config src/db//drizzle.config.ts`. - **Mongo** β€” a Mongoose model in `src/db/mongo/` and a controller using it. - **No database** β€” an in-memory skeleton. Refuses to overwrite an existing controller. ## npm scripts | Script | What it does | | -------------------- | ------------------------------------------------------------- | | `npm run dev` | Dev server with hot reload (`tsx watch`) | | `npm run build` | Compile to `dist/` (tests + `src/mcp` excluded) | | `npm start` | Run the compiled server (`node dist/server.js`) | | `npm test` | Run the vitest suite once | | `npm run test:watch` | Vitest in watch mode | | `npm run typecheck` | `tsc --noEmit` | | `npm run lint` | ESLint over the repo | | `npm run format` | Prettier `--write` | | `npm run verify` | typecheck + lint + test β€” what CI and the pre-commit hook run | | `npm run gen ` | Generate a controller (above) | | `npm run mcp` | Start the MCP server over stdio (only with the MCP module) | With the Next.js front end (`--web`) the project is a workspaces monorepo, so these live in `apps/api` and the root gains orchestration instead: `npm run dev` starts both apps, `npm run build` and `npm run verify` fan out to every workspace, and `npm run gen ` forwards to `apps/api`. See [Web front end](../guides/web.md). ## Git hooks Husky runs `npm run verify` on pre-commit. Bypass in an emergency with `git commit --no-verify` (CI will still catch you). # Configuration reference All configuration is environment variables, declared and validated with zod in `src/config/index.ts` β€” the **only** file that reads `process.env`. On invalid config the process prints each problem and exits before binding a port. `.env` files are loaded automatically in development (via `dotenv`); in production, inject real environment variables instead. ## Variables | Variable | Type / default | Purpose | | ------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `NODE_ENV` | `development` \| `test` \| `production` β€” default `development` | Log format (pretty vs JSON), stack-trace exposure, dev request logging | | `PORT` | positive integer β€” default `8000` | HTTP port | | `CORS_ORIGINS` | comma-separated list β€” default _(empty = allow all)_ | Allowed CORS origins, e.g. `https://app.example.com,https://admin.example.com` | | `MONGODB_URI` | string β€” optional | **Enables the Mongo module.** Standard connection string | | `DATABASE_URL` | string β€” optional | **Enables the Postgres module** (Drizzle). Postgres connection string | | `SQLITE_PATH` | string β€” optional | **Enables the SQLite module** (Drizzle). File path, or `:memory:` | | `AUTH0_DOMAIN` | string β€” optional | **Enables Auth0** (with `AUTH0_AUDIENCE`). Tenant domain, e.g. `my-tenant.eu.auth0.com` | | `AUTH0_AUDIENCE` | string β€” optional | **Enables Auth0** (with `AUTH0_DOMAIN`). The API identifier from the Auth0 dashboard | | `JWT_SECRET` | string β€” optional | **Enables local JWT auth** (jose). Signs `/auth/login` tokens and verifies Bearer tokens | | `AUTH_DEV_EMAIL` | string β€” optional | Seeds one account in the in-memory user store (only used when no database is configured) | | `AUTH_DEV_PASSWORD` | string β€” optional | Password for `AUTH_DEV_EMAIL`. Development only β€” the store is not persistent | | `CLERK_SECRET_KEY` | string β€” optional | **Enables Clerk auth.** Clerk secret key | | `SENTRY_DSN` | string β€” optional | **Enables Sentry** error reporting | | `X402_PAY_TO` | string β€” optional | **Enables x402 payments** for `@paidRoute`. Wallet address receiving payments | | `X402_NETWORK` | string β€” default `base-sepolia` | x402 network (`base-sepolia` testnet, `base` mainnet) | | `MCP_API_URL` | string β€” default `http://localhost:8000` | API base URL the `npm run mcp` server calls into | ## Feature flags `config.features` is derived from which variables are present β€” this is the entire opt-in mechanism: ```ts features: { mongo: Boolean(env.MONGODB_URI), postgres: Boolean(env.DATABASE_URL), sqlite: Boolean(env.SQLITE_PATH), auth0: Boolean(env.AUTH0_DOMAIN && env.AUTH0_AUDIENCE), jwt: Boolean(env.JWT_SECRET), clerk: Boolean(env.CLERK_SECRET_KEY), sentry: Boolean(env.SENTRY_DSN), x402: Boolean(env.X402_PAY_TO) } ``` `src/integrations/index.ts` initializes each integration only when its flag is true; `/readyz` checks only enabled integrations. ## Adding a variable 1. Add it to the zod schema in `src/config/index.ts`: ```ts REDIS_URL: z.string().optional(); ``` 2. Expose it (and a feature flag if it gates an integration) on the exported `config` object. 3. Document it in `.env.example`. Never read `process.env` elsewhere β€” the single-schema rule is what makes misconfiguration fail loudly at boot instead of quietly at 3am. # Core API reference Everything below is exported from `src/core` (`import { ... } from '../core'`). ## `Routable` Base class for controllers. ```ts class UserController extends Routable { constructor() { super('/users'); // base path (default '/') } } ``` - `registerToRouter(app, basePath?)` β€” builds an `express.Router` from the class's decorated methods and mounts it. Called for you by the app factory for every class exported from `src/controllers/index.ts`. ## `@route(method, path, middlewares?)` Declares an endpoint on a `Routable` method. ```ts @route('get', '/:id') async show(req: Request): Promise { ... } @route('post', '/', [validate({ body: schema }), rateLimiter]) async create(req: Request): Promise { ... } ``` - `method`: `'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head'` - `path`: Express path, relative to the controller's base path - `middlewares`: optional `RequestHandler[]`, run before the handler - Handlers may throw β€” Express 5 forwards rejected promises to the central error handler. ## `@protectedRoute(method, path, middlewares?)` Like `@route`, but the configured auth provider runs first; your `middlewares` run after it. Answers **501** when no provider is configured. See [Authentication](../guides/authentication.md). ## `req.resHandler` (`ResponseHandler`) Attached to every request. Every method logs structured metadata (status, method, endpoint, `callId`) and returns the Express `Response`. **Success:** | Method | Status | | ---------------- | ------ | | `ok(data?)` | 200 | | `created(data?)` | 201 | | `noContent()` | 204 | **Failure** β€” all respond with a consistent envelope `{ statusCode, statusReason, errorId, callId, ...extra }`: | Method | Status | | --------------------------- | ------------------------------------------------------- | | `badRequest(message?)` | 400 | | `validation(issues)` | 400 | | `wrongToken(message?)` | 401 | | `forbidden(message?)` | 403 | | `notFound(message?)` | 404 | | `conflict(message?)` | 409 | | `error(err)` | 500 β€” sanitized in production, stack included otherwise | | `notImplemented(message?)` | 501 | | `serviceUnavailable(data?)` | 503 | | `manualError(code, data?)` | any `ErrorCode` | ## `AppError` and `ERROR_CODES` Throw from anywhere (controller, service, model hook); the central error handler maps it: ```ts throw new AppError(ERROR_CODES.CONFLICT, 'Email already registered'); throw new AppError(ERROR_CODES.FORBIDDEN, 'Owners only', { userId }); ``` - `new AppError(code, message?, details?)` β€” `details` is included in the response body when provided. - `ERROR_CODES` entries carry `{ id, statusCode, statusReason }`; `id` is a stable application-level code clients can switch on. Add your own domain codes to `src/core/errors.ts`. ## `validate(schemas)` Zod validation middleware for `body`, `query`, and `params`: ```ts @route('post', '/', [validate({ body: createUserSchema, query: pageSchema })]) ``` - On failure: 400 with `issues: [{ part, path, message }]`. - On success: the parsed/transformed result **replaces `req.body`** (query/params are read-only getters in Express 5 and are validated in place). ## `setAuthProvider(middlewares)` / `requireAuth()` The pluggable-auth seam. Integrations call `setAuthProvider([...])` at boot; `@protectedRoute` runs the registered chain per request via `requireAuth()`. See `src/core/auth.ts`. ## Request extensions Declared in `src/types/express.d.ts`, available on every `Request`: | Property | Type | Source | | ---------------- | ----------------- | ---------------------------------------------------------------------------------- | | `req.callId` | `string` | `x-call-id` header or a generated UUID; echoed in the response header and all logs | | `req.resHandler` | `ResponseHandler` | attached by middleware before any route runs | ## `createApp(options?)` The pure app factory (`src/app.ts`). No I/O β€” integrations boot separately in `server.ts`. ```ts const app = createApp({ extraRoutables: [new FakeController()] }); ``` - `extraRoutables` β€” extra `Routable` instances mounted after the auto-registered controllers and before the error handlers. Built for tests; see `src/__tests__/app.test.ts`. # Agent guide Instructions for AI coding agents (Claude Code, Cursor, Copilot, Codex, …) working in a Chassis project. Follow these conventions and the code you produce will match what a human maintainer would write β€” that's the whole point of this file. **Golden rule:** finish every change by running `npm run verify` and only stop when it passes. It runs typecheck + lint + tests β€” the same gate CI uses. Green means done; anything else means keep going. ## The 60-second mental model - **Endpoints are controller methods.** A controller is a class extending `Routable`; each method is decorated with `@route(method, path)`. - **Exported controllers auto-mount.** Everything exported from `src/controllers/index.ts` is registered at boot. There are no router files to edit. - **Responses go through `req.resHandler`** β€” `ok`, `created`, `notFound`, `validation`, … Never call `res.status().json()` directly. - **Errors are thrown, not handled.** `throw new AppError(ERROR_CODES.X, msg)` from anywhere; a central handler maps it. Do not write try/catch in controllers β€” Express 5 forwards rejected promises for you. - **Config is one validated file.** `src/config` is the only place that reads `process.env`. ## Do this - **Add an endpoint:** run `npm run gen ` to scaffold a controller + test, then edit the generated methods. It's DB-aware β€” it wires the controller to the installed ORM (Drizzle or Mongoose) and, for Drizzle, appends a table to `src/db//schema.ts`. (Or copy an existing `*.controller.ts` and export it from `src/controllers/index.ts`.) - **Protect or charge for a route** with `@protectedRoute(...)` (auth) or `@paidRoute(method, path, price)` (x402 payments). Both answer `501` until their module is configured β€” never silently open or free. - **Validate input** with `validate({ body|query|params: zodSchema })` in the route's middleware array. Define the schema next to the controller or in `src/schemas`. - **Return via `req.resHandler`** and match the semantic helper to the outcome (`created` for 201, `noContent` for 204, `notFound`, `conflict`, …). See `docs/reference/core-api.md` for the full list. - **Signal errors** by throwing `AppError` with the right `ERROR_CODES` entry. Add new domain codes to `src/core/errors.ts` rather than inventing ad-hoc status numbers. - **Add a config value** by extending the zod schema in `src/config`, exposing it on the `config` object, and documenting it in `.env.example`. - **Keep controllers thin.** Push real logic into `src/services/*` and data access into `src/models/*`. Controllers translate HTTP ↔ domain. ## Don't do this - ❌ Don't call `res.status(...).json(...)` β€” use `req.resHandler`. - ❌ Don't wrap controller bodies in try/catch β€” throw `AppError` instead. - ❌ Don't read `process.env` outside `src/config`. - ❌ Don't hand-write route registration or `express.Router()` β€” the decorators and the app factory do it. - ❌ Don't edit `src/core/**` to build a feature. That's the framework; features live in controllers, services, models, schemas, integrations. - ❌ Don't add a dependency when a listed one already covers it (zod for validation, winston for logging, your installed ORM β€” Drizzle or Mongoose β€” for data access). - ❌ Don't disable lint rules or loosen `tsconfig` to make `verify` pass β€” fix the actual issue. ## Where things live | Need to… | Go to | | ---------------------------- | ------------------------------------------------- | | Add/change an endpoint | `src/controllers/*.controller.ts` | | Validate request input | `validate({...})` + a zod schema | | Business logic | `src/services/*` (create if absent) | | Database models | `src/models/*` (create if absent) | | Add an env var / config | `src/config/index.ts` + `.env.example` | | Add an error type | `src/core/errors.ts` (`ERROR_CODES`) | | Add an optional integration | `src/integrations/*` β€” see `docs/modules.md` | | Framework internals (rarely) | `src/core/*` | | Front-end page or API call | `web/app/*`, `web/lib/api.ts` (if present) | | Front-end auth | `web/auth/providers/*` β€” see `docs/guides/web.md` | ## Reference Deeper docs live in `docs/` (`docs/README.md` is the index). The most useful for agents: - `docs/reference/core-api.md` β€” every decorator, `resHandler` method, `AppError`, `validate()` - `docs/guides/building-an-api.md` β€” a full worked CRUD resource - `docs/architecture.md` β€” request lifecycle and why the pieces fit ## Definition of done 1. Feature works (add or update a test proving it). 2. `npm run verify` passes β€” typecheck, lint, and tests all green. When a web app is present, verify covers it too; adding an auth provider there means adding it to `web/auth/providers/conformance.ts`. 3. `npm run build` passes. In a monorepo that also runs `next build`, which is the only thing that exercises front-end middleware and bundling. 4. No new `process.env` reads, no `res.status().json()`, no try/catch in controllers, no edits to `src/core` for feature work. # Maintainers guide The step-by-step path from this repo to a public, reusable project β€” and the routine for keeping it healthy afterwards. ## Going live ### 1. Rename the repository (once) Rename to `chassis` (or `chassis-api`) in GitHub β†’ Settings. GitHub redirects old clone URLs automatically. Then update the references: - [ ] `REPO` constant in `cli/index.mjs` (used for the template tarball) - [ ] `repository.url` in `package.json` and `cli/package.json` - [ ] Clone URLs in `README.md` and `docs/getting-started.md` ### 2. Flip the template switch (once) GitHub β†’ Settings β†’ General β†’ check **Template repository**. Visitors get a "Use this template" button that creates a clean copy without your git history. ### 3. Publish the CLI (once, then per release) ```bash cd cli # sanity check first β€” scaffold from the local template and verify it CHASSIS_TEMPLATE=.. node index.mjs /tmp/chassis-smoke --bare cd /tmp/chassis-smoke && npm install && npm run verify && cd - # and once with the front end, which exercises the monorepo restructure CHASSIS_TEMPLATE=.. node index.mjs /tmp/chassis-full --preset fullstack cd /tmp/chassis-full && npm install && npm run verify && cd - npm login npm publish # unscoped packages are public by default ``` Verify the flow end-to-end from a clean directory: ```bash npm create chassis@latest smoke-test -- --yes ``` ### 4. Enable Renovate (once) Install the [Renovate GitHub app](https://github.com/apps/renovate) on the repo β€” `renovate.json` is already configured (non-major updates are grouped into a single PR). This is the single most important step for a template: **templates rot silently**, and automated dependency PRs + green CI are what keep it trustworthy for strangers. Alternative: GitHub's own Dependabot (Settings β†’ Code security). ## The web app `web/` is a self-contained package with its own `package.json`, lockfile, tsconfig and eslint config β€” it is **not** an npm workspace of the template, because a project scaffolded without `--web` must stay a plain single package. Install it separately: ```bash npm install && npm ci --prefix web ``` Root `npm run verify` then delegates to it, and CI installs both. That delegation is what keeps `web/` from rotting: it is typechecked, linted and tested on every PR even though no generated project ever ships it in this shape. Its tests are deliberately narrow β€” `apiFetch`, the `/api/session` route handler and the local-JWT middleware. Those hold the parts that would fail silently and expensively: whether the bearer token is attached, and whether the session token stays in an httpOnly cookie instead of reaching client JavaScript. Rendering is left to `next build` in the scaffold suite. Two files there exist only for this repo and never reach a generated project β€” `web/auth/types.ts` and `web/auth/providers/conformance.ts`. The latter typechecks all four auth providers against the same contract, which is the only place that check can happen: a generated project keeps exactly one provider, so a provider that drifts out of shape would otherwise break only for whoever picked it. **Adding an auth provider means adding it there too.** ## The documentation site `site/` builds the published docs from the markdown in this repo β€” one self-contained `dist/index.html` with sidebar nav, client-side search and highlighted code, no server required. ```bash npm ci --prefix site npm run build --prefix site # then open site/dist/index.html npm run check --prefix site # build without writing (what CI runs) ``` It holds **no prose of its own**: every page is rendered from `docs/**`, `README.md` and `AGENTS.md`, so the site cannot drift from the repo. Adding a doc means adding it to `site/pages.mjs` β€” the build fails on any markdown file under `docs/` that no section lists, so a page can never be published without a way to reach it. Like `cli/`, `site/` is template-only: it is in the CLI's ignore list and never reaches a generated project, and a scaffold test asserts that. Its `marked` dependency therefore never lands in anyone's `package.json`. Pushing to `master` publishes it via `.github/workflows/docs.yml`. That needs **Settings β†’ Pages β†’ Source: GitHub Actions** enabled once. The stylesheet in `site/theme.mjs` duplicates the tokens from `web/app/globals.css` on purpose β€” the web app is a template that gets copied into user projects, so it must not import from this build. ## chassis-mcp `mcp-server/` is published separately as [`chassis-mcp`](https://www.npmjs.com/package/chassis-mcp): an MCP server that scaffolds Chassis projects, so an agent can create one without knowing the CLI's flags. ```bash npm ci --prefix mcp-server npm test --prefix mcp-server # drives it over stdio, like a real client ``` It **imports the option catalog from `create-chassis`** rather than restating it, so it cannot advertise a stack the CLI does not support β€” and its tests run against the CLI in this checkout via `CHASSIS_CLI`, so a change to `cli/modules.mjs` fails here rather than in someone's agent. Like `cli/` and `site/`, it is template-only and never reaches a generated project. Note the directory is `mcp-server`, not `mcp`: the CLI's ignore list matches by **basename**, so a top-level `mcp/` would also have excluded `src/mcp` and silently broken the `--mcp` module. Releasing it follows the same shape as the CLI β€” bump `mcp-server/package.json`, `npm publish` from that directory, tag `mcp-v`. ## Routine maintenance ### Weekly-ish - Merge Renovate PRs once CI is green. CI runs `npm run verify` on Node 20 and 22, which covers typecheck, lint, and the test suite. ### When changing the template - Keep the [module contract](modules.md) intact: anything specific to an optional integration stays on **single lines** tagged `// chassis:`, or the CLI's pruning breaks. - Two marker rules the test suite enforces, both learned the hard way: - The marker must be **last on the line**. A marker after an opening brace (`sqliteTable('users', { // chassis:jwt`) gets moved onto its own line by Prettier, and pruning then deletes the body but keeps the declaration. Put the construct in its own file and mark the import. - A module name must not appear as `chassis:` anywhere else in the template. Pruning drops any line _containing_ the string, so naming a module `routes` would delete `Symbol('chassis:routes')` from `src/core/routable.ts`. - After touching integrations or markers, run the scaffold suite: ```bash node --test cli/scaffold.test.mjs # catalog + structural SCAFFOLD_BUILD=1 node --test cli/scaffold.test.mjs # + install/verify/build ``` The fast layers take seconds and cover the catalog, the CLI's failure modes, and every prune permutation. The build layer is the only one that runs `next build`, so it is the only thing that catches a front-end bundling break in a generated project β€” run it before releasing the CLI. A pruned project must pass `verify` **and** `build` with zero warnings β€” that's the contract the CLI advertises. Coverage is the thing to watch, not depth. `--auth jwt --db none` shipped broken (an import left unused once every database line was pruned) purely because no build case ever compiled that combination; any layer would have caught it. When you add a module, add the combination that empties out something else. ### Releasing CLI changes 1. Bump `version` in `cli/package.json` (semver: new prompts/flags = minor, fixes = patch). 2. `cd cli && npm publish`. 3. Tag the repo (`git tag cli-vX.Y.Z && git push --tags`) so CLI versions map to template states. ### Upgrading major dependencies Express, Mongoose, ESLint, and the Sentry SDK occasionally ship breaking majors. For each: read the migration guide, upgrade in a branch, run `npm run verify` plus both CLI smoke tests (`--yes` and `--bare`), and check the boot log still shows a clean standalone start. ## Support surface Keep these in sync when the code changes β€” they're the public promise: | Artifact | Source of truth for | | --------------- | ------------------------------------------- | | `README.md` | First impression, quick start | | `docs/` | Everything else (index in `docs/README.md`) | | `.env.example` | Every supported variable | | `cli/README.md` | npm package page for `create-chassis` |