Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/silver-otters-gather.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@opensaas/stack-auth': minor
'@opensaas/stack-core': minor
---

Drive better-auth through a stack-authored Auth adapter over the Unsafe surface

`@opensaas/stack-auth` no longer hands better-auth `prismaAdapter`. It builds its
own adapter with better-auth's `createAdapterFactory`, running on the Unsafe
surface a Prisma 8 context carries: eight methods on the ORM lane, and
`incrementOne` plus an unconditional `deleteMany` as single typed-SQL statements
through the surface's own executors. `consumeOne` is `where(…).delete()` inside one
transaction on the surface's transaction-bound lanes, answering the row only
when the delete itself claimed it — the at-most-one guarantee better-auth asks
for, held against concurrent replays of the same token. See ADR-0060.

`createAuth(config, rawOpensaasContext)` keeps its signature; nothing in an
app's `lib/auth.ts` changes. Two new keys are refused at config time, alongside
the existing `betterAuthOptions.database`:

```typescript
authPlugin({
betterAuthOptions: {
// both throw: the database mints auth ids, and the adapter implements no joins
advanced: { database: { generateId: () => id, joins: true } },
},
})
```

`authPlugin` now pins `db.idField: 'uuid7'` on every list it injects, so auth
ids are minted by the database like every other list's.

`@opensaas/stack-core` gains the engine-owned LIKE-pattern escaping the adapter
lowers `contains` / `starts_with` / `ends_with` and insensitive `eq` through
(`escapeLikeLiteral` and the four pattern builders, on
`@opensaas/stack-core/internal`) — one escaper, shared with the secured
surface's Where vocabulary.

Known limits of the adapter, all stated: no joins, no `createSchema` (so
better-auth's CLI is unsupported against it), no better-auth transaction option
yet, no issuer-scoped account uniqueness until the schema gap in #986 closes,
and errors arrive as the driver's own rather than normalised.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Status: accepted

> **Amended 2026-09-07 by [The Auth adapter over the Unsafe surface](https://github.com/OpenSaasAU/stack/pull/1221)** ([#1161](https://github.com/OpenSaasAU/stack/issues/1161), review of the implementation). The `consumeOne` decision below is unchanged — it is still `where(…).delete()` on the ORM lane — but the mechanism sentence attached to it is wrong. Prisma does **not** lower that to one `DELETE … RETURNING`: it emits a SELECT that resolves the identity and then a DELETE, so two racing consumers can both resolve the same row and both be answered it. The at-most-one guarantee therefore comes from the shape better-auth's own reference adapter uses, and that is what is implemented: the pair runs inside one transaction on the Unsafe surface's transaction-bound lanes, and the row is handed back only when the DELETE itself claimed it (a row-count gate). Read the sentence below as "two statements under a transaction, gated on the delete's own count", not as a single returning statement.
>
> **Amended 2026-09-05 by [Delete the Node build, enforce erasable-only output, and retire the scaffolder's `--db` flag](https://github.com/OpenSaasAU/stack/issues/1138)** (Prisma 8 map). The last decision below — "The plain-Node anchor keeps its shape" — rests on a file that no longer exists. `examples/starter-auth/scripts/node-build-create-user.mjs` and `e2e/starter-auth/04-node-build.spec.ts` were **deleted**, not re-targeted: the examples do not build until the spec's example conversion, and there is no Prisma 8 better-auth adapter to drive them with until this record is implemented. The property [ADR-0054](0054-the-generated-bundle-loads-natively-under-plain-node-from-the-committed-contract.md) actually asserts — the generated bundle loads under the real `node` binary with no flags, no loader and no bundler — is guarded instead by `packages/cli/tests/bundle-node-load.test.ts`, at CLI level over the contract fixture. What that test does **not** cover is better-auth: no `createAuth`, no `signUpEmail`, no Auth adapter. So this record's verification plan is short one item, and implementing it should bring a better-auth-shaped anchor back with the example conversion — tracked on [#1178](https://github.com/OpenSaasAU/stack/issues/1178).

`@opensaas/stack-auth` wires better-auth through `prismaAdapter(context.prisma, { provider })` (`packages/auth/src/server/index.ts`), behind the lazy `Proxy` [ADR-0014](0014-orm-client-access-stays-async-document-the-sync-escape-hatch.md) documents. That adapter is written against Prisma Client's per-model delegates — `findFirst`/`findMany`/`update`/`updateMany`/`delete`/`deleteMany`/`count`/`$transaction`, `WhereUniqueInput` flattening, `P2025` for not-found — none of which a Prisma 8 client has: the client is `postgres({ contractJson })`, its ORM lane is `Collection`s, and a query is a value ([ADR-0039](0039-context-db-is-a-query-value-surface-with-engine-owned-terminals.md)). better-auth 1.7.1 ships adapters for prisma, drizzle, kysely, mongodb and memory only; its `main` (1.7.2) still peers `@prisma/client ^5 || ^6 || ^7`; and the sole upstream trace of Prisma 8 is [better-auth#11077](https://github.com/better-auth/better-auth/issues/11077) (2026-08-31), an unanswered feature request that targets the Prisma-Client-shaped migration, not `@prisma/orm-postgres`. Two standing records had already assumed an answer without naming one: [ADR-0038](0038-access-enforcement-stays-in-terminal-operations-with-the-spi-as-a-tripwire.md) keeps the Unsafe surface partly so that "the auth adapter needs no exemption machinery", and [ADR-0049](0049-extension-packs-are-declared-the-tripwire-is-stack-owned.md) says better-auth's adapter gets the unscoped mark "by construction, being handed that surface through `rawOpensaasContext`". [#1086](https://github.com/OpenSaasAU/stack/issues/1086) asked what that adapter is.
Expand All @@ -22,7 +24,7 @@ Read at Prisma `8.0.0-rc.8` (`packages/3-extensions/sql-orm-client/src/{collecti

## Decisions

- **ORM lane primary; typed SQL for exactly the operations the `Collection` cannot express.** Eight methods are `Collection` calls on the Unsafe surface's ORM lane. `incrementOne` is one typed-SQL `UPDATE … SET n = n + δ … RETURNING`, and `deleteMany` with an empty `where` — which better-auth's own test cleanup issues — is one typed-SQL unconditional `DELETE`. Both run through the Unsafe surface's own executors and are marked as unscoped there ([ADR-0056](0056-app-authored-sql-lives-on-the-unsafe-surface-which-stamps-at-execution.md)). `consumeOne` is `where(…).delete()`: Prisma resolves the first matching identity and deletes by it with `RETURNING`, so of two racing consumers exactly one gets the row, which is the at-most-one guarantee better-auth asks for. If the build finds the internal `or()` import unacceptable, the fallback is typed SQL throughout — still the Unsafe surface, never a second client.
- **ORM lane primary; typed SQL for exactly the operations the `Collection` cannot express.** Eight methods are `Collection` calls on the Unsafe surface's ORM lane. `incrementOne` is one typed-SQL `UPDATE … SET n = n + δ … RETURNING`, and `deleteMany` with an empty `where` — which better-auth's own test cleanup issues — is one typed-SQL unconditional `DELETE`. Both run through the Unsafe surface's own executors and are marked as unscoped there ([ADR-0056](0056-app-authored-sql-lives-on-the-unsafe-surface-which-stamps-at-execution.md)). `consumeOne` is `where(…).delete()` — see the 2026-09-07 amendment at the top of this record for the mechanism that actually delivers its at-most-one guarantee: the resolve-then-delete pair runs in one transaction and the row is answered only when the delete claimed it. If the build finds the internal `or()` import unacceptable, the fallback is typed SQL throughout — still the Unsafe surface, never a second client.
- **The Unsafe surface carries the ORM lane — `Collection`s and `transaction` — and this record says so.** ADR-0056 named the SQL builder, the raw tag and the executors and left the ORM lane to [#1076](https://github.com/OpenSaasAU/stack/issues/1076); ADR-0049 assumed it. The Auth adapter is the lane's first concrete consumer, so its existence is recorded here. **How a query on that lane acquires its mark is not decided here.** At write-up, [ADR-0059](0059-the-engine-stamp-is-an-ambient-origin-the-executing-surface-enters.md) (#1076, on an open branch) resolves it as a transparent `Proxy` that enters the unsafe origin around each call, measured covering both statements of `update()` and `delete()`; this record depends on the lane existing and being marked, not on the mechanism, and the adapter's requirement — every statement of `update()`, `delete()` and `consumeOne` must pass the tripwire — is stated so any later change to the tripwire is checked against it.
- **The ORM mints auth ids.** The adapter sets `disableIdGeneration: true` (with `supportsUUIDs: true`, `supportsNumericIds: false`), and `authPlugin`'s per-list pin under [ADR-0048](0048-the-deleted-psl-constructs-become-config-defaults-not-ddl.md) is named: `uuid7`, the same strategy as every other list. `create()` returns the inserted row, so better-auth learns the id exactly as it did from Prisma Client. `advanced.database.generateId` joins the passthrough keys `buildBetterAuthOptions` refuses: an app-supplied generator would write a non-UUID into a uuid column.
- **`config.transaction` is implemented**, rebinding a second factory instance to the transaction-bound Unsafe surface ADR-0056 gives the transaction context — the shape better-auth's own Kysely and Prisma adapters use. Sign-up writes user, account and session; silently losing atomicity on the login path is the degradation [ADR-0022](0022-access-control-fails-closed-when-it-cannot-scope.md) exists to refuse. [ADR-0042](0042-transactions-lose-isolation-levels-and-database-errors-become-stack-owned.md)'s no-isolation-level rule applies unchanged: auth transactions run at Read Committed.
Expand Down
7 changes: 7 additions & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"./mcp": {
"types": "./dist/mcp/index.d.ts",
"default": "./dist/mcp/index.js"
},
"./adapter": {
"types": "./dist/adapter/index.d.ts",
"default": "./dist/adapter/index.js"
}
},
"scripts": {
Expand Down Expand Up @@ -62,6 +66,7 @@
"peerDependencies": {
"@better-auth/mcp": "^1.7.0",
"@opensaas/stack-core": "^0",
"@prisma/orm-postgres": "8.0.0-rc.8",
"better-auth": "^1.4.0",
"next": "^15.0.0 || ^16.0.0",
"react": "^18.0.0 || ^19.0.0"
Expand All @@ -73,8 +78,10 @@
},
"devDependencies": {
"@better-auth/mcp": "^1.7.1",
"@better-auth/test-utils": "1.7.1",
"@opensaas/stack-cli": "workspace:*",
"@opensaas/stack-core": "workspace:*",
"@prisma/orm-postgres": "8.0.0-rc.8",
"@types/node": "^26.1.1",
"@types/react": "^19.2.14",
"@typescript/native": "npm:typescript@^7.0.2",
Expand Down
Loading
Loading