Chassis gives you NestJS-style controller ergonomics on plain Express 5 — in a handful of small files you can actually read. Pick a database, an auth provider and an optional Next.js front end; the scaffolder ships only what you chose.
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, Clerk, or built-in local sign-in), an optional Next.js front end, Sentry, an MCP server, and x402 payments — and the CLI ships only what you chose.
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 — the project, its
conventions and its docs index, in one fetch
llms-full.txt — every
documentation page, concatenated
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.
Pick-your-stack scaffolder — presets or à la carte: database + ORM (Mongo/Postgres/SQLite), auth (Auth0/Clerk/local), 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)
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.
Local sign-in ships in three variants — emailed link, the classic credential
form, or both. Run npm create chassis --help to see the --auth values, or
read Authentication. Whichever you pick, they
share one session layer.
code
POST /auth/magic/request {email, returnTo?} → 202, identical for every address
GET /auth/magic/:token → confirm page — consumes nothing
POST /auth/magic/redeem {token} → session + redirect
POST /auth/magic/code {email, code} → same, from the other device
POST /auth/refresh | /auth/logout | /auth/revoke-all
Four things worth knowing about the emailed-link flow:
GET never spends a token. Mail security scanners prefetch links, and a
single-use token burned by a scanner is how this feature usually breaks in
production. Redemption is a POST, on a click.
Every email carries a six-digit code too, so someone who asks on a laptop
and reads their mail on a phone can still finish on the laptop.
The request endpoint will not tell you who has an account — same body,
same timing, every address.
Refresh tokens rotate on every use, and replaying a spent one revokes the
whole session family. Sliding SESSION_IDLE, hard SESSION_ABSOLUTE cap.
Variable
Default
JWT_SECRET
(required)
SESSION_IDLE / SESSION_ABSOLUTE
30d / 90d
MAGIC_TOKEN_TTL / MAGIC_CODE_ATTEMPTS
15m / 5
MAGIC_LINK_BASE_URL
http://localhost:8000
SMTP_URL
unset → logs the email
Chassis binds no email or SMS provider — bind yours through setMailTransport()
or setSmsTransport(). Proving an address fires one hook, setOnVerified(),
and that is the whole extension surface: consent and onboarding are yours.
Read them at dvd90.github.io/chassis —
searchable, one page. The source lives in docs/ and the site
is generated from it, so the two can never disagree:
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:
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).
Controllers are testable without a running server. For DB-backed tests,
either point MONGODB_URI at a throwaway database, use
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.
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.
# .env
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chassis # postgres# or
SQLITE_PATH=chassis.db # sqlite
src/db/<engine>/ 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:
/readyz pings the database so orchestrators only route traffic once it's
reachable. With Docker enabled, docker compose up -d starts a matching
Postgres.
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.
The three are parallel integrations. To swap after scaffolding, follow the
module contract: add src/integrations/<db>.ts, an env var +
features.<db> 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.
@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.
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.).
Chassis owns the credentials itself. Which sign-in methods a project has was
decided when it was scaffolded; the guide for each one it kept sits alongside
this page — see the sidebar, or docs/guides/.
Whichever you chose, they all need a signing secret:
bash
# .env
JWT_SECRET=a-long-random-string
...and they all share one session layer: a short-lived access token plus a
rotating refresh token, with reuse detection and revoke-all. See
Sessions.
Unlike the hosted providers there is no third-party user directory, so Chassis
ships the missing half: src/db/users.ts resolves an identity store the same
way integrations resolve themselves, by feature flag, at call time.
Configured database
Store
SQLite / Postgres
Drizzle users table (src/db/<db>/users.ts)
MongoDB
Mongoose User model (src/db/mongo/users.ts)
none
in-memory, seeded from AUTH_DEV_EMAIL
Pick a database and the store follows it — no code change. The in-memory
fallback exists so --db none still boots and signs in during development; it
is process-local and forgets everything on restart. Add a database before
putting local auth in front of real users.
With Drizzle, generate the migration before first use:
@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.
Then register it in src/integrations/index.ts behind a feature flag,
exactly like the built-ins (the pattern is documented in
Modules). Every @protectedRoute in the app now uses
your provider — controllers don't change at all.
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
<SignIn/> → 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.
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.
scrypt, from Node's node:crypto (src/utils/password.ts). A memory-hard
KDF in the standard library, so there is no argon2 or bcrypt dependency and no
native build to fail on someone's machine. Stored as
scrypt$<salt-hex>$<key-hex>, compared with timingSafeEqual.
The minimum length is eight characters, enforced by the zod schema in
src/controllers/Password.controller.ts. Change it there and in the matching
minLength on the web form.
On the identity row, but reached only through src/db/passwords.ts — never
through the identity store itself. src/db/users.ts deals in who someone is;
this module deals in one way of proving it.
That separation is not decoration. It is what allows a project scaffolded
without this module to carry no password code, no password_hash column, and
no mention of the word anywhere in its tree — rather than a dead column and a
disabled route.
POST /auth/login answers 401 with one message for an unknown address, a
wrong password, and an identity that has no password at all — someone who only
ever signed in another way. One branch, one message; otherwise the endpoint
becomes a way to enumerate who has an account and how they signed up.
When a project keeps more than one, they share a single identity table and a
single session layer. An identity may have a password, may have been proven
some other way, or both; either route produces the same session. Someone who
never set a password simply has no hash stored, and /auth/login refuses them
without saying why.
Sign in with an emailed link — no secret to remember, nothing to reset.
Every email carries two credentials for the same sign-in:
a link, which is the normal path, and
a six-digit code, which is what makes the flow work across devices.
That second one is not a lesser fallback. Someone asks for a link on a laptop
and opens their mail on a phone; without a code, the laptop tab they are
waiting on can never finish. With one, they type six digits and carry on.
POST /auth/magic/request {email, returnTo?} → 202, identical every time
↓ (email arrives with link + code)
GET /auth/magic/:token → confirm page. Consumes nothing.
POST /auth/magic/redeem {token} → session, redirect to returnTo
or
POST /auth/magic/code {email, code} → same session, same outcome
GET and HEAD never spend a token. Corporate mail security scanners —
Outlook SafeLinks and its many equivalents — fetch every link in every message
before a human sees it. A single-use token consumed by a scanner is the single
most common way a magic-link implementation breaks in production, and it fails
in the worst way: it works for you and mysteriously never works for the
customer whose IT department bought a security product.
So the link only shows a page. Redemption happens on a user gesture, which
is a POST. That is the whole reason for the extra click.
With --web, MAGIC_LINK_BASE_URL can point at the Next.js app, which serves
its own confirmation page at /auth/magic/[token]. Without it, the API serves
a minimal self-contained one — no JavaScript required, since it is a form.
Wrong codes before everything for that address is voided
MAGIC_LINK_BASE_URL
http://localhost:8000
Origin the emailed link points at
MAGIC_RETURN_TO_ORIGINS
(unset)
Comma-separated origins allowed as absolute returnTo
MAGIC_FROM
no-reply@localhost
Sender address
SMTP_URL
(unset)
SMTP transport; unset logs the email instead
Rate limits are constants rather than variables — 3 requests per address and 20
per IP, each per 15 minutes, in separate buckets. One attacker enumerating many
addresses from one IP and another hammering a single address are different
attacks, and a single limit cannot catch both.
It will not tell you who has an account.POST /auth/magic/request
answers 202 with a byte-identical body for every address, and answers
before it looks anything up — so the reply cannot be timed either.
Latest wins. Asking for a new link voids every outstanding link and
code for that address, so a forwarded old email is useless.
Nothing is stored in the clear. The table is a credentials table: only
SHA-256 digests of the token and the code are written. The raw values exist
only in the email.
The code is compared in constant time, and capped at
MAGIC_CODE_ATTEMPTS wrong tries — after which the link dies too. Someone
guessing six digits does not get to keep the link that came with them.
returnTo cannot be turned into an open redirect. It is validated when
the link is issued, stored server-side, and validated again at redemption.
The default policy is same-origin paths only; absolute URLs need their origin
listed in MAGIC_RETURN_TO_ORIGINS. A value posted back by the browser is
never trusted — a redemption endpoint that trusts one is the classic hole.
That is the entire extension surface, deliberately. Marketing consent, double
opt-in state machines, welcome sequences and GDPR capture copy are product
concerns with product-specific legal requirements; Chassis holds no opinion and
no state about any of them. It tells you an address was proven, and gets out of
the way. A hook that throws is logged and ignored — a failing product
integration must not cost someone their sign-in.
Email goes out through the MailTransport seam, and the code can optionally go
out by SMS through SmsTransport. Chassis binds no provider for either. See
Transports.
docker compose up -d mailpit # SMTP on 1025, web inbox on 8025
SMTP_URL=smtp://localhost:1025 npm run dev
curl localhost:8000/auth/magic/request \
-H'content-type: application/json' \
-d'{"email":"dev@example.com","returnTo":"/account"}'# → 202 {"status":"sent","message":"If that address can sign in, a link is on its way."}
Open http://localhost:8025 and the message is there, link and code both.
With no SMTP_URL set, the whole email is written to the log instead — enough
to finish a sign-in from a terminal with nothing installed.
However a project signs people in, it shares one session layer. Signing in
returns two tokens:
Token
Lives
Used for
Access token
15 minutes
Authorization: Bearer on the API
Refresh token
SESSION_IDLE, sliding
Getting the next access token
code
POST /auth/refresh → new access token, and a NEW refresh token
POST /auth/logout → revoke this session. Idempotent.
POST /auth/revoke-all → revoke every session for this identity
Browsers get the refresh token as an httpOnly, SameSite=Lax cookie scoped to
/auth. API clients get it in the response body and send it back the same way.
Both paths work; neither is privileged.
The refresh token changes on every use. The one you presented is marked
spent, and a fresh one comes back.
That matters because of what happens when a spent token shows up again. Either
a client replayed it, or somebody stole it — and from the server's position
those are indistinguishable. So it assumes the worse and revokes the entire
family: every token descended from that sign-in, including the legitimate
one the real user is holding. They sign in again; the thief gets nothing.
This is the only mechanism that catches a stolen refresh token at all. Without
rotation, a copied token works quietly until it expires.
code
sign in ──► A
└─ refresh(A) ──► B A marked spent
└─ refresh(B) ──► C
refresh(A) again ──► ✗ 401, family {A,B,C} revoked
Come back inside SESSION_IDLE and the session refreshes silently, forever —
until SESSION_ABSOLUTE, which nothing resets. At 90 days everyone signs in
again, however active they were. That cap is the point: it puts a ceiling on
how long a compromise nobody noticed can last.
Both are evaluated against an injected clock (src/utils/clock.ts), never
new Date(). That is what lets src/services/session.test.ts prove the
91-day behaviour in microseconds instead of waiting a quarter.
refresh_tokens is a credentials table: only the SHA-256 of each token is
stored, so a database dump cannot be replayed. Rows carry family_id,
rotated_at and revoked_at, plus a denormalized family_created_at so the
absolute window needs no second table.
Expired rows are cleaned up on read. There is no scheduled sweep — Chassis has
no job runner, and adding one to delete rows would be the largest dependency in
the module. If a long-lived deployment accumulates dead rows faster than you
like, a periodic delete from refresh_tokens where expires_at < now() is the
whole fix.
The refresh cookie is ambient credentials, which is what CSRF exploits, so
/auth/refresh, /auth/logout and /auth/revoke-all sit behind a
same-origin check on top of SameSite=Lax. A request with no Origin header
is allowed through: that is a non-browser client, which sends no cookie it did
not choose to send.
Endpoints whose credential travels in the URL are deliberately exempt: there
the token is the credential, and it may well arrive by a cross-site
navigation by design — a same-origin check would break the very feature it was
meant to protect.
The users table gained verified_at, and any credential column it carries
became nullable. Existing rows are unaffected and no backfill is needed —
whatever an identity already had, it keeps, and verified_at stays null until
the address is proven. refresh_tokens is new. Generate the migration the
usual way:
How sign-in emails — and, if you want them, SMS codes — actually leave the
building.
Chassis binds no email or SMS provider, and never will. That choice belongs
to the product: it depends on your deliverability history, your data-residency
rules and your invoice. A template that picks one for you is a template you
spend an afternoon fighting. What ships instead is the seam, plus enough of an
implementation to develop against.
With no configuration, the message is written to the log — the flow works on a
laptop with nothing installed. Set SMTP_URL and it goes over SMTP, which is
what makes the mailpit setup in Magic link work.
For production, bind your provider at boot, next to the other integrations:
ts
// src/integrations/mail.tsimport { Resend } from'resend';
import { setMailTransport } from'../mail';
exportfunction initResend(): void {
const resend = new Resend(process.env.RESEND_API_KEY);
setMailTransport({
async send({ to, subject, html, text }) {
await resend.emails.send({ from: 'you@example.com', to, subject, html, text });
}
});
}
Then call initResend() from src/integrations/index.ts behind a feature flag,
exactly like the built-ins. The shape is identical for every provider:
Provider
Package
The one call
Resend
resend
resend.emails.send({ from, to, subject, html, text })
SendGrid
@sendgrid/mail
sgMail.send({ from, to, subject, html, text })
Postmark
postmark
client.sendEmail({ From, To, Subject, HtmlBody, TextBody })
SES
@aws-sdk/client-ses
ses.send(new SendEmailCommand({ ... }))
SMTP
nodemailer(shipped)
already wired — just set SMTP_URL
Whatever you bind, keep it fast or keep it queued: delivery runs after the
request has already been answered, but a transport that hangs still holds a
connection open.
import twilio from'twilio';
import { setSmsTransport, setSmsRecipient } from'../sms';
const client = twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
setSmsTransport({
async send({ to, text }) {
await client.messages.create({ from: '+15550000000', to, body: text });
}
});
// Chassis has no phone number to send to — this is where yours lives.
setSmsRecipient((identity) => phoneBook.get(identity.id) ?? null);
mb.messages.create({ originator, recipients, body })
#Why a recipient resolver rather than a phone column
Because the alternative is worse. A column would mean a migration nobody asked
for, a verification flow for the number itself, and a channel-selection setting
— all to support a feature most projects will not switch on. The resolver keeps
that entirely in the product: unbound, it returns null, and SMS silently does
nothing. There is no MAGIC_CHANNEL variable for the same reason — the
channels are simply whichever transports you bound.
That is exactly how src/__tests__/magic.test.ts checks that one email leaves
carrying both credentials. Call setMailTransport() with no argument to put
the default back.
--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
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 <Name>
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.
It runs server-side only — it reads the session to attach
Authorization: Bearer <token>, 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).
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:
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.
Playwright drives a production next build, so what it checks is the artifact
that ships rather than the dev server.
bash
npm run e2e:setup # download Chromium — once
npm run e2e
Both script names work in either layout, so CI does not have to know whether
the project is a single package or a workspaces monorepo.
They are deliberately not part of npm run verify: that has to stay
runnable on a clean machine with no browser installed. CI runs them as their
own job.
web/e2e/smoke.spec.ts covers / and /sign-in structurally — no provider
names, no copy — so it survives whichever auth you scaffolded with. Add specs
next to it.
docker-compose.yml builds the API from apps/api. The Dockerfile falls
back from npm ci to npm install when it finds no lockfile, which is what
makes that build context work at all: in a workspaces monorepo the lockfile
lives at the repo root, outside apps/api. The web app has no compose
service — deploy it wherever you deploy Next.
A second entrypoint off the same build: src/jobs/run.ts schedules everything
registered in src/jobs/index.ts and stays up. Same config, same integrations,
same image as the API — a different process.
bash
npm run jobs # schedule everything, stay up
npm run jobs -- purge-old # run one job once and exit
There is no job type. A job with a schedule runs on that cron expression;
a job without one starts at boot and keeps running. A queue consumer is the
second kind:
ts
{
name: 'inbox-consumer',
async run({ logger, signal }) {
while (!signal.aborted) {
const message = await receive({ signal });
if (message) await handle(message);
}
logger.info('consumer drained');
}
}
signal is aborted on SIGTERM. A long-running job must watch it — the
shutdown failsafe kills the process ten seconds later either way.
Read the clock through now() from src/utils/clock.ts, never new Date().
That is what lets a test drive a job's date logic with setClock, exactly as
the session and magic-link services do.
A throwing job is logged and swallowed. That is deliberate: one bad run must
not take the process — and every other schedule with it — down. Overlapping
runs are also prevented; a run still going when the next tick arrives skips
that tick rather than stacking a second copy.
So the process never tells you a job is broken. Sentry does.
With the sentry module kept, every run opens a check-in before it starts and
closes it as ok or error. The check-in is what catches the failure mode
plain error reporting cannot: a schedule that stops firing at all reports
nothing, and a missed check-in is exactly what Sentry alerts on.
Create a monitor in Sentry whose slug matches the job's name, and give it the
same schedule. Without the sentry module the check-in lines prune away and the
jobs run unwatched.
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 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.
The x402 module (--x402) gates routes behind an HTTP 402 payment using the
x402 protocol — 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.
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.
@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.
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
Lockfile-aware — npm ci when a package-lock.json exists
(reproducible), npm install before then. A freshly scaffolded project has
no lockfile: the CLI strips the template's, since it lists dependencies your
project may not have kept. So docker build works immediately, and gets
reproducible the moment you run npm install.
bash
docker build -t my-api .
docker run -p8000:8000--env-file .env my-api
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.
dist/ is compiled JavaScript, so a Sentry trace points at the build, not at
your source — unless the source maps are uploaded under the same release the
running process reports.
The CI workflow does the upload after npm run build. It needs three things
set on the repository, and skips itself silently until they exist:
Where
Name
Value
Actions secret
SENTRY_AUTH_TOKEN
a token with project:releases scope
Actions var
SENTRY_ORG
your Sentry org slug
Actions var
SENTRY_PROJECT
your Sentry project slug
Then set SENTRY_RELEASE on the running service to the same commit SHA the
upload used. Both sides have to agree — a release mismatch is the usual reason
maps are uploaded and traces stay minified anyway.
The jobs module adds a second entrypoint off the same build: npm run start:jobs (node dist/jobs/run.js). Deploy it as its own service from the
same image, and run one replica unless every job is idempotent. See
Background jobs.
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.
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.
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.
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).
Every log line passes through a redaction format before any transport sees
it. Metadata keys that name a credential — auth headers, cookies, tokens of
any spelling, secrets, sign-in codes and email addresses — come out as
[redacted], two levels deep. The exact list is at the top of
src/utils/logger.ts.
URLs are logged as the route pattern the request matched
(/auth/magic/:token), never the concrete path. That matters because tokens
travel in the path, so originalUrl in a log is a live credential at rest.
The 404 handler follows the same rule, in its response body as well as its
log.
Two deliberate gaps, both marked in src/utils/logger.ts:
the log message is never redacted, only the metadata — that is what
keeps the console mail transport able to print a sign-in link in dev;
an unmatched path has no pattern to fall back to, so a 404 strips the
query but keeps the path.
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.
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.
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.
One file in src/integrations/<name>.ts exporting init<Name>()
(plus optional close<Name>() / readiness helpers).
One feature flag in src/config derived from its env vars:
features.<name> = Boolean(env.SOME_VAR).
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).
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.
Auth — none / auth0 / clerk for hosted providers, or one of three
local variants (see --help for their names). All share the
setAuthProvider() seam. See Authentication.
Each has a web half (web/auth/providers/<name>) behind the equivalent
front-end seam — one re-export line in web/auth/active.ts. The local
variants additionally ship what the hosted providers don't need: sign-in
controllers, an identity store that follows the database choice, and a
session layer. See Web front end.
Mechanically a group variant is just a module in the chassis:<name>
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.
The three local auth variants are the exception: they own no files at all, and
exist only to name a combination of implied modules — session, password
and magic, declared in IMPLIED in cli/modules.mjs.
js
// cli/modules.mjs — each local variant names the modules it composes
implies: ['session', 'password'];
implies: ['session', 'magic'];
implies: ['session', 'password', 'magic'];
The indirection buys something specific. A file may be claimed by exactly one
module — the catalog test enforces it — so the variant that keeps both sign-in
methods, needing the union of two file sets, could not be expressed as a flat
files list. And the
session layer is shared by all three, so it cannot belong to any of them.
Implied modules deliberately live outside MODULES, which means the
interactive "Custom" path never offers them and presets never list them: they
are consequences of an auth choice, not choices of their own. They still prune
exactly like anything else — chassis:session, chassis:password and
chassis:magic markers behave identically to a toggle's.
@protectedRoute (auth) and @paidRoute (x402) live in src/core and are
always present — with no provider configured they answer 501, never open.
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.
Create src/integrations/redis.ts with initRedis() /
closeRedis().
Add REDIS_URL to the schema in src/config and
features.redis: Boolean(env.REDIS_URL).
Register it in src/integrations/index.ts:
if (config.features.redis) await initRedis();
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.
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.
Downloads the template (GitHub tarball; requires tar on PATH)
Resolves your selection (preset → prompts → flags, flags win) and shows
a confirmation summary
Prunes everything not chosen — deletes each declined module's files
and directories, strips every line carrying its chassis:<name> 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.
Renames the package, resets the version, git-inits (optional), installs
(optional)
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:
npm run gen user # → UserController at /users
npm run gen BlogPost # → BlogPostController at /blog-posts
Creates src/controllers/<Name>.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/<engine>/schema.ts. Generate the migration with
npx drizzle-kit generate --config src/db/<engine>/drizzle.config.ts.
Mongo — a Mongoose model in src/db/mongo/ and a controller using it.
typecheck + lint + test — what CI and the pre-commit hook run
npm run gen <Name>
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 <Name> forwards to apps/api. See
Web front end.
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.
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.
Like @route, but the configured auth provider runs first; your
middlewares run after it. Answers 501 when no provider is
configured. See Authentication.
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.
The pluggable-auth seam. Integrations call setAuthProvider([...]) at
boot; @protectedRoute runs the registered chain per request via
requireAuth(). See src/core/auth.ts.
extraRoutables — extra Routable instances mounted after the
auto-registered controllers and before the error handlers. Built for
tests; see src/__tests__/app.test.ts.
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.
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.
Add an endpoint: run npm run gen <Name> 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/<engine>/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 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.
❌ Don't turn the dynamic import('jose') in src/integrations/jwt.ts or
src/services/session.ts into a top-level import. jose is ESM-only and this
is a CommonJS build; a static import fails typecheck.
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.
npm run build passes. In a monorepo that also runs next build, which
is the only thing that exercises front-end middleware and bundling.
No new process.env reads, no res.status().json(), no try/catch in
controllers, no edits to src/core for feature work.
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:
Install the Renovate GitHub app 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).
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.
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.
mcp-server/ is published separately as
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<version>.
The CLI is versioned separately from the template it downloads: a release from
months ago still fetches today's master. So a new top-level directory leaks
into projects generated by every already-published version — which is
exactly how mcp-server/ shipped in create-chassis@0.3.0.
Declare it in .chassisignore rather than in the CLI's ignore list. The
CLI reads that file from the template it downloads, so one template change
fixes every CLI version at once, with no release.
A bare name matches any basename. Beware collisions: mcp would also match
src/mcp, which is why the directory is mcp-server.
An entry containing / matches a path relative to the repository root — use
that for single files, like .github/workflows/docs.yml.
node --test cli/scaffold.test.mjs fails on any committed top-level path that
is neither declared there nor in the test's shipped list, so this cannot be
forgotten twice.
Keep the module contract intact: anything specific to an
optional integration stays on single lines tagged
// chassis:<name>, 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:session) 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:<name> 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:
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 verifyandbuild 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.
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.
The template is on TypeScript 6.0, the bridge release: it turns every TS 7
removal into an error while still being the JavaScript compiler the ecosystem
can introspect. Passing it clean is the real proof the template is 7-ready —
and npx -y -p typescript@7 tsc --noEmit does exit 0 here, root and web/.
The version is pinned with ~, not ^, on purpose. typescript-eslint
supports <6.1.0, and the CLI scaffolds projects without a lockfile — so a
caret would resolve to 6.1 the day it ships and break npm run verify for
every new project. Loosen it only once typescript-eslint's peer range moves.
Both package.json and web/package.json are pinned this way.
Going to 7 is blocked on the same package: 7.0 ships no programmatic compiler
API (that lands in 7.1), so typescript-eslint cannot run on it at all — nor
can ts-jest, ts-morph, or the Vue/Svelte/Astro template checkers. npm run lint
breaks the moment typescript@7 is installed; verify fails on the lint leg,
never the compile leg. When that clears, the upgrade is a version bump and
nothing else. The work is already in the template:
moduleResolution: "node16" in tsconfig.json. TS 7 removed
node/node10. Output stays CommonJS (no "type": "module"), so dist/,
npm start and the Dockerfile are unchanged.
jose is imported dynamically in src/integrations/jwt.ts and
src/services/session.ts. It publishes no require condition,
so a static import from a CommonJS file is a TS1479 error under node16.
node16 also emits a real import() rather than downleveling it to
require, which is what makes an ESM-only package work in the CJS build.
declare module '*.css' in web/types/next.d.ts. Next types
*.module.css but not plain global stylesheets, and TS 6 stopped letting an
unresolvable side-effect import pass (TS2882).
experimentalDecorators survives in TS 7, and there is no
emitDecoratorMetadata or reflect-metadata here, so @route needs
nothing. That is the part that breaks other decorator frameworks.
To try the new compiler without touching verify:
npx -y -p typescript@7 tsc --noEmit -p tsconfig.build.json.
Chassis ships one first-party auth option: local JWT with a password. The
chassis:jwt module bundles four unrelated things — password hashing, JWT
minting, the user store, and the web sign-in form — with two consequences:
a product that wants email-link sign-in cannot get one, and
a product that wants password auth removed cannot remove it without losing
the session layer too.
This adds a magic-link module (256-bit link token plus a 6-digit cross-device
code, both hashed at rest) and a refresh-token session layer (rotation, reuse
detection, revoke-all), and splits chassis:jwt into three composable modules
so --auth magic-only scaffolds a project containing no password code at all.
Established by reading the repo, not assumed. Everything below is why the
design looks the way it does.
Concern
Reality
Session primitive
Local JWT, stateless bearer.SignJWT HS256 via a dynamic import('jose'), TOKEN_TTL = '1h', no jti/iss/aud, no refresh — src/controllers/Auth.controller.ts:19-49. Verification discards the claims — src/integrations/jwt.ts:29
Cookies
The API sets none. The only cookie boundary is the Next route web/app/api/session/route.ts:30-37 (chassis_token, httpOnly, SameSite=Lax, secure in production)
Auth seam
Module-level provider with a 501-when-unset fallback — src/core/auth.ts:9-37. Mirrored by payments, src/core/payments.ts:11-15
DI
No container. Controllers are constructed zero-arg (src/app.ts:40), so an optional capability is a module-level let x plus setX(), resolved per request
Store selection
A flag-keyed table, not an if-chain, so pruning to zero databases keeps config referenced and the build green — src/db/users.ts:35-44
Errors
throw new AppError(ERROR_CODES.X); codes are a satisfies Record<string, ErrorCode> table at src/core/errors.ts:8-37, mapped at src/core/errorHandler.ts:19-24
Responses
13 methods on ResponseHandler, all JSON — src/core/response.ts:29-95. No HTML, no redirect
Config
One zod schema, process.exit(1) on bad env — src/config/index.ts:9-40. No duration parsing anywhere; '1h' is a hardcoded string
Tests
vitest, src/**/*.test.ts. Integration is supertest against createApp({ extraRoutables }) with no listen(). Env-dependent modules must be imported dynamically inside beforeAll — src/__tests__/auth.test.ts:14-16
Clock
None. One new Date() in src/ (src/db/sqlite/users.ts:30) and no fake timers anywhere, so token expiry is currently untestable
Mail
None. No transport, no dependency, no SMTP_* env var
Rate limiting
None./auth/login is unthrottled and runs scrypt per attempt
CSRF
None beyond SameSite=Lax on the one web cookie. The API is cookie-free, so classic CSRF does not reach it today
Migrations
No drizzle/ directory and no db:migrate script.drizzle-kit generate is run by hand (docs/guides/authentication.md:110-115); tests create tables with raw SQL (src/db/sqlite/users.test.ts:15-24)
Queues
None. No scheduler, outbox, or job runner
Prune model
chassis:<module> end-of-line markers are line-stripped for declined modules, then files/crossFiles/deps/scripts are deleted — cli/index.mjs:333-406. The catalog is cli/modules.mjs
The primitive found above is a stateless bearer JWT, so the session layer is
short-lived access token + rotating refresh token, not cookie sessions.
Access token — HS256 JWT, ACCESS_TOKEN_TTL (default 15m), sub is the
identity id plus sid for the family. Verified by the existing
src/integrations/jwt.ts.
Refresh token — 256-bit base64url, SHA-256 at rest, one row per token,
family_id grouping every token descended from a single sign-in.
Rotated on every use — the presented row gets rotated_at, a fresh row is
inserted into the same family.
Reuse detection — presenting a row that already has rotated_at revokes
the entire family and answers 401.
SESSION_IDLE (30d) is the per-token TTL, renewed on each rotation.
SESSION_ABSOLUTE (90d) caps the family's creation time and is checked on
every refresh.
A magic redemption or a password sign-in always starts a new family —
that is the rotation-on-auth-event requirement.
iat/exp are passed to jose as explicit epoch numbers and verification passes
currentDate, so the injected clock drives token expiry with no system-clock
patching. No new Date() appears in auth logic; src/utils/clock.ts is the
only time source.
chassis:jwt splits into three implied modules composed by auth variants:
code
IMPLIED — a new export in cli/modules.mjs; never prompted, never a preset key
session jose, clock, refresh store, users table, refresh/logout/revoke-all
password scrypt, register/login, password_hash column, dev-password env
magic magic store, mail, SMS seam, rate limit, confirm page, code fallback
GROUPS.auth.variants — composition only; no files or deps of their own
none
auth0 unchanged
clerk unchanged
jwt implies: ['session', 'password'] ← name kept
magic-only implies: ['session', 'magic']
password+magic implies: ['session', 'password', 'magic']
implies is necessary rather than stylistic: the catalog integrity test forbids
one file being claimed by two modules, and password+magic needs the union of
two file sets. Per-variant file lists cannot express that; composition is about
six lines in the CLI.
jwt keeps its name so --auth jwt, all four presets, the published
create-chassis, and the catalog-derived chassis-mcp schema keep working.
descriptor() consults IMPLIED after GROUPS and MODULES.
kept gains the selected variant's implies; declined sweeps
Object.keys(IMPLIED) minus kept (cli/index.mjs:313-331).
selectWebAuthProvider maps all three local variants onto the existing jwt
web provider, so web/auth/providers/conformance.ts needs no new entry.
The marker pruner is not changed..tsx is absent from both the pruner's
extension list (cli/index.mjs:344) and the marker-residue grep in
.github/workflows/published.yml:66-67. Rather than extend two regexes and then
fight Prettier over where a marker may legally sit inside JSX — where
{/* chassis:x */} does not match MARKER_LINE and would leak into kept output
— every marker stays in .ts, and a new integrity test asserts that no .tsx
file ever contains a chassis: marker. Today's silent gap becomes a guarded
invariant.
issue() (latest-wins void, then generate both credentials), probe(), redeemToken(), redeemCode(), validateReturnTo(), setOnVerified()
src/db/magic.ts, src/db/memory-magic.ts
MagicStore and magicStore(), same table pattern
src/mail/index.ts
MailTransport { send({ to, subject, html, text }) }, setMailTransport(), default console logger
src/mail/smtp.ts
nodemailer to mailpit. The only shipped transport, for dev and the e2e
src/mail/template.ts
One function returning { subject, html, text }; link primary, code secondary, no images required to function
src/sms/index.ts
SmsTransport { send({ to, text }) }, setSmsTransport(), setSmsRecipient((identity) => string | null). Both default to no-ops
src/controllers/Magic.controller.ts
The four endpoints in §5
web/app/auth/magic/[token]/page.tsx
Confirm page — a server component calling the JSON probe
web/app/api/session/magic/route.ts
POSTs redeem, sets cookies at the existing web boundary, redirects
No production delivery providers ship — not for mail, not for SMS. Resend,
SendGrid, SES, Postmark, Twilio, Vonage, SNS and MessageBird are documented
bindings against these two seams and nothing more (docs/guides/transports.md).
There is no MAGIC_CHANNEL variable either: the channels are whatever the
product bound. setSmsRecipient() exists so SMS needs no phone column and no
identity-schema change — an unbound resolver means SMS silently does nothing.
src/controllers/Auth.controller.ts becomes
src/controllers/Password.controller.ts (chassis:password), reduced to
register and login.
src/utils/password.ts is unchanged; ownership moves to chassis:password.
src/integrations/jwt.ts moves to chassis:session, gains algorithms and
currentDate on jwtVerify, and stops discarding claims — it attaches
req.identityId.
src/db/users.ts: passwordHash becomes optional and marked
chassis:password; verifiedAt is added; UserStore gains findById,
createFromEmail and markVerified.
Per-engine cross-files join IMPLIED.session.crossFiles and
IMPLIED.magic.crossFiles: src/db/{sqlite,postgres}/{sessions,magic}.ts and
their .schema.ts, src/db/mongo/{sessions,magic}.ts, plus marked export *
lines in each engine's schema.ts.
src/__tests__/auth.test.ts splits into password.test.ts, session.test.ts
and magic.test.ts.
web/auth/providers/jwt.client.tsx splits so no .tsx needs a marker. The
password form moves to jwt.password-form.tsx (chassis:password), a new
jwt.magic-form.tsx (chassis:magic) joins it, and the shell maps over a
registry whose lines carry the markers:
src/core/response.ts gains html(markup) and seeOther(url), plus SEE_OTHER
in src/core/errors.ts — about 14 lines. They are unmarked, because core is
never pruned, and they are generic responders rather than magic-specific ones.
This is a deliberate framework extension; see conflict 1.
Every markdown file under docs/ must appear in site/pages.mjs or the site
build fails (site/build.mjs:342-361).
docs/design/magic-link.md — this note. docs/design is added to
.chassisignore so it never ships into a generated project; nested paths are
honored (.chassisignore:11, cli/index.mjs:276).
docs/guides/authentication.md becomes a provider-agnostic overview.
New and module-owned, so they are deleted with their module:
docs/guides/password-auth.md, docs/guides/magic-link.md,
docs/guides/sessions.md, docs/guides/transports.md.
docs/reference/configuration.md — auth env rows move into the module guides.
docs/modules.md and docs/maintainers.md document the IMPLIED/implies
contract; docs/reference/cli.md lists the new --auth values.
README.md gains a magic-link section, worded without "password".
{ email, returnTo? } → 202 with a byte-identical body every time. Responds before touching the store, then does lookup, issue and send in a detached promise, so response timing is uniform by construction. Rate limited per-email and per-IP in separate buckets
GET, HEAD
/auth/magic/:token
Never consumes. An HTML confirm page by default; with Accept: application/json, { status: 'valid' | 'expired' | 'used', returnTo }
POST
/auth/magic/redeem
{ token }, single use. Sets the refresh cookie and answers 303 to the re-validated returnTo. No CSRF check — the token is the credential
POST
/auth/magic/code
{ email, code }, constant-time compare, MAGIC_CODE_ATTEMPTS cap, then void every credential for that email
POST
/auth/refresh
Cookie or body. Rotates; reuse revokes the family. sameOrigin
POST
/auth/logout
Idempotent, revokes one family. sameOrigin
POST
/auth/revoke-all
@protectedRoute, revokes every family for the identity. sameOrigin
POST
/auth/register, /auth/login
Unchanged, chassis:password
Three Routables share the /auth base path; Express mounts multiple routers
on one path without complaint (src/core/routable.ts:57).
The GET-never-consumes rule is the point of the two-step flow: corporate mail
security scanners prefetch links, and a single-use token consumed by a HEAD
from a scanner is the most common magic-link production failure.
returnTo defaults to path-only — a single leading /, no backslash, no
//, no scheme, no control characters — with MAGIC_RETURN_TO_ORIGINS allowing
specific absolute origins. It is validated at request time, stored server-side
with the credential, and re-validated at redemption; the redeemed value is
never trusted on its own.
On success: verified_at is set if unset, onVerified(identity) fires, a new
session family is created, and the response redirects to the validated
returnTo. There is no consent or double-opt-in machinery here and never will
be — verified_at plus the hook is the entire surface products build on.
Unknown email: the link is still sent, and the identity is created on
redemption, so sign-up and sign-in are one flow. That is what makes the
identical 202 honest rather than a fiction. No config flag; an invite-only
product changes one line in magic.ts.
No migration infrastructure exists (see §1), so these follow the established
hand-run convention: npx drizzle-kit generate --config src/db/<engine>/drizzle.config.ts.
Mongo is schemaless and needs indexes only. Tests keep creating tables with raw
SQL, as src/db/sqlite/users.test.ts:15-24 does.
users — add verified_at (nullable timestamp); make password_hashnullable, since a magic-only identity has no password. Existing rows are
unaffected; products migrating an existing database are pointed at
docs/guides/sessions.md.
refresh_tokens (new) — id, family_id, user_id, token_hash
(unique), created_at, expires_at, family_created_at, rotated_at
(nullable), revoked_at (nullable). Indexes on token_hash (unique) and
family_id. family_created_at is denormalized onto every row so the
absolute window needs no second table.
magic_credentials (new) — id, email (indexed), token_hash
(unique), code_hash, attempts (default 0), return_to (nullable),
created_at, expires_at, consumed_at (nullable), voided_at (nullable).
One row per request holds both credentials, since they share an expiry and
are voided together.
Both hashes are SHA-256 hex of the raw value; the raw values exist only in the
email. Token lookup is by hash equality in SQL — safe, because the token is
256 bits of entropy — while the 6-digit code is fetched by email and compared
with timingSafeEqual, where constant time actually matters.
Expired rows are deleted on read plus a documented manual sweep. There is no
cron: the repo has no scheduler, and adding one for row cleanup would be the
largest new dependency in the change.
Each phase is gated: TDD, failing test first, and npm run verify plus
npm run build green before the next begins.
P1 — token and code core. Pure logic, fake clock, no IO. Co-located unit
tests for issue, void, redeem, expiry, latest-wins voiding tokens and codes,
the attempt cap, hash round-trips, and a validateReturnTo table.
P2 — endpoints and transport. Enumeration: 202 bodies byte-identical for
known and unknown emails. Rate-limit buckets independent. Scanner test: GET,
then HEAD, then GET again, and the token is still redeemable; only POST
consumes it. Open-redirect table covering https://evil, //evil,
\/\/evil, /\evil, %2f%2fevil and an allowlisted path. Capture transport
receives one email carrying both credentials. Unbound SmsTransport is a no-op.
P3 — sessions. Rotation on use; reuse revokes the family; idle versus
absolute expiry driven by the fake clock; revoke-all kills every device; logout
is idempotent; access-token expiry via currentDate.
P4 — finish. Code-fallback e2e (request on client A, redeem the code on A
while the link stays unopened); the scaffold flag and its residue check in CI;
Sentry wiring — auth failures tagged, identityId only, never a raw token or
email in an event; docs and README.
Nothing counts as done until an already-running script enforces it.
Gate
Change required
npm run verify (also the pre-commit hook)
None. vitest.config.ts already includes src/**/*.test.ts; web tests arrive via verify:web. The mailpit e2e is describe.skipIf(!process.env.MAILPIT) so verify stays green without Docker
npm run build
Must pass with every combination pruned — the reason duration.ts is its own file and the stores use the flag-table pattern
ci.yml → verify
None. It already runs node --test cli/*.test.mjs, the site build and chassis-mcp
ci.yml → new mail-e2e job
A mailpit service plus MAILPIT=1 npm test. Every line marked # chassis:magic, so it prunes out of non-magic projects and generated magic apps inherit the job
ci.yml → scaffold
Invocation unchanged; the coverage lands in scaffold.test.mjs
published.yml
None — because every marker stays in .ts; the residue grep does not cover .tsx and a test enforces the invariant instead
docker.yml
Add a --auth magic-only --docker case so the mailpit compose block is exercised
site/build.mjs
Five new docs each need a site/pages.mjs entry
mcp-server
The schema is catalog-derived, so variants appear free. Assert list_chassis_options offers the new variants; extend chassis_conventions with the new module names
cli/scaffold.test.mjs gains: implies resolvability; IMPLIED folded into the
"no file claimed twice" and "declared files exist" tests; session, password
and magic added to the marker-name set and checked against mid-line
chassis: text (the Symbol('chassis:routes') trap at src/core/routable.ts:23);
composition-only variants carved out of "marked-or-has-files"; the no-markers-in-.tsx
invariant; and assertNoModuleResidue(dir, declined) with a pattern set per
module — symmetric across all three rather than a one-off password grep. The
SCAFFOLD_BUILD matrix gains --auth magic-only --db postgres and
--auth password+magic --db sqlite.
cli/select.test.mjs: GROUPS.auth goes from four variants to six and the
scripted prompter answers by index, so existing cases shift and must be
re-pinned. Add a case per new variant, and assert IMPLIED keys are never
prompted.
docker-compose.yml: a mailpit service with every line marked # chassis:magic
and a marked SMTP_URL on the api service. It must declare no named
volume — the top-level volumes: block is entirely chassis:mongo-owned so
that it prunes cleanly.
Flagged rather than silently resolved. Items 1 and 2 were explicitly approved.
A src/core edit is required.html() and seeOther() are needed for a
browser confirm page and the post-redeem redirect, and CLAUDE.md forbids
editing src/core/** to build a feature. Approved as a deliberate framework
extension rather than a feature-driven one.
The API will set cookies. Today it is bearer-only and cookie-free, and
web/app/api/session/route.ts is the sole cookie boundary. This is a new
convention and it brings CSRF into the API for the first time. Mitigated with
SameSite=Lax, Secure, and an Origin check on refresh, logout and
revoke-all only; redeem is exempt because it is self-proving. No new
dependency — Express 5 has res.cookie().
rg -i password cannot return nothing, as literally specified.POSTGRES_PASSWORD: postgres # chassis:postgres (docker-compose.yml:27) is
correct to keep, and the string password also matches the word
passwordless. CI therefore asserts no hits except POSTGRES_PASSWORD, and
kept docs say "magic link", never "passwordless".
.md is excluded from marker pruning on purpose (cli/index.mjs:341-342
— the docs describe the whole module system, including declined parts). That
is not changed; password prose and its env rows move into a module-owned doc
file that is deleted with the module.
.tsx is invisible to both the pruner and the residue grep. Sidestepped
by keeping every marker in .ts and adding a test that enforces it.
Catalog integrity tests forbid a file claimed twice and require every
module be marked or have files, so composition-only variants need a carve-out.
No queue exists, so "enqueue the send" is a detached promise after the
Marked // ponytail: fire-and-forget; a real queue when delivery needs retries or visibility.
No rate limiter exists, so it is an in-process fixed-window Map, not a
new dependency. Marked // ponytail: per-instance; shared store when horizontally scaled.
nodemailer is the only new runtime dependency, pruned with magic.
Hand-rolling SMTP over node:net was the alternative and is the wrong kind of
lazy — dot-stuffing, CRLF handling and TLS are easy to get quietly wrong.
Auth.controller.ts is renamed, and it is referenced by name in
AGENTS.md and three docs.
No duration-parsing precedent exists.src/utils/duration.ts rather than
a helper inside config/index.ts, which would become an unused local once
both modules are pruned and fail the build.
No migration infrastructure exists; new tables follow the hand-run
drizzle-kit generate convention.
users gains verified_at and password_hash becomes nullable — a
schema change to an existing table. Migrating existing rows is the product's
concern, documented in docs/guides/sessions.md.
MailTransport and SmsTransport are interfaces with one implementation
each. Normally that reads as speculative abstraction; here it is the point.
Binding an ESP inside Chassis is explicitly out of scope, so the seam is
the feature and every provider stays documentation.
Seven changes, each forced by something the plan could not have known without
writing the code.
The password hash moved behind its own store. The plan kept it on the
identity row, reached through UserStore. That leaves the word "password" in
src/db/users.ts — a file every local variant keeps — so --auth magic-only
could never be clean. As built, src/db/users.ts deals only in identities and
src/db/passwords.ts owns the credential, with a per-engine implementation
each. users.password_hash survives as a marked column in the schema file, so
declining the module drops the column outright.
AUTH_DEV_EMAIL belongs to the session module, not the password module.
Seeding a development identity is useful without a password; only
AUTH_DEV_PASSWORD is password-specific.
Durations needed a helper, for a formatting reason. Written inline,
SESSION_IDLE: z.string().regex(/^\d+[smhd]$/).default('30d'), // chassis:session
exceeds the print width, and Prettier then splits the chain across four lines —
leaving the marker on the last one, where pruning it would delete .default(...)
and break the declaration. durationSchema() in src/utils/duration.ts keeps
each env line short. This is the same trap the schema files already warn about.
Three core additions, not two.accepted() joined html() and
seeOther() — the enumeration-safe request endpoint answers 202, and the
alternative was abusing manualError. TOO_MANY_REQUESTS was added to
ERROR_CODES for the rate limiter, which AGENTS.md explicitly sanctions.
express.urlencoded is now mounted in src/app.ts, marked chassis:magic,
because the API's confirmation page is a plain form and a form posts urlencoded.
The web session route had to be split — this was a real bug.POST /api/session proxied to the API's /auth/login, which does not exist in
a magic-only project. Sign-in is now per-method (/api/session/password,
/api/session/magic), and /api/session keeps only what they share: turning an
API response into cookies, and signing out.
A table column cannot carry a marker inside SQL.src/db/sqlite/users.test.ts
creates its table from a COLUMNS array rather than one SQL string, so the
optional column sits on its own markable line. A marker inside the template
literal would either be invalid SQL in the template or survive into generated
projects.
The whole-tree password grep holds, with two named exemptions.docs/modules.md and docs/reference/cli.md describe the scaffolder itself, so
naming a module the reader did not scaffold is their job — the same reason .md
is exempt from marker pruning. Everything else, prose included, is scanned. The
check generalized into assertNoModuleResidue in cli/scaffold.test.mjs, which
holds magic and session to the same standard rather than special-casing
password. It caught the web-route bug above, and eleven pieces of prose that
would have shipped into projects that had pruned the module they described.
npm run verify, npm run build, node --test cli/*.test.mjs (96 passing),
npm run check --prefix site — all green.
A scaffolded --auth magic-only --db postgres project installs, runs its 116
tests, and builds. rg -i password over it returns only POSTGRES_PASSWORD
and the two scaffolder docs.
The mailpit e2e ran against real SMTP: request → email carrying link and code
→ two GETs and a HEAD leaving the token redeemable → POST redeem → session →
20-day gap → silent refresh → day 91 → forced re-auth.