feat(auth): magic-link sign-in and a rotating-refresh session layer - #16
Merged
Conversation
Adds passwordless sign-in by emailed link, plus the session layer every local auth variant now shares, and splits `chassis:jwt` so a project can be scaffolded with password auth removed entirely. The module split is the substance of the change. `chassis:jwt` bundled four unrelated things — password hashing, JWT minting, the user store and the web sign-in form — so magic-link auth could not be added without inheriting passwords, and passwords could not be removed without losing the session layer. It becomes three implied modules (`session`, `password`, `magic`) composed by auth variants through a new `implies` field, because a file may be claimed by only one module and the both-methods variant needs the union of two file sets. --auth jwt password (name kept; presets, published --auth magic-only emailed link CLI and docs keep working) --auth password+magic both Magic link: - GET and HEAD never consume 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 6-digit code as well, so someone who requests on a laptop and reads mail on a phone can still finish on the laptop. - POST /auth/magic/request answers 202 with a byte-identical body for every address, before touching the store, so neither body nor timing reveals who has an account. - Tokens and codes are SHA-256 at rest; codes compare in constant time and are capped, after which the link dies with them. Issuing voids all outstanding credentials for the address. - returnTo is validated at issue, stored server-side, and re-validated at redemption, deny-by-default against RFC 3986 path characters. Sessions: - 15-minute access token plus a refresh token rotated on every use. Replaying a spent token revokes the whole family — the only thing that catches a stolen refresh token at all. - Sliding SESSION_IDLE (30d) inside a hard SESSION_ABSOLUTE cap (90d), both read through an injected clock, so the 91-day behaviour is tested in microseconds rather than waited for. - POST /auth/refresh, /auth/logout (idempotent), /auth/revoke-all. Delivery is a seam: MailTransport ships a console logger and SMTP (mailpit); SmsTransport ships nothing and stays silent until a product binds both a gateway and a recipient resolver. No ESP is bound here, and the providers are documentation only. Proving an address fires setOnVerified() and sets verified_at — the whole extension surface, with no consent machinery. Also fixes a bug the new residue test found: 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 and /api/session keeps only what the methods share. Pipeline: assertNoModuleResidue holds every splittable module to the same standard rather than special-casing one grep; a new invariant keeps chassis: markers out of .tsx, where neither the pruner nor the residue grep can see them; the scaffold matrix picks up the new variants automatically; and a mailpit e2e job runs the whole flow against real SMTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An audit of the previous commit turned up one pruning bug and five stale references. All of them are consequences of `jwt` becoming a composition-only variant and `Auth.controller.ts` being renamed. The bug: web/.env.example marked its local-auth block `chassis:jwt`, which was correct when jwt was the only local variant. It no longer is — every non-selected variant name is declined, so scaffolding `--auth password+magic` or `--auth magic-only` silently dropped the block. Verified against the previous commit in a worktree: the generated file ended at API_URL. The block describes the session boundary, so it is now `chassis:session` and worded for whichever sign-in methods a project kept. Also: - `nowSeconds()` was written and then never used — session.ts inlined the same computation. It now calls the helper. - conformance.ts marked the shared provider files `chassis:jwt`; they belong to the session module. Harmless (the CLI deletes that file regardless) but wrong. - AGENTS.md and docs/maintainers.md pointed the jose dynamic-import rule at `src/controllers/Auth.controller.ts`, which no longer exists — it is `src/services/session.ts`. - docs/maintainers.md illustrated the Prettier marker trap with `chassis:jwt`, a marker that no longer appears anywhere. - llms.txt still described local auth as register/login only, with no mention of the session endpoints. Verified no source file is unclaimed by the catalog, so nothing new ships into projects that declined it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four gaps that a monorepo-readiness review turned up as genuinely missing — everything else it proposed already ships. Log redaction. Magic-link tokens travel in the URL path, so `originalUrl` in a log line is a live credential at rest, and both the response logger and the request logger wrote it verbatim. They now log the matched route pattern (`/auth/magic/:token`), which is also the better field to group on. A winston format strips credential-named keys out of metadata before any transport sees them, two levels deep. The 404 handler was echoing the full URL with its query into the response body as well as the log; it follows the same rule now. Two limits are deliberate and marked in src/utils/logger.ts: the log message is never redacted, only metadata — that is what lets the console mail transport print a sign-in link in dev — and an unmatched path has no pattern to fall back to, so a 404 strips the query but keeps the path. Jobs. A second entrypoint off the same build: `npm run jobs` schedules the registry and stays up, `npm run jobs -- <name>` runs one and exits. No mode enum — a job with a schedule is cron, one without starts at boot and keeps running, so a queue consumer is just a job. Failure is logged and swallowed so one bad run cannot take every other schedule down with it, which is why Sentry Crons check-ins open before the run: a schedule that stops firing alerts the same as one that throws. croner, zero dependencies, pruned with the module. Sentry. `Sentry.init` now tags the release, and CI uploads source maps after the build — master only, skipped until the secrets exist, pinned to one matrix leg. `npx --yes @sentry/cli`, so nothing but CI carries the tool. Playwright. A preset for the web template plus a smoke suite that asserts structure rather than copy, so it survives whichever auth provider was scaffolded. Outside `npm run verify`, which has to stay runnable with no browser installed; CI runs it as its own job. The root `e2e` and `e2e:setup` scripts keep the same names in both layouts so one CI job serves both. Tested: unit and HTTP-level redaction; the jobs entrypoint driven as a real process for exit codes and SIGTERM; check-in call shapes; and scaffold build cases for jobs-without-sentry and jobs-in-a-monorepo — the combinations where marked lines have to prune together and still compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI installs with `npm ci` on Node 20, which ships npm 10.8.2, and it was failing on three missing nested `typescript@5.9.3` entries. `x402`, `x402-express` and `@coinbase/cdp-sdk` each depend on typescript@^5, which cannot dedupe against the root's ~6.0.3. npm 11 leaves those nested copies out of the lock; npm 10 refuses to install without them. So a lockfile written on a machine running npm 11 is rejected by the CI that has to consume it — and nothing local catches it, because `npm install` and `npm run verify` are both happy either way, and even `npm ci --dry-run` skips the sync check. Regenerated with `npm@10.8.2 install --package-lock-only`. Verified by running a real `npm ci` against a clean copy of package.json + package-lock.json under both 10.8.2 and 11.8.0. Predates the previous commit — master installs cleanly, the magic-link commits did not. Fixed here because it is what is red on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g jobs Two defects the CI run surfaced, both in the harness rather than the tests that caught them. Signal handlers were registered at the very end of `main`, after integrations had connected and after the jobs were already running. A SIGTERM landing in that window hit a process with no handler and killed it outright — mid-job, with no drain — and a rolling deploy sends exactly that. They are installed first now, before `initIntegrations`, with a guard so a second signal during a drain cannot start a second shutdown and race two exits. And a long-running job that parks on something it does not own — an abort signal, a library that keeps no handle — did not hold the event loop open, because an unsettled promise never does. A jobs process whose only work was such a job exited immediately, reporting success, having run nothing. One ref'd interval now holds the process for as long as those jobs are in flight, released when they settle or on shutdown. `code: null` was how both showed up: killed by signal rather than exiting under its own control. The tests now assert on `code` and `signal` together so that reads as the diagnosis rather than a bare `expected null to be 0`. Also: the SIGTERM cases re-sent the signal on every stdout chunk, since the readiness string stays matched — the repeat landed on a process already draining. Signal once. And every case in run.test.ts spawns a real process, so 5s was the wrong default for that file; the describe carries 30s, which a loaded two-core runner needs and a real failure never reaches. Verified by reverting each fix in turn and confirming the matching test fails, then five full-suite runs with a second suite running concurrently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The site build fails on any doc that is not reachable from site/pages.mjs, which is the point of that check — a guide nobody can navigate to is not documentation. docs/guides/jobs.md was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…registry Every fixture-driven case in run.test.ts was passing on Node 22 and failing on Node 20, and the reason was the test harness, not the harness under test. These files compile to CommonJS, so `run.ts` reads `jobs` out of the require cache. The fixtures were `.mts` — ESM — so they populated a second, separate copy of the module. Node 22 unifies the two graphs and the bug is invisible; Node 20 keeps them apart, so the entrypoint booted with an empty registry and reported "No jobs registered" while the fixture thought it had registered one. Confirmed directly: on Node 20 an ESM import and a CJS require of the same file are not the same object; on Node 24 they are. Fixtures are `.cjs` now, so everything shares one graph on both. This is also why the scaffold build cases for jobs failed — a generated project runs the same tests, on Node 20. Raise the suite's timeout to 15s while here. Files run in parallel, and the ones that spawn processes or hash passwords starve the rest on a two-core runner past the 5s default — that had already been patched around twice, once on the logging tests and once on a session test that predates this branch. One rule in vitest.config.ts replaces both. Nothing here is meant to take seconds, so it only absorbs scheduling noise: a broken test still fails on its assertion rather than the clock. Verified by scaffolding `--preset minimal --jobs`, installing it under Node 20.20.2 and running its verify there (37 tests, all seven entrypoint cases), plus five full-suite runs on Node 24 with a second suite running concurrently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds passwordless sign-in by emailed link, the session layer every local auth variant now shares, and a module split that lets a project be scaffolded with password auth removed entirely.
Design note:
docs/design/magic-link.md— §8 lists the conventions this change had to bend, §9 records where the implementation departed from the plan.The module split
chassis:jwtbundled four unrelated things — password hashing, JWT minting, the user store and the web sign-in form. So magic-link auth could not be added without inheriting passwords, and passwords could not be removed without losing the session layer with them.It becomes three implied modules composed by auth variants:
--authjwtsession,passwordmagic-onlysession,magicpassword+magicsession,password,magicimpliesis necessary rather than stylistic: a file may be claimed by exactly one module (the catalog test enforces it), sopassword+magic— needing the union of two file sets — cannot be expressed as a flatfileslist.jwtkeeps its name, so--auth jwt, all four presets, the already-publishedcreate-chassisand the catalog-derivedchassis-mcpschema keep working unchanged.Magic link
GETandHEADnever consume a token. Mail security scanners prefetch links, and a single-use token burned by a scanner is how this feature usually breaks in production. The link renders a confirmation page; redemption is aPOST, on a click.202with a byte-identical body for every address, sent before the store is touched, so neither the body nor the timing distinguishes a known address from an unknown one.returnTocannot become an open redirect. Validated at issue, stored server-side, re-validated at redemption, deny-by-default against RFC 3986 path characters. The browser never gets to post one back.Sessions
15-minute access token plus a refresh token rotated on every use. Replaying a spent token revokes the entire family — the only thing that catches a stolen refresh token at all. Sliding
SESSION_IDLE(30d) inside a hardSESSION_ABSOLUTEcap (90d), both read through an injected clock, so the 91-day behaviour is tested in microseconds.POST /auth/refresh,/auth/logout(idempotent),/auth/revoke-all.Delivery is a seam, not a binding
MailTransportships a console logger and SMTP (mailpit).SmsTransportships nothing and stays silent until a product binds both a gateway and a recipient resolver — which is why this adds nophonecolumn and no channel switch. Resend, SendGrid, SES, Postmark, Twilio, Vonage, SNS and MessageBird are documented ten-line bindings indocs/guides/transports.mdand nothing more.Proving an address sets
verified_atand firessetOnVerified(). That is the whole extension surface: no consent machinery, no double opt-in, no GDPR copy.A bug this found
POST /api/sessionproxied 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/sessionkeeps only what they share: turning an API response into cookies, and signing out.Pipeline
assertNoModuleResidueholdspassword,magicandsessionto the same standard, rather than special-casing one grep. It caught the bug above and eleven pieces of prose that would have shipped into projects that had pruned the module they described.chassis:markers out of.tsx, where neither the pruner nor the residue grep inpublished.ymlcan see them. The two sign-in forms sit behind a marked.tsregistry instead.SCAFFOLD_BUILDlayer.mail-e2ejob (markedchassis:magic, so generated magic projects inherit it) runs the flow against real SMTP.Verification
npm run verify(138 + 16),npm run build,node --test cli/*.test.mjs(96),npm run check --prefix site,npm test --prefix mcp-server(9) — all green.A scaffolded
--auth magic-only --db postgresproject installs, runs its own 116 tests, and builds. The mailpit e2e ran against real SMTP end to end: request → email carrying link and code → twoGETs and aHEADleaving the token redeemable →POSTredeem → session → 20-day gap → silent refresh → day 91 → forced re-auth.One deviation worth a look
rg -i passwordon a generatedmagic-onlyapp returnsPOSTGRES_PASSWORD(the database's own credential, correctly kept) plus two docs —docs/modules.mdanddocs/reference/cli.md. Those describe the scaffolder itself, so naming variants you did not pick is their job, and one variant is literally calledpassword+magic. That is the same reason.mdis already exempt from marker pruning. Everything else — code, config, env and all other prose — is scanned. If you want the criterion to hold with zero exemptions, renaming that variant is the change that gets you there.🤖 Generated with Claude Code