feat(auth): signup, login, and JWT verification as a composed module#163
feat(auth): signup, login, and JWT verification as a composed module#163wmadden-electric wants to merge 33 commits into
Conversation
Design write-up + contract sketch for review before implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Rewrites the strawman spec around the settled design (pack-shipped schema, deploy-time preflight, minted secret, proxy golden path), adds the slice plan (TML-3076..3079) and the design-discussion record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
A platform-minted per-instance secret for the auth module, mirroring
s3Credentials: kind 'auth-secret', config { value: string }, minted once
(32 random bytes, base64) and kept stable across deploys by returning the
persisted output on reconcile.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
A pn database can now carry an extension-pack requirement:
- pnPackRequirement({ packId, headHash }) — a prisma-next-kind required
contract; pnContract().satisfies answers yes to any pack requirement
(wireability only)
- runPackPreflight — deploy-time enforcement, invoked from the prisma-next
lowering before the migration step: the wired resource must be a
pnPostgres whose config lists the pack at the required head
- resolvePnProject surfaces the config's extensionPacks beside the
migrations dir; they thread into the control client so dbInit/migrate
run PN's multi-space aggregate; the app-space noop short-circuit is
suppressed when packs are declared
- the PnMigration resource folds "<packId>:<headHash>" entries (sorted)
into its diff key, so a pack upgrade produces a distinct deploy step;
reconcile reloads descriptors from the config path (props must stay
serializable)
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…tion Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
New workspace package @internal/auth carrying the auth contract space as
a Prisma Next extension pack:
- src/pack/contract.prisma: the Better Auth schema at the pinned
better-auth 1.6.24 (plugins jwt + bearer + admin + magicLink,
email+password), transcribed from Better Auth's own generator output
under PSL namespace auth; emitted contract.{json,d.ts} committed
- src/pack/migrations/0001_init: the authored EMPTY -> head migration
(prisma-next migration plan output)
- src/pack/schema.sql: flat idempotent DDL for the testing export
(pnpm generate:schema)
- src/pack/index.ts: authPack descriptor (managed policy, in-memory
shipped migrations, load-time head-hash self-check); identity
constants live once in src/pack/constants.ts
- conformance test against local Postgres: a deploy-path migration
(empty app space + authPack through the PN control client) and an
applied schema.sql both leave Better Auth's own migration generator
with zero pending changes
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
biome now ignores src/pack/contract.json and the authored migration JSONs (regeneration must not dirty the tree), and the empty-app test fixture moves to the source/ + emitted/ layout the existing fixtures use, so its emitted pair falls under the standing fixtures ignore. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…odule pnPackRequirement/packRequirementOf move from prisma-next.ts into their own pn-pack-requirement.ts (shared plane) and join the MAIN barrel: authDb() in the auth module needs the requirement contract on its authoring surface, and importing it via ./prisma-next would drag @prisma-next/postgres/runtime + pg (node: imports) into every consumer bundle. The module is self-contained (invariant 7 bans prisma-next / @prisma-next specifiers, type-only included, from the main barrel graph); its PnPackRequirementCmp mirrors PnCmp structurally so typed wiring still accepts any pnContract() provider. ./prisma-next re-exports everything, so the D2 surface and its tests are unchanged. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The module's full typed surface (S1 shape — no email dep yet): - contract.ts: userRecord/sessionRecord (ISO strings, banExpiresAt), authApiContract (kind 'auth-api', url = the service's own producer output), authSessionContract + authAdminContract, authApi() (thin URL-anchored client), jwtVerifier() (jose remote JWKS, 30 s clock tolerance, null for ANY token-content failure, throws on operational errors, no iss/aud per D15), authDb() (pnPackRequirement pinned to AUTH_PACK_ID @ AUTH_PACK_HEAD_HASH, identity hydrate) - auth-module.ts: auth() — db boundary dep, baseUrl param need, minted authSecret provisioned inside, three ports returned - auth-service.ts: the compute node (no port param — port is compute's reserved service param) + default bare node - auth-store.ts + pg-auth-store.ts: one method per port op; AUTH_SCHEMA qualification, quoted "user", null-safe effective-ban predicate mirrored in SQL, base64url keyset cursor, ILIKE escaping, ban implies revoke in one transaction - handlers.ts: session + admin maps over the store; exactly-one-of getUser with the pinned message - tests: type-level surface, unit (codec/predicate/escaping/handlers), store integration against the pack's schema.sql on local Postgres, and a bundling probe holding the authoring barrel free of node:/bun/ effect/alchemy/@prisma-next/better-auth tokens Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
- auth-options.ts: the ONE Better Auth configuration, every spec-pinned
value asserted field-by-field in tests; connection hardening
reimplemented locally (20s connect, 5s idle, error listener,
search_path=auth). S1 email seam: sendEmail absent -> the pinned
no-op log; present -> { purpose, to, url } forwarded (the testing
export's capture hook). advanced.database.generateId is deliberately
NOT set to false: at better-auth 1.6.24 that disables generation and
breaks signup against the pack schema (verified empirically) — the
spec's stated intent (BA's default generator, text ids) is the
no-override behavior.
- proxy.ts: authProxy() with every pinned behavior, proven against a
stub upstream (headers minus host, x-forwarded-*, path+search
verbatim, redirect passthrough, set-cookie passthrough, 502 text).
The entrypoint / testing export / integration half is blocked on two
serve() conflicts (flat rpc dispatch vs the pinned two-port getUser;
non-rpc api port in expose) — recorded in the dispatch report.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…, generateId erratum Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
A service may expose non-rpc ports beside its rpc ones (the auth module's public api port carries connection config, not methods). serve() now dispatches only kind-'rpc' contracts — runtime (the methodTable flatten skips other kinds) and type level (Handlers<S> maps only rpc port keys, so no handler map is demanded for a port nobody could handle). The duplicate-method construction throw still fires between rpc ports; no wire change. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…l loop
- admin op getUser -> findUser (pinned rename: rpc dispatch is flat and
session.getUser owns the name); the exactly-one-of message follows
- execution/fetch-router.ts: the ONE fetch composition (/health,
/api/auth* public, /rpc/* bearer-checked in serve(), else 404) shared
by the deployed entrypoint and the local server
- execution/auth-entrypoint.ts: bare-node boot per email's pattern —
framework accessors only, no schema work at boot
- execution/testing.ts: startLocalAuthServer — pack schema applied
idempotently from the generated schema-sql.ts twin (bundler-safe;
conformance test pins it against schema.sql), fixed dev secret,
default capture of { template, to, url } into capturedEmails,
serve() no-keys pass-through, ephemeral port
- auth-options: jwt definePayload = default user object + sid (the
pinned claim set requires sid; better-auth 1.6.24's default payload
carries none)
- exports ./auth-entrypoint + ./testing, tsdown passes per email
- integration suite against local Postgres: signup (verification link
captured) -> login (cookie + bearer) -> /api/auth/token -> the real
jwtVerifier() hydrate -> session/admin ports over real rpc HTTP ->
ban/unban instant logout -> revocation idempotency -> magic-link
capture completed end to end -> /health + 404 fallthrough
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
- examples/auth: the smoke harness per spec — a dedicated pn database carrying ONLY the auth pack (empty app space; migration plan materialised migrations/auth/ committed), auth() wired with envParam(AUTH_BASE_URL), an api service (authProxy on /api/auth/*, JWT-verified /me, /session lookup) and an ops service holding only the admin port; scripts/smoke.ts drives the deployed loop; local integration test runs the same wiring against startLocalAuthServer - public @prisma/composer-prisma-cloud gains ./auth, ./auth/pack, ./auth/auth-entrypoint, ./auth/testing (email's re-export pattern; auth-service.mjs re-emitted beside index for import.meta resolution) - module README: contract scope, golden-path wiring, the pack, sessions and JWTs, local dev (S1 for real; S2/S4 sections stubbed) - depcruise aliases + architecture entries for the new surfaces Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…superseded Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ed path mounts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
better-auth lazy-imports optional kysely dialects, which split the auth-entrypoint/testing passes into sibling chunk files the deploy packager never ships — the deployed service crashlooped on "Cannot find module ./default-query-compiler-*.mjs". Force inlineDynamicImports on both passes so each entry is one file. Found by the real deploy; the deployed smoke passes 10/10 after. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
|
Important Review skippedToo many files! This PR contains 147 files, which is 47 over the limit of 100. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (147)
You can disable this status message by setting the ✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
wmadden
left a comment
There was a problem hiding this comment.
Lots of problems here. Why is this not using Prisma Next migrations?
There was a problem hiding this comment.
Don't write servers by hand. Use express or hono or something
There was a problem hiding this comment.
Rewritten on Hono, mirroring examples/email. 07fe5104
| export interface SessionPort { | ||
| getSession(input: { | ||
| token: string; | ||
| }): Promise<{ session: { id: string } | null; user: { id: string; email: string } | null }>; | ||
| } |
There was a problem hiding this comment.
What is this? Why isn't this an RPC contract?
There was a problem hiding this comment.
The hand-declared SessionPort interface is gone — the app's deps are now typed Client<typeof authSessionContract>, the exact shape rpc(contract) hydrates, so the types follow the contract automatically. 07fe5104
There was a problem hiding this comment.
Again, don't write servers by hand
There was a problem hiding this comment.
Same — on Hono now. 07fe5104
| // Only rpc ports carry dispatchable methods; a non-rpc exposed port | ||
| // (its __cmp is connection config, not a function map) is not serve()'s | ||
| // to handle — the type-level mirror is RpcPortKeys in Handlers<S>. | ||
| if (contract.kind !== 'rpc') continue; |
There was a problem hiding this comment.
no magic strings. preferably, no string comparison. use a type guard
There was a problem hiding this comment.
isRpcContract already existed in rpc.ts but was private to it. Exported it and used it here, so there is one canonical guard instead of two spellings. bda99ac3
There was a problem hiding this comment.
Why does Prisma Cloud have an "auth secret"? Why did the platform gain a primitive specific to a single sharable module?
There was a problem hiding this comment.
Agreed — deleted. The platform now has a generic minted secret source, mintedSecret(), sitting beside envSecret(name) in the existing ADR-0029 slot model: it resolves a secret slot by minting a stable value at deploy. auth() just binds its service's ordinary secret slot to it internally, so consumers still declare no secrets. No core change was needed. s3-credentials stays as it is — the bucket's own primitive. f4315e4b
| import type { Contract } from '@internal/core'; | ||
|
|
||
| /** A dependency's claim that its pn database carries extension pack `packId` at `headHash`. */ | ||
| export interface PnPackRequirement { |
There was a problem hiding this comment.
What is a pack requirement? Look up the PN glossary/ubiquitous language
There was a problem hiding this comment.
Checked the PN glossary — there is no counterpart. The nearest PN concept is capability negotiation, which this is not. It is our own deploy-wiring construct (a dependency's claim that its database carries pack X at head Y, checked by our preflight), so it now has a name that says that instead of one that sounds like PN vocabulary: RequiredPackHead / requiredPackHead / requiredPackHeadOf, file required-pack-head.ts. Say the word if you want a different name. d4302327
| import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control'; | ||
| import { authPack } from '../src/pack/index.ts'; | ||
|
|
||
| function idempotent(sql: string): string { |
There was a problem hiding this comment.
Ewwwww what is all this raw SQL? This is PN's job
There was a problem hiding this comment.
Agreed, and deleted. To be clear about what this was: deploys always ran PN migrations (dbInit/migrate through the control client) — this rendered SQL was a second path used only by the local testing bootstrap. It is gone: startLocalAuthServer now runs the real dbInit against the caller's local database, so local and deploy share one mechanism. scripts/generate-schema.ts, src/pack/schema.sql, and src/pack/schema-sql.ts are all deleted. The bootstrap is also safer than the SQL it replaced — it no-ops when the auth space is already signed at the pack head, and refuses a database at a different head or one carrying other contract spaces, instead of applying blindly. 51983e40
| const { db, secret } = service.load(); | ||
| const { baseUrl, port } = service.config(); | ||
|
|
||
| // S1: sendEmail absent — the three Better Auth send callbacks log the |
There was a problem hiding this comment.
Read this as: no Drive slice/decision identifiers in shipped code, since those artifacts get deleted at close-out. Swept them out of 39 files across the repo — comments now carry the reasoning instead of a pointer. It also caught one that was not a comment: the no-op sender logged auth: email delivery not wired (slice S2): … at runtime, shipping the identifier to users. Now auth: email delivery not wired: <purpose> for <email>. 5f1b1c3b (log line: 30ef8d74)
There was a problem hiding this comment.
Urgh, this belongs in the prisma cloud/compute implementation, not a shared module. We need a way to compose rpc ports in one service
There was a problem hiding this comment.
Agreed — moved. composeServiceFetch now lives in @internal/service-rpc (health probe + one optional public non-rpc handler + serve()'s rpc handler + 404), and auth's fetch-router.ts is deleted. Both the deployed entrypoint and the local server use it, so they provably serve one topology. This is only the composition seam — first-class multi-port services stay the wired-egress project's scope. bda99ac3
There was a problem hiding this comment.
Deleted with the rest of the rendered-SQL path — see the reply on scripts/generate-schema.ts. 51983e40
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
resolvePnProject/PnProject -> resolvePrismaNextConfig/ResolvedPrismaNextConfig (the loaded thing is a config), packHeads -> packHeadRefHashes (a pack head is a contract-space head ref identified by its storage hash), and the pack requirement trio -> requiredPackHead/requiredPackHeadOf/RequiredPackHead with the __cmp field renamed to match (the concept is compose's own, not PN glossary). pn-pack-requirement.ts is renamed to required-pack-head.ts and the architecture config glob follows. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…secret slot mintedSecret() is a second ADR-0029 secret source beside envSecret(name): the deploy mints a stable 32-byte base64 value (Web Crypto, no node: imports) via a MintedSecret Alchemy resource, provisions it under a framework-owned COMPOSER_*_MINTED var, and writes the slot pointer row at that var — boot double-lookup is unchanged. Reconcile returns the persisted value, so the secret is stable across deploys; rotation is destroy/recreate. The secret preflight skips minted bindings (nothing for a user to provision). The bespoke authSecret resource, descriptor, and contract are deleted; the auth service declares an ordinary secret slot which auth() binds to mintedSecret() internally, so consumers still see zero secret slots. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
A service's whole fetch surface composes in one place: /health probe, an optional public non-rpc handler under its path prefix, serve()'s rpc handler for /rpc/*, else 404. The auth entrypoint and its local test server consume it, replacing the module-local fetch-router.ts, so the deployed and local topologies cannot drift — and any future service mixing a public port with rpc ports gets the same seam. Single-port only by design; multi-port routing is a separate project. serve() now narrows exposed contracts with the exported isRpcContract guard instead of an inline kind comparison. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
startLocalAuthServer no longer applies a rendered schema.sql: a new ensureLocalAuthSchema materialises the pack space from the descriptor's own shipped data into a temp dir and runs the PN control client's dbInit (auth pack + empty app space) against the caller's database — the same loader → planner → runner pipeline a consumer deploy runs, so local dev and deploy cannot drift. Repeat boots no-op off the signed marker; a database at an older pack head or carrying foreign contract spaces fails with instructions instead of being half-migrated. The schema.sql generator, the committed schema.sql, and its TS twin are deleted; the conformance test keeps its real assertion (PN-migrated scratch DB, Better Auth reports zero pending changes) and now proves the bootstrap path instead of file drift. The store and conformance integration tests bootstrap through the same path. The tsdown testing pass bundles the @prisma-next graph so the shipped testing artifact stays self-contained (verified: only node builtins, bun, and declared deps remain external). Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Slice/decision shorthand from planning docs (S1/S2, D1-D16, "slice 2", ...) meant nothing to a reader of the shipped code; each reference is replaced with the actual reasoning or dropped where the surrounding sentence already carried it. The pinned no-email log line loses its ID too: "auth: email delivery not wired: <purpose> for <email>". Amazon-S3 mentions are untouched — that S is a product, not a slice. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The two example apps drop their hand-rolled Request routers and hand-declared SessionPort/AdminPort interfaces for Hono (the email example's pattern) and Client<typeof authSessionContract>/Client<typeof authAdminContract> — the exact client shapes rpc() hydrates, so the example can no longer drift from the module's contracts. Routes and responses are unchanged; malformed JSON bodies now answer the same 400 as a missing field instead of surfacing as a server error. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
… in shipped strings) Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
|
All 14 comments addressed; the branch is updated ( "Why is this not using Prisma Next migrations?" Deploys always did. The raw SQL you found was a second path, used only by the local testing bootstrap. It is deleted: The rest, briefly:
Re-verified after the rework: root typecheck, 280 target / 63 service-rpc / 84 auth / 2 example tests, lint + depcruise + casts, and a fresh build confirming the testing bundle is self-contained. The deployed smoke has not been re-run since the rework — the secret now mints through a different resource id, so it is worth one more deploy before merge; say if you want that now or after your next look. Two things I did not change and want your call on: the name |
The helper reached the published API through the root barrel, which `@prisma/composer/service-rpc` re-exports wholesale. It has no external consumers yet, and the multi-port services work is expected to change its input shape, so un-publishing it now costs nothing and un-publishing it later would break people. It moves to the `./compose-fetch` subpath of `@internal/service-rpc` — the same shape `@internal/prisma-cloud/connection` already uses for a helper shared between first-party packages but kept off the published surface. The auth module imports it from there; the root barrel no longer names it, so `@prisma/composer/service-rpc` no longer offers it. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The public handler is matched before /rpc/*, so a pathPrefix of "/", "/rpc", or anything /rpc/... starts with routed every rpc request to the public handler — turning a service-key-checked surface into an unauthenticated one with no error anywhere. The constraint lived only in a doc comment. composeServiceFetch now throws when the prefix overlaps the rpc route, naming the prefix and the route it collides with. The test drives every overlapping prefix through the constructor and asserts the message; it fails against the previous implementation, which returned a handler without complaint. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
|
Superseded by #173. The S1 auth module is re-applied there on the ADR-0042 input model (base #161): the instance signing value is now a |
Adding authentication to an app becomes this:
That is the whole wiring. You bind a database and a public URL; you get signup, login, logout, email/password auth, sessions, JWTs with JWKS verification, and an admin surface. No secret to provision — the instance secret is minted for you at deploy.
Linear: TML-3076 · first of four slices; email flows (#165), the shared-database consumer example, and embedded mode follow.
The decision: your database, our migration
Better Auth is a library that owns Postgres tables. The central choice here is where those tables live and who creates them: they live in your database, and the framework migrates them, as a Prisma Next extension pack carrying an
authcontract space and authored migrations.That means one deploy migration step creates and evolves the auth tables beside your own, the database marker is signed per contract space, and Better Auth's own migrator never runs anywhere — not at boot, not at deploy. Upgrading the module is
bump package → migration plan → deploy, and a deploy preflight refuses loudly if a wired database's config doesn't list the pack at all. (Detecting a config that lists the pack but sits at a stale on-disk head is Prisma Next's own job — its migration step reads the materialisedrefs/head.json— not this preflight's; see the note below.)The payoff arrives in a later slice but the mechanism is already here: because the auth tables are a contract space in your database, your own schema can put a real foreign key on
auth:User.The second decision is sessions are stateless by default. JWTs carry a 15-minute TTL and verify against the instance's JWKS with no database access — that is the entire value of the verifier binding. Instant logout is available, but as a deliberate per-route call to the
sessionport rather than a cost every request pays.How it fits together
Three ports, one service.
apiis the public Better Auth HTTP surface — public by design, since it is the authentication.sessionanswers online checks (getSession,getUser) and is the instant-logout path.admincarries the back-office operations (findUser,listUsers,listSessions,revokeSession,revokeUserSessions,banUser,unbanUser). You wire the app toapi+sessionand the back office alone toadmin, so each consumer only ever holds the typed client for what it needs.Read that as ergonomics, not as an enforced boundary. Today
serve()checks a caller's bearer token against one accepted key set for the whole service and then dispatches to any method on it, so a key issued forsessionwould also be accepted onadmin. Per-binding keys make calls attributable and individually revocable; they do not scope methods. Isolating ports at the transport level is what thewired-egressproject exists to fix. If that gap matters before then, the honest fix is two services rather than a stronger claim.Admin handlers talk to the database directly. Better Auth's admin plugin authorizes through admin sessions; our ports authorize through wiring. Minting sessions to satisfy the plugin would have been a hack, so these handlers run their own SQL.
Browsers reach auth on your origin.
authProxy()is a one-line mount that forwards/api/auth/*to the auth service, so cookies stay first-party and magic-link redirects land where you expect.Local development needs no cloud account.
startLocalAuthServer()runs the real module against a local Postgres, initialising the schema through the same Prisma NextdbInitthe deploy uses, and captures auth emails in memory so tests can read verification and magic-link URLs straight out.Platform and framework additions, both generic rather than auth-specific:
mintedSecret()is a new secret source besideenvSecret()that mints a stable value at deploy, so any module can have a secret nobody provisions;composeServiceFetch()in@internal/service-rpccomposes a health probe, one public non-rpc handler, and the rpc handler into a single service surface. Prisma Next's vocabulary is used as PN defines it (head refs, storage hashes, config) — the one genuinely new concept, a dependency's claim that a database carries a given pack at a given head, is namedRequiredPackHeadrather than borrowed vocabulary.Proof it works:
examples/authdeploys to real Prisma Cloud, and its smoke ran the full loop — signup, login, JWT mint, stateless verify, session lookup, admin revoke, instant logout — then tore the deployment down cleanly.What changed after the first review
The first round of this PR was reworked rather than patched. The auth-specific platform secret became the generic
mintedSecret()source; a hand-rolled SQL bootstrap for local development was deleted in favour of the real Prisma NextdbInitpath; the module-local fetch router moved into the framework; invented Prisma Next vocabulary was replaced with PN's own terms; example apps moved to Hono with contract-derived types; and transient project identifiers were swept out of shipped code, including one that was reaching users in a log line.Re-verified after the rework: root typecheck, 280 target / 63 service-rpc / 84 auth / 2 example tests, lint, dependency-layering, and casts — plus a fresh build confirming the deployed bundle excludes the Prisma Next control client while the testing bundle is self-contained. The deployed smoke has not been re-run since the rework; the secret now mints through a different resource identity, so that deserves one more real deploy before merge.
Alternatives considered
🤖 Generated with Claude Code