Skip to content

feat(fastify): add the Fastify adapter - #132

Merged
Bccorb merged 4 commits into
mainfrom
feat/fastify-adapter
Jul 30, 2026
Merged

feat(fastify): add the Fastify adapter#132
Bccorb merged 4 commits into
mainfrom
feat/fastify-adapter

Conversation

@Bccorb

@Bccorb Bccorb commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes the open question on #72: whether core is actually strong enough for a second adapter.

What this adds

@seamless-auth/fastify, serving the same routes as the Express adapter and issuing the same cookies. Register it under a prefix; Fastify's encapsulation keeps the cookie and origin hooks off the rest of the application. requireAuth and requireRole ship as preHandler hooks, alongside getSeamlessUser.

Express Fastify
source 2,300 lines, 29 files 1,178 lines, 13 files
bundle 46,481 bytes 28,848 bytes

Roughly half the code for the same surface. That difference is the epic's payoff, not cleverness here: cookie signing, failure rendering, proxying, delivery, and the guard decisions all come from core now.

What building it found

This is the part worth reviewing. Three things core still left to each adapter, all caught by the parity suite rather than by reading.

1. Cookie lifetime was underspecified. SetCookieCommand carried only maxAgeSeconds. Express emitted Expires and Max-Age; Fastify emitted Max-Age alone, which older clients treat as a session cookie. Both satisfied the type. The command now carries an expires, and clearing carries the epoch, so the same instruction produces the same header everywhere.

- "seamless-access=<signed>; Expires=<t>; HttpOnly; Max-Age=300; ..."
+ "seamless-access=<signed>; HttpOnly; Max-Age=300; ..."

2. ensureCookies takes a mount-relative path, and nothing says so. It matches the request path against its own route table with startsWith. Express hands a mounted router a req.path with the mount point already stripped; Fastify's req.url keeps the prefix. Passing /auth/organizations instead of /organizations matched nothing, so every route silently lost its cookie payload and the proxy gate 401'd. Silent, not loud, which is the bad kind.

3. The last guard decisions were still adapter-side. checkOrigin, authenticateCookie, and authorizeRoles now join checkProxyIdentity in core: each takes the request facts and returns a GuardRejection or nothing. Express calls them too, as a straight substitution. Without this the Fastify adapter would have copied ~200 lines of security-relevant decision logic, which is the duplication the epic exists to remove.

Also filed separately: #131, a route param interpolated unencoded into the upstream URL in the Express adapter. Writing the 27 proxy routes as a declarative table centralized the encoding, which made the two hand-written exceptions visible.

Parity is enforced, not claimed

packages/fastify/tests/parity.test.js runs the same requests through both adapters against the same mocked auth API and asserts the status, body, and every Set-Cookie header match: 23 cases covering passthrough and coded failures, the OAuth sibling code, admin and metrics and session and system-config routes, identity rejections, cookie set and clear, four cookie policies, repeated query parameters, route param encoding, and the cross-site block.

It is not decorative. It failed 12 of 23 on the first run and found both contract gaps above.

Scope

createSeamlessConsoleProxy has no Fastify equivalent yet. It proxies the admin console's static assets and is a separate concern from the auth routes. Noted in the README.

Checks

pnpm build clean. pnpm test passes: 357 tests across the three packages (189 core, 145 express, 23 fastify).

Follow-up worth filing

The admin, metrics, session, and system-config routes are pure "call a core handler, apply the result" boilerplate: 520 lines in Express, 136 here as a table. Core could own that table outright and both adapters would shrink again. I did not do it in this PR because it changes the Express adapter substantially and this one is already large.

Bccorb added 3 commits July 29, 2026 21:56
Adds @seamless-auth/fastify, serving the same routes as the Express adapter
and issuing the same cookies. Registered under a prefix, so Fastify's
encapsulation keeps the cookie and origin hooks off the rest of the
application. requireAuth and requireRole ship as preHandler hooks.

Writing a second adapter is what the epic in #72 was for, and it found
three things core still left to each adapter.

The last guard decisions move into core alongside checkProxyIdentity:
checkOrigin, authenticateCookie and authorizeRoles each take the request
facts and return a GuardRejection or nothing. Express now calls them too,
which is a straight substitution.

SetCookieCommand and ClearCookieCommand gain an expires. The command
previously specified only a max age, so two adapters could satisfy it and
still emit different headers: Express sent Expires and Max-Age, Fastify sent
Max-Age alone, which older clients treat as a session cookie. The parity
suite caught it.

ensureCookies matches a mount-relative path. Express hands a mounted router
a req.path with the mount point already stripped, Fastify does not, so the
prefix has to come off explicitly or every route silently loses its cookie
payload. Also caught by the parity suite.

The parity suite runs the same requests through both adapters against the
same mocked auth API and asserts status, body and every Set-Cookie header
match, so the two cannot drift.

Refs #72
The types package already defines it, field for field. Declaring it again
here is the duplication #118 and #120 were about.

Re-exported under the same name from core and the adapters, so nothing
changes for adopters. The re-export is type-only, so it is erased at compile
time: the emitted core still imports only @seamless-auth/types/role/matching
and neither zod nor the schema barrel enters the runtime graph.
Copied from the express package.json, the version said 0.10.0, which would
have published a first release claiming the history of a package that has
been shipping for ten minors.

Set to 0.0.0 so the pending minor changeset releases it as 0.1.0. The
package is deliberately outside the core/express linked group, so it
versions on its own until it has the same track record.
The errorCode/errorBody split was marked major, which changesets takes
literally and would have published core and express as 1.0.0. For a 0.x
package the breaking bump is the minor, and the changesets README still
describes these as pre-1.0.

Marked minor, so the release is 0.11.0. The changeset still leads with
BREAKING and the migration guidance, and now says why a breaking change is
shipping as a minor.
@Bccorb
Bccorb merged commit d7c758a into main Jul 30, 2026
2 checks passed
@Bccorb
Bccorb deleted the feat/fastify-adapter branch July 30, 2026 03:21
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