Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .chassisignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ mcp-server
.github/workflows/docs.yml
.github/workflows/published.yml
.github/workflows/docker.yml

# Design notes record how Chassis itself was built and discuss modules a
# generated project may have declined — including, by name, ones it pruned.
docs/design
39 changes: 31 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,34 @@ DATABASE_URL= # chassis:postgres
# Setting SQLITE_PATH enables the SQLite + Drizzle integration. # chassis:sqlite
SQLITE_PATH= # chassis:sqlite

# ── Local JWT (optional) ────────────────────────────── chassis:jwt
# Setting JWT_SECRET enables Bearer-token auth on @protectedRoute, # chassis:jwt
# and POST /auth/register + /auth/login to mint tokens. # chassis:jwt
JWT_SECRET= # chassis:jwt
# Users live in whichever database is configured above. With none, they # chassis:jwt
# live in memory and these seed a single dev account at boot. # chassis:jwt
AUTH_DEV_EMAIL= # chassis:jwt
AUTH_DEV_PASSWORD= # chassis:jwt
# ── Local auth: sessions (optional) ─────────────────── chassis:session
# Setting JWT_SECRET enables Bearer-token auth on @protectedRoute # chassis:session
# and lets /auth/* issue sessions. # chassis:session
JWT_SECRET= # chassis:session
# Sliding idle window, then a hard cap measured from sign-in. # chassis:session
SESSION_IDLE=30d # chassis:session
SESSION_ABSOLUTE=90d # chassis:session
# Identities live in whichever database is configured above. With none # chassis:session
# they live in memory, and this seeds a single dev identity at boot. # chassis:session
AUTH_DEV_EMAIL= # chassis:session

# ── Local auth: password (optional) ────────────────── chassis:password
# Gives AUTH_DEV_EMAIL a password in the in-memory store. # chassis:password
AUTH_DEV_PASSWORD= # chassis:password

# ── Local auth: magic link (optional) ─────────────────── chassis:magic
# Both the emailed link and the 6-digit code live this long. # chassis:magic
MAGIC_TOKEN_TTL=15m # chassis:magic
# Wrong codes before every credential for that address is voided. # chassis:magic
MAGIC_CODE_ATTEMPTS=5 # chassis:magic
# Origin the emailed link points at — your web app, if you have one. # chassis:magic
MAGIC_LINK_BASE_URL=http://localhost:8000 # chassis:magic
# Absolute URLs allowed as ?returnTo=. Paths are always allowed. # chassis:magic
MAGIC_RETURN_TO_ORIGINS= # chassis:magic
MAGIC_FROM=no-reply@localhost # chassis:magic
# Unset logs the email instead of sending it. `docker compose up -d mailpit` # chassis:magic
# then use smtp://localhost:1025 to read it at http://localhost:8025. # chassis:magic
SMTP_URL= # chassis:magic

# ── Clerk (optional) ────────────────────────────────── chassis:clerk
# Setting CLERK_SECRET_KEY enables Clerk auth on @protectedRoute. # chassis:clerk
Expand All @@ -43,6 +63,9 @@ CLERK_SECRET_KEY= # chassis:clerk
# ── Sentry (optional) ──────────────────────────────── chassis:sentry
# Setting SENTRY_DSN enables error reporting to Sentry. # chassis:sentry
SENTRY_DSN= # chassis:sentry
# The build these traces came from. Must match the release the source # chassis:sentry
# maps were uploaded under, or traces stay minified. CI uses the SHA. # chassis:sentry
SENTRY_RELEASE= # chassis:sentry

# ── x402 payments (optional) ────────────────────────── chassis:x402
# Setting X402_PAY_TO enables payment-gated @paidRoute. # chassis:x402
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
push:
branches: [master, main]
pull_request:
# A manual trigger, so a run can be started without an empty commit — after
# a GitHub Actions outage swallows the original event, for instance.
workflow_dispatch:

jobs:
verify:
Expand Down Expand Up @@ -36,6 +39,20 @@ jobs:
- name: Build
run: npm run build

# Without this, every production stack trace is minified `dist/` output. # chassis:sentry
# `npx --yes` on purpose: nothing but CI ever runs @sentry/cli, so it is # chassis:sentry
# not worth a devDependency. Skipped entirely until the secret exists, # chassis:sentry
# and pinned to one matrix leg so the release is not uploaded twice. # chassis:sentry
- name: Upload source maps to Sentry # chassis:sentry
if: ${{ matrix.node-version == 20 && github.ref == 'refs/heads/master' && env.SENTRY_AUTH_TOKEN != '' }} # chassis:sentry
env: # chassis:sentry
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} # chassis:sentry
SENTRY_ORG: ${{ vars.SENTRY_ORG }} # chassis:sentry
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} # chassis:sentry
run: | # chassis:sentry
npx --yes @sentry/cli sourcemaps inject dist # chassis:sentry
npx --yes @sentry/cli sourcemaps upload dist --release "$GITHUB_SHA" # chassis:sentry

# Seconds, and it catches a mistyped marker or a stale module catalog
# entry — both of which fail silently. Worth running here for fast
# feedback rather than only in the slow scaffold job below.
Expand All @@ -52,6 +69,64 @@ jobs:
- name: chassis-mcp # chassis:template
run: npm ci --prefix mcp-server && npm test --prefix mcp-server # chassis:template

mail-e2e: # chassis:magic
# The magic-link flow against a real SMTP server, because the capture # chassis:magic
# transport in the unit tests cannot prove an email actually sends. # chassis:magic
# Skipped by `npm run verify`, which must stay runnable with no Docker. # chassis:magic
runs-on: ubuntu-latest # chassis:magic
timeout-minutes: 10 # chassis:magic
# chassis:magic
services: # chassis:magic
mailpit: # chassis:magic
image: axllent/mailpit:latest # chassis:magic
ports: # chassis:magic
- 1025:1025 # chassis:magic
- 8025:8025 # chassis:magic
# chassis:magic
steps: # chassis:magic
- uses: actions/checkout@v4 # chassis:magic
# chassis:magic
- uses: actions/setup-node@v4 # chassis:magic
with: # chassis:magic
node-version: 20 # chassis:magic
cache: npm # chassis:magic
# chassis:magic
- name: Install dependencies # chassis:magic
run: npm ci # chassis:magic
# chassis:magic
- name: Magic-link end to end # chassis:magic
run: MAILPIT=1 npx vitest run src/__tests__/magic.e2e.test.ts # chassis:magic
env: # chassis:magic
SMTP_URL: smtp://localhost:1025 # chassis:magic
MAILPIT_API: http://localhost:8025 # chassis:magic

web-e2e: # chassis:web
# Playwright against a production `next build`. Its own job because it # chassis:web
# needs a browser download, and `npm run verify` must stay runnable on # chassis:web
# a clean machine with nothing installed. # chassis:web
runs-on: ubuntu-latest # chassis:web
timeout-minutes: 20 # chassis:web
# chassis:web
steps: # chassis:web
- uses: actions/checkout@v4 # chassis:web
# chassis:web
- uses: actions/setup-node@v4 # chassis:web
with: # chassis:web
node-version: 20 # chassis:web
cache: npm # chassis:web
# chassis:web
- name: Install dependencies # chassis:web
run: npm ci # chassis:web
# chassis:web
- name: Install web dependencies # chassis:template
run: npm ci --prefix web # chassis:template
# chassis:web
- name: Install Chromium # chassis:web
run: npm run e2e:setup # chassis:web
# chassis:web
- name: Browser smoke test # chassis:web
run: npm run e2e # chassis:web

scaffold:
# Scaffolds every project type and asserts each installs, verifies,
# builds, and ships only the files/deps its chosen modules need (no dead
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ uses. Green means done; anything else means keep going.
- ❌ 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/controllers/Auth.controller.ts` into a top-level import. jose is
ESM-only and this is a CommonJS build; a static import fails `typecheck`.
`src/services/session.ts` into a top-level import. jose is ESM-only and this
is a CommonJS build; a static import fails `typecheck`.

## Where things live

Expand Down
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**[📖 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.
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.

```ts
export class UserController extends Routable {
Expand Down Expand Up @@ -74,7 +74,7 @@ rest of the codebase rather than fighting it.
- **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
- **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
Expand Down Expand Up @@ -122,6 +122,49 @@ Copy `.env.example` to `.env`. Each integration turns on when its variables are

Using a different IdP? Call `setAuthProvider([...yourMiddleware])` at boot and `@protectedRoute` uses it — see `src/core/auth.ts`.

### Sign in without a third party

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](docs/guides/authentication.md). Whichever you pick, they
share one session layer.

```
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.

Guides: [magic link](docs/guides/magic-link.md) ·
[sessions](docs/guides/sessions.md) ·
[transports](docs/guides/transports.md)

## Project structure

```
Expand Down
14 changes: 8 additions & 6 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ These are mutually exclusive groups. Choosing a database brings its ORM along.
| Group | Values | ORM |
| --------------- | ---------------------------------------- | ------------------ |
| `--db <name>` | `none` · `mongo` · `postgres` · `sqlite` | Mongoose / Drizzle |
| `--auth <name>` | `none` · `auth0` · `jwt` · `clerk` | — |
| `--auth <name>` | `none` · `auth0` · `clerk` · `jwt` · `magic-only` · `password+magic` | — |

- **`mongo`** — MongoDB via Mongoose.
- **`postgres`** — Postgres via Drizzle (the flagship SQL stack).
- **`sqlite`** — SQLite via Drizzle; zero-infra, in-memory by default.
- **`auth0` / `jwt` / `clerk`** — all register through one pluggable
- **`auth0` / `clerk` / the local variants** — all register through one pluggable
`setAuthProvider()` seam behind the `@protectedRoute` decorator.

---
Expand Down Expand Up @@ -117,14 +117,16 @@ provider:

| `--auth` | Front end |
| -------- | --------------------------------------------------------------------------- |
| `jwt` | sign-in form → `/api/session` → API `/auth/login` → **httpOnly cookie** |
| local | sign-in form → `/api/session/*` → the API → **httpOnly cookies** |
| `auth0` | `@auth0/nextjs-auth0`, with the API audience set so you get an access token |
| `clerk` | `@clerk/nextjs` — `<SignIn/>` and `auth().getToken()` |
| `none` | no sign-in; requests go out unauthenticated |

With `--auth jwt` the API also gains `POST /auth/register` and `/auth/login`,
with users stored in whichever database you chose (or in memory when you chose
none). Passwords use scrypt from `node:crypto` — no native build.
With a local auth variant the API also gains its own sign-in endpoints and a
session layer (short-lived access token, rotating refresh token with reuse
detection), with identities stored in whichever database you chose — or in
memory when you chose none. Emailed-link sign-in delivers through a transport
seam; Chassis binds no email provider.

---

Expand Down
24 changes: 21 additions & 3 deletions cli/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ import path from 'node:path';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
import readline from 'node:readline/promises';
import { MODULES, GROUPS, PRESETS, MONOREPO, descriptor } from './modules.mjs';
import {
MODULES,
GROUPS,
IMPLIED,
PRESETS,
MONOREPO,
descriptor,
impliedBy
} from './modules.mjs';
import { resolveSelection } from './select.mjs';

const REPO = 'dvd90/chassis';
Expand Down Expand Up @@ -309,10 +317,11 @@ await fetchTemplate(targetDir);

// ── Prune everything not chosen ────────────────────────────

// Kept = chosen db/auth variants + enabled toggles. Declined = the rest.
// Kept = chosen db/auth variants + whatever they imply + enabled toggles.
// Declined = the rest.
const kept = [
...(sel.db !== 'none' ? [sel.db] : []),
...(sel.auth !== 'none' ? [sel.auth] : []),
...(sel.auth !== 'none' ? [sel.auth, ...impliedBy(sel.auth)] : []),
...Object.entries(sel.modules)
.filter(([, on]) => on)
.map(([key]) => key)
Expand All @@ -326,6 +335,11 @@ for (const group of Object.values(GROUPS)) {
if (key !== 'none' && !kept.includes(key)) declined.push(key);
}
}
// Implied modules are never chosen directly, so they are declined whenever
// the selected auth variant did not ask for them.
for (const key of Object.keys(IMPLIED)) {
if (!kept.includes(key)) declined.push(key);
}
for (const [key, on] of Object.entries(sel.modules)) {
if (!on) declined.push(key);
}
Expand Down Expand Up @@ -549,6 +563,10 @@ function restructureToMonorepo(webDir) {
build: 'npm run build --workspaces --if-present',
verify: 'npm run verify --workspaces --if-present',
gen: `npm run gen -w ${MONOREPO.apiDir} --`,
// Same two names as the single-package layout, so the CI job that
// runs them does not need to know which layout it is in.
e2e: `npm run e2e -w ${MONOREPO.webDir}`,
'e2e:setup': `npm exec -w ${MONOREPO.webDir} -- playwright install --with-deps chromium`,
format: 'prettier --write .',
prepare: 'husky || true'
},
Expand Down
Loading
Loading