Skip to content

feat(auth): signup, login, and JWT verification as a composed module#163

Closed
wmadden-electric wants to merge 33 commits into
mainfrom
claude/prisma-composer-auth-module-0ade44
Closed

feat(auth): signup, login, and JWT verification as a composed module#163
wmadden-electric wants to merge 33 commits into
mainfrom
claude/prisma-composer-auth-module-0ade44

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Adding authentication to an app becomes this:

// module.ts (the root)
const db = provision(pnPostgres({ name: 'database', contract: appContract, config: './prisma-next.config.ts' }), { id: 'database' });

const identity = provision(auth(), {
  id: 'auth',
  deps: { db },                                      // YOUR database — the module owns tables in it, not a database
  params: { baseUrl: envParam('AUTH_BASE_URL') },   // the public origin browsers see
});

provision(apiService, {
  id: 'api',
  deps: { authApi: identity.api, verifier: identity.api, session: identity.session },
});
// in your service
const proxy = authProxy(authApi);        // route /api/auth/* through this → first-party cookies
await verifier.verify(bearerToken);      // stateless JWT check, no database access

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 auth contract 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 materialised refs/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 session port rather than a cost every request pays.

How it fits together

  • Three ports, one service. api is the public Better Auth HTTP surface — public by design, since it is the authentication. session answers online checks (getSession, getUser) and is the instant-logout path. admin carries the back-office operations (findUser, listUsers, listSessions, revokeSession, revokeUserSessions, banUser, unbanUser). You wire the app to api + session and the back office alone to admin, 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 for session would also be accepted on admin. Per-binding keys make calls attributable and individually revocable; they do not scope methods. Isolating ports at the transport level is what the wired-egress project 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 Next dbInit the 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 beside envSecret() that mints a stable value at deploy, so any module can have a secret nobody provisions; composeServiceFetch() in @internal/service-rpc composes 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 named RequiredPackHead rather than borrowed vocabulary.

  • Proof it works: examples/auth deploys 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 Next dbInit path; 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

  • Let Better Auth run its own migrator. Rejected: the framework migrates deterministically and verifies per contract space, and a library creating tables behind the framework's back forfeits both that and the cross-space foreign key.
  • Create the schema at boot. Rejected: too late to help. A green deploy followed by a failed boot is exactly the failure this design exists to prevent, so the check runs at deploy time instead.
  • A remote identity provider instead of a library. Rejected for v1: Better Auth in-process keeps user data in the customer's own database, which is the point of the foreign-key story.
  • Stateful sessions checked on every request. Rejected as the default: it makes every authenticated request a database round trip. Offered as an explicit opt-in instead.
  • Embedded-only (a library you mount yourself). Not rejected — deferred to its own slice. It ships as a library export sharing one options builder with the service, so both shapes behave identically.
  • An auth-specific platform secret primitive. Rejected during review: the platform already models secrets, so the mechanism belongs there generically.

🤖 Generated with Claude Code

wmadden-electric and others added 23 commits July 22, 2026 16:10
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>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 147 files, which is 47 over the limit of 100.

To get a review, narrow the scope:
• coderabbit review --committed # exclude uncommitted changes
• coderabbit review --dir # limit to a subdirectory
• coderabbit review --base # compare against a closer base

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 configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1dc15348-0796-4a25-b5b0-4466d212bbae

📥 Commits

Reviewing files that changed from the base of the PR and between 127f95c and 2420710.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (147)
  • .agents/rules/exports-entrypoints.mdc
  • .drive/projects/auth-module/design-notes.md
  • .drive/projects/auth-module/plan.md
  • .drive/projects/auth-module/slices/auth-module-core/plan.md
  • .drive/projects/auth-module/slices/auth-module-core/spec.md
  • .drive/projects/auth-module/slices/rpc-port-isolation/spec.md
  • .drive/projects/auth-module/spec.md
  • architecture.config.json
  • biome.jsonc
  • examples/auth/contract.d.ts
  • examples/auth/contract.json
  • examples/auth/contract.prisma
  • examples/auth/migrations/app/20260723T0709_init/end-contract.d.ts
  • examples/auth/migrations/app/20260723T0709_init/end-contract.json
  • examples/auth/migrations/app/20260723T0709_init/migration.json
  • examples/auth/migrations/app/20260723T0709_init/migration.ts
  • examples/auth/migrations/app/20260723T0709_init/ops.json
  • examples/auth/migrations/auth/0001_init/migration.json
  • examples/auth/migrations/auth/0001_init/ops.json
  • examples/auth/migrations/auth/contract.d.ts
  • examples/auth/migrations/auth/contract.json
  • examples/auth/migrations/auth/refs/head.json
  • examples/auth/module.ts
  • examples/auth/package.json
  • examples/auth/prisma-composer.config.ts
  • examples/auth/prisma-next.config.ts
  • examples/auth/scripts/smoke.ts
  • examples/auth/src/api/app.ts
  • examples/auth/src/api/server.ts
  • examples/auth/src/api/service.ts
  • examples/auth/src/contract.ts
  • examples/auth/src/ops/app.ts
  • examples/auth/src/ops/server.ts
  • examples/auth/src/ops/service.ts
  • examples/auth/tests/local.integration.test.ts
  • examples/auth/tests/pg-harness.ts
  • examples/auth/tsconfig.json
  • examples/auth/turbo.json
  • examples/cron/tests/jobs-roundtrip.integration.test.ts
  • examples/email/module.ts
  • packages/0-framework/1-core/core/src/__tests__/lowering.test.ts
  • packages/0-framework/1-core/core/src/__tests__/params.test-d.ts
  • packages/0-framework/1-core/core/src/config.ts
  • packages/0-framework/2-authoring/service-rpc/package.json
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/compose-fetch.test.ts
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/invariants.test.ts
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/serve-handlers.test-d.ts
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/serve-mixed-expose.test.ts
  • packages/0-framework/2-authoring/service-rpc/src/compose-fetch.ts
  • packages/0-framework/2-authoring/service-rpc/src/exports/compose-fetch.ts
  • packages/0-framework/2-authoring/service-rpc/src/exports/index.ts
  • packages/0-framework/2-authoring/service-rpc/src/rpc.ts
  • packages/0-framework/2-authoring/service-rpc/src/serve.ts
  • packages/0-framework/2-authoring/service-rpc/tsdown.config.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/fixtures/packed-contract/pack.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/fixtures/packed-contract/source/contract.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/fixtures/packed-contract/source/prisma-next.config.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/minted-secret.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/pack-preflight.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/pn-config.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/pn-extension-packs.integration.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/pn-migration-resource.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next-migrate.integration.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts
  • packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts
  • packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts
  • packages/1-prisma-cloud/1-extensions/target/src/exports/index.ts
  • packages/1-prisma-cloud/1-extensions/target/src/minted-secret-resource.ts
  • packages/1-prisma-cloud/1-extensions/target/src/pn-config.ts
  • packages/1-prisma-cloud/1-extensions/target/src/pn-migration-resource.ts
  • packages/1-prisma-cloud/1-extensions/target/src/preflight.ts
  • packages/1-prisma-cloud/1-extensions/target/src/prisma-next-migrate.ts
  • packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts
  • packages/1-prisma-cloud/1-extensions/target/src/required-pack-head.ts
  • packages/1-prisma-cloud/1-extensions/target/src/s3-credentials-resource.ts
  • packages/1-prisma-cloud/1-extensions/target/src/secret.ts
  • packages/1-prisma-cloud/1-extensions/target/src/serializer.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/README.md
  • packages/1-prisma-cloud/2-shared-modules/auth/package.json
  • packages/1-prisma-cloud/2-shared-modules/auth/prisma-next.config.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/auth-options.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/auth-store.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/barrel-invariants.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/contract.test-d.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/contract.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/fixtures/empty-app/emitted/contract.d.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/fixtures/empty-app/source/contract.prisma
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/fixtures/empty-app/source/prisma-next.config.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/fixtures/probe-authoring.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/handlers.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/local-server.integration.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/module.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/pack-conformance.integration.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/pg-auth-store.integration.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/postgres-harness.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/__tests__/proxy.test.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/auth-module.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/auth-options.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/auth-service.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/auth-store.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/contract.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/execution/auth-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/execution/empty-app-contract.json
  • packages/1-prisma-cloud/2-shared-modules/auth/src/execution/local-schema.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/execution/testing.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/exports/auth-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/exports/auth-service.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/exports/index.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/exports/pack.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/exports/testing.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/handlers.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pack/constants.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pack/contract.d.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pack/contract.json
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pack/contract.prisma
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pack/index.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pack/migrations/0001_init/migration.json
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pack/migrations/0001_init/ops.json
  • packages/1-prisma-cloud/2-shared-modules/auth/src/pg-auth-store.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/src/proxy.ts
  • packages/1-prisma-cloud/2-shared-modules/auth/tsconfig.json
  • packages/1-prisma-cloud/2-shared-modules/auth/tsdown.config.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test-d.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/execution/scheduler-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/email/src/__tests__/contract.test-d.ts
  • packages/1-prisma-cloud/2-shared-modules/email/src/contract.ts
  • packages/1-prisma-cloud/2-shared-modules/email/src/email-module.ts
  • packages/1-prisma-cloud/2-shared-modules/email/src/execution/email-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/email/src/handlers.ts
  • packages/1-prisma-cloud/2-shared-modules/email/src/outbox-store.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/__tests__/memory-store.test.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/contract.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/execution/storage-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/exports/index.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/handler.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/memory-store.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/storage-server.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/store.ts
  • packages/9-public/composer-prisma-cloud/package.json
  • packages/9-public/composer-prisma-cloud/src/exports/auth-pack.ts
  • packages/9-public/composer-prisma-cloud/src/exports/auth-testing.ts
  • packages/9-public/composer-prisma-cloud/src/exports/auth.ts
  • packages/9-public/composer-prisma-cloud/tsdown.config.ts
  • tsconfig.depcruise.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/prisma-composer-auth-module-0ade44
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/prisma-composer-auth-module-0ade44

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of problems here. Why is this not using Prisma Next migrations?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't write servers by hand. Use express or hono or something

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten on Hono, mirroring examples/email. 07fe5104

Comment thread examples/auth/src/api/app.ts Outdated
Comment on lines +14 to +18
export interface SessionPort {
getSession(input: {
token: string;
}): Promise<{ session: { id: string } | null; user: { id: string; email: string } | null }>;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this? Why isn't this an RPC contract?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, don't write servers by hand

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no magic strings. preferably, no string comparison. use a type guard

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does Prisma Cloud have an "auth secret"? Why did the platform gain a primitive specific to a single sharable module?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a pack requirement? Look up the PN glossary/ubiquitous language

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ewwwww what is all this raw SQL? This is PN's job

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NO TRANSIENT PROJECT IDS

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Urgh, this belongs in the prisma cloud/compute implementation, not a shared module. We need a way to compose rpc ports in one service

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@wmadden-electric

Copy link
Copy Markdown
Contributor Author

All 14 comments addressed; the branch is updated (659b41e3..30ef8d74). Replies are on each thread — the summary:

"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: startLocalAuthServer now runs the real PN dbInit through the control client, the same machinery the deploy uses. scripts/generate-schema.ts, src/pack/schema.sql, and src/pack/schema-sql.ts are gone. The new bootstrap also refuses to touch a database it does not fully own, where the old SQL applied blindly.

The rest, briefly:

  • authSecret deleted. The platform gained a generic mintedSecret() secret source beside envSecret() in the existing ADR-0029 model; auth binds an ordinary secret slot to it. s3-credentials untouched.
  • fetch-router.ts deleted. composeServiceFetch moved into @internal/service-rpc; deployed and local topologies now share it.
  • Vocabulary fixed against the PN glossary: PnProjectResolvedPrismaNextConfig, packHeadspackHeadRefHashes, PnPackRequirementRequiredPackHead (no PN counterpart exists for that one — it is ours, and the name now says so).
  • isRpcContract exported and used instead of the kind !== 'rpc' literal.
  • Transient identifiers swept from 39 files, including a runtime log line that shipped (slice S2) to users.
  • Example apps on Hono with contract-derived types instead of hand-declared port interfaces.

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 RequiredPackHead, and a follow-up the review surfaced — a service with sibling secret slots named x and x_MINTED would collide on the generated var name (unreachable in shipped code; there is a pre-existing case-collision class next to it, so a single normalization check would cover both).

@wmadden-electric wmadden-electric changed the title feat(auth): Better Auth as a composed module — pack-owned schema, stateless JWT verification, session/admin ports feat(auth): signup, login, and JWT verification as a composed module Jul 23, 2026
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>
@wmadden-electric

Copy link
Copy Markdown
Contributor Author

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 generatedParam (a config param, not a mintedSecret), and the whole thing is review-SATISFIED with a 13/13 deployed smoke on real Prisma Cloud. Closing per the supersession — the branch stays put in case #161 needs unwinding. Review moves to #173.

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.

2 participants