diff --git a/.chassisignore b/.chassisignore index 26f1f58..b63dfd0 100644 --- a/.chassisignore +++ b/.chassisignore @@ -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 diff --git a/.env.example b/.env.example index 38c849c..9c3f329 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22a8dfb..b3e3fb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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. @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 57f6bdc..0d335ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 07478d3..4de0b94 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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 @@ -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 ``` diff --git a/cli/README.md b/cli/README.md index a09206a..53b0c35 100644 --- a/cli/README.md +++ b/cli/README.md @@ -73,12 +73,12 @@ These are mutually exclusive groups. Choosing a database brings its ORM along. | Group | Values | ORM | | --------------- | ---------------------------------------- | ------------------ | | `--db ` | `none` · `mongo` · `postgres` · `sqlite` | Mongoose / Drizzle | -| `--auth ` | `none` · `auth0` · `jwt` · `clerk` | — | +| `--auth ` | `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. --- @@ -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` — `` 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. --- diff --git a/cli/index.mjs b/cli/index.mjs index 45748c7..b0e95ee 100644 --- a/cli/index.mjs +++ b/cli/index.mjs @@ -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'; @@ -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) @@ -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); } @@ -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' }, diff --git a/cli/modules.mjs b/cli/modules.mjs index f02b4c3..7c20e12 100644 --- a/cli/modules.mjs +++ b/cli/modules.mjs @@ -9,7 +9,7 @@ export const MODULES = { sentry: { label: 'Sentry error reporting', - files: ['src/integrations/sentry.ts'], + files: ['src/integrations/sentry.ts', 'src/integrations/sentry.test.ts'], deps: ['@sentry/node'] }, mcp: { @@ -23,10 +23,16 @@ export const MODULES = { files: ['src/integrations/x402.ts'], deps: ['x402-express'] }, + jobs: { + label: 'Background jobs (cron + long-running)', + files: ['src/jobs', 'docs/guides/jobs.md'], + deps: ['croner'], + scripts: ['jobs', 'start:jobs'] + }, web: { label: 'Next.js front end (npm-workspaces monorepo)', files: ['web'], - scripts: ['verify:web', 'dev:web'], + scripts: ['verify:web', 'dev:web', 'e2e', 'e2e:setup'], // `deps` are always the API's. The web app keeps its own package.json, // so its dependencies are declared under `web` blocks and pruned there. web: { deps: ['next', 'react', 'react-dom'] } @@ -82,39 +88,24 @@ export const GROUPS = { deps: ['@auth0/nextjs-auth0'] } }, + // The three local variants are composition only: they own no files and + // no dependencies, and exist to name a combination of IMPLIED modules. + // `jwt` keeps its name so `--auth jwt`, the presets, already-published + // CLI versions and the docs all keep working. jwt: { - label: 'Local JWT (jose)', - files: [ - 'src/integrations/jwt.ts', - 'src/controllers/Auth.controller.ts', - 'src/utils/password.ts', - 'src/db/users.ts', - 'src/db/memory-users.ts', - 'src/__tests__/auth.test.ts' - ], - // Deleted with jwt, but present only when the matching database was - // also chosen — the auth × db cross-product a flat `files` list - // cannot express, so it is never asserted present. - crossFiles: [ - 'src/db/sqlite/users.ts', - 'src/db/sqlite/users.schema.ts', - 'src/db/sqlite/users.test.ts', - 'src/db/postgres/users.ts', - 'src/db/postgres/users.schema.ts', - 'src/db/mongo/users.ts' - ], - deps: ['jose'], - web: { - provider: 'jwt', - files: [ - 'web/auth/providers/jwt.tsx', - 'web/auth/providers/jwt.client.tsx', - 'web/auth/providers/jwt.shared.ts', - 'web/auth/providers/jwt.middleware.ts', - 'web/auth/providers/jwt.middleware.test.ts', - 'web/app/api' - ] - } + label: 'Local — password', + implies: ['session', 'password'], + web: { provider: 'jwt' } + }, + 'magic-only': { + label: 'Local — magic link, no password', + implies: ['session', 'magic'], + web: { provider: 'jwt' } + }, + 'password+magic': { + label: 'Local — password and magic link', + implies: ['session', 'password', 'magic'], + web: { provider: 'jwt' } }, clerk: { label: 'Clerk', @@ -133,34 +124,168 @@ export const GROUPS = { } }; +/** + * Modules that are never chosen directly — an auth variant pulls them in via + * `implies`. They live outside MODULES so the interactive "Custom" path never + * prompts for them and presets never have to list them. + * + * The split exists because the three local auth variants share a session layer + * and overlap on the password and magic halves. A file may be claimed by only + * one module, so a variant that needed the union of two file sets could not be + * expressed with a flat `files` list; composition can. + */ +export const IMPLIED = { + session: { + label: 'Session layer (access token + rotating refresh token)', + files: [ + 'src/integrations/jwt.ts', + 'src/services/session.ts', + 'src/services/session.test.ts', + 'src/controllers/Session.controller.ts', + 'src/middleware/rateLimit.ts', + 'src/middleware/sameOrigin.ts', + 'src/utils/clock.ts', + 'src/utils/duration.ts', + 'src/utils/duration.test.ts', + 'src/utils/tokens.ts', + 'src/utils/tokens.test.ts', + 'src/utils/cookies.ts', + 'src/db/users.ts', + 'src/db/memory-users.ts', + 'src/db/sessions.ts', + 'src/db/memory-sessions.ts', + 'src/__tests__/session.test.ts', + 'docs/guides/sessions.md' + ], + // Present only when the matching database was also chosen — the auth × db + // cross-product a flat `files` list cannot express, so it is never + // asserted present. + crossFiles: [ + 'src/db/sqlite/users.ts', + 'src/db/sqlite/users.schema.ts', + 'src/db/sqlite/users.test.ts', + 'src/db/sqlite/sessions.ts', + 'src/db/sqlite/sessions.schema.ts', + 'src/db/postgres/users.ts', + 'src/db/postgres/users.schema.ts', + 'src/db/postgres/sessions.ts', + 'src/db/postgres/sessions.schema.ts', + 'src/db/mongo/users.ts', + 'src/db/mongo/sessions.ts' + ], + deps: ['jose'], + web: { + files: [ + 'web/auth/providers/jwt.tsx', + 'web/auth/providers/jwt.client.tsx', + 'web/auth/providers/jwt.forms.ts', + 'web/auth/providers/jwt.shared.ts', + 'web/auth/providers/jwt.middleware.ts', + 'web/auth/providers/jwt.middleware.test.ts', + 'web/app/api' + ] + } + }, + password: { + label: 'Password sign-in', + files: [ + 'src/utils/password.ts', + 'src/controllers/Password.controller.ts', + 'src/db/passwords.ts', + 'src/db/memory-passwords.ts', + 'src/__tests__/password.test.ts', + 'docs/guides/password-auth.md' + ], + crossFiles: [ + 'src/db/sqlite/passwords.ts', + 'src/db/sqlite/passwords.test.ts', + 'src/db/postgres/passwords.ts', + 'src/db/mongo/passwords.ts' + ], + web: { + files: [ + 'web/auth/providers/jwt.password-form.tsx', + // Nested inside web/app/api, which the session module owns wholesale. + 'web/app/api/session/password' + ] + } + }, + magic: { + label: 'Magic-link sign-in', + files: [ + 'src/services/magic.ts', + 'src/services/magic.test.ts', + 'src/controllers/Magic.controller.ts', + 'src/controllers/magic.page.ts', + 'src/db/magic.ts', + 'src/db/memory-magic.ts', + 'src/mail', + 'src/sms', + 'src/__tests__/magic.test.ts', + 'src/__tests__/magic.e2e.test.ts', + 'docs/guides/magic-link.md', + 'docs/guides/transports.md' + ], + crossFiles: [ + 'src/db/sqlite/magic.ts', + 'src/db/sqlite/magic.schema.ts', + 'src/db/postgres/magic.ts', + 'src/db/postgres/magic.schema.ts', + 'src/db/mongo/magic.ts' + ], + deps: ['nodemailer'], + devDeps: ['@types/nodemailer'], + web: { + files: [ + 'web/auth/providers/jwt.magic-form.tsx', + 'web/app/auth', + // Nested inside web/app/api, which the session module owns wholesale. + 'web/app/api/session/magic' + ] + } + } +}; + // One-pick presets. `Custom` (handled in the CLI) prompts for each choice. export const PRESETS = { api: { label: 'Recommended API — Postgres + JWT + Sentry + Docker', db: 'postgres', auth: 'jwt', - modules: { sentry: true, mcp: false, x402: false, web: false }, + modules: { sentry: true, mcp: false, x402: false, jobs: false, web: false }, docker: true }, fullstack: { label: 'Full-stack — Postgres + JWT + Next.js front end + Sentry + Docker', db: 'postgres', auth: 'jwt', - modules: { sentry: true, mcp: false, x402: false, web: true }, + modules: { sentry: true, mcp: false, x402: false, jobs: false, web: true }, docker: true }, lite: { label: 'Lite — SQLite + JWT, no external infrastructure', db: 'sqlite', auth: 'jwt', - modules: { sentry: false, mcp: false, x402: false, web: false }, + modules: { + sentry: false, + mcp: false, + x402: false, + jobs: false, + web: false + }, docker: false }, minimal: { label: 'Minimal — no database, no auth, standalone', db: 'none', auth: 'none', - modules: { sentry: false, mcp: false, x402: false, web: false }, + modules: { + sentry: false, + mcp: false, + x402: false, + jobs: false, + web: false + }, docker: false } }; @@ -191,10 +316,15 @@ export const MONOREPO = { rootDevDeps: ['prettier', 'husky'] }; -/** Look up a module or group-variant descriptor by name. */ +/** Look up a module, group-variant or implied-module descriptor by name. */ export function descriptor(name) { for (const group of Object.values(GROUPS)) { if (group.variants[name]) return group.variants[name]; } - return MODULES[name] ?? null; + return MODULES[name] ?? IMPLIED[name] ?? null; +} + +/** The modules a selected group variant drags in with it. */ +export function impliedBy(name) { + return descriptor(name)?.implies ?? []; } diff --git a/cli/scaffold.test.mjs b/cli/scaffold.test.mjs index 636cdce..0634fff 100644 --- a/cli/scaffold.test.mjs +++ b/cli/scaffold.test.mjs @@ -32,7 +32,15 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; -import { MODULES, GROUPS, PRESETS, MONOREPO, descriptor } from './modules.mjs'; +import { + MODULES, + GROUPS, + IMPLIED, + PRESETS, + MONOREPO, + descriptor, + impliedBy +} from './modules.mjs'; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -48,9 +56,13 @@ const templatePkg = JSON.parse( fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8') ); -/** Every module + group-variant name (excluding the empty `none`). */ +/** + * Every module, group-variant and implied-module name (excluding the empty + * `none`). Implied modules are never chosen directly, but they own files and + * dependencies, so every oracle below has to know about them. + */ function allNames() { - const names = Object.keys(MODULES); + const names = [...Object.keys(MODULES), ...Object.keys(IMPLIED)]; for (const group of Object.values(GROUPS)) { for (const key of Object.keys(group.variants)) { if (key !== 'none') names.push(key); @@ -59,6 +71,11 @@ function allNames() { return names; } +/** A selection plus everything it drags in. */ +function expand(names) { + return [...new Set(names.flatMap((name) => [name, ...impliedBy(name)]))]; +} + const allModuleDeps = new Set(); const allModuleDevDeps = new Set(); for (const name of allNames()) { @@ -102,6 +119,12 @@ function resolvePath(dir, file, monorepo) { if (file === 'web' || file.startsWith('web/')) { return path.join(dir, path.dirname(MONOREPO.webDir), file); } + // Only the API's own paths move under apps/api. Everything else — docs, + // README, .github — stays at the repo root, so a module that owns a doc + // file must be looked for there. + if (!MONOREPO.apiPaths.includes(file.split('/')[0])) { + return path.join(dir, file); + } return path.join(dir, MONOREPO.apiDir, file); } @@ -170,8 +193,102 @@ function walkText(dir, out = [], skip = NOT_WALKED) { return out; } + +/** + * What a declined module must leave no trace of. + * + * The acceptance criterion for `--auth magic-only` is that the word "password" + * appears nowhere in a generated project. Rather than special-casing that one + * grep, every splittable module declares what its absence should look like — + * so magic and session are held to the same standard. + * + * `everywhere` widens the scan to markdown. Only `password` sets it: docs + * deliberately describe modules a project declined (that is why .md is exempt + * from marker pruning), so the module-specific prose lives in module-owned doc + * files that get deleted outright. + */ +const RESIDUE = { + password: { + pattern: /password/i, + // The database's own credential, correctly kept, and nothing to do with + // how people sign in. + allow: /POSTGRES_PASSWORD/, + everywhere: true + }, + + magic: { pattern: /\bmagic\b|mailpit|nodemailer/i }, + session: { pattern: /refreshToken|refresh_token|SESSION_ABSOLUTE/i } +}; + +/** + * Files exempt from the residue scan, because naming a module the reader did + * not scaffold is their job rather than a leak. Everything else, prose + * included, is held to the rule. + * + * - the two docs whose subject *is* the module system — the same reason .md + * is exempt from marker pruning in the first place; + * - the logger and the request-logging test, whose redaction denylist has to + * spell out the credential key names it strips. `password` and `token` + * appear there in every project, including ones with no password and no + * tokens, and that is correct: a denylist that only covers the modules you + * kept is a denylist with holes. + */ +const RESIDUE_EXEMPT = [ + 'docs/modules.md', + 'docs/reference/cli.md', + 'src/utils/logger.ts', + 'src/utils/logger.test.ts', + 'src/__tests__/logging.test.ts' +]; + +function residueExempt(dir, file) { + const rel = path.relative(dir, file).split(path.sep).join('/'); + return RESIDUE_EXEMPT.some((p) => rel === p || rel.endsWith('/' + p)); +} + +function assertNoModuleResidue(dir, declined) { + for (const name of declined) { + const rule = RESIDUE[name]; + if (!rule) continue; + + const files = rule.everywhere ? walkAll(dir) : walkText(dir, []); + + for (const file of files) { + if (residueExempt(dir, file)) continue; + const lines = fs.readFileSync(file, 'utf8').split('\n'); + for (const [index, line] of lines.entries()) { + if (!rule.pattern.test(line)) continue; + if (rule.allow?.test(line)) continue; + assert.fail( + `declined module ${name} left a trace in ` + + `${path.relative(dir, file)}:${index + 1}: ${line.trim()}` + ); + } + } + } +} + +/** + * Every text file, markdown included. Lockfiles are skipped: they are full of + * transitive package names nobody chose (`magic-string`, for one), and none of + * it is residue from a pruned module. + */ +function walkAll(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + if (entry.name === 'package-lock.json') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkAll(full, out); + else if (/\.(ts|tsx|mjs|yml|yaml|json|md|example)$/.test(entry.name)) { + out.push(full); + } else if (entry.name.startsWith('.env')) out.push(full); + } + return out; +} + /** The heart of the test: assert the scaffold carries exactly `keptNames`. */ -function assertScaffold(dir, keptNames) { +function assertScaffold(dir, selected) { + const keptNames = expand(selected); const monorepo = keptNames.includes('web'); const at = (file) => resolvePath(dir, file, monorepo); const pkg = readPkg(monorepo ? path.join(dir, MONOREPO.apiDir) : dir); @@ -226,6 +343,8 @@ function assertScaffold(dir, keptNames) { } } + assertNoModuleResidue(dir, declined); + // 4. Not one marker survives in the code — declined modules take their // whole line, kept ones have the marker text stripped. (Docs keep // theirs: they describe the full module system.) @@ -305,6 +424,23 @@ function assertScaffold(dir, keptNames) { `template-only file shipped: ${gone}` ); } + + // The browser tests moved with the app, and the root drives them by the + // same two script names the single-package layout uses — that is what + // lets one CI job serve both layouts. + for (const kept of ['playwright.config.ts', 'e2e']) { + assert.ok( + fs.existsSync(path.join(dir, MONOREPO.webDir, kept)), + `browser tests missing from ${MONOREPO.webDir}: ${kept}` + ); + } + for (const script of ['e2e', 'e2e:setup']) { + assert.match( + rootPkg.scripts?.[script] ?? '', + new RegExp(MONOREPO.webDir), + `root "${script}" does not target ${MONOREPO.webDir}` + ); + } } else { assert.ok(!rootPkg.workspaces, 'unexpected workspaces in single package'); assert.ok(fs.existsSync(path.join(dir, 'src')), 'src missing'); @@ -530,6 +666,9 @@ test('catalog: every module is marked or has files of its own', () => { } } for (const name of allNames()) { + // A variant that only composes implied modules owns no files and no + // markers of its own — that is the whole point of `implies`. + if (impliedBy(name).length) continue; assert.ok( marked.has(name) || declaredPaths(descriptor(name)).length > 0, `${name} has no markers and no files — pruning it would be a no-op` @@ -537,12 +676,71 @@ test('catalog: every module is marked or has files of its own', () => { } }); +test('catalog: no .tsx file carries a chassis: marker', () => { + // The pruner does not read .tsx (cli/index.mjs), and neither does the + // marker-residue grep in .github/workflows/published.yml. A marker there + // would therefore survive into every generated project, silently. Rather + // than teach two regexes about JSX — where Prettier moves comments around + // and `{/* ... */}` does not match the end-of-line pattern anyway — markers + // stay in .ts and this test keeps them there. + const offenders = []; + (function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (NOT_WALKED.includes(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.tsx')) { + const source = fs.readFileSync(full, 'utf8'); + if (/chassis:\w+/.test(source)) { + offenders.push(path.relative(repoRoot, full)); + } + } + } + })(repoRoot); + + assert.deepEqual( + offenders, + [], + 'markers in .tsx are invisible to the pruner — move them to a .ts file' + ); +}); + +test('catalog: every implies target is a real module', () => { + for (const name of allNames()) { + for (const target of impliedBy(name)) { + assert.ok( + descriptor(target), + `${name} implies "${target}", which no module declares` + ); + assert.ok( + IMPLIED[target], + `${name} implies "${target}", which must live in IMPLIED so the ` + + `interactive path never prompts for it` + ); + } + } +}); + +test('catalog: every implied module is reachable from some variant', () => { + const reachable = new Set(allNames().flatMap((name) => impliedBy(name))); + for (const name of Object.keys(IMPLIED)) { + assert.ok( + reachable.has(name), + `IMPLIED.${name} is implied by nothing — it could never be scaffolded` + ); + } +}); + test('catalog: every auth provider file is claimed by exactly one provider', () => { // An unclaimed file in web/auth/providers ships with *every* provider — // and then imports a module the CLI deleted. This is the guard against // adding a provider file (or a test for one) and forgetting the catalog. const owner = new Map(); - for (const [name, variant] of Object.entries(GROUPS.auth.variants)) { + const claimants = [ + ...Object.entries(GROUPS.auth.variants), + ...Object.entries(IMPLIED) + ]; + for (const [name, variant] of claimants) { for (const file of variant.web?.files ?? []) { const previous = owner.get(file); assert.ok(!previous, `${file} claimed by both ${previous} and ${name}`); @@ -804,7 +1002,8 @@ const structural = [ { label: 'toggle sentry', flags: ['--preset', 'minimal', '--sentry'], kept: ['sentry'] }, // prettier-ignore { label: 'toggle mcp', flags: ['--preset', 'minimal', '--mcp'], kept: ['mcp'] }, // prettier-ignore { label: 'toggle x402', flags: ['--preset', 'minimal', '--x402'], kept: ['x402'] }, // prettier-ignore - { label: 'every toggle at once', flags: ['--preset', 'minimal', '--db', 'postgres', '--auth', 'jwt', '--sentry', '--mcp', '--x402', '--web', '--docker'], kept: ['postgres', 'jwt', 'sentry', 'mcp', 'x402', 'web'] }, // prettier-ignore + { label: 'toggle jobs', flags: ['--preset', 'minimal', '--jobs'], kept: ['jobs'] }, // prettier-ignore + { label: 'every toggle at once', flags: ['--preset', 'minimal', '--db', 'postgres', '--auth', 'jwt', '--sentry', '--mcp', '--x402', '--jobs', '--web', '--docker'], kept: ['postgres', 'jwt', 'sentry', 'mcp', 'x402', 'jobs', 'web'] }, // prettier-ignore { label: 'preset lite', flags: ['--preset', 'lite'], kept: ['sqlite', 'jwt'] }, // prettier-ignore { label: 'preset api', flags: ['--preset', 'api'], kept: ['postgres', 'jwt', 'sentry'] }, // prettier-ignore { label: 'preset fullstack', flags: ['--preset', 'fullstack'], kept: ['postgres', 'jwt', 'sentry', 'web'] }, // prettier-ignore @@ -984,6 +1183,32 @@ test('structural: preset stacks match PRESETS', () => { assert.deepEqual(PRESETS.minimal.db, 'none'); }); +test('structural: the shipped CI workflow follows the magic module', () => { + // A whole workflow job is marked line-by-line, which is the largest marked + // block in the template — exactly the kind of thing that half-prunes and + // leaves invalid YAML behind. + const withMagic = scaffold(['--preset', 'minimal', '--auth', 'magic-only']); + const withoutMagic = scaffold(['--preset', 'minimal', '--auth', 'jwt']); + + try { + const ci = (dir) => + fs.readFileSync(path.join(dir, '.github/workflows/ci.yml'), 'utf8'); + + assert.match(ci(withMagic), /mail-e2e:/); + assert.match(ci(withMagic), /axllent\/mailpit/); + assert.doesNotMatch(ci(withoutMagic), /mail-e2e:/); + assert.doesNotMatch(ci(withoutMagic), /mailpit/); + + // ...and what survives is still a single well-formed job list. + assert.match(ci(withoutMagic), /^jobs:$/m); + assert.match(ci(withoutMagic), /^ {2}scaffold:$/m); + } finally { + for (const dir of [withMagic, withoutMagic]) { + fs.rmSync(path.dirname(dir), { recursive: true, force: true }); + } + } +}); + // ── Build cases (SCAFFOLD_BUILD=1): install + verify ──────── const build = [ @@ -992,6 +1217,14 @@ const build = [ { label: 'api (postgres/jwt/sentry)', flags: ['--preset', 'api'], kept: ['postgres', 'jwt', 'sentry'], db: 'postgres' }, // prettier-ignore { label: 'mongo/clerk/sentry/mcp/x402', flags: ['--preset', 'minimal', '--db', 'mongo', '--auth', 'clerk', '--sentry', '--mcp', '--x402', '--docker'], kept: ['mongo', 'clerk', 'sentry', 'mcp', 'x402'], db: 'mongo' }, // prettier-ignore { label: 'postgres/auth0/mcp', flags: ['--preset', 'minimal', '--db', 'postgres', '--auth', 'auth0', '--mcp'], kept: ['postgres', 'auth0', 'mcp'], db: 'postgres' }, // prettier-ignore + // Jobs check in to Sentry across four marked lines around one try/catch. + // Keeping jobs while declining sentry is what proves they prune together — + // `noUnusedLocals` turns a half-pruned check-in into a build break. + { label: 'jobs without sentry', flags: ['--preset', 'minimal', '--jobs'], kept: ['jobs'], db: null }, // prettier-ignore + // The jobs entrypoint test spawns the entrypoint and resolves it from the + // package root, so the monorepo — where that root is apps/api, not the repo + // — is the layout where a wrong assumption about cwd would surface. + { label: 'jobs + web (monorepo)', flags: ['--preset', 'minimal', '--jobs', '--web'], kept: ['jobs', 'web'], db: null }, // prettier-ignore // Local JWT with no database at all: every db import in the user-store // seam prunes away, which is where an unused-import build break hides. { label: 'jwt, no database', flags: ['--preset', 'minimal', '--auth', 'jwt'], kept: ['jwt'], db: null }, // prettier-ignore @@ -1000,7 +1233,13 @@ const build = [ { label: 'fullstack (sqlite/jwt/web)', flags: ['--preset', 'minimal', '--db', 'sqlite', '--auth', 'jwt', '--web'], kept: ['sqlite', 'jwt', 'web'], db: 'sqlite' }, // prettier-ignore { label: 'web + clerk', flags: ['--preset', 'minimal', '--auth', 'clerk', '--web'], kept: ['clerk', 'web'], db: null }, // prettier-ignore { label: 'web + auth0', flags: ['--preset', 'minimal', '--auth', 'auth0', '--web'], kept: ['auth0', 'web'], db: null }, // prettier-ignore - { label: 'web + no auth', flags: ['--preset', 'minimal', '--web'], kept: ['web'], db: null } // prettier-ignore + { label: 'web + no auth', flags: ['--preset', 'minimal', '--web'], kept: ['web'], db: null }, // prettier-ignore + // The two new local variants. magic-only is the one that has to compile with + // every password reference pruned; password+magic is the one where both + // halves share a session layer. + { label: 'magic-only (postgres)', flags: ['--preset', 'minimal', '--db', 'postgres', '--auth', 'magic-only'], kept: ['postgres', 'magic-only'], db: 'postgres' }, // prettier-ignore + { label: 'password+magic (sqlite)', flags: ['--preset', 'minimal', '--db', 'sqlite', '--auth', 'password+magic'], kept: ['sqlite', 'password+magic'], db: 'sqlite' }, // prettier-ignore + { label: 'magic-only, no database + web', flags: ['--preset', 'minimal', '--auth', 'magic-only', '--web'], kept: ['magic-only', 'web'], db: null } // prettier-ignore ]; for (const [buildIndex, { label, flags, kept, db }] of build.entries()) { diff --git a/cli/select.test.mjs b/cli/select.test.mjs index edf2ac5..c94bd9a 100644 --- a/cli/select.test.mjs +++ b/cli/select.test.mjs @@ -12,7 +12,7 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { MODULES, GROUPS, PRESETS } from './modules.mjs'; +import { MODULES, GROUPS, IMPLIED, PRESETS } from './modules.mjs'; import { presetChoices, resolveSelection } from './select.mjs'; /** A prompter that answers from a script and records what it was asked. */ @@ -118,6 +118,36 @@ test('Custom walks database, auth, every module, then Docker', async () => { ); }); +test('Custom never prompts for an implied module', async () => { + // Implied modules live outside MODULES precisely so that the interactive + // path cannot offer them: they are consequences of an auth choice, not + // choices themselves. Putting one in MODULES would ask the user whether + // they want a session layer they already implicitly asked for. + const { prompts, asked } = scripted('custom', 'none', 'none'); + const sel = await resolveSelection({ prompts }); + + for (const key of Object.keys(IMPLIED)) { + assert.ok(!(key in sel.modules), `"${key}" leaked into the module toggles`); + assert.ok( + !asked.some((a) => a.prompt.includes(IMPLIED[key].label)), + `Custom asked about the implied module "${key}"` + ); + } +}); + +test('every local auth variant survives selection and implies a session', async () => { + for (const variant of ['jwt', 'magic-only', 'password+magic']) { + const { prompts } = scripted(); + const sel = await resolveSelection({ prompts, skipPrompts: true, auth: variant }); // prettier-ignore + + assert.equal(sel.auth, variant, `--auth ${variant} was not honoured`); + assert.ok( + GROUPS.auth.variants[variant].implies?.includes('session'), + `${variant} must imply the session layer, or it ships no way to sign in` + ); + } +}); + test('Custom asks about every module in the catalog', async () => { // Adding a module without it appearing here would silently make it // unreachable for anyone using the interactive flow. diff --git a/docker-compose.yml b/docker-compose.yml index dbee11b..1f165be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: PORT: 8000 MONGODB_URI: mongodb://mongo:27017/chassis # chassis:mongo DATABASE_URL: postgres://postgres:postgres@db:5432/chassis # chassis:postgres + SMTP_URL: smtp://mailpit:1025 # chassis:magic depends_on: # chassis:mongo - mongo # chassis:mongo @@ -29,5 +30,15 @@ services: ports: # chassis:postgres - '5432:5432' # chassis:postgres + # Catches every email the app sends; read them at http://localhost:8025. + # ponytail: no named volume on purpose — the top-level `volumes:` block + # below belongs entirely to mongo, and a second owner would stop it + # pruning cleanly. Nothing here is worth surviving a restart anyway. + mailpit: # chassis:magic + image: axllent/mailpit:latest # chassis:magic + ports: # chassis:magic + - '1025:1025' # chassis:magic + - '8025:8025' # chassis:magic + volumes: # chassis:mongo mongo-data: # chassis:mongo diff --git a/docs/README.md b/docs/README.md index 6642374..eb74a92 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ | [Getting started](getting-started.md) | Step-by-step: scaffold, run, add your first endpoint, test, build | | [Building an API](guides/building-an-api.md) | A complete CRUD resource: model, validation, errors, tests | | [Database](guides/database.md) | Choosing Mongo/Postgres/SQLite, the ORM, migrations, DB-aware gen | -| [Authentication](guides/authentication.md) | Auth0, local JWT (register/login), Clerk, on the API and the web | +| [Authentication](guides/authentication.md) | Auth0, Clerk, local auth, and the guides for each sign-in method | | [Web front end](guides/web.md) | The Next.js app, the monorepo layout, swapping auth providers | | [MCP server](guides/mcp.md) | Exposing the API to AI agents as MCP tools | | [Payments (x402)](guides/payments-x402.md) | Payment-gating routes with `@paidRoute` and the x402 protocol | diff --git a/docs/architecture.md b/docs/architecture.md index 5a2c918..71859ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,27 @@ Two intentional paths: reports to Sentry when enabled, and returns a sanitized 500 (stack traces are only included outside production). +## Logging + +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. + ## Why an app factory? `createApp()` performs no I/O — integrations boot separately in diff --git a/docs/design/magic-link.md b/docs/design/magic-link.md new file mode 100644 index 0000000..f5ba698 --- /dev/null +++ b/docs/design/magic-link.md @@ -0,0 +1,469 @@ +# Design: magic-link auth + refresh sessions + +Status: **built.** The design below is what shipped; §9 records where the +implementation departed from the plan, and why. + +## Why + +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. + +## 1. Current state + +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` 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:` 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` | + +## 2. Session strategy: the local-JWT variant + +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. + +## 3. Module decomposition + +`chassis:jwt` splits into three *implied* modules composed by auth variants: + +``` +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. + +### CLI changes + +1. `export const IMPLIED = { session, password, magic }`, usual descriptor shape. +2. `descriptor()` consults `IMPLIED` after `GROUPS` and `MODULES`. +3. `kept` gains the selected variant's `implies`; `declined` sweeps + `Object.keys(IMPLIED)` minus kept (`cli/index.mjs:313-331`). +4. `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. + +## 4. File plan + +### New — session (`chassis:session`) + +| File | Contents | +|---|---| +| `src/utils/clock.ts` | `let clock = () => new Date()`, `now()`, `setClock()` — the `core/auth.ts` seam idiom, six lines | +| `src/utils/duration.ts` | `seconds('15m') -> 900`. Its own file, not a config helper, so pruning both modules cannot leave an unused local (`noUnusedLocals` is on) | +| `src/utils/tokens.ts` | `randomToken()` (32 bytes base64url), `randomCode()` (`crypto.randomInt`), `sha256()`, `timingSafeEqualHex()` | +| `src/utils/cookies.ts` | Three-line `req.headers.cookie` read. Express 5 has `res.cookie()` natively, so no dependency | +| `src/services/session.ts` | Mint access JWT; issue, rotate, revoke refresh; family reuse detection; idle and absolute checks | +| `src/db/sessions.ts`, `src/db/memory-sessions.ts` | `RefreshTokenStore` and `sessionStore()`, cloned from the table pattern at `src/db/users.ts:35-44` | +| `src/controllers/Session.controller.ts` | `POST /auth/refresh`, `/auth/logout`, `/auth/revoke-all` | +| `src/middleware/rateLimit.ts` | Fixed-window `Map` keyed by a caller-supplied function, driven by the injected clock | +| `src/middleware/sameOrigin.ts` | `Origin`/`Sec-Fetch-Site` check against `config.corsOrigins`, for the cookie endpoints only | + +### New — magic (`chassis:magic`) + +| File | Contents | +|---|---| +| `src/services/magic.ts` | `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. + +### Renamed and restructured + +- `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: + + ```ts + // web/auth/providers/jwt.forms.ts — plain .ts, pruned already, Prettier-stable + import { PasswordForm } from './jwt.password-form'; // chassis:password + import { MagicForm } from './jwt.magic-form'; // chassis:magic + + export const forms = [ + PasswordForm, // chassis:password + MagicForm // chassis:magic + ]; + ``` + + Pruning either line leaves valid TypeScript (`[PasswordForm,]` and + `[MagicForm]` both parse), and the `.tsx` shell contains zero markers. + +### Core additions + +`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. + +### Docs + +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". + +## 5. API surface + +| Method | Path | Behaviour | +|---|---|---| +| `POST` | `/auth/magic/request` | `{ 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 `Routable`s 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`. + +### Environment variables + +Defaults live in the zod schema; each line is marked in `.env.example`. + +- `chassis:session` — `JWT_SECRET` (moves from `chassis:jwt`), + `ACCESS_TOKEN_TTL=15m`, `SESSION_IDLE=30d`, `SESSION_ABSOLUTE=90d` +- `chassis:magic` — `MAGIC_TOKEN_TTL=15m`, `MAGIC_CODE_ATTEMPTS=5`, + `MAGIC_LINK_BASE_URL=http://localhost:8000`, `MAGIC_RETURN_TO_ORIGINS?`, + `MAGIC_FROM=no-reply@localhost`, `SMTP_URL?`, `MAGIC_RATE_PER_EMAIL=3`, + `MAGIC_RATE_PER_IP=20`, `MAGIC_RATE_WINDOW=15m` +- `chassis:password` — `AUTH_DEV_EMAIL`, `AUTH_DEV_PASSWORD` (move from + `chassis:jwt`) + +Durations stay strings in `config`; `src/utils/duration.ts` parses at the point +of use. `config.features.jwt` becomes `config.features.session`. + +### Migration list + +No migration infrastructure exists (see §1), so these follow the established +hand-run convention: `npx drizzle-kit generate --config src/db//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. + +1. **`users`** — add `verified_at` (nullable timestamp); make `password_hash` + **nullable**, since a magic-only identity has no password. Existing rows are + unaffected; products migrating an existing database are pointed at + `docs/guides/sessions.md`. +2. **`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. +3. **`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. + +## 6. Phases + +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. + +## 7. Pipeline + +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. + +## 8. Conflicts with existing conventions + +Flagged rather than silently resolved. Items 1 and 2 were explicitly approved. + +1. **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. +2. **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()`. +3. **`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". +4. **`.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. +5. **`.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. +6. **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. +7. **No queue exists**, so "enqueue the send" is a detached promise after the + 202. Marked `// ponytail: fire-and-forget; a real queue when delivery needs + retries or visibility`. +8. **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`. +9. **`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. +10. **`Auth.controller.ts` is renamed**, and it is referenced by name in + `AGENTS.md` and three docs. +11. **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. +12. **No migration infrastructure exists**; new tables follow the hand-run + `drizzle-kit generate` convention. +13. **`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`. +14. **`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. + +## 9. As built — where it departed from the plan + +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. + +### Verified + +- `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. diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index 85e3d03..5d2e6ea 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -62,63 +62,44 @@ handler. To read the token's claims in a handler, use the `auth` property that `express-oauth2-jwt-bearer` sets on the request (`req.auth?.payload.sub`, etc.). -## Option B — Local JWT (built in) +## Option B — Local auth (built in) -Pick `--auth jwt` for self-issued Bearer tokens with no third party. Set a -secret: +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 ``` -Unlike the hosted providers, this one has no user directory behind it — so -Chassis ships the missing half: `src/controllers/Auth.controller.ts` mints -tokens that `src/integrations/jwt.ts` then verifies (HS256, via -[jose](https://github.com/panva/jose)). - -```bash -curl localhost:8000/auth/register -H 'content-type: application/json' \ - -d '{"email":"dev@example.com","password":"correct-horse-42"}' -# → 201 { "user": { "id": "1", "email": "dev@example.com" }, "token": "eyJ..." } - -curl localhost:8000/auth/login -H 'content-type: application/json' \ - -d '{"email":"dev@example.com","password":"correct-horse-42"}' -# → 200 { "user": {...}, "token": "eyJ..." } -``` - -Passwords are hashed with **scrypt** from Node's `node:crypto` — a -memory-hard KDF in the standard library, so there is no argon2/bcrypt -dependency and no native build (`src/utils/password.ts`). +...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](sessions.md). -### Where users are stored +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. -`src/db/users.ts` resolves the store the same way integrations resolve -themselves — by feature flag, at call time: - -| Configured database | Store | -| ------------------- | ------------------------------------------------------------- | -| SQLite / Postgres | Drizzle `users` table (`src/db//users.ts`) | -| MongoDB | Mongoose `User` model (`src/db/mongo/users.ts`) | -| _none_ | in-memory, seeded from `AUTH_DEV_EMAIL` / `AUTH_DEV_PASSWORD` | +| Configured database | Store | +| ------------------- | ----------------------------------------------- | +| SQLite / Postgres | Drizzle `users` table (`src/db//users.ts`) | +| MongoDB | Mongoose `User` model (`src/db/mongo/users.ts`) | +| _none_ | in-memory, seeded from `AUTH_DEV_EMAIL` | Pick a database and the store follows it — no code change. The in-memory -fallback exists so `--auth jwt --db none` still boots and logs in during -development; it is process-local and forgets everything on restart. Add a -database before putting local JWT in front of real users. +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 for the `users` table before first -use: +With Drizzle, generate the migration before first use: ```bash npx drizzle-kit generate --config src/db/sqlite/drizzle.config.ts ``` -Tokens are access tokens only, valid for one hour — there is no refresh -rotation. Clients re-authenticate when a token expires; add a refresh -endpoint alongside `/auth/login` if you need sessions to outlive that -without a password prompt. - ## Option C — Clerk (built in) Pick `--auth clerk` to use [Clerk](https://clerk.com). Set the key (Clerk reads diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index cb20f95..ffa491d 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -71,6 +71,32 @@ Any platform that runs a Node server or a Dockerfile works: - Set `PORT` if the platform injects its own (Chassis reads it) - Set `NODE_ENV=production` +## Readable stack traces (Sentry) + +`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. + +## Running jobs + +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](jobs.md). + ## Production checklist - [ ] `NODE_ENV=production` — enables JSON logs, hides stack traces from @@ -80,7 +106,9 @@ Any platform that runs a Node server or a Dockerfile works: - [ ] Secrets (`MONGODB_URI`, `SENTRY_DSN`, …) come from the platform's secret store, not a committed file — `.env` is gitignored, keep it that way -- [ ] `SENTRY_DSN` set if you want error reporting (recommended) +- [ ] `SENTRY_DSN` set if you want error reporting (recommended), and + `SENTRY_RELEASE` set to the deployed commit SHA so traces resolve to + source - [ ] Point probes at `/healthz` and `/readyz` - [ ] CI is green (`npm run verify` — the included GitHub Actions workflow runs it on Node 20 and 22) diff --git a/docs/guides/jobs.md b/docs/guides/jobs.md new file mode 100644 index 0000000..b0d6c9b --- /dev/null +++ b/docs/guides/jobs.md @@ -0,0 +1,94 @@ +# Background jobs + +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 +``` + +## Defining a job + +```ts +// src/jobs/index.ts +import { now } from '../utils/clock'; + +export const jobs: JobDefinition[] = [ + { + name: 'purge-expired-tokens', + schedule: '0 3 * * *', + async run({ logger }) { + const cutoff = now(); + logger.info(`purging tokens issued before ${cutoff.toISOString()}`); + } + } +]; +``` + +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. + +## Failure + +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. + +## Sentry Crons + +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. + +## Deploying + +The jobs process is the same image as the API with a different command: + +```yaml +# one service per entrypoint, one image +api: + image: your-app + command: node dist/server.js +jobs: + image: your-app + command: node dist/jobs/run.js +``` + +Run exactly one jobs replica unless every job is idempotent — nothing here +coordinates schedules across processes. + +``` +ponytail: no distributed lock. Two replicas run every cron twice. Add an +advisory lock keyed on the job name the day you need to scale out. +``` diff --git a/docs/guides/magic-link.md b/docs/guides/magic-link.md new file mode 100644 index 0000000..c5b1cd9 --- /dev/null +++ b/docs/guides/magic-link.md @@ -0,0 +1,116 @@ +# Magic link + +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. + +## The flow + +``` +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 +``` + +## Why the confirmation page exists + +`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. + +## Configuration + +| Variable | Default | Meaning | +| ------------------------- | ----------------------- | ------------------------------------------------ | +| `MAGIC_TOKEN_TTL` | `15m` | Lifetime of both the link and the code | +| `MAGIC_CODE_ATTEMPTS` | `5` | 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. + +## What it guarantees + +- **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. + +## The verification hook + +Redeeming proves the address. The first time that happens, `verified_at` is +stamped on the identity and one hook fires: + +```ts +import { setOnVerified } from './services/magic'; + +setOnVerified(async (identity) => { + await analytics.track('email_verified', { id: identity.id }); +}); +``` + +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. + +## Delivery + +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](transports.md). + +## Trying it locally + +```bash +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 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. diff --git a/docs/guides/password-auth.md b/docs/guides/password-auth.md new file mode 100644 index 0000000..bdeba3e --- /dev/null +++ b/docs/guides/password-auth.md @@ -0,0 +1,63 @@ +# Password sign-in + +Email and password, self-issued. + +```bash +curl localhost:8000/auth/register -H 'content-type: application/json' \ + -d '{"email":"dev@example.com","password":"correct-horse-42"}' +# → 201 { "user": {...}, "accessToken": "eyJ...", "refreshToken": "...", "expiresIn": 900 } + +curl localhost:8000/auth/login -H 'content-type: application/json' \ + -d '{"email":"dev@example.com","password":"correct-horse-42"}' +# → 200 { "user": {...}, "accessToken": "eyJ...", "refreshToken": "...", "expiresIn": 900 } +``` + +Both return a session rather than a bare token — see [Sessions](sessions.md) +for what to do with the refresh half. + +## Hashing + +**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$$`, 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. + +## Where the hash lives + +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. + +## Development seeding + +| Variable | Meaning | +| ------------------- | ---------------------------------------------------------- | +| `AUTH_DEV_EMAIL` | Seeds one identity in the in-memory store | +| `AUTH_DEV_PASSWORD` | Gives that identity a password, hashed lazily on first use | + +Only the in-memory store, only when no database is configured, and gone on +restart. It exists so `--db none` still signs in during development. + +## Failure modes are indistinguishable on purpose + +`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. + +## Alongside other sign-in methods + +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. diff --git a/docs/guides/sessions.md b/docs/guides/sessions.md new file mode 100644 index 0000000..07f06e7 --- /dev/null +++ b/docs/guides/sessions.md @@ -0,0 +1,94 @@ +# Sessions + +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 | + +``` +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. + +## Rotation and reuse detection + +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. + +``` +sign in ──► A + └─ refresh(A) ──► B A marked spent + └─ refresh(B) ──► C + refresh(A) again ──► ✗ 401, family {A,B,C} revoked +``` + +## The two windows + +| Variable | Default | Bounds | +| ------------------ | ------- | --------------------------------------------- | +| `SESSION_IDLE` | `30d` | Time since the last refresh — renewed on each | +| `SESSION_ABSOLUTE` | `90d` | Time since the sign-in itself — never renewed | + +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. + +## Storage + +`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. + +## CSRF + +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. + +## Migrating an existing database + +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: + +```bash +npx drizzle-kit generate --config src/db/postgres/drizzle.config.ts +``` diff --git a/docs/guides/transports.md b/docs/guides/transports.md new file mode 100644 index 0000000..717859b --- /dev/null +++ b/docs/guides/transports.md @@ -0,0 +1,123 @@ +# Transports + +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. + +| Seam | Ships | +| ---------------- | -------------------------------------------------- | +| `MailTransport` | console logger (default) and SMTP (`SMTP_URL`) | +| `SmsTransport` | nothing — unbound, and therefore silent | + +## Mail + +```ts +export interface MailTransport { + send(message: { + to: string; + subject: string; + html: string; + text: string; + }): Promise; +} +``` + +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](magic-link.md) work. + +For production, bind your provider at boot, next to the other integrations: + +```ts +// src/integrations/mail.ts +import { Resend } from 'resend'; +import { setMailTransport } from '../mail'; + +export function 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. + +## SMS + +Optional, off, and silent until you wire two things: + +```ts +export interface SmsTransport { + send(message: { to: string; text: string }): Promise; +} +``` + +```ts +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); +``` + +| Provider | Package | The one call | +| --------------- | ------------------------ | ---------------------------------------------- | +| **Twilio** | `twilio` | `client.messages.create({ from, to, body })` | +| **Vonage** | `@vonage/server-sdk` | `vonage.sms.send({ to, from, text })` | +| **AWS SNS** | `@aws-sdk/client-sns` | `sns.send(new PublishCommand({ PhoneNumber, Message }))` | +| **MessageBird** | `messagebird` | `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. + +## Testing against them + +Bind a capture transport and assert on what would have been sent: + +```ts +const inbox: MailMessage[] = []; +setMailTransport({ + async send(message) { + inbox.push(message); + } +}); +``` + +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. diff --git a/docs/guides/web.md b/docs/guides/web.md index 0a14703..2143d11 100644 --- a/docs/guides/web.md +++ b/docs/guides/web.md @@ -75,6 +75,27 @@ template-only and never ships. `/account` is guarded twice on purpose — middleware redirects the obvious cases, and the page re-checks. Middleware alone is never the guard. +## Browser tests + +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 `docker-compose.yml` builds the API from `apps/api`. The Dockerfile falls diff --git a/docs/maintainers.md b/docs/maintainers.md index c4c9e97..1ca695b 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -171,7 +171,7 @@ forgotten twice. `// chassis:`, or the CLI's pruning breaks. - Two marker rules the test suite enforces, both learned the hard way: - The marker must be **last on the line**. A marker after an opening - brace (`sqliteTable('users', { // chassis:jwt`) gets moved onto its own + 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:` anywhere else in the @@ -238,7 +238,7 @@ nothing else. The work is already in the template: `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/controllers/Auth.controller.ts`. It publishes no `require` condition, + `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. diff --git a/docs/modules.md b/docs/modules.md index c9059c6..636a1d4 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -33,6 +33,7 @@ exception). | `sentry` | `SENTRY_DSN` | `Sentry.init` at boot, `captureException` in the error handler | | `x402` | `X402_PAY_TO` | registers the payment gate used by `@paidRoute` | | `mcp` | _(none — separate stdio process)_ | `npm run mcp` server exposing the API as agent tools | +| `jobs` | _(none — a second entrypoint)_ | `npm run jobs` cron + long-running harness, Sentry Crons check-ins | | `web` | _(none — a separate app)_ | Next.js front end; restructures the project into a workspaces monorepo | ## Choice groups @@ -42,19 +43,45 @@ because their options are mutually exclusive: - **Database** — `none` / `mongo` / `postgres` / `sqlite`. The ORM follows the choice (Mongoose or Drizzle). See [Database](guides/database.md). -- **Auth** — `none` / `auth0` / `jwt` / `clerk`, all sharing the +- **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](guides/authentication.md). - Each variant also has a **web half** (`web/auth/providers/`) behind - the equivalent front-end seam — one re-export line in `web/auth/active.ts`. - Local JWT additionally ships the piece the hosted providers don't need: a - register/login controller and a user store that follows the database - choice. See [Web front end](guides/web.md). + Each has a **web half** (`web/auth/providers/`) 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](guides/web.md). Mechanically a group variant is just a module in the `chassis:` namespace: choosing Postgres declines `mongo` and `sqlite`, which prune exactly like a declined toggle. The template ships every variant installed together; the CLI keeps only the one you pick. +## Implied modules + +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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9fa9682..d99296c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -30,7 +30,7 @@ generated list of presets, choices, and toggles. | Group | Values | | --------------- | ---------------------------------------- | | `--db ` | `none` · `mongo` · `postgres` · `sqlite` | -| `--auth ` | `none` · `auth0` · `jwt` · `clerk` | +| `--auth ` | `none` · `auth0` · `clerk` · `jwt` · `magic-only` · `password+magic` | Choosing a database brings its ORM: `mongo` → Mongoose, `postgres`/`sqlite` → Drizzle. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 91a4fa1..5dca477 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -20,9 +20,10 @@ in production, inject real environment variables instead. | `SQLITE_PATH` | string — optional | **Enables the SQLite module** (Drizzle). File path, or `:memory:` | | `AUTH0_DOMAIN` | string — optional | **Enables Auth0** (with `AUTH0_AUDIENCE`). Tenant domain, e.g. `my-tenant.eu.auth0.com` | | `AUTH0_AUDIENCE` | string — optional | **Enables Auth0** (with `AUTH0_DOMAIN`). The API identifier from the Auth0 dashboard | -| `JWT_SECRET` | string — optional | **Enables local JWT auth** (jose). Signs `/auth/login` tokens and verifies Bearer tokens | -| `AUTH_DEV_EMAIL` | string — optional | Seeds one account in the in-memory user store (only used when no database is configured) | -| `AUTH_DEV_PASSWORD` | string — optional | Password for `AUTH_DEV_EMAIL`. Development only — the store is not persistent | +| `JWT_SECRET` | string — optional | **Enables local auth.** Signs access tokens and verifies Bearer tokens | +| `SESSION_IDLE` | duration — default `30d` | Sliding refresh-token lifetime — see [Sessions](../guides/sessions.md) | +| `SESSION_ABSOLUTE` | duration — default `90d` | Hard cap on a session's age, measured from sign-in and never renewed | +| `AUTH_DEV_EMAIL` | string — optional | Seeds one identity in the in-memory store (only used when no database is configured) | | `CLERK_SECRET_KEY` | string — optional | **Enables Clerk auth.** Clerk secret key | | `SENTRY_DSN` | string — optional | **Enables Sentry** error reporting | | `X402_PAY_TO` | string — optional | **Enables x402 payments** for `@paidRoute`. Wallet address receiving payments | @@ -40,7 +41,7 @@ features: { postgres: Boolean(env.DATABASE_URL), sqlite: Boolean(env.SQLITE_PATH), auth0: Boolean(env.AUTH0_DOMAIN && env.AUTH0_AUDIENCE), - jwt: Boolean(env.JWT_SECRET), + session: Boolean(env.JWT_SECRET), clerk: Boolean(env.CLERK_SECRET_KEY), sentry: Boolean(env.SENTRY_DSN), x402: Boolean(env.X402_PAY_TO) diff --git a/llms.txt b/llms.txt index 2039efc..d41f381 100644 --- a/llms.txt +++ b/llms.txt @@ -27,8 +27,9 @@ - Config: only `src/config/index.ts` reads `process.env`; it's a zod schema and the app exits on invalid config. - Never edit `src/core/**` for feature work. -- Local JWT ships `/auth/register` and `/auth/login`; users live in the - configured database (`src/db/users.ts`), or in memory when there is none. +- Local auth ships its own sign-in endpoints plus `/auth/refresh`, + `/auth/logout` and `/auth/revoke-all`; identities live in the configured + database (`src/db/users.ts`), or in memory when there is none. - Front end (only when `web/` or `apps/web/` exists): pages are server components, API calls go through `apiFetch` in `lib/api.ts`, and auth is imported from `auth/active.ts` — never from a provider directly. diff --git a/mcp-server/index.mjs b/mcp-server/index.mjs index 1ed61ef..2109b94 100644 --- a/mcp-server/index.mjs +++ b/mcp-server/index.mjs @@ -114,7 +114,7 @@ server.registerTool( ), notes: [ 'Every option is independent: a preset can be overridden field by field.', - 'auth=jwt ships POST /auth/register and /auth/login plus a user store; auth0 and clerk are hosted.', + 'Local auth (jwt, magic-only, password+magic) ships its own sign-in endpoints, an identity store and a rotating-refresh session layer; auth0 and clerk are hosted.', 'web=true adds a Next.js front end and makes the project an npm-workspaces monorepo (apps/api + apps/web).', 'The generated project passes `npm run verify` and `npm run build` as created.' ] diff --git a/mcp-server/server.test.mjs b/mcp-server/server.test.mjs index e8f34f5..e28dad9 100644 --- a/mcp-server/server.test.mjs +++ b/mcp-server/server.test.mjs @@ -75,7 +75,14 @@ test('list_chassis_options mirrors the CLI catalog', async () => { // Imported from create-chassis, so this fails if the catalog moves. assert.ok(options.presets.fullstack, 'no fullstack preset'); assert.deepEqual(options.db, ['none', 'mongo', 'postgres', 'sqlite']); - assert.deepEqual(options.auth, ['none', 'auth0', 'jwt', 'clerk']); + assert.deepEqual(options.auth, [ + 'none', + 'auth0', + 'jwt', + 'magic-only', + 'password+magic', + 'clerk' + ]); assert.ok(options.addons.web, 'no web add-on'); assert.equal(options.presets.fullstack.db, 'postgres'); assert.ok(options.presets.fullstack.modules.includes('web')); @@ -96,7 +103,7 @@ test('create_chassis_project scaffolds a single-package project', async () => { assert.equal(result.apiRoot, '.'); assert.ok(result.layout.includes('src')); assert.ok(!result.layout.includes('apps')); - assert.ok(fs.existsSync(path.join(directory, 'src/controllers/Auth.controller.ts'))); // prettier-ignore + assert.ok(fs.existsSync(path.join(directory, 'src/controllers/Password.controller.ts'))); // prettier-ignore assert.ok(fs.existsSync(result.conventions), 'AGENTS.md not reported'); assert.match(result.writeCodeLikeThis, /resHandler/); assert.ok(result.nextSteps.some((s) => s.includes('npm install'))); diff --git a/package-lock.json b/package-lock.json index b0114ea..fa291f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@sentry/node": "^9.0.0", "better-sqlite3": "^12.11.1", "cors": "^2.8.5", + "croner": "^10.0.1", "dotenv": "^16.4.5", "drizzle-orm": "^0.45.2", "express": "^5.1.0", @@ -21,6 +22,7 @@ "helmet": "^8.0.0", "jose": "^6.2.3", "mongoose": "^8.9.0", + "nodemailer": "^7.0.13", "postgres": "^3.4.9", "winston": "^3.17.0", "x402-express": "^1.2.0", @@ -31,6 +33,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.10.0", + "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.2", "drizzle-kit": "^0.31.10", "eslint": "^9.18.0", @@ -1206,8 +1209,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "extraneous": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5552,6 +5556,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.6.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz", @@ -7640,6 +7654,25 @@ "node": ">=0.8" } }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -10406,6 +10439,15 @@ "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -15219,8 +15261,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "extraneous": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16200,8 +16243,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "extraneous": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index f140ad3..57797cb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.build.json", "start": "node dist/server.js", + "jobs": "tsx src/jobs/run.ts", + "start:jobs": "node dist/jobs/run.js", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", @@ -18,6 +20,8 @@ "verify": "npm run typecheck && npm run lint && npm run test && npm run verify:web", "verify:web": "npm --prefix web run verify", "dev:web": "npm --prefix web run dev", + "e2e": "npm --prefix web run e2e", + "e2e:setup": "npm --prefix web exec -- playwright install --with-deps chromium", "gen": "node scripts/generate.mjs", "mcp": "tsx src/mcp/server.ts", "prepare": "husky || true" @@ -34,6 +38,7 @@ "@sentry/node": "^9.0.0", "better-sqlite3": "^12.11.1", "cors": "^2.8.5", + "croner": "^10.0.1", "dotenv": "^16.4.5", "drizzle-orm": "^0.45.2", "express": "^5.1.0", @@ -41,6 +46,7 @@ "helmet": "^8.0.0", "jose": "^6.2.3", "mongoose": "^8.9.0", + "nodemailer": "^7.0.13", "postgres": "^3.4.9", "winston": "^3.17.0", "x402-express": "^1.2.0", @@ -51,6 +57,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.10.0", + "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.2", "drizzle-kit": "^0.31.10", "eslint": "^9.18.0", diff --git a/site/pages.mjs b/site/pages.mjs index ee71489..d9d9b7f 100644 --- a/site/pages.mjs +++ b/site/pages.mjs @@ -19,7 +19,12 @@ export const SECTIONS = [ { file: 'docs/guides/building-an-api.md', title: 'Building an API' }, { file: 'docs/guides/database.md', title: 'Database' }, { file: 'docs/guides/authentication.md', title: 'Authentication' }, + { file: 'docs/guides/password-auth.md', title: 'Password sign-in' }, + { file: 'docs/guides/magic-link.md', title: 'Magic link' }, + { file: 'docs/guides/sessions.md', title: 'Sessions' }, + { file: 'docs/guides/transports.md', title: 'Mail & SMS transports' }, { file: 'docs/guides/web.md', title: 'Web front end' }, + { file: 'docs/guides/jobs.md', title: 'Background jobs' }, { file: 'docs/guides/mcp.md', title: 'MCP server' }, { file: 'docs/guides/payments-x402.md', title: 'Payments (x402)' }, { file: 'docs/guides/deployment.md', title: 'Deployment' } @@ -44,7 +49,12 @@ export const SECTIONS = [ title: 'Project', pages: [ { file: 'AGENTS.md', title: 'Agent guide' }, - { file: 'docs/maintainers.md', title: 'Maintainers' } + { file: 'docs/maintainers.md', title: 'Maintainers' }, + { + file: 'docs/design/magic-link.md', + title: 'Design: magic link', + blurb: 'Magic-link auth and refresh sessions, as designed' + } ] } ]; diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts deleted file mode 100644 index 8e21050..0000000 --- a/src/__tests__/auth.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import request from 'supertest'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Express } from 'express'; -import { hashPassword, verifyPassword } from '../utils/password'; - -/** - * End-to-end check of the local-JWT provider: register → login → call a - * `@protectedRoute` with the returned token. Backed by the in-memory user - * store, so it runs with no database configured — the `--auth jwt --db none` - * combination the scaffolder allows. - * - * JWT_SECRET has to exist before src/config parses the environment, so the - * app is imported dynamically rather than at the top of the file. For the - * same reason, do not add a static import here for anything that reaches - * `../config` — it would run before `beforeAll` and read an unset secret. - */ -let app: Express; - -beforeAll(async () => { - process.env.JWT_SECRET = 'test-secret-that-is-long-enough-to-sign-with'; - - const { createApp } = await import('../app.js'); - const { initJwt } = await import('../integrations/jwt.js'); - const { Routable, protectedRoute } = await import('../core/index.js'); - - class SecretController extends Routable { - constructor() { - super('/secret-demo'); - } - - @protectedRoute('get', '/') - async show(req: import('express').Request) { - return req.resHandler.ok({ secret: true }); - } - } - - initJwt(); - app = createApp({ extraRoutables: [new SecretController()] }); -}); - -const account = { email: 'Dev@Example.com', password: 'correct-horse-42' }; - -describe('password hashing', () => { - it('round-trips a password and rejects a wrong one', async () => { - const hash = await hashPassword('correct-horse-42'); - expect(hash.startsWith('scrypt$')).toBe(true); - expect(await verifyPassword('correct-horse-42', hash)).toBe(true); - expect(await verifyPassword('wrong-horse-42', hash)).toBe(false); - }); - - it('rejects a malformed stored hash instead of throwing', async () => { - expect(await verifyPassword('whatever', 'not-a-hash')).toBe(false); - }); -}); - -describe('POST /auth/register', () => { - it('creates an account, normalizes the email and returns a token', async () => { - const res = await request(app).post('/auth/register').send(account); - expect(res.status).toBe(201); - expect(res.body.user.email).toBe('dev@example.com'); - expect(typeof res.body.token).toBe('string'); - }); - - it('rejects a duplicate email with 409', async () => { - const res = await request(app).post('/auth/register').send(account); - expect(res.status).toBe(409); - }); - - it('rejects a short password with a structured 400', async () => { - const res = await request(app) - .post('/auth/register') - .send({ email: 'other@example.com', password: 'short' }); - expect(res.status).toBe(400); - expect(res.body.issues[0].part).toBe('body'); - }); -}); - -describe('POST /auth/login', () => { - it('returns a token that opens a @protectedRoute', async () => { - const login = await request(app).post('/auth/login').send(account); - expect(login.status).toBe(200); - - const res = await request(app) - .get('/secret-demo') - .set('authorization', `Bearer ${login.body.token}`); - expect(res.status).toBe(200); - expect(res.body.secret).toBe(true); - }); - - it('rejects a wrong password with 401', async () => { - const res = await request(app) - .post('/auth/login') - .send({ ...account, password: 'not-the-password' }); - expect(res.status).toBe(401); - }); - - it('answers 401 for an unknown email — same shape as a wrong password', async () => { - const res = await request(app) - .post('/auth/login') - .send({ email: 'nobody@example.com', password: 'correct-horse-42' }); - expect(res.status).toBe(401); - }); - - it('refuses a forged token', async () => { - const res = await request(app) - .get('/secret-demo') - .set('authorization', 'Bearer not.a.real.token'); - expect(res.status).toBe(401); - }); -}); - -describe('user store selection', () => { - it('falls back to the in-memory store when no database is configured', async () => { - // The seam in src/db/users.ts resolves per call, by feature flag — with - // no DB env vars set, that must be the in-memory store. - const { userStore } = await import('../db/users.js'); - const { memoryUsers } = await import('../db/memory-users.js'); - expect(userStore()).toBe(memoryUsers); - }); -}); - -describe('when JWT_SECRET is unset', () => { - it('answers 501 rather than minting an unsigned token', async () => { - vi.resetModules(); - const secret = process.env.JWT_SECRET; - delete process.env.JWT_SECRET; - - try { - const { createApp } = await import('../app.js'); - const res = await request(createApp()) - .post('/auth/login') - .send({ email: 'dev@example.com', password: 'correct-horse-42' }); - - expect(res.status).toBe(501); - expect(res.body.errorId).toBe(1501); - } finally { - process.env.JWT_SECRET = secret; - } - }); -}); diff --git a/src/__tests__/logging.test.ts b/src/__tests__/logging.test.ts new file mode 100644 index 0000000..a825640 --- /dev/null +++ b/src/__tests__/logging.test.ts @@ -0,0 +1,111 @@ +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Request, Response } from 'express'; +import { createApp } from '../app'; +import { ERROR_CODES, Routable, route } from '../core'; +import { logger } from '../utils/logger'; + +/** + * The unit tests in src/utils/logger.test.ts prove the format and the path + * helper in isolation. This one proves they are actually wired: a real request + * carrying a credential in the URL, through the real app, and nothing + * sensitive comes out of the real logger. + * + * That is the failure this guards against — not a broken redactor, but a + * correct redactor nobody plugged in. + */ +class SecretController extends Routable { + constructor() { + super('/probe'); + } + + @route('get', '/:token') + async show(req: Request): Promise { + return req.resHandler.ok({ seen: true }); + } + + /** A handler attaching context to an error — the `...extra` spread path. */ + @route('post', '/credentials') + async credentials(req: Request): Promise { + return req.resHandler.manualError(ERROR_CODES.BAD_REQUEST, { + email: 'user@example.com', + password: 'hunter2' + }); + } +} + +const app = createApp({ extraRoutables: [new SecretController()] }); + +let lines: string[]; + +/** + * Winston types `Transport.log` as an optional overloaded method, which spying + * on resolves to `never`. The runtime contract is stable and simple, so narrow + * it to that rather than fight the declaration. + */ +type Writable = { + log: (info: Record, next: () => void) => void; +}; + +beforeEach(() => { + lines = []; + // The Console transport is where a log line actually becomes output, so it + // is the honest place to read: whatever arrives here is what would be + // written. Silenced in tests by default — turn it on and swallow the write. + logger.silent = false; + vi.spyOn( + logger.transports[0] as unknown as Writable, + 'log' + ).mockImplementation((info, next) => { + lines.push(String(info[Symbol.for('message')])); + next(); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + logger.silent = true; +}); + +const logged = () => lines.join('\n'); + +describe('request logging', () => { + it('never writes a token that travelled in the URL path', async () => { + await request(app).get('/probe/LEAKY-TOKEN-1234').expect(200); + + expect(logged()).not.toContain('LEAKY-TOKEN-1234'); + expect(logged()).toContain('/probe/:token'); + }); + + it('never writes a query string', async () => { + await request(app) + .get('/probe/LEAKY-TOKEN-1234?next=/account&code=778899') + .expect(200); + + expect(logged()).not.toContain('778899'); + expect(logged()).not.toContain('next='); + }); + + it('strips the query off an unmatched path too', async () => { + await request(app).get('/nope?token=LEAKY-TOKEN-1234').expect(404); + + expect(logged()).not.toContain('LEAKY-TOKEN-1234'); + expect(logged()).toContain('/nope'); + }); + + it('redacts credentials a handler puts in the error payload', async () => { + await request(app).post('/probe/credentials').expect(400); + + expect(logged()).not.toContain('hunter2'); + expect(logged()).not.toContain('user@example.com'); + expect(logged()).toContain('[redacted]'); + }); + + it('still logs the fields an operator needs', async () => { + await request(app).get('/probe/LEAKY-TOKEN-1234').expect(200); + + expect(logged()).toContain('callId'); + expect(logged()).toContain('GET'); + expect(logged()).toContain('200'); + }); +}); diff --git a/src/__tests__/magic.e2e.test.ts b/src/__tests__/magic.e2e.test.ts new file mode 100644 index 0000000..8129482 --- /dev/null +++ b/src/__tests__/magic.e2e.test.ts @@ -0,0 +1,138 @@ +import request from 'supertest'; +import { beforeAll, afterAll, describe, expect, it } from 'vitest'; +import type { Express } from 'express'; + +/** + * The whole flow against real SMTP: request → email in a real inbox → confirm + * page → redeem → session → a 20-day idle gap → silent refresh → the 91-day + * cap → forced re-auth. + * + * Needs mailpit, so it is opt-in: + * + * docker compose up -d mailpit + * MAILPIT=1 npm test + * + * Skipped otherwise, which keeps `npm run verify` runnable with nothing + * installed. CI runs it as its own job. + */ +const MAILPIT_API = process.env.MAILPIT_API ?? 'http://localhost:8025'; +const enabled = Boolean(process.env.MAILPIT); + +let app: Express; +let setClock: (fn?: () => Date) => void; +let clock = new Date('2026-01-01T00:00:00.000Z'); + +const advanceDays = (days: number) => { + clock = new Date(clock.getTime() + days * 86_400_000); +}; + +interface MailpitMessage { + ID: string; + To: { Address: string }[]; +} + +async function latestEmailFor(address: string): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + const res = await fetch(`${MAILPIT_API}/api/v1/messages`); + const { messages } = (await res.json()) as { messages: MailpitMessage[] }; + const match = messages.find((message) => + message.To.some((to) => to.Address === address) + ); + + if (match) { + const detail = await fetch(`${MAILPIT_API}/api/v1/message/${match.ID}`); + const { Text } = (await detail.json()) as { Text: string }; + return Text; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`no email for ${address} arrived within 5s`); +} + +beforeAll(async () => { + if (!enabled) return; + + process.env.JWT_SECRET = 'e2e-secret-that-is-long-enough-to-sign-with'; + process.env.SMTP_URL = process.env.SMTP_URL ?? 'smtp://localhost:1025'; + process.env.MAGIC_LINK_BASE_URL = 'http://localhost:8000'; + process.env.SESSION_IDLE = '30d'; + process.env.SESSION_ABSOLUTE = '90d'; + + const { createApp } = await import('../app.js'); + const { initJwt } = await import('../integrations/jwt.js'); + ({ setClock } = await import('../utils/clock.js')); + + setClock(() => clock); + initJwt(); + app = createApp(); + + await fetch(`${MAILPIT_API}/api/v1/messages`, { method: 'DELETE' }); +}); + +afterAll(() => { + if (enabled) setClock(); +}); + +describe.skipIf(!enabled)('magic link over real SMTP', () => { + it('carries a sign-in from an inbox to a session, and ages it out', async () => { + const address = 'e2e@example.com'; + + // 1. Ask for a link. + const requested = await request(app) + .post('/auth/magic/request') + .send({ email: address, returnTo: '/account' }); + expect(requested.status).toBe(202); + + // 2. One email, carrying both credentials. + const body = await latestEmailFor(address); + const token = /\/auth\/magic\/([A-Za-z0-9_-]+)/.exec(body)?.[1]; + const code = /code instead: (\d{6})/.exec(body)?.[1]; + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(code).toMatch(/^\d{6}$/); + + // 3. What a mail security scanner does to it. Twice, plus a HEAD. + expect((await request(app).get(`/auth/magic/${token}`)).status).toBe(200); + expect((await request(app).head(`/auth/magic/${token}`)).status).toBe(200); + expect((await request(app).get(`/auth/magic/${token}`)).status).toBe(200); + + // 4. The click. This is the only thing that spends the token. + const redeemed = await request(app) + .post('/auth/magic/redeem') + .set('accept', 'application/json') + .send({ token }); + + expect(redeemed.status).toBe(200); + expect(redeemed.body.user.email).toBe(address); + expect(redeemed.body.returnTo).toBe('/account'); + let refreshToken = redeemed.body.refreshToken as string; + + // 5. Twenty days away. The session refreshes without a prompt. + advanceDays(20); + const silent = await request(app) + .post('/auth/refresh') + .send({ refreshToken }); + expect(silent.status).toBe(200); + refreshToken = silent.body.refreshToken; + + // Stay active so the idle window never lapses. + for (const gap of [25, 25]) { + advanceDays(gap); + const next = await request(app) + .post('/auth/refresh') + .send({ refreshToken }); + expect(next.status).toBe(200); + refreshToken = next.body.refreshToken; + } + + // 6. Day 91. The absolute cap ends it regardless. + advanceDays(21); + const forced = await request(app) + .post('/auth/refresh') + .send({ refreshToken }); + + expect(forced.status).toBe(401); + expect(forced.body.message).toMatch(/maximum age/); + }); +}); diff --git a/src/__tests__/magic.test.ts b/src/__tests__/magic.test.ts new file mode 100644 index 0000000..a6d5c74 --- /dev/null +++ b/src/__tests__/magic.test.ts @@ -0,0 +1,316 @@ +import request from 'supertest'; +import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Express } from 'express'; + +/** + * The magic-link endpoints end to end, over HTTP, against the in-memory + * stores and a capture mail transport. + * + * JWT_SECRET has to exist before src/config parses the environment, so every + * module that reaches `../config` is imported dynamically. Do not add a static + * import here for anything that does. + */ +type MailMessage = import('../mail/index.js').MailMessage; + +let app: Express; +let inbox: MailMessage[] = []; +let setClock: (fn?: () => Date) => void; +let resetMemoryMagic: () => void; +let resetMemoryUsers: () => void; +let resetMemorySessions: () => void; + +let clock = new Date('2026-01-01T00:00:00.000Z'); + +beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret-that-is-long-enough-to-sign-with'; + process.env.MAGIC_TOKEN_TTL = '15m'; + process.env.MAGIC_CODE_ATTEMPTS = '5'; + process.env.MAGIC_LINK_BASE_URL = 'https://api.example'; + + const { createApp } = await import('../app.js'); + const { initJwt } = await import('../integrations/jwt.js'); + const { setMailTransport } = await import('../mail/index.js'); + ({ setClock } = await import('../utils/clock.js')); + ({ resetMemoryMagic } = await import('../db/memory-magic.js')); + ({ resetMemoryUsers } = await import('../db/memory-users.js')); + ({ resetMemorySessions } = await import('../db/memory-sessions.js')); + + setMailTransport({ + async send(message) { + inbox.push(message); + } + }); + + initJwt(); + app = createApp(); +}); + +afterAll(async () => { + const { setMailTransport } = await import('../mail/index.js'); + setMailTransport(); + setClock(); + delete process.env.JWT_SECRET; + delete process.env.MAGIC_LINK_BASE_URL; +}); + +beforeEach(() => { + // Move past every rate-limit window. The limiter reads the injected clock, + // so this resets the buckets without any sleeping or special-casing. + clock = new Date(clock.getTime() + 60 * 60_000); + setClock(() => clock); + inbox = []; + resetMemoryMagic(); + resetMemoryUsers(); + resetMemorySessions(); +}); + +/** POST a request and wait for the detached delivery to land. */ +async function requestLink(email: string, returnTo?: string) { + const res = await request(app) + .post('/auth/magic/request') + .send({ email, ...(returnTo === undefined ? {} : { returnTo }) }); + + await vi.waitFor(() => expect(inbox.length).toBeGreaterThan(0)); + const body = inbox[inbox.length - 1].text; + const token = /\/auth\/magic\/([A-Za-z0-9_-]+)/.exec(body)?.[1]; + const code = /code instead: (\d{6})/.exec(body)?.[1]; + + return { res, token: token as string, code: code as string }; +} + +describe('POST /auth/magic/request', () => { + it('answers 202 with a byte-identical body for known and unknown addresses', async () => { + await requestLink('known@example.com'); + const { token } = await requestLink('known@example.com'); + await request(app).post('/auth/magic/redeem').send({ token }); + + const known = await request(app) + .post('/auth/magic/request') + .send({ email: 'known@example.com' }); + const unknown = await request(app) + .post('/auth/magic/request') + .send({ email: 'nobody-at-all@example.com' }); + + expect(known.status).toBe(202); + expect(unknown.status).toBe(202); + // Byte-for-byte, not merely deep-equal: no field order, no whitespace, + // and no length difference to measure. + expect(known.text).toBe(unknown.text); + }); + + it('sends one email carrying both the link and the code', async () => { + const { token, code } = await requestLink('a@example.com'); + + expect(inbox).toHaveLength(1); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(code).toMatch(/^\d{6}$/); + expect(inbox[0].to).toBe('a@example.com'); + expect(inbox[0].html).toContain(`https://api.example/auth/magic/${token}`); + expect(inbox[0].html).toContain(code); + }); + + it('rejects a malformed address before doing anything', async () => { + const res = await request(app) + .post('/auth/magic/request') + .send({ email: 'not-an-email' }); + + expect(res.status).toBe(400); + expect(inbox).toHaveLength(0); + }); +}); + +describe('rate limiting', () => { + it('limits per address, and the bucket is that address alone', async () => { + for (let i = 0; i < 3; i++) { + const ok = await request(app) + .post('/auth/magic/request') + .send({ email: 'target@example.com' }); + expect(ok.status).toBe(202); + } + + const blocked = await request(app) + .post('/auth/magic/request') + .send({ email: 'target@example.com' }); + expect(blocked.status).toBe(429); + + // A different address is untouched — separate buckets, not one shared + // counter. + const other = await request(app) + .post('/auth/magic/request') + .send({ email: 'someone-else@example.com' }); + expect(other.status).toBe(202); + }); + + it('limits per IP across many different addresses', async () => { + // Under the per-address cap every time, so only the IP bucket can stop it. + const statuses: number[] = []; + for (let i = 0; i < 21; i++) { + const res = await request(app) + .post('/auth/magic/request') + .send({ email: `user${i}@example.com` }); + statuses.push(res.status); + } + + expect(statuses.filter((s) => s === 202)).toHaveLength(20); + expect(statuses[20]).toBe(429); + }); +}); + +describe('GET /auth/magic/:token — the scanner guarantee', () => { + it('does not consume the token on GET or HEAD, however many times', async () => { + const { token } = await requestLink('a@example.com'); + + // Exactly what a mail security scanner does to every link it sees. + expect((await request(app).get(`/auth/magic/${token}`)).status).toBe(200); + expect((await request(app).head(`/auth/magic/${token}`)).status).toBe(200); + expect((await request(app).get(`/auth/magic/${token}`)).status).toBe(200); + + // Still redeemable afterwards — this is the whole point. + const redeemed = await request(app) + .post('/auth/magic/redeem') + .set('accept', 'application/json') + .send({ token }); + + expect(redeemed.status).toBe(200); + expect(redeemed.body.user.email).toBe('a@example.com'); + }); + + it('renders a confirm page with a POST form, not a link', async () => { + const { token } = await requestLink('a@example.com'); + const res = await request(app).get(`/auth/magic/${token}`); + + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.text).toContain('
{ + const { token } = await requestLink('a@example.com', '/dashboard'); + const res = await request(app) + .get(`/auth/magic/${token}`) + .set('accept', 'application/json'); + + expect(res.body).toEqual({ status: 'valid', returnTo: '/dashboard' }); + }); + + it('reports a spent token as used', async () => { + const { token } = await requestLink('a@example.com'); + await request(app).post('/auth/magic/redeem').send({ token }); + + const res = await request(app) + .get(`/auth/magic/${token}`) + .set('accept', 'application/json'); + + expect(res.body.status).toBe('used'); + }); +}); + +describe('POST /auth/magic/redeem', () => { + it('is single use', async () => { + const { token } = await requestLink('a@example.com'); + expect((await request(app).post('/auth/magic/redeem').send({ token })).status).toBe(303); + + const second = await request(app).post('/auth/magic/redeem').send({ token }); + expect(second.status).toBe(401); + }); + + it('sets an httpOnly, SameSite=Lax refresh cookie scoped to /auth', async () => { + const { token } = await requestLink('a@example.com'); + const res = await request(app).post('/auth/magic/redeem').send({ token }); + + const cookie = res.headers['set-cookie'][0]; + expect(cookie).toMatch(/^chassis_refresh=/); + expect(cookie).toMatch(/HttpOnly/i); + expect(cookie).toMatch(/SameSite=Lax/i); + expect(cookie).toMatch(/Path=\/auth/i); + }); + + it('redirects to a validated returnTo', async () => { + const { token } = await requestLink('a@example.com', '/dashboard'); + const res = await request(app).post('/auth/magic/redeem').send({ token }); + + expect(res.status).toBe(303); + expect(res.headers.location).toBe('/dashboard'); + }); +}); + +describe('open redirect', () => { + it.each([ + 'https://evil.example/steal', + '//evil.example', + '/\\evil.example', + '\\/\\/evil.example', + 'javascript:alert(1)', + 'http://evil.example' + ])('never redirects to %o', async (returnTo) => { + const { token } = await requestLink('a@example.com', returnTo); + + const probe = await request(app) + .get(`/auth/magic/${token}`) + .set('accept', 'application/json'); + expect(probe.body.returnTo).toBeNull(); + + const res = await request(app).post('/auth/magic/redeem').send({ token }); + expect(res.status).toBe(303); + expect(res.headers.location).toBe('/'); + }); + + it('does allow an ordinary same-origin path', async () => { + const { token } = await requestLink('a@example.com', '/app/settings'); + const res = await request(app).post('/auth/magic/redeem').send({ token }); + expect(res.headers.location).toBe('/app/settings'); + }); +}); + +describe('POST /auth/magic/code', () => { + it('signs in on the requesting device while the link sits unopened', async () => { + const { token, code } = await requestLink('a@example.com'); + + const res = await request(app) + .post('/auth/magic/code') + .set('accept', 'application/json') + .send({ email: 'a@example.com', code }); + + expect(res.status).toBe(200); + expect(res.body.user.email).toBe('a@example.com'); + expect(res.body.accessToken.split('.')).toHaveLength(3); + + // The untouched link died with the code it came with. + const probe = await request(app) + .get(`/auth/magic/${token}`) + .set('accept', 'application/json'); + expect(probe.body.status).toBe('used'); + }); + + it('voids everything after the attempt cap', async () => { + const { code } = await requestLink('a@example.com'); + + for (let attempt = 0; attempt < 5; attempt++) { + const res = await request(app) + .post('/auth/magic/code') + .send({ email: 'a@example.com', code: '000000' }); + expect(res.status).toBe(401); + } + + const withRightCode = await request(app) + .post('/auth/magic/code') + .send({ email: 'a@example.com', code }); + expect(withRightCode.status).toBe(401); + }); +}); + +describe('the session it establishes', () => { + it('opens a @protectedRoute with the access token', async () => { + const { token } = await requestLink('a@example.com'); + const redeemed = await request(app) + .post('/auth/magic/redeem') + .set('accept', 'application/json') + .send({ token }); + + const res = await request(app) + .post('/auth/revoke-all') + .set('authorization', `Bearer ${redeemed.body.accessToken}`); + + expect(res.status).toBe(204); + }); +}); diff --git a/src/__tests__/password.test.ts b/src/__tests__/password.test.ts new file mode 100644 index 0000000..d6dc271 --- /dev/null +++ b/src/__tests__/password.test.ts @@ -0,0 +1,129 @@ +import request from 'supertest'; +import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vitest'; +import type { Express } from 'express'; +import { hashPassword, verifyPassword } from '../utils/password'; + +/** + * Register → sign in → call a `@protectedRoute` with the token that came back. + * Backed by the in-memory stores, so it runs with no database configured — the + * `--auth jwt --db none` combination the scaffolder allows. + * + * JWT_SECRET has to exist before src/config parses the environment, so the app + * is imported dynamically. For the same reason, do not add a static import + * here for anything that reaches `../config`. + */ +let app: Express; +let resetMemoryUsers: () => void; +let resetMemoryPasswords: () => void; +let resetMemorySessions: () => void; + +beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret-that-is-long-enough-to-sign-with'; + + const { createApp } = await import('../app.js'); + const { initJwt } = await import('../integrations/jwt.js'); + ({ resetMemoryUsers } = await import('../db/memory-users.js')); + ({ resetMemoryPasswords } = await import('../db/memory-passwords.js')); + ({ resetMemorySessions } = await import('../db/memory-sessions.js')); + + initJwt(); + app = createApp(); +}); + +afterAll(() => { + delete process.env.JWT_SECRET; +}); + +beforeEach(() => { + resetMemoryUsers(); + resetMemoryPasswords(); + resetMemorySessions(); +}); + +const account = { email: 'Dev@Example.com', password: 'correct-horse-42' }; + +describe('password hashing', () => { + it('round-trips a password and rejects a wrong one', async () => { + const hash = await hashPassword('correct-horse-42'); + expect(hash.startsWith('scrypt$')).toBe(true); + expect(await verifyPassword('correct-horse-42', hash)).toBe(true); + expect(await verifyPassword('wrong', hash)).toBe(false); + }); +}); + +describe('POST /auth/register', () => { + it('creates an account and returns a session', async () => { + const res = await request(app).post('/auth/register').send(account); + + expect(res.status).toBe(201); + expect(res.body.user.email).toBe('dev@example.com'); + expect(res.body.accessToken.split('.')).toHaveLength(3); + expect(res.body.refreshToken).toBeTruthy(); + }); + + it('rejects a duplicate address', async () => { + await request(app).post('/auth/register').send(account); + const res = await request(app).post('/auth/register').send(account); + + expect(res.status).toBe(409); + }); + + it('rejects a short password', async () => { + const res = await request(app) + .post('/auth/register') + .send({ email: 'a@example.com', password: 'short' }); + + expect(res.status).toBe(400); + }); +}); + +describe('POST /auth/login', () => { + beforeEach(async () => { + await request(app).post('/auth/register').send(account); + }); + + it('returns a token that opens a @protectedRoute', async () => { + const login = await request(app).post('/auth/login').send(account); + expect(login.status).toBe(200); + + const res = await request(app) + .post('/auth/revoke-all') + .set('authorization', `Bearer ${login.body.accessToken}`); + + expect(res.status).toBe(204); + }); + + it('gives the same answer for a wrong password and an unknown address', async () => { + const wrong = await request(app) + .post('/auth/login') + .send({ ...account, password: 'not-the-password' }); + const unknown = await request(app) + .post('/auth/login') + .send({ email: 'nobody@example.com', password: 'not-the-password' }); + + expect(wrong.status).toBe(401); + expect(unknown.status).toBe(401); + expect(wrong.body.message).toBe(unknown.body.message); + }); + + it('refuses an identity that has no password stored', async () => { + const { userStore } = await import('../db/users.js'); + await userStore().create('no-credential@example.com'); + + const res = await request(app) + .post('/auth/login') + .send({ email: 'no-credential@example.com', password: 'anything-at-all' }); + + expect(res.status).toBe(401); + }); +}); + +describe('a forged token', () => { + it('does not open a @protectedRoute', async () => { + const res = await request(app) + .post('/auth/revoke-all') + .set('authorization', 'Bearer not.a.token'); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts new file mode 100644 index 0000000..848b28f --- /dev/null +++ b/src/__tests__/session.test.ts @@ -0,0 +1,216 @@ +import request from 'supertest'; +import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vitest'; +import type { Express } from 'express'; + +/** + * The session endpoints over HTTP: refresh, logout, revoke-all — including the + * cookie handling and the same-origin guard, neither of which the unit tests + * in src/services/session.test.ts can reach. + */ +let app: Express; +let setClock: (fn?: () => Date) => void; +let resetMemoryUsers: () => void; +let resetMemorySessions: () => void; +let startSession: typeof import('../services/session.js').startSession; +let createUser: (email: string) => Promise<{ id: string; email: string }>; + +let clock = new Date('2026-01-01T00:00:00.000Z'); + +beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret-that-is-long-enough-to-sign-with'; + process.env.SESSION_IDLE = '30d'; + process.env.SESSION_ABSOLUTE = '90d'; + process.env.CORS_ORIGINS = 'https://app.example'; + + const { createApp } = await import('../app.js'); + const { initJwt } = await import('../integrations/jwt.js'); + ({ setClock } = await import('../utils/clock.js')); + ({ resetMemoryUsers } = await import('../db/memory-users.js')); + ({ resetMemorySessions } = await import('../db/memory-sessions.js')); + ({ startSession } = await import('../services/session.js')); + + const { userStore } = await import('../db/users.js'); + createUser = (email) => userStore().create(email); + + initJwt(); + app = createApp(); +}); + +afterAll(() => { + setClock(); + delete process.env.JWT_SECRET; + delete process.env.CORS_ORIGINS; +}); + +beforeEach(() => { + clock = new Date('2026-01-01T00:00:00.000Z'); + setClock(() => clock); + resetMemoryUsers(); + resetMemorySessions(); +}); + +async function signIn(email = 'a@example.com') { + return startSession(await createUser(email)); +} + +describe('POST /auth/refresh', () => { + it('rotates a token presented in the body', async () => { + const session = await signIn(); + const res = await request(app) + .post('/auth/refresh') + .send({ refreshToken: session.refreshToken }); + + expect(res.status).toBe(200); + expect(res.body.refreshToken).not.toBe(session.refreshToken); + expect(res.body.accessToken.split('.')).toHaveLength(3); + expect(res.body.expiresIn).toBe(900); + }); + + it('rotates a token presented in the cookie', async () => { + const session = await signIn(); + const res = await request(app) + .post('/auth/refresh') + .set('cookie', `chassis_refresh=${session.refreshToken}`); + + expect(res.status).toBe(200); + expect(res.headers['set-cookie'][0]).toMatch(/^chassis_refresh=/); + }); + + it('401s when nothing is presented', async () => { + expect((await request(app).post('/auth/refresh')).status).toBe(401); + }); + + it('detects reuse and kills the family', async () => { + const session = await signIn(); + const rotated = await request(app) + .post('/auth/refresh') + .send({ refreshToken: session.refreshToken }); + + const replay = await request(app) + .post('/auth/refresh') + .send({ refreshToken: session.refreshToken }); + expect(replay.status).toBe(401); + expect(replay.body.message).toMatch(/reuse detected/); + + const current = await request(app) + .post('/auth/refresh') + .send({ refreshToken: rotated.body.refreshToken }); + expect(current.status).toBe(401); + }); + + it('refreshes silently after a 20-day idle gap, then forces re-auth at 91 days', async () => { + const session = await signIn(); + let token = session.refreshToken; + + const days = (n: number) => { + clock = new Date(clock.getTime() + n * 86_400_000); + }; + + // Someone who comes back every few weeks: each gap is under SESSION_IDLE, + // so every refresh succeeds without a sign-in prompt. + for (const gap of [20, 25, 25]) { + days(gap); + const silent = await request(app) + .post('/auth/refresh') + .send({ refreshToken: token }); + expect(silent.status).toBe(200); + token = silent.body.refreshToken; + } + + // Day 91. The token in hand is fresh and well inside its idle window; the + // absolute cap is what ends the session. + days(21); + const forced = await request(app) + .post('/auth/refresh') + .send({ refreshToken: token }); + + expect(forced.status).toBe(401); + expect(forced.body.message).toMatch(/maximum age/); + }); +}); + +describe('POST /auth/logout', () => { + it('revokes the session and is idempotent', async () => { + const session = await signIn(); + + const first = await request(app) + .post('/auth/logout') + .send({ refreshToken: session.refreshToken }); + expect(first.status).toBe(204); + + const second = await request(app) + .post('/auth/logout') + .send({ refreshToken: session.refreshToken }); + expect(second.status).toBe(204); + + const third = await request(app).post('/auth/logout'); + expect(third.status).toBe(204); + + const refreshed = await request(app) + .post('/auth/refresh') + .send({ refreshToken: session.refreshToken }); + expect(refreshed.status).toBe(401); + }); + + it('clears the cookie', async () => { + const res = await request(app).post('/auth/logout'); + expect(res.headers['set-cookie'][0]).toMatch(/^chassis_refresh=;/); + }); +}); + +describe('POST /auth/revoke-all', () => { + it('requires authentication', async () => { + expect((await request(app).post('/auth/revoke-all')).status).toBe(401); + }); + + it('kills every device for the caller', async () => { + const user = await createUser('multi@example.com'); + const phone = await startSession(user); + const laptop = await startSession(user); + + const res = await request(app) + .post('/auth/revoke-all') + .set('authorization', `Bearer ${laptop.accessToken}`); + expect(res.status).toBe(204); + + for (const session of [phone, laptop]) { + const refreshed = await request(app) + .post('/auth/refresh') + .send({ refreshToken: session.refreshToken }); + expect(refreshed.status).toBe(401); + } + }); +}); + +describe('the same-origin guard', () => { + it('rejects a cross-site browser request', async () => { + const session = await signIn(); + const res = await request(app) + .post('/auth/refresh') + .set('origin', 'https://evil.example') + .set('sec-fetch-site', 'cross-site') + .send({ refreshToken: session.refreshToken }); + + expect(res.status).toBe(403); + }); + + it('allows an allowlisted origin', async () => { + const session = await signIn(); + const res = await request(app) + .post('/auth/refresh') + .set('origin', 'https://app.example') + .set('sec-fetch-site', 'cross-site') + .send({ refreshToken: session.refreshToken }); + + expect(res.status).toBe(200); + }); + + it('allows a client that sends no Origin at all', async () => { + const session = await signIn(); + const res = await request(app) + .post('/auth/refresh') + .send({ refreshToken: session.refreshToken }); + + expect(res.status).toBe(200); + }); +}); diff --git a/src/app.ts b/src/app.ts index 5bd90a9..9514048 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,6 +26,9 @@ export function createApp(options: CreateAppOptions = {}): Express { app.use(callIdMiddleware); app.use(ResponseHandler.middleware); app.use(express.json()); + // The sign-in confirmation page is a plain HTML form, and a form posts // chassis:magic + // urlencoded — so it works with no client-side JavaScript at all. // chassis:magic + app.use(express.urlencoded({ extended: false })); // chassis:magic if (config.env === 'development') { app.use(requestLogger); diff --git a/src/config/index.ts b/src/config/index.ts index 3e7f32e..650dbc9 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import { z } from 'zod'; +import { durationSchema } from '../utils/duration'; // chassis:session /** * All environment variables are declared and validated here — nothing else @@ -17,11 +18,20 @@ const envSchema = z.object({ SQLITE_PATH: z.string().optional(), // chassis:sqlite AUTH0_DOMAIN: z.string().optional(), // chassis:auth0 AUTH0_AUDIENCE: z.string().optional(), // chassis:auth0 - JWT_SECRET: z.string().optional(), // chassis:jwt - AUTH_DEV_EMAIL: z.string().optional(), // chassis:jwt - AUTH_DEV_PASSWORD: z.string().optional(), // chassis:jwt + JWT_SECRET: z.string().optional(), // chassis:session + SESSION_IDLE: durationSchema('30d'), // chassis:session + SESSION_ABSOLUTE: durationSchema('90d'), // chassis:session + AUTH_DEV_EMAIL: z.string().optional(), // chassis:session + AUTH_DEV_PASSWORD: z.string().optional(), // chassis:password + MAGIC_TOKEN_TTL: durationSchema('15m'), // chassis:magic + MAGIC_CODE_ATTEMPTS: z.coerce.number().int().positive().default(5), // chassis:magic + MAGIC_LINK_BASE_URL: z.string().default('http://localhost:8000'), // chassis:magic + MAGIC_RETURN_TO_ORIGINS: z.string().optional(), // chassis:magic + MAGIC_FROM: z.string().default('no-reply@localhost'), // chassis:magic + SMTP_URL: z.string().optional(), // chassis:magic CLERK_SECRET_KEY: z.string().optional(), // chassis:clerk SENTRY_DSN: z.string().optional(), // chassis:sentry + SENTRY_RELEASE: z.string().optional(), // chassis:sentry X402_PAY_TO: z.string().optional(), // chassis:x402 X402_NETWORK: z.string().default('base-sepolia'), // chassis:x402 MCP_API_URL: z.string().default('http://localhost:8000') // chassis:mcp @@ -51,10 +61,16 @@ export const config = { postgres: { url: env.DATABASE_URL }, // chassis:postgres sqlite: { path: env.SQLITE_PATH ?? ':memory:' }, // chassis:sqlite auth0: { domain: env.AUTH0_DOMAIN, audience: env.AUTH0_AUDIENCE }, // chassis:auth0 - jwt: { secret: env.JWT_SECRET }, // chassis:jwt - authDev: { email: env.AUTH_DEV_EMAIL, password: env.AUTH_DEV_PASSWORD }, // chassis:jwt + jwt: { secret: env.JWT_SECRET }, // chassis:session + session: { idle: env.SESSION_IDLE, absolute: env.SESSION_ABSOLUTE }, // chassis:session + authDev: { email: env.AUTH_DEV_EMAIL }, // chassis:session + authDevPassword: env.AUTH_DEV_PASSWORD, // chassis:password + magic: { tokenTtl: env.MAGIC_TOKEN_TTL, attempts: env.MAGIC_CODE_ATTEMPTS }, // chassis:magic + magicLink: { base: env.MAGIC_LINK_BASE_URL }, // chassis:magic + magicReturnTo: { origins: env.MAGIC_RETURN_TO_ORIGINS }, // chassis:magic + mail: { from: env.MAGIC_FROM, smtpUrl: env.SMTP_URL }, // chassis:magic clerk: { secretKey: env.CLERK_SECRET_KEY }, // chassis:clerk - sentry: { dsn: env.SENTRY_DSN }, // chassis:sentry + sentry: { dsn: env.SENTRY_DSN, release: env.SENTRY_RELEASE }, // chassis:sentry x402: { payTo: env.X402_PAY_TO, network: env.X402_NETWORK }, // chassis:x402 mcp: { apiUrl: env.MCP_API_URL }, // chassis:mcp /** @@ -66,7 +82,7 @@ export const config = { postgres: Boolean(env.DATABASE_URL), // chassis:postgres sqlite: Boolean(env.SQLITE_PATH), // chassis:sqlite auth0: Boolean(env.AUTH0_DOMAIN && env.AUTH0_AUDIENCE), // chassis:auth0 - jwt: Boolean(env.JWT_SECRET), // chassis:jwt + session: Boolean(env.JWT_SECRET), // chassis:session clerk: Boolean(env.CLERK_SECRET_KEY), // chassis:clerk sentry: Boolean(env.SENTRY_DSN), // chassis:sentry x402: Boolean(env.X402_PAY_TO) // chassis:x402 diff --git a/src/controllers/Auth.controller.ts b/src/controllers/Auth.controller.ts deleted file mode 100644 index 56bd73d..0000000 --- a/src/controllers/Auth.controller.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Request, Response } from 'express'; -import { z } from 'zod'; -import { AppError, ERROR_CODES, Routable, route, validate } from '../core'; -import { config } from '../config'; -import { AuthUser, userStore } from '../db/users'; -import { hashPassword, verifyPassword } from '../utils/password'; - -/** - * Register/login for the local-JWT provider — the one auth option that has - * no hosted user directory behind it (Auth0 and Clerk issue their own - * tokens, so they ship no controller). Tokens are signed with the same - * JWT_SECRET that src/integrations/jwt.ts verifies, so a token minted here - * is accepted by every `@protectedRoute`. - * - * ponytail: access tokens only, no refresh rotation — clients re-login when - * a token expires. Add a refresh endpoint here if sessions need to outlive - * TOKEN_TTL without a password prompt. - */ -const TOKEN_TTL = '1h'; - -const credentials = z.object({ - email: z.string().email(), - password: z.string().min(8, 'Password must be at least 8 characters') -}); - -type Credentials = z.infer; - -/** The signing key, or a 501 when the module is present but unconfigured. */ -function signingKey(): Uint8Array { - if (!config.jwt.secret) { - throw new AppError( - ERROR_CODES.NOT_IMPLEMENTED, - 'Local JWT auth is not configured. Set JWT_SECRET (see .env.example).' - ); - } - return new TextEncoder().encode(config.jwt.secret); -} - -// ponytail: jose ships ESM only, so a CJS build can't statically import it. -// Back to a top-level import the day this package goes "type": "module". -async function issueToken(user: AuthUser): Promise { - const { SignJWT } = await import('jose'); - return new SignJWT({ email: user.email }) - .setProtectedHeader({ alg: 'HS256' }) - .setSubject(user.id) - .setIssuedAt() - .setExpirationTime(TOKEN_TTL) - .sign(signingKey()); -} - -export class AuthController extends Routable { - constructor() { - super('/auth'); - } - - /** - * @desc Create an account and return a bearer token - * @access Public - */ - @route('post', '/register', [validate({ body: credentials })]) - async register(req: Request): Promise { - signingKey(); - const { email, password } = req.body as Credentials; - const store = userStore(); - - if (await store.findByEmail(email)) { - throw new AppError(ERROR_CODES.CONFLICT, 'Email already registered'); - } - - const user = await store.create(email, await hashPassword(password)); - return req.resHandler.created({ user, token: await issueToken(user) }); - } - - /** - * @desc Exchange credentials for a bearer token - * @access Public - */ - @route('post', '/login', [validate({ body: credentials })]) - async login(req: Request): Promise { - signingKey(); - const { email, password } = req.body as Credentials; - const stored = await userStore().findByEmail(email); - - // One branch for both failure modes: never reveal which emails exist. - if (!stored || !(await verifyPassword(password, stored.passwordHash))) { - return req.resHandler.wrongToken('Invalid email or password'); - } - - const user: AuthUser = { id: stored.id, email: stored.email }; - return req.resHandler.ok({ user, token: await issueToken(user) }); - } -} diff --git a/src/controllers/Magic.controller.ts b/src/controllers/Magic.controller.ts new file mode 100644 index 0000000..d123f29 --- /dev/null +++ b/src/controllers/Magic.controller.ts @@ -0,0 +1,164 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import { Routable, route, validate } from '../core'; +import { logger } from '../utils/logger'; +import { rateLimit } from '../middleware/rateLimit'; +import { + deliver, + probe, + redeemCode, + redeemToken, + validateReturnTo, + type Redemption +} from '../services/magic'; +import { setRefreshCookie, startSession } from '../services/session'; +import { confirmPage, resultPage } from './magic.page'; + +/** + * Sign in by emailed link, with a typed code as the cross-device fallback. + * See docs/guides/magic-link.md. + */ +const requestBody = z.object({ + email: z.string().email(), + returnTo: z.string().optional() +}); + +const codeBody = z.object({ + email: z.string().email(), + code: z.string() +}); + +const redeemBody = z.object({ token: z.string() }); + +/** + * ponytail: constants, not environment variables. Nobody tunes these per + * deployment, and two more env vars would be two more things to document, + * validate and get wrong. Separate buckets on purpose — an attacker + * enumerating many addresses from one IP and one hammering a single address + * are different attacks, and one limit cannot catch both. + */ +const PER_EMAIL = { limit: 3, window: '15m' }; +const PER_IP = { limit: 20, window: '15m' }; + +function wantsJson(req: Request): boolean { + return req.accepts(['html', 'json']) === 'json'; +} + +/** Establish the session and answer in whichever shape the caller wants. */ +async function completeSignIn( + req: Request, + res: Response, + redemption: Redemption +): Promise { + const session = await startSession(redemption.user); + setRefreshCookie(res, session.refreshToken); + + if (wantsJson(req)) { + return req.resHandler.ok({ + user: session.user, + accessToken: session.accessToken, + refreshToken: session.refreshToken, + expiresIn: session.expiresIn, + returnTo: redemption.returnTo + }); + } + + return req.resHandler.seeOther(redemption.returnTo ?? '/'); +} + +export class MagicController extends Routable { + constructor() { + super('/auth/magic'); + } + + /** + * @desc Email a sign-in link and code + * @access Public + */ + @route('post', '/request', [ + validate({ body: requestBody }), + rateLimit({ + ...PER_EMAIL, + key: (req) => `email:${String(req.body?.email).toLowerCase()}`, + message: 'Too many sign-in requests for that address.' + }), + rateLimit({ ...PER_IP, key: (req) => `ip:${req.ip}` }) + ]) + async request(req: Request): Promise { + const { email, returnTo } = req.body as z.infer; + const destination = validateReturnTo(returnTo); + + // Answer before doing any work, and answer the same way every time. + // Whether this address exists is not something a caller gets to learn — + // not from the body, and not from how long the reply took. Issuing and + // sending happen after the response has already gone out. + const response = req.resHandler.accepted({ + status: 'sent', + message: 'If that address can sign in, a link is on its way.' + }); + + void deliver(email, destination).catch((error: Error) => { + // Never log the address itself — an error log is not a place to leak a + // user directory. + logger.error('Failed to deliver magic link', { error: error.message }); + }); + + return response; + } + + /** + * @desc Show the confirmation page for a link — never consumes it + * @access Public + * + * GET and HEAD are safe by contract, and here that is load-bearing rather + * than pedantic: mail security scanners prefetch links, and a single-use + * token spent by a scanner is the classic way this flow breaks. Nothing is + * consumed until the person clicks, which is a POST. + */ + @route('get', '/:token') + async confirm(req: Request): Promise { + const token = String(req.params.token); + const { status, returnTo } = await probe(token); + + if (wantsJson(req)) return req.resHandler.ok({ status, returnTo }); + + return req.resHandler.html( + status === 'valid' + ? confirmPage(token) + : resultPage( + status === 'expired' + ? 'That link has expired.' + : 'That link has already been used.' + ) + ); + } + + /** + * @desc Redeem a link token and start a session + * @access Public — the token is the credential, which is also why this is + * exempt from the same-origin check: it arrives by a cross-site + * navigation out of a mail client, by design. + */ + @route('post', '/redeem', [validate({ body: redeemBody })]) + async redeem(req: Request, res: Response): Promise { + const { token } = req.body as z.infer; + return completeSignIn(req, res, await redeemToken(token)); + } + + /** + * @desc Redeem the six-digit code instead of the link + * @access Public + */ + @route('post', '/code', [ + validate({ body: codeBody }), + rateLimit({ + ...PER_IP, + key: (req) => `code-ip:${req.ip}`, + message: 'Too many code attempts.' + }) + ]) + async code(req: Request, res: Response): Promise { + const { email, code } = req.body as z.infer; + return completeSignIn(req, res, await redeemCode(email, code)); + } +} diff --git a/src/controllers/Password.controller.ts b/src/controllers/Password.controller.ts new file mode 100644 index 0000000..5d957e0 --- /dev/null +++ b/src/controllers/Password.controller.ts @@ -0,0 +1,84 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import { AppError, ERROR_CODES, Routable, route, validate } from '../core'; +import { userStore } from '../db/users'; +import { passwordStore } from '../db/passwords'; +import { hashPassword, verifyPassword } from '../utils/password'; +import { setRefreshCookie, signingKey, startSession } from '../services/session'; + +/** + * Register and sign in with a password — the classic half of local auth. + * + * Sessions are not this module's business: both endpoints hand off to + * src/services/session.ts, the same layer every other sign-in method uses, so + * a project with several flows has one session implementation, not several. + */ +const credentials = z.object({ + email: z.string().email(), + password: z.string().min(8, 'Password must be at least 8 characters') +}); + +type Credentials = z.infer; + +export class PasswordController extends Routable { + constructor() { + super('/auth'); + } + + /** + * @desc Create an account and start a session + * @access Public + */ + @route('post', '/register', [validate({ body: credentials })]) + async register(req: Request, res: Response): Promise { + signingKey(); + const { email, password } = req.body as Credentials; + const users = userStore(); + + if (await users.findByEmail(email)) { + throw new AppError(ERROR_CODES.CONFLICT, 'Email already registered'); + } + + const user = await users.create(email); + await passwordStore().set(user.id, await hashPassword(password)); + + const session = await startSession(user); + setRefreshCookie(res, session.refreshToken); + + return req.resHandler.created({ + user: session.user, + accessToken: session.accessToken, + refreshToken: session.refreshToken, + expiresIn: session.expiresIn + }); + } + + /** + * @desc Exchange credentials for a session + * @access Public + */ + @route('post', '/login', [validate({ body: credentials })]) + async login(req: Request, res: Response): Promise { + signingKey(); + const { email, password } = req.body as Credentials; + const stored = await userStore().findByEmail(email); + const hash = stored ? await passwordStore().get(stored.id) : null; + + // One branch for every failure mode — unknown address, an identity that + // never set a password, wrong password — so none of them can be told + // apart from outside. + if (!stored || !hash || !(await verifyPassword(password, hash))) { + return req.resHandler.wrongToken('Invalid email or password'); + } + + const session = await startSession({ id: stored.id, email: stored.email }); + setRefreshCookie(res, session.refreshToken); + + return req.resHandler.ok({ + user: session.user, + accessToken: session.accessToken, + refreshToken: session.refreshToken, + expiresIn: session.expiresIn + }); + } +} diff --git a/src/controllers/Session.controller.ts b/src/controllers/Session.controller.ts new file mode 100644 index 0000000..5941c66 --- /dev/null +++ b/src/controllers/Session.controller.ts @@ -0,0 +1,80 @@ +import { Request, Response } from 'express'; +import { AppError, ERROR_CODES, Routable, protectedRoute, route } from '../core'; +import { sameOrigin } from '../middleware/sameOrigin'; +import { readCookie } from '../utils/cookies'; +import { + REFRESH_COOKIE, + clearRefreshCookie, + endSession, + refreshSession, + revokeAllSessions, + setRefreshCookie +} from '../services/session'; + +/** + * Session lifecycle: exchange a refresh token for a new access token, sign out + * of one device, or sign out of all of them. + * + * The refresh token arrives either in the httpOnly cookie (browsers) or in the + * body (API clients and mobile apps). Cookie-bearing routes go through + * `sameOrigin`, because a cookie is ambient credentials; the body path is + * unaffected by CSRF and unaffected by that check. + */ +function presentedToken(req: Request): string | undefined { + const body = req.body as { refreshToken?: string } | undefined; + return body?.refreshToken ?? readCookie(req, REFRESH_COOKIE); +} + +export class SessionController extends Routable { + constructor() { + super('/auth'); + } + + /** + * @desc Rotate the refresh token and mint a new access token + * @access Public (the refresh token is the credential) + */ + @route('post', '/refresh', [sameOrigin]) + async refresh(req: Request, res: Response): Promise { + const presented = presentedToken(req); + if (!presented) { + throw new AppError(ERROR_CODES.WRONG_TOKEN, 'No refresh token supplied'); + } + + const session = await refreshSession(presented); + setRefreshCookie(res, session.refreshToken); + + return req.resHandler.ok({ + user: session.user, + accessToken: session.accessToken, + refreshToken: session.refreshToken, + expiresIn: session.expiresIn + }); + } + + /** + * @desc Sign out of this device + * @access Public — idempotent, so an already-dead session is still a 204 + */ + @route('post', '/logout', [sameOrigin]) + async logout(req: Request, res: Response): Promise { + await endSession(presentedToken(req)); + clearRefreshCookie(res); + return req.resHandler.noContent(); + } + + /** + * @desc Sign out of every device for this identity + * @access Private + */ + @protectedRoute('post', '/revoke-all', [sameOrigin]) + async revokeAll(req: Request, res: Response): Promise { + if (!req.identityId) { + throw new AppError(ERROR_CODES.WRONG_TOKEN, 'Unidentified token'); + } + + await revokeAllSessions(req.identityId); + clearRefreshCookie(res); + return req.resHandler.noContent(); + } +} diff --git a/src/controllers/index.ts b/src/controllers/index.ts index eafa59e..46113f9 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -5,4 +5,6 @@ */ export * from './Status.controller'; export * from './Health.controller'; -export * from './Auth.controller'; // chassis:jwt +export * from './Session.controller'; // chassis:session +export * from './Password.controller'; // chassis:password +export * from './Magic.controller'; // chassis:magic diff --git a/src/controllers/magic.page.ts b/src/controllers/magic.page.ts new file mode 100644 index 0000000..6d311c1 --- /dev/null +++ b/src/controllers/magic.page.ts @@ -0,0 +1,68 @@ +/** + * The confirmation page. + * + * ponytail: one inline HTML string, no view engine, no template directory, no + * client-side JavaScript. It exists to turn a prefetchable GET into a + * deliberate POST, and a form does that on its own. Products with a front end + * point MAGIC_LINK_BASE_URL at their own page instead and never see this one. + */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function page(title: string, body: string): string { + return [ + '', + '', + '', + // Scanners and previewers must not be encouraged to fetch anything here. + '', + `${title}`, + '
', + body, + '
' + ].join(''); +} + +/** + * Note there is no `returnTo` field: the destination was validated and stored + * when the link was issued, and redemption re-reads it from there. A value + * posted back from this page would be attacker-controlled, which is exactly + * how a redemption endpoint becomes an open redirect. + */ +export function confirmPage(token: string): string { + return page( + 'Confirm sign-in', + [ + '

Confirm sign-in

', + '

Click below to finish signing in on this device.

', + '', + ``, + '', + '' + ].join('') + ); +} + +export function resultPage(message: string): string { + return page( + 'Sign-in link', + [ + '

This link no longer works

', + `

${escapeHtml(message)}

`, + '

Request a new one and it will arrive in a moment.

' + ].join('') + ); +} diff --git a/src/core/errorHandler.ts b/src/core/errorHandler.ts index dbe00a7..72bc83f 100644 --- a/src/core/errorHandler.ts +++ b/src/core/errorHandler.ts @@ -1,5 +1,6 @@ import { Application, NextFunction, Request, Response } from 'express'; import { AppError } from './errors'; +import { logPath } from '../utils/logger'; import { config } from '../config'; // chassis:sentry import { captureException } from '../integrations/sentry'; // chassis:sentry @@ -10,7 +11,12 @@ import { captureException } from '../integrations/sentry'; // chassis:sentry */ export function registerErrorHandlers(app: Application): void { app.use((req: Request, _res: Response) => { - req.resHandler.notFound(`Cannot ${req.method} ${req.originalUrl}`); + // `logPath`, not `originalUrl`: an unmatched request is exactly the one + // whose URL may still hold a credential — a mistyped or already-rotated + // sign-in link — and this message reaches both the log and the response + // body. Echoing the query back is how a token ends up in someone's + // browser history and an error-tracking dashboard at the same time. + req.resHandler.notFound(`Cannot ${req.method} ${logPath(req)}`); }); app.use((err: unknown, req: Request, _res: Response, _next: NextFunction) => { diff --git a/src/core/errors.ts b/src/core/errors.ts index b3b6edb..670e515 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -8,7 +8,9 @@ export interface ErrorCode { export const ERROR_CODES = { OK: { id: 0, statusCode: 200, statusReason: 'OK' }, CREATED: { id: 0, statusCode: 201, statusReason: 'Created' }, + ACCEPTED: { id: 0, statusCode: 202, statusReason: 'Accepted' }, NO_CONTENT: { id: 0, statusCode: 204, statusReason: 'No Content' }, + SEE_OTHER: { id: 0, statusCode: 303, statusReason: 'See Other' }, BAD_REQUEST: { id: 1000, statusCode: 400, statusReason: 'Bad Request' }, VALIDATION: { id: 1001, statusCode: 400, statusReason: 'Validation Failed' }, WRONG_TOKEN: { @@ -19,6 +21,11 @@ export const ERROR_CODES = { FORBIDDEN: { id: 1003, statusCode: 403, statusReason: 'Forbidden' }, NOT_FOUND: { id: 1004, statusCode: 404, statusReason: 'Not Found' }, CONFLICT: { id: 1005, statusCode: 409, statusReason: 'Conflict' }, + TOO_MANY_REQUESTS: { + id: 1006, + statusCode: 429, + statusReason: 'Too Many Requests' + }, SERVER_ERROR: { id: 1500, statusCode: 500, diff --git a/src/core/response.ts b/src/core/response.ts index f8f7c87..ff5562c 100644 --- a/src/core/response.ts +++ b/src/core/response.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import { ERROR_CODES, ErrorCode } from './errors'; -import { logger } from '../utils/logger'; +import { logger, logPath } from '../utils/logger'; import { config } from '../config'; export interface ValidationIssue { @@ -36,11 +36,44 @@ export class ResponseHandler { return this.res.status(201).json(data ?? { success: true }); } + /** + * The work was queued, not completed. Used where the response must not + * reveal what the server went on to do — a sign-in request whose reply is + * identical for a known and an unknown address, for instance. + */ + accepted(data?: unknown): Response { + this.logMeta(ERROR_CODES.ACCEPTED); + return this.res.status(202).json(data ?? { success: true }); + } + noContent(): Response { this.logMeta(ERROR_CODES.NO_CONTENT); return this.res.status(204).send(); } + /** + * Send a self-contained HTML page. + * + * The API is otherwise JSON-only. This exists because some flows have to be + * completed by a person in a browser — an emailed sign-in link has to render + * a page and wait for a click, since mail security scanners prefetch links + * and would otherwise consume a single-use token. + */ + html(markup: string): Response { + this.logMeta(ERROR_CODES.OK); + return this.res.status(200).type('html').send(markup); + } + + /** + * Redirect after a successful POST, so that reloading the destination never + * re-submits it. + */ + seeOther(url: string): Response { + const code = ERROR_CODES.SEE_OTHER; + this.logMeta(code); + return this.res.status(code.statusCode).location(url).send(); + } + badRequest(message?: string): Response { return this.sendError(ERROR_CODES.BAD_REQUEST, { message }); } @@ -112,7 +145,7 @@ export class ResponseHandler { errorId: code.id, callId: this.req.callId, method: this.req.method, - endpoint: this.req.originalUrl, + endpoint: logPath(this.req), ...extra }; diff --git a/src/db/magic.ts b/src/db/magic.ts new file mode 100644 index 0000000..14ce2f1 --- /dev/null +++ b/src/db/magic.ts @@ -0,0 +1,52 @@ +import { config } from '../config'; +import { memoryMagic } from './memory-magic'; +import { sqliteMagic } from './sqlite/magic'; // chassis:sqlite +import { postgresMagic } from './postgres/magic'; // chassis:postgres +import { mongoMagic } from './mongo/magic'; // chassis:mongo + +/** + * Magic-link credential storage. + * + * Both the link token and the fallback code are stored only as SHA-256 + * digests. Because issuing voids everything outstanding for an address, + * `findLiveByEmail` returns at most one row. + */ +export interface NewMagicCredential { + email: string; + tokenHash: string; + codeHash: string; + returnTo: string | null; + createdAt: Date; + expiresAt: Date; +} + +export interface MagicCredential { + id: string; + email: string; + codeHash: string; + attempts: number; + returnTo: string | null; + expiresAt: string; + consumedAt: string | null; + voidedAt: string | null; +} + +export interface MagicStore { + insert(credential: NewMagicCredential): Promise; + findByTokenHash(tokenHash: string): Promise; + findLiveByEmail(email: string): Promise; + markConsumed(id: string, at: Date): Promise; + bumpAttempts(id: string): Promise; + voidAllForEmail(email: string, at: Date): Promise; +} + +const stores: Array<[feature: string, store: MagicStore]> = [ + ['sqlite', sqliteMagic], // chassis:sqlite + ['postgres', postgresMagic], // chassis:postgres + ['mongo', mongoMagic] // chassis:mongo +]; + +export function magicStore(): MagicStore { + const configured = stores.find(([feature]) => config.features[feature]); + return configured?.[1] ?? memoryMagic; +} diff --git a/src/db/memory-magic.ts b/src/db/memory-magic.ts new file mode 100644 index 0000000..5509793 --- /dev/null +++ b/src/db/memory-magic.ts @@ -0,0 +1,78 @@ +import type { + MagicCredential, + MagicStore, + NewMagicCredential +} from './magic'; + +/** + * In-memory magic-link credentials — the fallback when no database is + * configured. + * + * ponytail: process-local, so a restart invalidates every outstanding link. + * That is acceptable for development (links live 15 minutes anyway); pick a + * database before running more than one instance. + */ +const credentials = new Map(); +let nextId = 1; + +export const memoryMagic: MagicStore = { + async insert(credential: NewMagicCredential): Promise { + credentials.set(credential.tokenHash, { + id: String(nextId++), + email: credential.email, + codeHash: credential.codeHash, + attempts: 0, + returnTo: credential.returnTo, + expiresAt: credential.expiresAt.toISOString(), + consumedAt: null, + voidedAt: null + }); + }, + + async findByTokenHash(tokenHash: string): Promise { + return credentials.get(tokenHash) ?? null; + }, + + async findLiveByEmail(email: string): Promise { + for (const credential of credentials.values()) { + if ( + credential.email === email.toLowerCase() && + !credential.consumedAt && + !credential.voidedAt + ) { + return credential; + } + } + return null; + }, + + async markConsumed(id: string, at: Date): Promise { + for (const credential of credentials.values()) { + if (credential.id === id) credential.consumedAt = at.toISOString(); + } + }, + + async bumpAttempts(id: string): Promise { + for (const credential of credentials.values()) { + if (credential.id === id) credential.attempts += 1; + } + }, + + async voidAllForEmail(email: string, at: Date): Promise { + for (const credential of credentials.values()) { + if ( + credential.email === email.toLowerCase() && + !credential.consumedAt && + !credential.voidedAt + ) { + credential.voidedAt = at.toISOString(); + } + } + } +}; + +/** Test-only: forget every issued credential. */ +export function resetMemoryMagic(): void { + credentials.clear(); + nextId = 1; +} diff --git a/src/db/memory-passwords.ts b/src/db/memory-passwords.ts new file mode 100644 index 0000000..366322e --- /dev/null +++ b/src/db/memory-passwords.ts @@ -0,0 +1,43 @@ +import { config } from '../config'; +import { hashPassword } from '../utils/password'; +import { devUserId } from './memory-users'; +import type { PasswordStore } from './passwords'; + +/** + * In-memory password hashes, paired with the in-memory identity store. + * + * Set AUTH_DEV_EMAIL and AUTH_DEV_PASSWORD to sign in with a single seeded + * account during development. The identity comes from ./memory-users.ts; only + * the hash is seeded here, lazily, so that hashing cost is paid on first use + * rather than at boot. + */ +const hashes = new Map(); +let seeding: Promise | undefined; + +function seed(): Promise { + seeding ??= (async () => { + const password = config.authDevPassword; + const id = devUserId(); + if (!password || !id) return; + hashes.set(id, await hashPassword(password)); + })(); + return seeding; +} + +export const memoryPasswords: PasswordStore = { + async get(userId: string): Promise { + await seed(); + return hashes.get(userId) ?? null; + }, + + async set(userId: string, hash: string): Promise { + await seed(); + hashes.set(userId, hash); + } +}; + +/** Test-only: forget every seeded and stored hash. */ +export function resetMemoryPasswords(): void { + hashes.clear(); + seeding = undefined; +} diff --git a/src/db/memory-sessions.ts b/src/db/memory-sessions.ts new file mode 100644 index 0000000..fef8513 --- /dev/null +++ b/src/db/memory-sessions.ts @@ -0,0 +1,61 @@ +import type { + NewRefreshToken, + RefreshToken, + RefreshTokenStore +} from './sessions'; + +/** + * In-memory refresh tokens — the fallback when no database is configured. + * + * ponytail: process-local, so every session dies with the process. Fine for + * development and tests; pick a database before real users depend on staying + * signed in across a deploy. + */ +const tokens = new Map(); +let nextId = 1; + +export const memorySessions: RefreshTokenStore = { + async insert(token: NewRefreshToken): Promise { + tokens.set(token.tokenHash, { + id: String(nextId++), + familyId: token.familyId, + userId: token.userId, + expiresAt: token.expiresAt.toISOString(), + familyCreatedAt: token.familyCreatedAt.toISOString(), + rotatedAt: null, + revokedAt: null + }); + }, + + async findByHash(tokenHash: string): Promise { + return tokens.get(tokenHash) ?? null; + }, + + async markRotated(id: string, at: Date): Promise { + for (const token of tokens.values()) { + if (token.id === id) token.rotatedAt = at.toISOString(); + } + }, + + async revokeFamily(familyId: string, at: Date): Promise { + for (const token of tokens.values()) { + if (token.familyId === familyId && !token.revokedAt) { + token.revokedAt = at.toISOString(); + } + } + }, + + async revokeAllForUser(userId: string, at: Date): Promise { + for (const token of tokens.values()) { + if (token.userId === userId && !token.revokedAt) { + token.revokedAt = at.toISOString(); + } + } + } +}; + +/** Test-only: forget every issued token. */ +export function resetMemorySessions(): void { + tokens.clear(); + nextId = 1; +} diff --git a/src/db/memory-users.ts b/src/db/memory-users.ts index 91b9a2c..0fae6d9 100644 --- a/src/db/memory-users.ts +++ b/src/db/memory-users.ts @@ -1,46 +1,71 @@ import { config } from '../config'; -import { hashPassword } from '../utils/password'; import type { AuthUser, StoredUser, UserStore } from './users'; /** - * In-memory user store — the fallback when no database is configured. + * In-memory identity store — the fallback when no database is configured. * * ponytail: process-local and non-persistent by design. It exists so that - * `--auth jwt --db none` still boots and logs in during development; pick a - * database (the store then follows it automatically, see ./users.ts) before - * putting local-JWT auth in front of real users. + * local auth still boots and signs in during development; pick a database + * (the store then follows it automatically, see ./users.ts) before putting + * this in front of real users. * - * Set AUTH_DEV_EMAIL / AUTH_DEV_PASSWORD to seed a single account at boot. + * Set AUTH_DEV_EMAIL to seed a single identity at boot. */ const users = new Map(); let nextId = 1; -let seeding: Promise | undefined; - -function seed(): Promise { - seeding ??= (async () => { - const { email, password } = config.authDev; - if (!email || !password) return; - const key = email.toLowerCase(); - users.set(key, { - id: String(nextId++), - email: key, - passwordHash: await hashPassword(password) - }); - })(); - return seeding; +let seeded = false; + +function insert(email: string): StoredUser { + const key = email.toLowerCase(); + const user: StoredUser = { id: String(nextId++), email: key, verifiedAt: null }; + users.set(key, user); + return user; +} + +function seed(): void { + if (seeded) return; + seeded = true; + if (config.authDev.email) insert(config.authDev.email); } export const memoryUsers: UserStore = { async findByEmail(email: string): Promise { - await seed(); + seed(); return users.get(email.toLowerCase()) ?? null; }, - async create(email: string, passwordHash: string): Promise { - await seed(); - const key = email.toLowerCase(); - const user: StoredUser = { id: String(nextId++), email: key, passwordHash }; - users.set(key, user); + async findById(id: string): Promise { + seed(); + for (const user of users.values()) { + if (user.id === id) return user; + } + return null; + }, + + async create(email: string): Promise { + seed(); + const user = insert(email); return { id: user.id, email: user.email }; + }, + + async markVerified(id: string, at: Date): Promise { + seed(); + for (const user of users.values()) { + if (user.id === id) user.verifiedAt = at.toISOString(); + } } }; + +/** Test-only: forget every seeded and created identity. */ +export function resetMemoryUsers(): void { + users.clear(); + nextId = 1; + seeded = false; +} + +/** The dev identity's id, if AUTH_DEV_EMAIL seeded one. */ +export function devUserId(): string | undefined { + seed(); + const email = config.authDev.email?.toLowerCase(); + return email ? users.get(email)?.id : undefined; +} diff --git a/src/db/mongo/magic.ts b/src/db/mongo/magic.ts new file mode 100644 index 0000000..6f752d7 --- /dev/null +++ b/src/db/mongo/magic.ts @@ -0,0 +1,97 @@ +import { Model, Schema, model, models } from 'mongoose'; + +/** + * Mongo-backed magic-link credentials, model included. Shape-compatible with + * `MagicStore` in ../magic.ts, which imports it by feature flag; nothing here + * depends on that file, so it stands alone when a different auth provider is + * scaffolded. + */ +export interface MagicCredentialDoc { + email: string; + tokenHash: string; + codeHash: string; + attempts: number; + returnTo: string | null; + createdAt: Date; + expiresAt: Date; + consumedAt: Date | null; + voidedAt: Date | null; +} + +const magicCredentialSchema = new Schema({ + email: { type: String, required: true, lowercase: true, index: true }, + tokenHash: { type: String, required: true, unique: true }, + codeHash: { type: String, required: true }, + attempts: { type: Number, default: 0 }, + returnTo: { type: String, default: null }, + createdAt: { type: Date, required: true }, + expiresAt: { type: Date, required: true }, + consumedAt: { type: Date, default: null }, + voidedAt: { type: Date, default: null } +}); + +// Reuse an already-compiled model rather than registering twice: this module +// is re-imported on dev-server reloads and between tests, and Mongoose throws +// `OverwriteModelError` on a duplicate registration. +export const MagicCredentialModel: Model = + (models.MagicCredential as Model) ?? + model('MagicCredential', magicCredentialSchema); + +function toCredential(doc: MagicCredentialDoc & { _id: unknown }) { + return { + id: String(doc._id), + email: doc.email, + codeHash: doc.codeHash, + attempts: doc.attempts, + returnTo: doc.returnTo, + expiresAt: doc.expiresAt.toISOString(), + consumedAt: doc.consumedAt?.toISOString() ?? null, + voidedAt: doc.voidedAt?.toISOString() ?? null + }; +} + +export const mongoMagic = { + async insert(credential: { + email: string; + tokenHash: string; + codeHash: string; + returnTo: string | null; + createdAt: Date; + expiresAt: Date; + }) { + await MagicCredentialModel.create(credential); + }, + + async findByTokenHash(tokenHash: string) { + const doc = await MagicCredentialModel.findOne({ tokenHash }).lean(); + return doc ? toCredential(doc) : null; + }, + + async findLiveByEmail(email: string) { + const doc = await MagicCredentialModel.findOne({ + email: email.toLowerCase(), + consumedAt: null, + voidedAt: null + }).lean(); + + return doc ? toCredential(doc) : null; + }, + + async markConsumed(id: string, at: Date) { + await MagicCredentialModel.updateOne( + { _id: id }, + { $set: { consumedAt: at } } + ); + }, + + async bumpAttempts(id: string) { + await MagicCredentialModel.updateOne({ _id: id }, { $inc: { attempts: 1 } }); + }, + + async voidAllForEmail(email: string, at: Date) { + await MagicCredentialModel.updateMany( + { email: email.toLowerCase(), consumedAt: null, voidedAt: null }, + { $set: { voidedAt: at } } + ); + } +}; diff --git a/src/db/mongo/passwords.ts b/src/db/mongo/passwords.ts new file mode 100644 index 0000000..d27b503 --- /dev/null +++ b/src/db/mongo/passwords.ts @@ -0,0 +1,17 @@ +import { UserModel } from './users'; + +/** + * Mongo-backed password hashes. The hash lives on the identity document, but + * reaching it goes through this file so that scaffolding without the password + * module removes every reference to it — see ../passwords.ts. + */ +export const mongoPasswords = { + async get(userId: string) { + const doc = await UserModel.findById(userId).select('passwordHash').lean(); + return doc?.passwordHash ?? null; + }, + + async set(userId: string, hash: string) { + await UserModel.updateOne({ _id: userId }, { $set: { passwordHash: hash } }); + } +}; diff --git a/src/db/mongo/sessions.ts b/src/db/mongo/sessions.ts new file mode 100644 index 0000000..4e4e2f9 --- /dev/null +++ b/src/db/mongo/sessions.ts @@ -0,0 +1,82 @@ +import { Model, Schema, model, models } from 'mongoose'; + +/** + * Mongo-backed refresh tokens, model included. Shape-compatible with + * `RefreshTokenStore` in ../sessions.ts, which imports it by feature flag; + * nothing here depends on that file, so it stands alone when a different auth + * provider is scaffolded. + */ +export interface RefreshTokenDoc { + familyId: string; + userId: string; + tokenHash: string; + createdAt: Date; + expiresAt: Date; + familyCreatedAt: Date; + rotatedAt: Date | null; + revokedAt: Date | null; +} + +const refreshTokenSchema = new Schema({ + familyId: { type: String, required: true, index: true }, + userId: { type: String, required: true, index: true }, + tokenHash: { type: String, required: true, unique: true }, + createdAt: { type: Date, required: true }, + expiresAt: { type: Date, required: true }, + familyCreatedAt: { type: Date, required: true }, + rotatedAt: { type: Date, default: null }, + revokedAt: { type: Date, default: null } +}); + +// Reuse an already-compiled model rather than registering twice: this module +// is re-imported on dev-server reloads and between tests, and Mongoose throws +// `OverwriteModelError` on a duplicate registration. +export const RefreshTokenModel: Model = + (models.RefreshToken as Model) ?? + model('RefreshToken', refreshTokenSchema); + +export const mongoSessions = { + async insert(token: { + familyId: string; + userId: string; + tokenHash: string; + createdAt: Date; + expiresAt: Date; + familyCreatedAt: Date; + }) { + await RefreshTokenModel.create(token); + }, + + async findByHash(tokenHash: string) { + const doc = await RefreshTokenModel.findOne({ tokenHash }).lean(); + return doc + ? { + id: String(doc._id), + familyId: doc.familyId, + userId: doc.userId, + expiresAt: doc.expiresAt.toISOString(), + familyCreatedAt: doc.familyCreatedAt.toISOString(), + rotatedAt: doc.rotatedAt?.toISOString() ?? null, + revokedAt: doc.revokedAt?.toISOString() ?? null + } + : null; + }, + + async markRotated(id: string, at: Date) { + await RefreshTokenModel.updateOne({ _id: id }, { $set: { rotatedAt: at } }); + }, + + async revokeFamily(familyId: string, at: Date) { + await RefreshTokenModel.updateMany( + { familyId, revokedAt: null }, + { $set: { revokedAt: at } } + ); + }, + + async revokeAllForUser(userId: string, at: Date) { + await RefreshTokenModel.updateMany( + { userId, revokedAt: null }, + { $set: { revokedAt: at } } + ); + } +}; diff --git a/src/db/mongo/users.ts b/src/db/mongo/users.ts index 0bf865d..4789e3c 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -1,20 +1,22 @@ import { Model, Schema, model, models } from 'mongoose'; /** - * Mongo-backed user store for local-JWT auth, model included — the Mongo + * Mongo-backed identity store for local auth, model included — the Mongo * equivalent of the Drizzle `users` table. Shape-compatible with `UserStore` * in ../users.ts, which imports it by feature flag; nothing here depends on * that file, so it stands alone when a different auth provider is scaffolded. */ export interface User { email: string; - passwordHash: string; + passwordHash?: string | null; // chassis:password + verifiedAt: Date | null; createdAt: Date; } const userSchema = new Schema({ email: { type: String, required: true, unique: true, lowercase: true }, - passwordHash: { type: String, required: true }, + passwordHash: { type: String, default: null }, // chassis:password + verifiedAt: { type: Date, default: null }, createdAt: { type: Date, default: Date.now } }); @@ -24,23 +26,31 @@ const userSchema = new Schema({ export const UserModel: Model = (models.User as Model) ?? model('User', userSchema); +function toStored(doc: { _id: unknown; email: string; verifiedAt: Date | null }) { + return { + id: String(doc._id), + email: doc.email, + verifiedAt: doc.verifiedAt?.toISOString() ?? null + }; +} + export const mongoUsers = { async findByEmail(email: string) { const doc = await UserModel.findOne({ email: email.toLowerCase() }).lean(); - return doc - ? { - id: String(doc._id), - email: doc.email, - passwordHash: doc.passwordHash - } - : null; + return doc ? toStored(doc) : null; + }, + + async findById(id: string) { + const doc = await UserModel.findById(id).lean(); + return doc ? toStored(doc) : null; }, - async create(email: string, passwordHash: string) { - const doc = await UserModel.create({ - email: email.toLowerCase(), - passwordHash - }); + async create(email: string) { + const doc = await UserModel.create({ email: email.toLowerCase() }); return { id: String(doc._id), email: doc.email }; + }, + + async markVerified(id: string, at: Date) { + await UserModel.updateOne({ _id: id }, { $set: { verifiedAt: at } }); } }; diff --git a/src/db/passwords.ts b/src/db/passwords.ts new file mode 100644 index 0000000..89006ca --- /dev/null +++ b/src/db/passwords.ts @@ -0,0 +1,29 @@ +import { config } from '../config'; +import { memoryPasswords } from './memory-passwords'; +import { sqlitePasswords } from './sqlite/passwords'; // chassis:sqlite +import { postgresPasswords } from './postgres/passwords'; // chassis:postgres +import { mongoPasswords } from './mongo/passwords'; // chassis:mongo + +/** + * The password module's half of the identity row. + * + * Kept apart from `./users.ts` on purpose: an identity is an email and whether + * it has been proven, while a stored hash is one particular way of proving it. + * Splitting them is what lets a project scaffolded without this module carry + * no password code, column, or type in it at all. + */ +export interface PasswordStore { + get(userId: string): Promise; + set(userId: string, hash: string): Promise; +} + +const stores: Array<[feature: string, store: PasswordStore]> = [ + ['sqlite', sqlitePasswords], // chassis:sqlite + ['postgres', postgresPasswords], // chassis:postgres + ['mongo', mongoPasswords] // chassis:mongo +]; + +export function passwordStore(): PasswordStore { + const configured = stores.find(([feature]) => config.features[feature]); + return configured?.[1] ?? memoryPasswords; +} diff --git a/src/db/postgres/magic.schema.ts b/src/db/postgres/magic.schema.ts new file mode 100644 index 0000000..de3bfc4 --- /dev/null +++ b/src/db/postgres/magic.schema.ts @@ -0,0 +1,22 @@ +import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; + +/** + * Magic-link credentials — a credentials table, so both the link token and the + * fallback code are stored only as SHA-256 digests. One row holds both, + * because they share an expiry and are voided together. + * + * Kept in its own file so the whole table can be pruned by the single marked + * re-export line in schema.ts. + */ +export const magicCredentials = pgTable('magic_credentials', { + id: serial('id').primaryKey(), + email: text('email').notNull(), + tokenHash: text('token_hash').notNull().unique(), + codeHash: text('code_hash').notNull(), + attempts: integer('attempts').default(0).notNull(), + returnTo: text('return_to'), + createdAt: timestamp('created_at').defaultNow().notNull(), + expiresAt: timestamp('expires_at').notNull(), + consumedAt: timestamp('consumed_at'), + voidedAt: timestamp('voided_at') +}); diff --git a/src/db/postgres/magic.ts b/src/db/postgres/magic.ts new file mode 100644 index 0000000..ad06f6f --- /dev/null +++ b/src/db/postgres/magic.ts @@ -0,0 +1,91 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { db } from './index'; +import { magicCredentials } from './schema'; + +/** + * Postgres-backed magic-link credentials. Shape-compatible with `MagicStore` + * in ../magic.ts, which imports it by feature flag; nothing here depends on + * that file, so it stands alone when a different auth provider is scaffolded. + */ +type Row = typeof magicCredentials.$inferSelect; + +function toCredential(row: Row) { + return { + id: String(row.id), + email: row.email, + codeHash: row.codeHash, + attempts: row.attempts, + returnTo: row.returnTo, + expiresAt: row.expiresAt.toISOString(), + consumedAt: row.consumedAt?.toISOString() ?? null, + voidedAt: row.voidedAt?.toISOString() ?? null + }; +} + +const live = (email: string) => + and( + eq(magicCredentials.email, email.toLowerCase()), + isNull(magicCredentials.consumedAt), + isNull(magicCredentials.voidedAt) + ); + +export const postgresMagic = { + async insert(credential: { + email: string; + tokenHash: string; + codeHash: string; + returnTo: string | null; + createdAt: Date; + expiresAt: Date; + }) { + await db.insert(magicCredentials).values({ + email: credential.email.toLowerCase(), + tokenHash: credential.tokenHash, + codeHash: credential.codeHash, + returnTo: credential.returnTo, + createdAt: credential.createdAt, + expiresAt: credential.expiresAt + }); + }, + + async findByTokenHash(tokenHash: string) { + const [row] = await db + .select() + .from(magicCredentials) + .where(eq(magicCredentials.tokenHash, tokenHash)) + .limit(1); + + return row ? toCredential(row) : null; + }, + + async findLiveByEmail(email: string) { + const [row] = await db + .select() + .from(magicCredentials) + .where(live(email)) + .limit(1); + + return row ? toCredential(row) : null; + }, + + async markConsumed(id: string, at: Date) { + await db + .update(magicCredentials) + .set({ consumedAt: at }) + .where(eq(magicCredentials.id, Number(id))); + }, + + async bumpAttempts(id: string) { + await db + .update(magicCredentials) + .set({ attempts: sql`${magicCredentials.attempts} + 1` }) + .where(eq(magicCredentials.id, Number(id))); + }, + + async voidAllForEmail(email: string, at: Date) { + await db + .update(magicCredentials) + .set({ voidedAt: at }) + .where(live(email)); + } +}; diff --git a/src/db/postgres/passwords.ts b/src/db/postgres/passwords.ts new file mode 100644 index 0000000..999840d --- /dev/null +++ b/src/db/postgres/passwords.ts @@ -0,0 +1,27 @@ +import { eq } from 'drizzle-orm'; +import { db } from './index'; +import { users } from './schema'; + +/** + * Postgres-backed password hashes. The hash lives on the identity row, but + * reaching it goes through this file so that scaffolding without the password + * module removes every reference to it — see ../passwords.ts. + */ +export const postgresPasswords = { + async get(userId: string) { + const [row] = await db + .select({ passwordHash: users.passwordHash }) + .from(users) + .where(eq(users.id, Number(userId))) + .limit(1); + + return row?.passwordHash ?? null; + }, + + async set(userId: string, hash: string) { + await db + .update(users) + .set({ passwordHash: hash }) + .where(eq(users.id, Number(userId))); + } +}; diff --git a/src/db/postgres/schema.ts b/src/db/postgres/schema.ts index b0b56f4..09d1ae9 100644 --- a/src/db/postgres/schema.ts +++ b/src/db/postgres/schema.ts @@ -10,4 +10,6 @@ export const examples = pgTable('examples', { createdAt: timestamp('created_at').defaultNow().notNull() }); -export * from './users.schema'; // chassis:jwt +export * from './users.schema'; // chassis:session +export * from './sessions.schema'; // chassis:session +export * from './magic.schema'; // chassis:magic diff --git a/src/db/postgres/sessions.schema.ts b/src/db/postgres/sessions.schema.ts new file mode 100644 index 0000000..61e2786 --- /dev/null +++ b/src/db/postgres/sessions.schema.ts @@ -0,0 +1,22 @@ +import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; + +/** + * Refresh tokens — one row per issued token, never updated in place except to + * mark it spent or revoked. Kept in its own file so the whole table can be + * pruned by the single marked re-export line in schema.ts. + * + * `family_id` groups every token descended from one sign-in, so presenting an + * already-rotated token can revoke the whole lineage. `family_created_at` is + * denormalized onto every row so the absolute window needs no second table. + */ +export const refreshTokens = pgTable('refresh_tokens', { + id: serial('id').primaryKey(), + familyId: text('family_id').notNull(), + userId: integer('user_id').notNull(), + tokenHash: text('token_hash').notNull().unique(), + createdAt: timestamp('created_at').defaultNow().notNull(), + expiresAt: timestamp('expires_at').notNull(), + familyCreatedAt: timestamp('family_created_at').notNull(), + rotatedAt: timestamp('rotated_at'), + revokedAt: timestamp('revoked_at') +}); diff --git a/src/db/postgres/sessions.ts b/src/db/postgres/sessions.ts new file mode 100644 index 0000000..eb4500b --- /dev/null +++ b/src/db/postgres/sessions.ts @@ -0,0 +1,83 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from './index'; +import { refreshTokens } from './schema'; + +/** + * Postgres-backed refresh tokens. Shape-compatible with `RefreshTokenStore` in + * ../sessions.ts, which imports it by feature flag; nothing here depends on + * that file, so it stands alone when a different auth provider is scaffolded. + */ +type Row = typeof refreshTokens.$inferSelect; + +function toToken(row: Row) { + return { + id: String(row.id), + familyId: row.familyId, + userId: String(row.userId), + expiresAt: row.expiresAt.toISOString(), + familyCreatedAt: row.familyCreatedAt.toISOString(), + rotatedAt: row.rotatedAt?.toISOString() ?? null, + revokedAt: row.revokedAt?.toISOString() ?? null + }; +} + +export const postgresSessions = { + async insert(token: { + familyId: string; + userId: string; + tokenHash: string; + createdAt: Date; + expiresAt: Date; + familyCreatedAt: Date; + }) { + await db.insert(refreshTokens).values({ + familyId: token.familyId, + userId: Number(token.userId), + tokenHash: token.tokenHash, + createdAt: token.createdAt, + expiresAt: token.expiresAt, + familyCreatedAt: token.familyCreatedAt + }); + }, + + async findByHash(tokenHash: string) { + const [row] = await db + .select() + .from(refreshTokens) + .where(eq(refreshTokens.tokenHash, tokenHash)) + .limit(1); + + return row ? toToken(row) : null; + }, + + async markRotated(id: string, at: Date) { + await db + .update(refreshTokens) + .set({ rotatedAt: at }) + .where(eq(refreshTokens.id, Number(id))); + }, + + async revokeFamily(familyId: string, at: Date) { + await db + .update(refreshTokens) + .set({ revokedAt: at }) + .where( + and( + eq(refreshTokens.familyId, familyId), + isNull(refreshTokens.revokedAt) + ) + ); + }, + + async revokeAllForUser(userId: string, at: Date) { + await db + .update(refreshTokens) + .set({ revokedAt: at }) + .where( + and( + eq(refreshTokens.userId, Number(userId)), + isNull(refreshTokens.revokedAt) + ) + ); + } +}; diff --git a/src/db/postgres/users.schema.ts b/src/db/postgres/users.schema.ts index 5ce3f2c..ecebcec 100644 --- a/src/db/postgres/users.schema.ts +++ b/src/db/postgres/users.schema.ts @@ -1,14 +1,19 @@ import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; /** - * Accounts for local-JWT auth. Kept in its own file so the whole table can - * be pruned by the single marked re-export line in schema.ts. A marker on - * the table itself would sit after an opening brace, where the formatter - * moves it onto its own line and pruning would break the declaration. + * Identities for local auth. Kept in its own file so the whole table can be + * pruned by the single marked re-export line in schema.ts. A marker on the + * table itself would sit after an opening brace, where the formatter moves it + * onto its own line and pruning would break the declaration. + * + * One column carries a marker and is therefore nullable: an identity created + * by a module that stores no credential simply leaves it empty, and declining + * that module drops the column outright. */ export const users = pgTable('users', { id: serial('id').primaryKey(), email: text('email').notNull().unique(), - passwordHash: text('password_hash').notNull(), + passwordHash: text('password_hash'), // chassis:password + verifiedAt: timestamp('verified_at'), createdAt: timestamp('created_at').defaultNow().notNull() }); diff --git a/src/db/postgres/users.ts b/src/db/postgres/users.ts index 58205c3..463f555 100644 --- a/src/db/postgres/users.ts +++ b/src/db/postgres/users.ts @@ -3,11 +3,19 @@ import { db } from './index'; import { users } from './schema'; /** - * Postgres-backed user store for local-JWT auth. Shape-compatible with + * Postgres-backed identity store for local auth. Shape-compatible with * `UserStore` in ../users.ts — it is imported there by feature flag, and * declares no dependency back on it so this file stands alone when a * different auth provider is scaffolded. */ +function toStored(row: { id: number; email: string; verifiedAt: Date | null }) { + return { + id: String(row.id), + email: row.email, + verifiedAt: row.verifiedAt?.toISOString() ?? null + }; +} + export const postgresUsers = { async findByEmail(email: string) { const [row] = await db @@ -16,17 +24,32 @@ export const postgresUsers = { .where(eq(users.email, email.toLowerCase())) .limit(1); - return row - ? { id: String(row.id), email: row.email, passwordHash: row.passwordHash } - : null; + return row ? toStored(row) : null; + }, + + async findById(id: string) { + const [row] = await db + .select() + .from(users) + .where(eq(users.id, Number(id))) + .limit(1); + + return row ? toStored(row) : null; }, - async create(email: string, passwordHash: string) { + async create(email: string) { const [row] = await db .insert(users) - .values({ email: email.toLowerCase(), passwordHash }) + .values({ email: email.toLowerCase() }) .returning(); return { id: String(row.id), email: row.email }; + }, + + async markVerified(id: string, at: Date) { + await db + .update(users) + .set({ verifiedAt: at }) + .where(eq(users.id, Number(id))); } }; diff --git a/src/db/sessions.ts b/src/db/sessions.ts new file mode 100644 index 0000000..80daa59 --- /dev/null +++ b/src/db/sessions.ts @@ -0,0 +1,51 @@ +import { config } from '../config'; +import { memorySessions } from './memory-sessions'; +import { sqliteSessions } from './sqlite/sessions'; // chassis:sqlite +import { postgresSessions } from './postgres/sessions'; // chassis:postgres +import { mongoSessions } from './mongo/sessions'; // chassis:mongo + +/** + * Refresh-token storage behind the session layer. + * + * Tokens are stored only as SHA-256 digests, so a database dump cannot be + * replayed. Resolution follows the same flag-keyed table as ./users.ts, so the + * store follows whichever database is configured and falls back to memory in + * development. + */ +export interface NewRefreshToken { + familyId: string; + userId: string; + tokenHash: string; + createdAt: Date; + expiresAt: Date; + familyCreatedAt: Date; +} + +export interface RefreshToken { + id: string; + familyId: string; + userId: string; + expiresAt: string; + familyCreatedAt: string; + rotatedAt: string | null; + revokedAt: string | null; +} + +export interface RefreshTokenStore { + insert(token: NewRefreshToken): Promise; + findByHash(tokenHash: string): Promise; + markRotated(id: string, at: Date): Promise; + revokeFamily(familyId: string, at: Date): Promise; + revokeAllForUser(userId: string, at: Date): Promise; +} + +const stores: Array<[feature: string, store: RefreshTokenStore]> = [ + ['sqlite', sqliteSessions], // chassis:sqlite + ['postgres', postgresSessions], // chassis:postgres + ['mongo', mongoSessions] // chassis:mongo +]; + +export function sessionStore(): RefreshTokenStore { + const configured = stores.find(([feature]) => config.features[feature]); + return configured?.[1] ?? memorySessions; +} diff --git a/src/db/sqlite/magic.schema.ts b/src/db/sqlite/magic.schema.ts new file mode 100644 index 0000000..a512085 --- /dev/null +++ b/src/db/sqlite/magic.schema.ts @@ -0,0 +1,22 @@ +import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; + +/** + * Magic-link credentials — a credentials table, so both the link token and the + * fallback code are stored only as SHA-256 digests. One row holds both, + * because they share an expiry and are voided together. + * + * Kept in its own file so the whole table can be pruned by the single marked + * re-export line in schema.ts. + */ +export const magicCredentials = sqliteTable('magic_credentials', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull(), + tokenHash: text('token_hash').notNull().unique(), + codeHash: text('code_hash').notNull(), + attempts: integer('attempts').notNull().default(0), + returnTo: text('return_to'), + createdAt: text('created_at').notNull(), + expiresAt: text('expires_at').notNull(), + consumedAt: text('consumed_at'), + voidedAt: text('voided_at') +}); diff --git a/src/db/sqlite/magic.ts b/src/db/sqlite/magic.ts new file mode 100644 index 0000000..f2b1c89 --- /dev/null +++ b/src/db/sqlite/magic.ts @@ -0,0 +1,91 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { db } from './index'; +import { magicCredentials } from './schema'; + +/** + * SQLite-backed magic-link credentials. Shape-compatible with `MagicStore` in + * ../magic.ts, which imports it by feature flag; nothing here depends on that + * file, so it stands alone when a different auth provider is scaffolded. + */ +type Row = typeof magicCredentials.$inferSelect; + +function toCredential(row: Row) { + return { + id: String(row.id), + email: row.email, + codeHash: row.codeHash, + attempts: row.attempts, + returnTo: row.returnTo, + expiresAt: row.expiresAt, + consumedAt: row.consumedAt, + voidedAt: row.voidedAt + }; +} + +const live = (email: string) => + and( + eq(magicCredentials.email, email.toLowerCase()), + isNull(magicCredentials.consumedAt), + isNull(magicCredentials.voidedAt) + ); + +export const sqliteMagic = { + async insert(credential: { + email: string; + tokenHash: string; + codeHash: string; + returnTo: string | null; + createdAt: Date; + expiresAt: Date; + }) { + await db.insert(magicCredentials).values({ + email: credential.email.toLowerCase(), + tokenHash: credential.tokenHash, + codeHash: credential.codeHash, + returnTo: credential.returnTo, + createdAt: credential.createdAt.toISOString(), + expiresAt: credential.expiresAt.toISOString() + }); + }, + + async findByTokenHash(tokenHash: string) { + const [row] = await db + .select() + .from(magicCredentials) + .where(eq(magicCredentials.tokenHash, tokenHash)) + .limit(1); + + return row ? toCredential(row) : null; + }, + + async findLiveByEmail(email: string) { + const [row] = await db + .select() + .from(magicCredentials) + .where(live(email)) + .limit(1); + + return row ? toCredential(row) : null; + }, + + async markConsumed(id: string, at: Date) { + await db + .update(magicCredentials) + .set({ consumedAt: at.toISOString() }) + .where(eq(magicCredentials.id, Number(id))); + }, + + async bumpAttempts(id: string) { + await db + .update(magicCredentials) + .set({ attempts: sql`${magicCredentials.attempts} + 1` }) + .where(eq(magicCredentials.id, Number(id))); + }, + + async voidAllForEmail(email: string, at: Date) { + await db + .update(magicCredentials) + .set({ voidedAt: at.toISOString() }) + .where(live(email)); + } +}; diff --git a/src/db/sqlite/passwords.test.ts b/src/db/sqlite/passwords.test.ts new file mode 100644 index 0000000..ce48939 --- /dev/null +++ b/src/db/sqlite/passwords.test.ts @@ -0,0 +1,28 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { sqlitePasswords } from './passwords'; +import { sqliteUsers } from './users'; +import { createUsersTable } from './users.test'; + +/** + * The password half of the identity row. In its own file so that scaffolding + * without the password module prunes it — ./users.test.ts must keep passing on + * its own. + */ +beforeAll(() => { + createUsersTable(); +}); + +describe('sqlitePasswords', () => { + it('round-trips a hash for an identity', async () => { + const user = await sqliteUsers.create('pw@example.com'); + expect(await sqlitePasswords.get(user.id)).toBeNull(); + + await sqlitePasswords.set(user.id, 'scrypt$aa$bb'); + expect(await sqlitePasswords.get(user.id)).toBe('scrypt$aa$bb'); + }); + + it('returns null for an identity that never had one', async () => { + const user = await sqliteUsers.create('no-credential@example.com'); + expect(await sqlitePasswords.get(user.id)).toBeNull(); + }); +}); diff --git a/src/db/sqlite/passwords.ts b/src/db/sqlite/passwords.ts new file mode 100644 index 0000000..1827618 --- /dev/null +++ b/src/db/sqlite/passwords.ts @@ -0,0 +1,27 @@ +import { eq } from 'drizzle-orm'; +import { db } from './index'; +import { users } from './schema'; + +/** + * SQLite-backed password hashes. The hash lives on the identity row, but + * reaching it goes through this file so that scaffolding without the password + * module removes every reference to it — see ../passwords.ts. + */ +export const sqlitePasswords = { + async get(userId: string) { + const [row] = await db + .select({ passwordHash: users.passwordHash }) + .from(users) + .where(eq(users.id, Number(userId))) + .limit(1); + + return row?.passwordHash ?? null; + }, + + async set(userId: string, hash: string) { + await db + .update(users) + .set({ passwordHash: hash }) + .where(eq(users.id, Number(userId))); + } +}; diff --git a/src/db/sqlite/schema.ts b/src/db/sqlite/schema.ts index 7326b1b..4d85888 100644 --- a/src/db/sqlite/schema.ts +++ b/src/db/sqlite/schema.ts @@ -10,4 +10,6 @@ export const examples = sqliteTable('examples', { createdAt: text('created_at').notNull() }); -export * from './users.schema'; // chassis:jwt +export * from './users.schema'; // chassis:session +export * from './sessions.schema'; // chassis:session +export * from './magic.schema'; // chassis:magic diff --git a/src/db/sqlite/sessions.schema.ts b/src/db/sqlite/sessions.schema.ts new file mode 100644 index 0000000..829d88f --- /dev/null +++ b/src/db/sqlite/sessions.schema.ts @@ -0,0 +1,22 @@ +import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; + +/** + * Refresh tokens — one row per issued token, never updated in place except to + * mark it spent or revoked. Kept in its own file so the whole table can be + * pruned by the single marked re-export line in schema.ts. + * + * `family_id` groups every token descended from one sign-in, so presenting an + * already-rotated token can revoke the whole lineage. `family_created_at` is + * denormalized onto every row so the absolute window needs no second table. + */ +export const refreshTokens = sqliteTable('refresh_tokens', { + id: integer('id').primaryKey({ autoIncrement: true }), + familyId: text('family_id').notNull(), + userId: text('user_id').notNull(), + tokenHash: text('token_hash').notNull().unique(), + createdAt: text('created_at').notNull(), + expiresAt: text('expires_at').notNull(), + familyCreatedAt: text('family_created_at').notNull(), + rotatedAt: text('rotated_at'), + revokedAt: text('revoked_at') +}); diff --git a/src/db/sqlite/sessions.ts b/src/db/sqlite/sessions.ts new file mode 100644 index 0000000..d21c82b --- /dev/null +++ b/src/db/sqlite/sessions.ts @@ -0,0 +1,80 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from './index'; +import { refreshTokens } from './schema'; + +/** + * SQLite-backed refresh tokens. Shape-compatible with `RefreshTokenStore` in + * ../sessions.ts, which imports it by feature flag; nothing here depends on + * that file, so it stands alone when a different auth provider is scaffolded. + */ +type Row = typeof refreshTokens.$inferSelect; + +function toToken(row: Row) { + return { + id: String(row.id), + familyId: row.familyId, + userId: row.userId, + expiresAt: row.expiresAt, + familyCreatedAt: row.familyCreatedAt, + rotatedAt: row.rotatedAt, + revokedAt: row.revokedAt + }; +} + +export const sqliteSessions = { + async insert(token: { + familyId: string; + userId: string; + tokenHash: string; + createdAt: Date; + expiresAt: Date; + familyCreatedAt: Date; + }) { + await db.insert(refreshTokens).values({ + familyId: token.familyId, + userId: token.userId, + tokenHash: token.tokenHash, + createdAt: token.createdAt.toISOString(), + expiresAt: token.expiresAt.toISOString(), + familyCreatedAt: token.familyCreatedAt.toISOString() + }); + }, + + async findByHash(tokenHash: string) { + const [row] = await db + .select() + .from(refreshTokens) + .where(eq(refreshTokens.tokenHash, tokenHash)) + .limit(1); + + return row ? toToken(row) : null; + }, + + async markRotated(id: string, at: Date) { + await db + .update(refreshTokens) + .set({ rotatedAt: at.toISOString() }) + .where(eq(refreshTokens.id, Number(id))); + }, + + async revokeFamily(familyId: string, at: Date) { + await db + .update(refreshTokens) + .set({ revokedAt: at.toISOString() }) + .where( + and( + eq(refreshTokens.familyId, familyId), + isNull(refreshTokens.revokedAt) + ) + ); + }, + + async revokeAllForUser(userId: string, at: Date) { + await db + .update(refreshTokens) + .set({ revokedAt: at.toISOString() }) + .where( + and(eq(refreshTokens.userId, userId), isNull(refreshTokens.revokedAt)) + ); + } +}; diff --git a/src/db/sqlite/users.schema.ts b/src/db/sqlite/users.schema.ts index f47ca88..2169afe 100644 --- a/src/db/sqlite/users.schema.ts +++ b/src/db/sqlite/users.schema.ts @@ -1,14 +1,19 @@ import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; /** - * Accounts for local-JWT auth. Kept in its own file so the whole table can - * be pruned by the single marked re-export line in schema.ts. A marker on - * the table itself would sit after an opening brace, where the formatter - * moves it onto its own line and pruning would break the declaration. + * Identities for local auth. Kept in its own file so the whole table can be + * pruned by the single marked re-export line in schema.ts. A marker on the + * table itself would sit after an opening brace, where the formatter moves it + * onto its own line and pruning would break the declaration. + * + * One column carries a marker and is therefore nullable: an identity created + * by a module that stores no credential simply leaves it empty, and declining + * that module drops the column outright. */ export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), email: text('email').notNull().unique(), - passwordHash: text('password_hash').notNull(), + passwordHash: text('password_hash'), // chassis:password + verifiedAt: text('verified_at'), createdAt: text('created_at').notNull() }); diff --git a/src/db/sqlite/users.test.ts b/src/db/sqlite/users.test.ts index 27eebb4..8043016 100644 --- a/src/db/sqlite/users.test.ts +++ b/src/db/sqlite/users.test.ts @@ -1,39 +1,70 @@ import { sql } from 'drizzle-orm'; import { beforeAll, describe, expect, it } from 'vitest'; +import { setClock } from '../../utils/clock'; import { db } from './index'; import { sqliteUsers } from './users'; /** - * The Drizzle-backed user store behind local-JWT auth. Co-located with - * src/db/sqlite so it is pruned with SQLite, and listed under the jwt - * module so it is pruned with local JWT too. + * The Drizzle-backed identity store. Co-located with src/db/sqlite so it is + * pruned with SQLite, and listed under the session module so it is pruned with + * local auth too. * - * SQLite runs in-memory by default, so this needs no infrastructure — but - * an in-memory database starts empty, hence the table is created here - * rather than by a migration. + * SQLite runs in-memory by default, so this needs no infrastructure — but an + * in-memory database starts empty, hence the table is created here rather than + * by a migration. The table mirrors the shared schema, including the nullable + * credential column other modules own — filling it is their business, covered + * by their own co-located tests. */ +export const NOW = new Date('2026-03-01T09:00:00.000Z'); + +/** + * Columns as data rather than one SQL string, so a column belonging to an + * optional module can carry a `chassis:` marker on its own line. A marker + * inside the SQL template literal could not: pruning is line-based, and SQL + * comment syntax is not what the stripper removes. + */ +const COLUMNS = [ + 'id integer primary key autoincrement', + 'email text not null unique', + 'password_hash text', // chassis:password + 'verified_at text', + 'created_at text not null' +]; + +export function createUsersTable(): void { + db.run(sql.raw(`create table if not exists users (${COLUMNS.join(', ')})`)); +} + beforeAll(() => { - db.run(sql` - create table users ( - id integer primary key autoincrement, - email text not null unique, - password_hash text not null, - created_at text not null - ) - `); + setClock(() => NOW); + createUsersTable(); }); describe('sqliteUsers', () => { - it('creates a user and returns its id and email', async () => { - const user = await sqliteUsers.create('Dev@Example.com', 'scrypt$aa$bb'); + it('creates an identity and returns its id and email', async () => { + const user = await sqliteUsers.create('Dev@Example.com'); expect(user.email).toBe('dev@example.com'); expect(user.id).toMatch(/^\d+$/); }); - it('finds a user case-insensitively and returns the hash', async () => { + it('finds an identity case-insensitively, unverified at first', async () => { const found = await sqliteUsers.findByEmail('DEV@example.COM'); expect(found?.email).toBe('dev@example.com'); - expect(found?.passwordHash).toBe('scrypt$aa$bb'); + expect(found?.verifiedAt).toBeNull(); + }); + + it('finds by id', async () => { + const created = await sqliteUsers.create('by-id@example.com'); + expect((await sqliteUsers.findById(created.id))?.email).toBe( + 'by-id@example.com' + ); + }); + + it('stamps verifiedAt from the injected clock', async () => { + const user = await sqliteUsers.create('verify@example.com'); + await sqliteUsers.markVerified(user.id, NOW); + const found = await sqliteUsers.findByEmail('verify@example.com'); + expect(found?.verifiedAt).toBe(NOW.toISOString()); }); it('returns null for an unknown email', async () => { diff --git a/src/db/sqlite/users.ts b/src/db/sqlite/users.ts index 0b7ec6c..a788595 100644 --- a/src/db/sqlite/users.ts +++ b/src/db/sqlite/users.ts @@ -1,13 +1,18 @@ import { eq } from 'drizzle-orm'; +import { now } from '../../utils/clock'; import { db } from './index'; import { users } from './schema'; /** - * SQLite-backed user store for local-JWT auth. Shape-compatible with + * SQLite-backed identity store for local auth. Shape-compatible with * `UserStore` in ../users.ts — it is imported there by feature flag, and * declares no dependency back on it so this file stands alone when a * different auth provider is scaffolded. */ +function toStored(row: { id: number; email: string; verifiedAt: string | null }) { + return { id: String(row.id), email: row.email, verifiedAt: row.verifiedAt }; +} + export const sqliteUsers = { async findByEmail(email: string) { const [row] = await db @@ -16,21 +21,35 @@ export const sqliteUsers = { .where(eq(users.email, email.toLowerCase())) .limit(1); - return row - ? { id: String(row.id), email: row.email, passwordHash: row.passwordHash } - : null; + return row ? toStored(row) : null; + }, + + async findById(id: string) { + const [row] = await db + .select() + .from(users) + .where(eq(users.id, Number(id))) + .limit(1); + + return row ? toStored(row) : null; }, - async create(email: string, passwordHash: string) { + async create(email: string) { const [row] = await db .insert(users) .values({ email: email.toLowerCase(), - passwordHash, - createdAt: new Date().toISOString() + createdAt: now().toISOString() }) .returning(); return { id: String(row.id), email: row.email }; + }, + + async markVerified(id: string, at: Date) { + await db + .update(users) + .set({ verifiedAt: at.toISOString() }) + .where(eq(users.id, Number(id))); } }; diff --git a/src/db/users.ts b/src/db/users.ts index 682a708..304795a 100644 --- a/src/db/users.ts +++ b/src/db/users.ts @@ -5,12 +5,16 @@ import { postgresUsers } from './postgres/users'; // chassis:postgres import { mongoUsers } from './mongo/users'; // chassis:mongo /** - * The user store behind local-JWT auth (`src/controllers/Auth.controller.ts`). + * Identities behind local auth — the directory the session layer signs tokens + * for. Auth0 and Clerk host their own, so this seam exists only for local + * providers. It resolves the same way integrations do (by feature flag, at + * call time), so the store follows whichever database is configured and falls + * back to an in-memory dev store when none is. * - * Auth0 and Clerk host their own user directories, so this seam exists only - * for the `jwt` provider. It resolves the same way integrations do — by - * feature flag, at call time — so the store follows whichever database is - * configured, and falls back to an in-memory dev store when none is. + * Deliberately free of any credential: an identity is an email address plus + * whether that address has been proven. *How* someone proves it belongs to + * whichever module does the proving, so nothing about sign-in methods appears + * here — which is what lets those modules be scaffolded away cleanly. */ export interface AuthUser { id: string; @@ -18,12 +22,15 @@ export interface AuthUser { } export interface StoredUser extends AuthUser { - passwordHash: string; + /** ISO 8601, or null until the address has been proven. */ + verifiedAt: string | null; } export interface UserStore { findByEmail(email: string): Promise; - create(email: string, passwordHash: string): Promise; + findById(id: string): Promise; + create(email: string): Promise; + markVerified(id: string, at: Date): Promise; } /** diff --git a/src/integrations/index.ts b/src/integrations/index.ts index 06d583a..510c4ef 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -4,7 +4,7 @@ import { initMongo, closeMongo } from './mongo'; // chassis:mongo import { initPostgres, closePostgres } from './postgres'; // chassis:postgres import { initSqlite, closeSqlite } from './sqlite'; // chassis:sqlite import { initAuth0 } from './auth0'; // chassis:auth0 -import { initJwt } from './jwt'; // chassis:jwt +import { initJwt } from './jwt'; // chassis:session import { initClerk } from './clerk'; // chassis:clerk import { initSentry } from './sentry'; // chassis:sentry import { initX402 } from './x402'; // chassis:x402 @@ -17,7 +17,7 @@ import { initX402 } from './x402'; // chassis:x402 export async function initIntegrations(): Promise { if (config.features.sentry) initSentry(); // chassis:sentry if (config.features.auth0) initAuth0(); // chassis:auth0 - if (config.features.jwt) initJwt(); // chassis:jwt + if (config.features.session) initJwt(); // chassis:session if (config.features.clerk) initClerk(); // chassis:clerk if (config.features.x402) initX402(); // chassis:x402 if (config.features.mongo) await initMongo(); // chassis:mongo diff --git a/src/integrations/jwt.ts b/src/integrations/jwt.ts index 71d87d8..6132acd 100644 --- a/src/integrations/jwt.ts +++ b/src/integrations/jwt.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from 'express'; import { setAuthProvider } from '../core/auth'; import { config } from '../config'; +import { now } from '../utils/clock'; import { logger } from '../utils/logger'; // ponytail: jose ships ESM only, so a CJS build can't statically import it. @@ -9,11 +10,11 @@ import { logger } from '../utils/logger'; const jose = import('jose'); /** - * Enabled when JWT_SECRET is set. Verifies a `Bearer ` (HS256). - * Issuing tokens is app-specific — sign them with the same secret via - * jose's SignJWT (see docs/guides/authentication.md). Read the verified - * claims in a handler with `const { jwtVerify } = await import('jose')` if - * you need them. + * Enabled when JWT_SECRET is set. Verifies a `Bearer ` (HS256) and + * attaches the verified subject as `req.identityId`, which is what + * `/auth/revoke-all` and any handler needing "who is this" reads. + * + * Tokens are minted by src/services/session.ts with the same secret. */ export function initJwt(): void { const secret = new TextEncoder().encode(config.jwt.secret); @@ -26,7 +27,16 @@ export function initJwt(): void { try { const { jwtVerify } = await jose; - await jwtVerify(token, secret); + const { payload } = await jwtVerify(token, secret, { + // Pin the algorithm: without this, jose would accept any algorithm + // the token's own header asks for. + algorithms: ['HS256'], + // Expiry is decided by the injected clock, so tests can age a token + // without touching the system clock. + currentDate: now() + }); + + req.identityId = payload.sub; next(); } catch { return req.resHandler.wrongToken('Invalid or expired token'); @@ -34,5 +44,5 @@ export function initJwt(): void { } ]); - logger.info('✅ Local JWT authentication enabled'); + logger.info('✅ Local authentication enabled'); } diff --git a/src/integrations/sentry.test.ts b/src/integrations/sentry.test.ts new file mode 100644 index 0000000..65d5e6a --- /dev/null +++ b/src/integrations/sentry.test.ts @@ -0,0 +1,109 @@ +import * as Sentry from '@sentry/node'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beginCheckIn, captureException, initSentry } from './sentry'; + +/** + * Sentry is mocked wholesale: what matters here is the shape of the calls + * Chassis makes, not what the SDK does with them. Two things can silently go + * wrong — a release that does not match the uploaded source maps (minified + * traces forever) and a check-in that never closes (a permanently "running" + * job in the Crons dashboard) — and neither shows up in any other test. + */ +vi.mock('@sentry/node', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureCheckIn: vi.fn(() => 'check-in-id') +})); + +// Hoisted so the config mock below can close over it, then mutated per test — +// cheaper and less brittle than resetting modules to re-read the environment. +const mocked = vi.hoisted(() => ({ + config: { + env: 'test', + sentry: { dsn: 'https://key@example.ingest.sentry.io/1', release: '' } + } +})); + +vi.mock('../config', () => mocked); + +beforeEach(() => { + vi.clearAllMocks(); + mocked.config.sentry.release = ''; +}); + +describe('initSentry', () => { + it('tags the release so uploaded source maps resolve', () => { + mocked.config.sentry.release = 'abc123'; + initSentry(); + + expect(Sentry.init).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://key@example.ingest.sentry.io/1', + environment: 'test', + release: 'abc123' + }) + ); + }); + + it('still initializes when no release is configured', () => { + initSentry(); + + expect(Sentry.init).toHaveBeenCalledWith( + expect.objectContaining({ release: '' }) + ); + }); +}); + +describe('beginCheckIn', () => { + it('opens in_progress immediately, before the job runs', () => { + beginCheckIn('nightly'); + + expect(Sentry.captureCheckIn).toHaveBeenCalledTimes(1); + expect(Sentry.captureCheckIn).toHaveBeenCalledWith({ + monitorSlug: 'nightly', + status: 'in_progress' + }); + }); + + it('closes ok against the same check-in id', () => { + beginCheckIn('nightly').ok(); + + expect(Sentry.captureCheckIn).toHaveBeenLastCalledWith({ + checkInId: 'check-in-id', + monitorSlug: 'nightly', + status: 'ok' + }); + }); + + it('closes error against the same check-in id', () => { + beginCheckIn('nightly').error(); + + expect(Sentry.captureCheckIn).toHaveBeenLastCalledWith({ + checkInId: 'check-in-id', + monitorSlug: 'nightly', + status: 'error' + }); + }); + + it('keeps each job on its own monitor', () => { + beginCheckIn('nightly').ok(); + beginCheckIn('hourly').ok(); + + const slugs = vi + .mocked(Sentry.captureCheckIn) + .mock.calls.map( + ([call]) => (call as { monitorSlug: string }).monitorSlug + ); + + expect(new Set(slugs)).toEqual(new Set(['nightly', 'hourly'])); + }); +}); + +describe('captureException', () => { + it('forwards to the SDK', () => { + const err = new Error('boom'); + captureException(err); + + expect(Sentry.captureException).toHaveBeenCalledWith(err); + }); +}); diff --git a/src/integrations/sentry.ts b/src/integrations/sentry.ts index 8f29389..c8c09e4 100644 --- a/src/integrations/sentry.ts +++ b/src/integrations/sentry.ts @@ -6,7 +6,10 @@ import { logger } from '../utils/logger'; export function initSentry(): void { Sentry.init({ dsn: config.sentry.dsn, - environment: config.env + environment: config.env, + // Must match the release the source maps were uploaded under, or the + // stack traces stay minified. CI sets both to the commit SHA. + release: config.sentry.release }); logger.info('✅ Sentry error reporting enabled'); @@ -16,3 +19,30 @@ export function initSentry(): void { export function captureException(err: unknown): void { Sentry.captureException(err); } + +export interface CheckIn { + ok(): void; + error(): void; +} + +/** + * Open a Sentry Crons check-in for one job run. + * + * The point of check-ins over plain exception capture: Sentry knows the + * schedule, so a run that never happens alerts too. A job that silently stops + * being scheduled is the failure mode you would otherwise never hear about. + * + * No-ops harmlessly if Sentry.init was never called. + */ +export function beginCheckIn(monitorSlug: string): CheckIn { + const checkInId = Sentry.captureCheckIn({ + monitorSlug, + status: 'in_progress' + }); + + return { + ok: () => Sentry.captureCheckIn({ checkInId, monitorSlug, status: 'ok' }), + error: () => + Sentry.captureCheckIn({ checkInId, monitorSlug, status: 'error' }) + }; +} diff --git a/src/jobs/index.test.ts b/src/jobs/index.test.ts new file mode 100644 index 0000000..c13d94e --- /dev/null +++ b/src/jobs/index.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { logger } from '../utils/logger'; +import { runJob, schedule, JobContext, JobDefinition } from './index'; + +const context = (signal: AbortSignal): JobContext => ({ logger, signal }); +const never = new AbortController().signal; + +afterEach(() => vi.restoreAllMocks()); + +describe('runJob', () => { + it('runs the job once', async () => { + const run = vi.fn().mockResolvedValue(undefined); + + await runJob({ name: 'once', run }, context(never)); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it('logs a failure instead of rethrowing it', async () => { + const error = vi.spyOn(logger, 'error').mockReturnValue(logger); + + await expect( + runJob( + { name: 'boom', run: () => Promise.reject(new Error('nope')) }, + context(never) + ) + ).resolves.toBeUndefined(); + + expect(error).toHaveBeenCalledWith('job boom failed', { error: 'nope' }); + }); +}); + +describe('schedule', () => { + it('starts an unscheduled job immediately and aborts it on shutdown', async () => { + const controller = new AbortController(); + let aborted = false; + + const consumer: JobDefinition = { + name: 'consumer', + run: ({ signal }) => + new Promise((resolve) => + signal.addEventListener('abort', () => { + aborted = true; + resolve(); + }) + ) + }; + + const running = schedule(context(controller.signal), [consumer]); + + // The long-running job is already in flight and still waiting. + await Promise.resolve(); + expect(aborted).toBe(false); + + running.stop(); + controller.abort(); + + await vi.waitFor(() => expect(aborted).toBe(true)); + }); + + it('does not fire a cron job at boot', async () => { + const run = vi.fn().mockResolvedValue(undefined); + + const running = schedule(context(never), [ + { name: 'nightly', schedule: '0 3 * * *', run } + ]); + + await Promise.resolve(); + running.stop(); + + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/src/jobs/index.ts b/src/jobs/index.ts new file mode 100644 index 0000000..91db6b1 --- /dev/null +++ b/src/jobs/index.ts @@ -0,0 +1,120 @@ +import { Cron } from 'croner'; +import { logger } from '../utils/logger'; +import { beginCheckIn } from '../integrations/sentry'; // chassis:sentry + +export interface JobContext { + logger: typeof logger; + /** + * Aborted on SIGTERM. A long-running job must watch it and return, or the + * shutdown failsafe will kill the process out from under it. + */ + signal: AbortSignal; +} + +export interface JobDefinition { + name: string; + /** + * A cron expression, five fields or six with seconds. Omit it for a job that + * starts once at boot and keeps running — a queue consumer is just a job + * with no schedule. + */ + schedule?: string; + run(ctx: JobContext): Promise; +} + +/** + * Every job the `jobs` process runs. Add yours here: + * + * ```ts + * export const jobs: JobDefinition[] = [ + * { + * name: 'purge-expired-tokens', + * schedule: '0 3 * * *', + * async run({ logger }) { + * logger.info(`purging as of ${now().toISOString()}`); + * } + * } + * ]; + * ``` + * + * Read the time through `now()` from src/utils/clock.ts, never `new Date()` — + * that is what lets a test drive a job's schedule-sensitive logic. + */ +export const jobs: JobDefinition[] = []; + +/** + * Run one job to completion. + * + * Failure is logged and swallowed on purpose: one bad run must not take the + * process — and therefore every other schedule — down with it. Which means the + * process is never how you find out a job is broken; see docs/guides/jobs.md. + */ +export async function runJob( + job: JobDefinition, + ctx: JobContext +): Promise { + const checkIn = beginCheckIn(job.name); // chassis:sentry + + try { + await job.run(ctx); + checkIn.ok(); // chassis:sentry + ctx.logger.info(`job ${job.name} finished`); + } catch (err) { + checkIn.error(); // chassis:sentry + ctx.logger.error(`job ${job.name} failed`, { + error: (err as Error).message + }); + } +} + +export interface ScheduledJobs { + /** Cancel every cron. In-flight runs are told to stop via `ctx.signal`. */ + stop(): void; +} + +/** + * Start every job: cron ones on their schedule, the rest immediately. + * + * `protect` is croner's overrun guard — a run still going when the next tick + * arrives skips that tick rather than stacking a second copy on top. + */ +export function schedule(ctx: JobContext, list = jobs): ScheduledJobs { + const crons: Cron[] = []; + const longRunning: Promise[] = []; + + for (const job of list) { + if (job.schedule) { + crons.push( + new Cron(job.schedule, { protect: true }, () => runJob(job, ctx)) + ); + } else { + longRunning.push(runJob(job, ctx)); + } + } + + // An unsettled promise does not hold the event loop open — only a handle + // does. Cron jobs bring their own timers, but a process whose only jobs are + // long-running ones would exit the moment they park on something they do not + // own (an abort signal, a callback from a library that keeps no handle), and + // it would exit reporting success. + // + // ponytail: one no-op interval as the keep-alive. The tidier answer is for + // every job to own a real handle, which is exactly what a harness cannot + // assume about code it did not write. + const keepAlive = longRunning.length + ? setInterval(() => {}, 60_000) + : undefined; + + const release = () => { + if (keepAlive) clearInterval(keepAlive); + }; + + void Promise.allSettled(longRunning).then(release); + + return { + stop: () => { + for (const cron of crons) cron.stop(); + release(); + } + }; +} diff --git a/src/jobs/run.test.ts b/src/jobs/run.test.ts new file mode 100644 index 0000000..6968e42 --- /dev/null +++ b/src/jobs/run.test.ts @@ -0,0 +1,233 @@ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * src/jobs/run.ts is a process, not a function: it parses argv, picks exit + * codes and installs signal handlers, and none of that is observable from an + * import. So these run it for real. + * + * The registry ships empty, so each case writes a fixture that pushes the jobs + * it needs and then imports the entrypoint — same module instance, because + * both resolve the same absolute path. + */ +// `process.cwd()` rather than `import.meta.url`: these files compile as +// CommonJS, where import.meta is unavailable. Vitest runs from the package +// root, and beforeAll fails loudly if that ever stops being true. +const projectRoot = process.cwd(); +const here = path.join(projectRoot, 'src', 'jobs'); +const quoted = (file: string) => JSON.stringify(path.join(here, file)); + +/** + * `node --import tsx`, not the `tsx` binary: the binary is a wrapper that + * spawns its own child, so a SIGTERM sent here would kill the wrapper and + * never reach the handler under test. + */ +const nodeArgs = (file: string, args: string[]) => [ + '--import', + 'tsx', + file, + ...args +]; + +let tmp: string; + +beforeAll(() => { + if (!fs.existsSync(path.join(here, 'run.ts'))) { + throw new Error(`expected the jobs entrypoint under ${here}`); + } + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chassis-jobs-')); +}); + +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +/** + * Write a fixture that registers `body`'s jobs, then boots the entrypoint. + * + * CommonJS, deliberately. These files compile to CJS, so `run.ts` reads the + * registry out of the require cache; an ESM fixture would populate a second, + * separate copy of the module. Node 22+ unifies the two graphs and hides + * that, Node 20 does not — there the entrypoint saw an empty registry and + * every case here passed or failed for the wrong reason. + */ +function fixture(name: string, body: string): string { + const file = path.join(tmp, `${name}.cjs`); + fs.writeFileSync( + file, + `const { jobs } = require(${quoted('index.ts')});\n` + + `${body}\n` + + `require(${quoted('run.ts')});\n` + ); + return file; +} + +// NODE_ENV=development because the logger is silent under `test`, and the log +// is the only thing a one-shot run leaves behind. +const env = { ...process.env, NODE_ENV: 'development' }; +const spawnOpts = { env, cwd: projectRoot }; + +function runSync(file: string, args: string[] = []) { + const result = spawnSync(process.execPath, nodeArgs(file, args), { + ...spawnOpts, + encoding: 'utf8' + }); + return { + status: result.status, + output: `${result.stdout ?? ''}${result.stderr ?? ''}` + }; +} + +describe('the jobs entrypoint', { timeout: 30_000 }, () => { + it('exits cleanly when nothing is registered', () => { + const { status, output } = runSync(fixture('empty', '')); + + expect(status).toBe(0); + expect(output).toContain('No jobs registered'); + }); + + it('runs one named job once, then exits', () => { + const marker = path.join(tmp, 'ran.txt'); + const { status, output } = runSync( + fixture( + 'oneshot', + `const fs = require('node:fs'); + jobs.push({ + name: 'alpha', + async run() { fs.appendFileSync(${JSON.stringify(marker)}, 'x'); } + }); + jobs.push({ name: 'beta', async run() { throw new Error('must not run'); } });` + ), + ['alpha'] + ); + + expect(status).toBe(0); + expect(output).toContain('job alpha finished'); + expect(fs.readFileSync(marker, 'utf8')).toBe('x'); + }); + + it('exits non-zero on an unknown job and names the ones it has', () => { + const { status, output } = runSync( + fixture('unknown', `jobs.push({ name: 'alpha', async run() {} });`), + ['ghost'] + ); + + expect(status).toBe(1); + expect(output).toContain('Unknown job: ghost'); + expect(output).toContain('alpha'); + }); + + it('still exits 0 when the job it ran threw — failure is logged, not fatal', () => { + const { status, output } = runSync( + fixture( + 'throws', + `jobs.push({ name: 'alpha', async run() { throw new Error('nope'); } });` + ), + ['alpha'] + ); + + expect(status).toBe(0); + expect(output).toContain('job alpha failed'); + expect(output).toContain('nope'); + }); + + // "a cron job does not fire at boot" lives in index.test.ts, where it costs + // nothing. Every case here spawns a real process, so the file is kept to the + // behaviour that genuinely needs one — anything else just starves the rest + // of the suite on a small runner. + + /** A job that parks until shutdown and holds no handle of its own. */ + const consumer = (onAbort = '') => `jobs.push({ + name: 'consumer', + run: ({ signal }) => + new Promise((resolve) => + signal.addEventListener('abort', () => { ${onAbort} resolve(); }) + ) + });`; + + interface Exit { + code: number | null; + /** Non-null when the process was killed rather than exiting by itself. */ + signal: NodeJS.Signals | null; + output: string; + } + + /** + * Spawn the entrypoint and SIGTERM it once `ready` shows up in its output — + * or immediately, if `ready` is null. + * + * Both `code` and `signal` are reported because the interesting failure is + * `code: null, signal: 'SIGTERM'`: the default disposition killed the + * process, meaning no handler was installed when the signal landed. + */ + function runUntil(file: string, ready: RegExp | null): Promise { + const child = spawn(process.execPath, nodeArgs(file, []), spawnOpts); + let output = ''; + let signalled = false; + + const signalOnce = () => { + if (signalled) return; + signalled = true; + // Once only: every later chunk still matches, and re-signalling a + // process that is already draining races its exit. + child.kill('SIGTERM'); + }; + + return new Promise((resolve, reject) => { + const onData = (chunk: Buffer) => { + output += chunk.toString(); + if (ready?.test(output)) signalOnce(); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('error', reject); + child.on('close', (code, signal) => resolve({ code, signal, output })); + if (!ready) signalOnce(); + }); + } + + it('stays up for a long-running job that holds no handle of its own', async () => { + const child = spawn( + process.execPath, + nodeArgs(fixture('keepalive', consumer()), []), + spawnOpts + ); + let exited = false; + child.on('close', () => (exited = true)); + + // An unsettled promise holds nothing open, so without the harness's + // keep-alive this process is gone within a second of boot — reporting + // success, having run nothing. The two outcomes are an immediate exit + // versus staying up forever, so a short wait separates them decisively. + await new Promise((resolve) => setTimeout(resolve, 2_000)); + expect(exited).toBe(false); + + child.kill('SIGTERM'); + await new Promise((resolve) => child.on('close', resolve)); + }); + + it('handles SIGTERM that arrives during boot', async () => { + // The integrations line is written by `initIntegrations`, well before the + // harness is up, so this lands in the boot window — where SIGTERM used to + // hit a process with no handler yet and kill it outright. + const exit = await runUntil( + fixture('sigterm-boot', consumer()), + /ntegrations/i + ); + + expect(exit).toMatchObject({ code: 0, signal: null }); + expect(exit.output).toContain('SIGTERM received'); + }); + + it('aborts a long-running job on SIGTERM and shuts down', async () => { + const exit = await runUntil( + fixture('sigterm', consumer(`console.log('CONSUMER-DRAINED');`)), + /Jobs running/ + ); + + expect(exit).toMatchObject({ code: 0, signal: null }); + expect(exit.output).toContain('SIGTERM received'); + expect(exit.output).toContain('CONSUMER-DRAINED'); + }); +}); diff --git a/src/jobs/run.ts b/src/jobs/run.ts new file mode 100644 index 0000000..63eb578 --- /dev/null +++ b/src/jobs/run.ts @@ -0,0 +1,81 @@ +import { jobs, runJob, schedule, JobContext, ScheduledJobs } from './index'; +import { initIntegrations, shutdownIntegrations } from '../integrations'; +import { logger } from '../utils/logger'; + +/** + * The second entrypoint. Same build, same image, same integrations as + * src/server.ts — a different process: + * + * npm run jobs schedule everything and stay up + * npm run jobs -- run one job once and exit (local dev, backfills) + * node dist/jobs/run.js the production form of the first + */ +async function main(): Promise { + const controller = new AbortController(); + // A no-op until the jobs are actually scheduled, so `shutdown` can be + // installed before there is anything to stop. + let running: ScheduledJobs = { stop: () => {} }; + let stopping = false; + + const shutdown = (signal: string): void => { + // A second SIGTERM during a drain must not start a second shutdown — + // that double-closes integrations and races two exits. + if (stopping) return; + stopping = true; + + logger.info(`${signal} received — stopping jobs`); + + running.stop(); + controller.abort(); + + shutdownIntegrations() + .catch((err: Error) => + logger.error(`Error during shutdown: ${err.message}`) + ) + .finally(() => process.exit(0)); + + // Failsafe: force-exit if a long-running job ignores its abort signal. + setTimeout(() => process.exit(1), 10_000).unref(); + }; + + // Before any work starts, including connecting integrations. A SIGTERM that + // lands during boot would otherwise hard-kill a process that has already + // begun a job — and a rolling deploy sends exactly that. + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + await initIntegrations(); + + const ctx: JobContext = { logger, signal: controller.signal }; + const requested = process.argv[2]; + + if (requested) { + const job = jobs.find((candidate) => candidate.name === requested); + + if (!job) { + logger.error(`Unknown job: ${requested}`, { + available: jobs.map((candidate) => candidate.name) + }); + await shutdownIntegrations(); + process.exit(1); + } + + await runJob(job, ctx); + await shutdownIntegrations(); + return; + } + + if (!jobs.length) { + logger.warn('No jobs registered — add one to src/jobs/index.ts'); + await shutdownIntegrations(); + return; + } + + running = schedule(ctx); + logger.info(`⏱️ Jobs running: ${jobs.map((job) => job.name).join(', ')}`); +} + +main().catch((err: Error) => { + logger.error(`Failed to start jobs: ${err.message}`, { stack: err.stack }); + process.exit(1); +}); diff --git a/src/mail/index.ts b/src/mail/index.ts new file mode 100644 index 0000000..0876479 --- /dev/null +++ b/src/mail/index.ts @@ -0,0 +1,65 @@ +import { config } from '../config'; +import { logger } from '../utils/logger'; +import { smtpTransport } from './smtp'; + +/** + * The mail seam. + * + * Chassis binds no email service provider, ever — that choice belongs to the + * product, and a template that picks one for you is a template you have to + * fight. Two implementations ship: a console logger (the default, so the magic + * flow works with zero configuration) and SMTP (so it works against mailpit in + * development). Everything else — Resend, SendGrid, SES, Postmark — is a + * ten-line `setMailTransport()` call documented in docs/guides/transports.md. + */ +export interface MailMessage { + to: string; + subject: string; + html: string; + text: string; +} + +export interface MailTransport { + send(message: MailMessage): Promise; +} + +/** + * The zero-configuration default. Prints the message — including the link and + * the code — so that a developer with no mail server can still complete a + * sign-in from the terminal. + */ +const consoleTransport: MailTransport = { + async send(message: MailMessage): Promise { + logger.info(`📧 mail to ${message.to} — ${message.subject}`, { + body: message.text + }); + } +}; + +let bound: MailTransport | undefined; + +/** Bind a product's provider. Called with no argument, restores the default. */ +export function setMailTransport(transport?: MailTransport): void { + bound = transport; +} + +let warned = false; + +export function mailTransport(): MailTransport { + if (bound) return bound; + if (config.mail.smtpUrl) return smtpTransport(config.mail.smtpUrl); + + // The console transport prints the link and the code — that is the whole + // point of it, and why the logger's redaction deliberately leaves the mail + // body alone. In production it means live sign-in credentials land in + // stdout, so say so once rather than failing a boot that may be deliberate. + if (config.env === 'production' && !warned) { + warned = true; + logger.warn( + 'No SMTP_URL and no bound transport: sign-in links and codes are being ' + + 'written to the log. Set SMTP_URL or call setMailTransport().' + ); + } + + return consoleTransport; +} diff --git a/src/mail/smtp.ts b/src/mail/smtp.ts new file mode 100644 index 0000000..e8caf6d --- /dev/null +++ b/src/mail/smtp.ts @@ -0,0 +1,28 @@ +import { createTransport } from 'nodemailer'; +import { config } from '../config'; +import type { MailMessage, MailTransport } from './index'; + +/** + * SMTP delivery, active when SMTP_URL is set. + * + * This exists for development and tests — point it at mailpit + * (`SMTP_URL=smtp://localhost:1025`) and every sign-in email lands in a web + * inbox you can read. It is a working transport rather than a stub, so it + * would also drive a real relay, but a production system should bind its + * provider's SDK through `setMailTransport()` instead. + */ +export function smtpTransport(url: string): MailTransport { + const transport = createTransport(url); + + return { + async send(message: MailMessage): Promise { + await transport.sendMail({ + from: config.mail.from, + to: message.to, + subject: message.subject, + text: message.text, + html: message.html + }); + } + }; +} diff --git a/src/mail/template.ts b/src/mail/template.ts new file mode 100644 index 0000000..01d2a73 --- /dev/null +++ b/src/mail/template.ts @@ -0,0 +1,54 @@ +/** + * The one sign-in email: a link to click, and a code to type if the link was + * opened on a different device than the one waiting to be signed in. + * + * Deliberately plain. No images, no external stylesheet, no web fonts — it has + * to be legible in a text-only client and must not depend on remote content + * that a privacy-conscious client will block anyway. + */ +export interface MagicEmailInput { + link: string; + code: string; + /** Human-readable lifetime, e.g. "15m". */ + expiresIn: string; +} + +export function magicEmail(input: MagicEmailInput): { + subject: string; + text: string; + html: string; +} { + const subject = 'Your sign-in link'; + + const text = [ + 'Sign in by opening this link:', + '', + input.link, + '', + `On another device? Enter this code instead: ${input.code}`, + '', + `Both expire in ${input.expiresIn}. If you did not request this, ignore`, + 'this email — nothing has changed on your account.' + ].join('\n'); + + const html = [ + '
', + '

Sign in by opening this link:

', + `

Sign in

`, + '

On another device? Enter this code instead:

', + `

${input.code}

`, + `

Both expire in ${input.expiresIn}. If you did not request this,`, + ' ignore this email — nothing has changed on your account.

', + '
' + ].join(''); + + return { subject, text, html }; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts index b7c4c09..37ab9e7 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,2 +1,4 @@ export * from './callId'; export * from './requestLogger'; +export * from './rateLimit'; // chassis:session +export * from './sameOrigin'; // chassis:session diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts new file mode 100644 index 0000000..1205adf --- /dev/null +++ b/src/middleware/rateLimit.ts @@ -0,0 +1,62 @@ +import type { Request, RequestHandler } from 'express'; +import { ERROR_CODES } from '../core'; +import { now } from '../utils/clock'; +import { seconds } from '../utils/duration'; + +/** + * Fixed-window rate limiting, keyed by whatever the caller says. + * + * ponytail: in-process counters, so limits are per-instance — two replicas + * mean twice the allowance. That is the right trade for a template: it needs + * no Redis, no dependency, and no infrastructure to be useful on day one. + * Swap the Map for a shared store when you run more than one instance and the + * limit has to be exact. + * + * The clock is injected, so lockout windows are testable without waiting. + */ +export interface RateLimitOptions { + /** What to count against — an address, an IP, a tenant. */ + key: (req: Request) => string; + limit: number; + /** Window length, e.g. '15m'. */ + window: string; + message?: string; +} + +interface Bucket { + count: number; + resetAt: number; +} + +export function rateLimit(options: RateLimitOptions): RequestHandler { + const buckets = new Map(); + const windowMs = seconds(options.window) * 1000; + + return (req, _res, next) => { + const at = now().getTime(); + const key = options.key(req); + const bucket = buckets.get(key); + + if (!bucket || bucket.resetAt <= at) { + // ponytail: sweep expired buckets only when the map has grown enough to + // be worth it, rather than paying for a timer that runs forever. + if (buckets.size > 10_000) { + for (const [existing, value] of buckets) { + if (value.resetAt <= at) buckets.delete(existing); + } + } + buckets.set(key, { count: 1, resetAt: at + windowMs }); + return next(); + } + + if (bucket.count >= options.limit) { + return req.resHandler.manualError(ERROR_CODES.TOO_MANY_REQUESTS, { + message: options.message ?? 'Too many requests. Try again shortly.', + retryAfter: Math.ceil((bucket.resetAt - at) / 1000) + }); + } + + bucket.count += 1; + next(); + }; +} diff --git a/src/middleware/requestLogger.ts b/src/middleware/requestLogger.ts index dcb4123..6a420fa 100644 --- a/src/middleware/requestLogger.ts +++ b/src/middleware/requestLogger.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from 'express'; -import { logger } from '../utils/logger'; +import { logger, logPath } from '../utils/logger'; /** Lightweight request logging (dev only — mounted in app.ts). */ export function requestLogger( @@ -12,7 +12,7 @@ export function requestLogger( res.on('finish', () => { const durationMs = Number(process.hrtime.bigint() - start) / 1e6; logger.info( - `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`, + `${req.method} ${logPath(req)} ${res.statusCode} ${durationMs.toFixed(1)}ms`, { callId: req.callId } ); }); diff --git a/src/middleware/sameOrigin.ts b/src/middleware/sameOrigin.ts new file mode 100644 index 0000000..1492d54 --- /dev/null +++ b/src/middleware/sameOrigin.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from 'express'; +import { config } from '../config'; + +/** + * Reject cross-site browser requests to endpoints that carry a cookie. + * + * The API is bearer-only everywhere except the session endpoints, which read + * the refresh cookie — and a cookie is ambient credentials, which is what CSRF + * exploits. `SameSite=Lax` already blocks the cross-site POST case in every + * current browser; this is the belt to that pair of braces. + * + * A request with no `Origin` is allowed: that is a non-browser client (curl, + * a server-side fetch, a mobile app), which sends no cookie it did not choose + * to send and therefore cannot be tricked into one. + * + * Endpoints whose credential travels in the URL are deliberately NOT protected + * by this: there the token itself is the credential, and it arrives by a + * cross-site navigation out of a mail client by design. + */ +export const sameOrigin: RequestHandler = (req, _res, next) => { + const site = req.headers['sec-fetch-site']; + if (site === 'same-origin' || site === 'none') return next(); + + const origin = req.headers.origin; + if (!origin) return next(); + + if (config.corsOrigins?.includes(origin)) return next(); + + return req.resHandler.forbidden('Cross-origin request rejected'); +}; diff --git a/src/services/magic.test.ts b/src/services/magic.test.ts new file mode 100644 index 0000000..6afd2bc --- /dev/null +++ b/src/services/magic.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setClock } from '../utils/clock'; +import { resetMemoryMagic } from '../db/memory-magic'; +import { resetMemoryUsers } from '../db/memory-users'; +import { userStore } from '../db/users'; +import { + issue, + probe, + redeemCode, + redeemToken, + setOnVerified, + validateReturnTo +} from './magic'; + +/** + * Pure-logic coverage for the credential core: no HTTP, no mail, no database + * beyond the in-memory stores. Every expiry assertion is driven by the + * injected clock, which is the whole reason src/utils/clock.ts exists. + */ +let clock = new Date('2026-01-01T12:00:00.000Z'); + +const advance = (ms: number) => { + clock = new Date(clock.getTime() + ms); +}; + +const MINUTE = 60_000; + +beforeEach(() => { + clock = new Date('2026-01-01T12:00:00.000Z'); + setClock(() => clock); + resetMemoryMagic(); + resetMemoryUsers(); + setOnVerified(() => {}); +}); + +afterEach(() => { + setClock(); +}); + +describe('issue', () => { + it('produces a 256-bit token and a six-digit code', async () => { + const { token, code } = await issue('a@example.com', null); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(code).toMatch(/^\d{6}$/); + }); + + it('is latest-wins: a new request voids the previous token AND code', async () => { + const first = await issue('a@example.com', null); + const second = await issue('a@example.com', null); + + expect((await probe(first.token)).status).toBe('used'); + await expect(redeemToken(first.token)).rejects.toThrow(/already been used/); + await expect(redeemCode('a@example.com', first.code)).rejects.toThrow(); + + // The newest pair still works. + expect((await probe(second.token)).status).toBe('valid'); + }); +}); + +describe('probe', () => { + it('never consumes the token, however many times it is called', async () => { + const { token } = await issue('a@example.com', '/dashboard'); + + for (let i = 0; i < 5; i++) { + expect(await probe(token)).toEqual({ + status: 'valid', + returnTo: '/dashboard' + }); + } + + // Still redeemable — this is the scanner-prefetch guarantee. + await expect(redeemToken(token)).resolves.toMatchObject({ + returnTo: '/dashboard' + }); + }); + + it('reports an unknown token as used, not valid', async () => { + expect(await probe('nope')).toEqual({ status: 'used', returnTo: null }); + }); + + it('reports expiry once the TTL has passed', async () => { + const { token } = await issue('a@example.com', null); + + advance(15 * MINUTE - 1); + expect((await probe(token)).status).toBe('valid'); + + advance(1); + expect((await probe(token)).status).toBe('expired'); + }); +}); + +describe('redeemToken', () => { + it('is single use', async () => { + const { token } = await issue('a@example.com', null); + await redeemToken(token); + await expect(redeemToken(token)).rejects.toThrow(/already been used/); + }); + + it('refuses an expired token', async () => { + const { token } = await issue('a@example.com', null); + advance(15 * MINUTE); + await expect(redeemToken(token)).rejects.toThrow(/expired/); + }); + + it('creates the identity, stamps verifiedAt and fires onVerified once', async () => { + const seen: string[] = []; + setOnVerified((identity) => { + seen.push(identity.email); + }); + + const first = await issue('new@example.com', null); + const { user } = await redeemToken(first.token); + + expect(user.email).toBe('new@example.com'); + expect(seen).toEqual(['new@example.com']); + + const stored = await userStore().findByEmail('new@example.com'); + expect(stored?.verifiedAt).toBe(clock.toISOString()); + + // A second sign-in must not re-fire the hook. + const second = await issue('new@example.com', null); + await redeemToken(second.token); + expect(seen).toEqual(['new@example.com']); + }); + + it('survives a throwing onVerified hook', async () => { + setOnVerified(() => { + throw new Error('product hook exploded'); + }); + + const { token } = await issue('a@example.com', null); + await expect(redeemToken(token)).resolves.toBeTruthy(); + }); +}); + +describe('redeemCode', () => { + it('signs in with the correct code', async () => { + const { code } = await issue('a@example.com', '/welcome'); + const result = await redeemCode('a@example.com', code); + expect(result.user.email).toBe('a@example.com'); + expect(result.returnTo).toBe('/welcome'); + }); + + it('leaves the link unopened — the cross-device case', async () => { + const { token, code } = await issue('a@example.com', null); + await redeemCode('a@example.com', code); + + // The pair is spent as a unit: the untouched link dies with the code. + expect((await probe(token)).status).toBe('used'); + }); + + it('voids everything after MAGIC_CODE_ATTEMPTS wrong tries', async () => { + const { token, code } = await issue('a@example.com', null); + + for (let attempt = 1; attempt <= 5; attempt++) { + await expect(redeemCode('a@example.com', '000000')).rejects.toThrow(); + } + + // Cap reached: the correct code no longer works, and neither does the link. + await expect(redeemCode('a@example.com', code)).rejects.toThrow(); + expect((await probe(token)).status).toBe('used'); + }); + + it('still accepts the right code before the cap', async () => { + const { code } = await issue('a@example.com', null); + for (let attempt = 1; attempt <= 4; attempt++) { + await expect(redeemCode('a@example.com', '000000')).rejects.toThrow(); + } + await expect(redeemCode('a@example.com', code)).resolves.toBeTruthy(); + }); + + it('gives one indistinguishable error for unknown address and wrong code', async () => { + await issue('known@example.com', null); + + const unknown = await redeemCode('nobody@example.com', '123456').catch( + (error: Error) => error.message + ); + const wrong = await redeemCode('known@example.com', '000000').catch( + (error: Error) => error.message + ); + + expect(unknown).toBe(wrong); + }); + + it('refuses an expired code', async () => { + const { code } = await issue('a@example.com', null); + advance(15 * MINUTE); + await expect(redeemCode('a@example.com', code)).rejects.toThrow(); + }); +}); + +describe('validateReturnTo', () => { + it.each([ + ['/dashboard', '/dashboard'], + ['/a/b?c=d#e', '/a/b?c=d#e'], + ['/', '/'] + ])('allows the same-origin path %o', (input, expected) => { + expect(validateReturnTo(input)).toBe(expected); + }); + + it.each([ + 'https://evil.example', + 'http://evil.example/x', + '//evil.example', + '/\\evil.example', + '\\/\\/evil.example', + 'javascript:alert(1)', + 'data:text/html,