Skip to content

feat(auth): magic-link sign-in and a rotating-refresh session layer - #16

Merged
dvd90 merged 7 commits into
masterfrom
feat/magic-link-auth
Aug 7, 2026
Merged

feat(auth): magic-link sign-in and a rotating-refresh session layer#16
dvd90 merged 7 commits into
masterfrom
feat/magic-link-auth

Conversation

@dvd90

@dvd90 dvd90 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

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: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 with them.

It becomes three implied modules composed by auth variants:

--auth Sign in with Implies
jwt email + password session, password
magic-only emailed link or code session, magic
password+magic either session, password, magic

implies is necessary rather than stylistic: a file may be claimed by exactly one module (the catalog test enforces it), so password+magic — needing the union of two file sets — cannot be expressed as a flat files list. jwt keeps its name, so --auth jwt, all four presets, the already-published create-chassis and the catalog-derived chassis-mcp schema keep working unchanged.

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. The link renders a confirmation page; redemption is a POST, on a click.
  • Every email carries a six-digit code too, so someone who requests on a laptop and reads their mail on a phone can still finish on the laptop.
  • The request endpoint reveals nothing. 202 with 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.
  • Nothing is stored in the clear. SHA-256 at rest for both credentials; codes compare in constant time and are capped, after which the link dies with them. Issuing voids everything outstanding for that address.
  • returnTo cannot 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 hard SESSION_ABSOLUTE cap (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

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 — which is why this adds no phone column and no channel switch. Resend, SendGrid, SES, Postmark, Twilio, Vonage, SNS and MessageBird are documented ten-line bindings in docs/guides/transports.md and nothing more.

Proving an address sets verified_at and fires setOnVerified(). That is the whole extension surface: no consent machinery, no double opt-in, no GDPR copy.

A bug this 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 (/api/session/password, /api/session/magic) and /api/session keeps only what they share: turning an API response into cookies, and signing out.

Pipeline

  • assertNoModuleResidue holds password, magic and session to 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.
  • A new invariant keeps chassis: markers out of .tsx, where neither the pruner nor the residue grep in published.yml can see them. The two sign-in forms sit behind a marked .ts registry instead.
  • The scaffold matrix picks the three variants up from the catalog automatically; three stacks were added to the SCAFFOLD_BUILD layer.
  • A mail-e2e job (marked chassis: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 postgres project installs, runs its own 116 tests, and builds. The mailpit e2e ran against real SMTP end to end: 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.

One deviation worth a look

rg -i password on a generated magic-only app returns POSTGRES_PASSWORD (the database's own credential, correctly kept) plus two docs — docs/modules.md and docs/reference/cli.md. Those describe the scaffolder itself, so naming variants you did not pick is their job, and one variant is literally called password+magic. That is the same reason .md is 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

dvd90 and others added 7 commits August 6, 2026 20:38
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>
@dvd90
dvd90 merged commit 33e27eb into master Aug 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant