diff --git a/.changeset/silver-otters-gather.md b/.changeset/silver-otters-gather.md new file mode 100644 index 00000000..51fdddc5 --- /dev/null +++ b/.changeset/silver-otters-gather.md @@ -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. diff --git a/docs/adr/0060-better-auth-is-driven-by-a-stack-authored-auth-adapter-over-the-unsafe-surface.md b/docs/adr/0060-better-auth-is-driven-by-a-stack-authored-auth-adapter-over-the-unsafe-surface.md index 5b2124b4..751d6748 100644 --- a/docs/adr/0060-better-auth-is-driven-by-a-stack-authored-auth-adapter-over-the-unsafe-surface.md +++ b/docs/adr/0060-better-auth-is-driven-by-a-stack-authored-auth-adapter-over-the-unsafe-surface.md @@ -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. @@ -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. diff --git a/packages/auth/package.json b/packages/auth/package.json index bec66249..2e6663a7 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -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": { @@ -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" @@ -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", diff --git a/packages/auth/src/adapter/index.ts b/packages/auth/src/adapter/index.ts new file mode 100644 index 00000000..4f398e22 --- /dev/null +++ b/packages/auth/src/adapter/index.ts @@ -0,0 +1,446 @@ +import { createAdapterFactory } from 'better-auth/adapters' +import { codecOf, param } from '@prisma/orm-postgres/relational-core' +import type { AdapterFactory, CleanedWhere, CustomAdapter } from 'better-auth/adapters' +import type { BetterAuthOptions } from 'better-auth' +import type { CodecRef, Expression, ScopeField } from '@prisma/orm-postgres/relational-core' +import type { OpenSaasConfig } from '@opensaas/stack-core' +import type { UnsafeSurface } from '@opensaas/stack-core/unsafe' +import { + authCollection, + authSqlTable, + type AuthCollection, + type AuthRow, + type AuthSqlFieldProxy, + type AuthSqlFunctions, +} from './surface.js' +import { applyOrmWhere, sqlWhere, type AuthFieldResolution } from './where.js' + +export { AuthModelUnreachableError } from './surface.js' +export { AuthWhereError } from './where.js' + +/** Thrown when the adapter cannot carry out an operation better-auth asked for. */ +export class AuthAdapterError extends Error { + constructor(message: string) { + super(`[@opensaas/stack-auth] ${message}`) + this.name = 'AuthAdapterError' + } +} + +/** + * Thrown when better-auth asks for a model the derived Auth lists do not carry. + * + * The registry is built by the same `deriveAuthLists` call that produced the + * lists, so a miss means better-auth is running with a plugin the OpenSaaS + * config was not built with — the table was never generated. + */ +export class AuthModelUnregisteredError extends Error { + constructor( + readonly model: string, + readonly registered: readonly string[], + ) { + super( + `[@opensaas/stack-auth] better-auth asked for model "${model}", which \`authPlugin\` ` + + `derived no list for. Registered models: ${registered.join(', ') || '(none)'}. ` + + `Pass a better-auth plugin to \`authPlugin({ betterAuthPlugins })\` so its tables ` + + `reach the generated schema.`, + ) + this.name = 'AuthModelUnregisteredError' + } +} + +/** How the adapter reaches one better-auth model on the two lanes. */ +interface ModelCoordinate { + readonly listKey: string + readonly table: string + readonly namespace: string +} + +/** What {@link opensaasAuthAdapter} needs to address the database. */ +export interface OpenSaasAuthAdapterOptions { + /** The resolved OpenSaaS config, for each derived list's table and schema. */ + config: OpenSaasConfig + /** The running context's Unsafe surface — the lanes every operation runs on. */ + unsafe: UnsafeSurface + /** better-auth model key → derived list key, from `getAuthListRegistry`. */ + registry: Record + /** + * Run `body` in one database transaction, against that transaction's own + * Unsafe surface. `consumeOne` is the caller: its resolve-then-delete pair + * has to commit or roll back as one, or a single-use token can be consumed + * twice (ADR-0060). + */ + transaction: (body: (unsafe: UnsafeSurface) => Promise) => Promise +} + +/** + * better-auth types every row-returning adapter method as answering the + * caller's own `T`, which no adapter can produce — the row comes from the + * database, not from the caller, and `T` is a free type parameter on every one + * of its method signatures. Its own reference adapters widen at this seam; + * this is the one place ours does. + */ +function asAdapterResult(row: unknown): T { + return row as T +} + +/** better-auth's `update` payload, which its own signature leaves as a free `T`. */ +function isColumnValues(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** An id as a `where` can carry it back — every Auth list is string-keyed (ADR-0048). */ +function isIdentity(value: unknown): value is string { + return typeof value === 'string' +} + +/** + * Rename a row's keys, passing through anything the mapping does not resolve. + * + * An application's own additions to a derived list (`extendUserList`) are real + * columns and reach the row, but better-auth's schema has never heard of them, + * so they travel under their own names rather than failing the read. + */ +function renameKeys( + row: Record, + rename: (key: string) => string | undefined, +): AuthRow { + const renamed: AuthRow = {} + for (const [key, value] of Object.entries(row)) { + renamed[rename(key) ?? key] = value + } + return renamed +} + +/** + * The stack-authored better-auth adapter: better-auth's own adapter factory + * over the Unsafe surface (ADR-0060). + * + * Eight of the ten methods are Collection calls on the surface's ORM lane. + * `incrementOne` and an unconditional `deleteMany` are single typed-SQL + * statements through the surface's executors, because a Collection expresses + * neither `SET n = n + δ` nor a `DELETE` with no `WHERE`. + * + * Every query runs marked as intentionally unscoped: this is auth's own + * bookkeeping, outside the Access Filter by construction (ADR-0038, ADR-0049). + * + * Known limits: + * - **No joins.** `advanced.database.joins` is refused at config time rather + * than left to the factory's silent per-model fallback. + * - **No `createSchema`.** The Auth lists derive from `getAuthTables` and the + * stack's generator emits the contract, so better-auth's CLI (`generate`, + * `migrate`) is unsupported against this adapter. + * - **No transaction option**, so the factory runs a transaction callback + * against the plain adapter with no atomicity. `consumeOne` opens its own + * transaction regardless — its at-most-one guarantee is not optional. + * - **No issuer-scoped account uniqueness.** better-auth declares the account + * identity key (`providerId` + `accountId`) as a table-level `@@unique`, + * which `deriveAuthLists` does not emit yet + * ([#986](https://github.com/OpenSaasAU/stack/issues/986)). Nothing in the + * database stops two concurrent sign-ins through the same issuer identity + * from creating two accounts; better-auth's own existence check is all that + * stands between them. + * - Errors arrive as the driver's own: the Unsafe surface is excluded from the + * stack's error normalisation (ADR-0042). + */ +export function opensaasAuthAdapter( + options: OpenSaasAuthAdapterOptions, +): AdapterFactory { + const { config, unsafe, registry, transaction } = options + + function coordinate(model: string, toModelKey: (model: string) => string): ModelCoordinate { + const listKey = registry[toModelKey(model)] + if (listKey === undefined) { + throw new AuthModelUnregisteredError(model, Object.keys(registry)) + } + const listDb = config.lists[listKey]?.db + return { listKey, table: listDb?.map ?? listKey, namespace: listDb?.schema ?? 'public' } + } + + return createAdapterFactory({ + config: { + adapterId: 'opensaas-stack', + adapterName: 'OpenSaaS Stack', + // The database mints every id: `authPlugin` pins `db.idField: 'uuid7'` + // on each list it injects, so the column carries its own default and + // better-auth must not send one of its own (ADR-0048, ADR-0060). + disableIdGeneration: true, + supportsUUIDs: true, + supportsNumericIds: false, + // Prisma's `timestamptz` codec decodes to a string at 8.0.0-rc.8, so the + // factory's own string↔Date conversion is what keeps better-auth's + // contract (it hands out `Date`s) true. + supportsDates: false, + supportsBooleans: true, + // better-auth's `json` and array field types derive to `text()` columns, + // so the factory serialises them rather than the database. + supportsJSON: false, + supportsArrays: false, + transaction: false, + }, + adapter: ({ + getDefaultModelName, + getDefaultFieldName, + getFieldName, + getFieldAttributes, + schema, + }): CustomAdapter => { + const at = (model: string): ModelCoordinate => coordinate(model, getDefaultModelName) + + const collectionFor = (model: string, lane: UnsafeSurface = unsafe): AuthCollection => { + const { namespace, listKey } = at(model) + return authCollection(lane, namespace, listKey) + } + + const toFieldKey = + (model: string) => + (column: string): string => { + try { + return getDefaultFieldName({ model, field: column }) + } catch { + return column + } + } + + const toColumn = + (model: string) => + (fieldKey: string): string | undefined => { + try { + return getFieldName({ model, field: fieldKey }) + } catch { + return undefined + } + } + + /** + * better-auth carries an `int8` column's value as a JS number, which + * Prisma's `pg/int8` codec refuses — it takes a `bigint` and nothing + * else. The attribute's own `bigint` flag is what says which columns + * those are. + */ + const isBigInt = (model: string, fieldKey: string): boolean => { + try { + return getFieldAttributes({ model, field: fieldKey }).bigint === true + } catch { + return false + } + } + + const resolveField = + (model: string) => + (column: string): AuthFieldResolution => { + const key = toFieldKey(model)(column) + return { key, isBigInt: isBigInt(model, key) } + } + + const narrow = ( + model: string, + where: readonly CleanedWhere[], + lane: UnsafeSurface = unsafe, + ): AuthCollection => applyOrmWhere(collectionFor(model, lane), where, resolveField(model)) + + /** + * The mirror of {@link inward}: only a column better-auth declares as + * `bigint` is narrowed back to a JS number, because only those were + * widened on the way in. Narrowing every `bigint` would silently mangle + * an application's own `int8` column above `Number.MAX_SAFE_INTEGER`. + */ + const outward = (model: string, row: AuthRow | null): AuthRow | null => { + if (row === null) return null + const narrowed: AuthRow = {} + for (const [key, value] of Object.entries(row)) { + narrowed[key] = typeof value === 'bigint' && isBigInt(model, key) ? Number(value) : value + } + return renameKeys(narrowed, toColumn(model)) + } + + const inward = (model: string, data: Record): AuthRow => { + const widened: AuthRow = {} + for (const [column, value] of Object.entries(data)) { + const field = resolveField(model)(column) + widened[field.key] = field.isBigInt && typeof value === 'number' ? BigInt(value) : value + } + return widened + } + + /** + * Every column better-auth declares for a model, for a `RETURNING` list. + * + * Named the way the SQL lane addresses them — by column, not by field + * key — which is also the name better-auth expects a returned row to + * carry, so the row needs no renaming on the way back. + */ + const columnsOf = (model: string): string[] => { + const fields = schema[getDefaultModelName(model)]?.fields ?? {} + return ['id', ...Object.keys(fields).map((field) => getFieldName({ model, field }))] + } + + const project = ( + collection: AuthCollection, + model: string, + select?: string[], + ): AuthCollection => + !select || select.length === 0 + ? collection + : collection.select(...select.map(toFieldKey(model))) + + /** A column's own codec, so a raw fragment carries the type the column declares. */ + const codecFor = ( + fields: AuthSqlFieldProxy, + table: string, + column: string, + ): { target: Expression; codec: CodecRef } => { + const target = fields[column] + const codec = target === undefined ? undefined : codecOf(target) + if (target === undefined || codec === undefined) { + throw new AuthAdapterError( + `the typed-SQL lane exposes no column "${column}" on "${table}".`, + ) + } + return { target, codec } + } + + return { + async create({ model, data, select }) { + const created = await project(collectionFor(model), model, select).create( + inward(model, data), + ) + return asAdapterResult(outward(model, created)) + }, + + async findOne({ model, where, select }) { + const found = await project(narrow(model, where), model, select).first() + return asAdapterResult(outward(model, found)) + }, + + async findMany({ model, where, limit, select, sortBy, offset }) { + let collection = project(narrow(model, where ?? []), model, select) + if (sortBy) { + const fieldKey = toFieldKey(model)(sortBy.field) + const descending = sortBy.direction === 'desc' + collection = collection.orderBy((accessor) => { + const field = accessor[fieldKey] + if (!field) { + throw new AuthAdapterError( + `the ORM lane exposes no field "${fieldKey}" on "${model}" to sort on.`, + ) + } + return descending ? field.desc() : field.asc() + }) + } + if (typeof offset === 'number') collection = collection.offset(offset) + const rows = await collection.limit(limit).all() + return asAdapterResult(rows.map((row) => outward(model, row))) + }, + + async count({ model, where }) { + const counted = await narrow(model, where ?? []).aggregate((aggregate) => ({ + n: aggregate.count(), + })) + return counted.n + }, + + async update({ model, where, update }) { + if (!isColumnValues(update)) { + throw new AuthAdapterError( + `update on "${model}" expects an object of column values, got ${typeof update}.`, + ) + } + const updated = await narrow(model, where).update(inward(model, update)) + return asAdapterResult(outward(model, updated)) + }, + + async updateMany({ model, where, update }) { + return await narrow(model, where).updateAndCount(inward(model, update)) + }, + + async delete({ model, where }) { + await narrow(model, where).delete() + }, + + async deleteMany({ model, where }) { + if (where.length > 0) return await narrow(model, where).deleteAndCount() + + // A Collection's `deleteAndCount` is checked against a prior + // `.where()`, so the unconditional delete better-auth's own test + // cleanup issues has to be the typed-SQL statement instead. + const { namespace, table } = at(model) + const stats = await unsafe.execute( + authSqlTable(unsafe, namespace, table).delete().build(), + ) + return stats.affectedRows + }, + + async consumeOne({ model, where }) { + // The ORM lane lowers a single-row delete to two statements: a SELECT + // that resolves the identity, then the DELETE. Unbracketed, two + // racing consumers both resolve the same row and both are answered + // it, so a magic-link or OTP token is accepted twice. The pair runs + // in one transaction and the row is only handed back when the DELETE + // itself claimed it — the shape better-auth's own reference adapter + // uses (ADR-0060). + return await transaction(async (lane) => { + const target = await narrow(model, where, lane).first() + if (target === null) return asAdapterResult(null) + + const identity = target.id + if (!isIdentity(identity)) { + throw new AuthAdapterError( + `consumeOne on "${model}" resolved a row with no string id to delete by.`, + ) + } + + const claimed = await narrow( + model, + [ + { + field: 'id', + operator: 'eq', + value: identity, + connector: 'AND', + mode: 'sensitive', + }, + ], + lane, + ).deleteAndCount() + return asAdapterResult(claimed > 0 ? outward(model, target) : null) + }) + }, + + async incrementOne({ model, where, increment, set }) { + const { namespace, table } = at(model) + const plan = authSqlTable(unsafe, namespace, table) + .update((fields: AuthSqlFieldProxy, fns: AuthSqlFunctions) => { + const assignments: Record> = {} + for (const [column, delta] of Object.entries(increment)) { + const field = resolveField(model)(column) + const { target, codec } = codecFor(fields, table, column) + const widened = field.isBigInt && typeof delta === 'number' ? BigInt(delta) : delta + assignments[column] = + fns.raw`${target} + ${param(widened, { codecId: codec.codecId })}`.returns({ + codecId: codec.codecId, + nullable: true, + }) + } + for (const [column, value] of Object.entries(set ?? {})) { + const field = resolveField(model)(column) + const { codec } = codecFor(fields, table, column) + const widened = field.isBigInt && typeof value === 'number' ? BigInt(value) : value + assignments[column] = + fns.raw`${param(widened, { codecId: codec.codecId })}`.returns({ + codecId: codec.codecId, + nullable: true, + }) + } + return assignments + }) + .where((fields, fns) => sqlWhere(fields, fns, where, resolveField(model))) + .returning(...columnsOf(model)) + .build() + + const updated = await unsafe.query(plan).first() + return asAdapterResult(outward(model, updated)) + }, + } + }, + }) +} diff --git a/packages/auth/src/adapter/surface.ts b/packages/auth/src/adapter/surface.ts new file mode 100644 index 00000000..783f4026 --- /dev/null +++ b/packages/auth/src/adapter/surface.ts @@ -0,0 +1,198 @@ +import type { + AnyExpression, + CodecRef, + Expression, + OrderByItem, + RawSqlBuilder, + ScopeField, + SqlOrmPlan, +} from '@prisma/orm-postgres/relational-core' +import type { UnsafeSurface } from '@opensaas/stack-core/unsafe' + +/** + * A row as the Auth adapter handles it: keyed by field key while it is on a + * Collection, by better-auth's column name once it is back at the factory. + */ +export type AuthRow = Record + +/** Every value better-auth's `Where` vocabulary can carry into a predicate. */ +export type AuthValue = string | number | bigint | boolean | Date | null + +/** + * The column operators the adapter reaches for, as Prisma's field proxy + * declares them. `ilike` is contributed by the Postgres target's operation + * types rather than by the shared set — it is there because the active target + * is Postgres. + */ +export interface AuthFieldProxy { + eq(value: AuthValue): AnyExpression + neq(value: AuthValue): AnyExpression + gt(value: AuthValue): AnyExpression + gte(value: AuthValue): AnyExpression + lt(value: AuthValue): AnyExpression + lte(value: AuthValue): AnyExpression + like(pattern: string): AnyExpression + ilike(pattern: string): AnyExpression + in(values: readonly AuthValue[]): AnyExpression + notIn(values: readonly AuthValue[]): AnyExpression + isNull(): AnyExpression + isNotNull(): AnyExpression + asc(): OrderByItem + desc(): OrderByItem +} + +export type AuthModelAccessor = Record + +/** Prisma's aggregate builder, narrowed to the one aggregate `count` needs. */ +export interface AuthAggregateBuilder { + count(): number +} + +/** + * The part of a Prisma `Collection` the adapter drives. + * + * Declared structurally rather than imported: the Unsafe surface types its ORM + * lane as `object` — it is built from a structural client, not from the app's + * emitted contract — and the adapter addresses models by a name resolved at + * runtime, so there is no contract-typed collection to name here. + */ +export interface AuthCollection { + where(fn: (model: AuthModelAccessor) => AnyExpression): AuthCollection + select(...fields: string[]): AuthCollection + orderBy(fn: (model: AuthModelAccessor) => OrderByItem): AuthCollection + limit(n: number): AuthCollection + offset(n: number): AuthCollection + all(): PromiseLike + first(): Promise + aggregate(fn: (aggregate: AuthAggregateBuilder) => { n: number }): Promise<{ n: number }> + create(data: AuthRow): Promise + update(data: AuthRow): Promise + updateAndCount(data: AuthRow): Promise + delete(): Promise + deleteAndCount(): Promise +} + +/** Prisma's SQL-builder expression namespace, narrowed to what the two typed-SQL methods need. */ +export interface AuthSqlFunctions { + eq(a: Expression, b: AuthValue): Expression + ne(a: Expression, b: AuthValue): Expression + gt(a: Expression, b: AuthValue): Expression + gte(a: Expression, b: AuthValue): Expression + lt(a: Expression, b: AuthValue): Expression + lte(a: Expression, b: AuthValue): Expression + and(...exprs: Expression[]): Expression + or(...exprs: Expression[]): Expression + in(expr: Expression, values: readonly AuthValue[]): Expression + notIn(expr: Expression, values: readonly AuthValue[]): Expression + ilike(expr: Expression, pattern: string): Expression + readonly raw: (strings: TemplateStringsArray, ...values: unknown[]) => RawSqlBuilder +} + +export type AuthSqlFieldProxy = Record> + +export interface AuthSqlStatement { + where( + expr: (fields: AuthSqlFieldProxy, fns: AuthSqlFunctions) => Expression, + ): AuthSqlStatement + returning(...columns: string[]): AuthSqlStatement + build(): SqlOrmPlan +} + +/** The part of Prisma's table-shaped SQL builder the two typed-SQL methods use. */ +export interface AuthSqlTable { + update( + set: ( + fields: AuthSqlFieldProxy, + fns: AuthSqlFunctions, + ) => Record>, + ): AuthSqlStatement + delete(): AuthSqlStatement +} + +/** A column's codec, as `codecOf` reports it for a field-proxy expression. */ +export type AuthCodecRef = CodecRef + +/** + * Thrown when the Unsafe surface's lanes carry nothing usable at the + * coordinate the Auth adapter derived for a better-auth model. + * + * The coordinate comes from the derived Auth lists, so a miss means the + * running client and the config disagree about what the database holds — a + * generation or wiring fault, reported rather than run against `undefined`. + */ +export class AuthModelUnreachableError extends Error { + constructor( + readonly lane: 'orm' | 'sql', + readonly namespace: string, + readonly entity: string, + ) { + super( + `[@opensaas/stack-auth] The Unsafe surface exposes no ` + + `${lane === 'orm' ? 'collection' : 'table'} at ${lane}."${namespace}"."${entity}". ` + + `The Auth adapter addresses the lists \`authPlugin\` derives, so re-run ` + + `\`opensaas generate\` and confirm the emitted contract carries them.`, + ) + this.name = 'AuthModelUnreachableError' + } +} + +function reach(container: unknown, key: string): unknown { + if (container === null || (typeof container !== 'object' && typeof container !== 'function')) { + return undefined + } + return Reflect.get(container, key) +} + +function hasMethods(value: unknown, methods: readonly string[]): boolean { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false + return methods.every((method) => typeof Reflect.get(value, method) === 'function') +} + +function isCollection(value: unknown): value is AuthCollection { + return hasMethods(value, ['where', 'select', 'all', 'first', 'create', 'aggregate']) +} + +function isSqlTable(value: unknown): value is AuthSqlTable { + return hasMethods(value, ['update', 'delete']) +} + +/** + * Resolve one lane's entry for a model, namespace coordinate first. + * + * Prisma keys both lanes by namespace and keeps a flat alias for names that + * are unique across the contract. The coordinate is the form that always + * resolves, so it is tried first and the flat form is the fallback. + */ +function resolve( + lane: unknown, + namespace: string, + entity: string, + is: (value: unknown) => value is T, +): T | undefined { + const namespaced = reach(reach(lane, namespace), entity) + if (is(namespaced)) return namespaced + const flat = reach(lane, entity) + return is(flat) ? flat : undefined +} + +/** The marked Collection for a list, off the Unsafe surface's ORM lane. */ +export function authCollection( + unsafe: UnsafeSurface, + namespace: string, + listKey: string, +): AuthCollection { + const collection = resolve(unsafe.orm, namespace, listKey, isCollection) + if (!collection) throw new AuthModelUnreachableError('orm', namespace, listKey) + return collection +} + +/** The typed-SQL builder for a list's table, off the Unsafe surface's SQL lane. */ +export function authSqlTable( + unsafe: UnsafeSurface, + namespace: string, + table: string, +): AuthSqlTable { + const builder = resolve(unsafe.sql, namespace, table, isSqlTable) + if (!builder) throw new AuthModelUnreachableError('sql', namespace, table) + return builder +} diff --git a/packages/auth/src/adapter/where.ts b/packages/auth/src/adapter/where.ts new file mode 100644 index 00000000..a567b997 --- /dev/null +++ b/packages/auth/src/adapter/where.ts @@ -0,0 +1,288 @@ +import { not, or } from '@prisma/orm-postgres/orm-client' +import { param } from '@prisma/orm-postgres/relational-core' +import { + likeContainsPattern, + likeEndsWithPattern, + likeEqualsPattern, + likeStartsWithPattern, +} from '@opensaas/stack-core/internal' +import type { AnyExpression, Expression, ScopeField } from '@prisma/orm-postgres/relational-core' +import type { CleanedWhere } from 'better-auth/adapters' +import type { + AuthCollection, + AuthFieldProxy, + AuthModelAccessor, + AuthSqlFieldProxy, + AuthSqlFunctions, + AuthValue, +} from './surface.js' + +/** + * Thrown when a `where` clause names something the lane cannot address, or + * carries a value the operator cannot take. better-auth validates neither + * before handing the clause over, so this is where the mismatch is named. + */ +export class AuthWhereError extends Error { + constructor(message: string) { + super(`[@opensaas/stack-auth] ${message}`) + this.name = 'AuthWhereError' + } +} + +/** + * How one better-auth column resolves against the model being queried: the + * field key the ORM lane is keyed by, and whether the column's codec is an + * `int8` — better-auth carries those values as JS numbers, Prisma's codec + * refuses anything but a `bigint`. + */ +export interface AuthFieldResolution { + readonly key: string + readonly isBigInt: boolean +} + +function widen(value: AuthValue, isBigInt: boolean): AuthValue { + return isBigInt && typeof value === 'number' ? BigInt(value) : value +} + +function scalar(clause: CleanedWhere, isBigInt = false): AuthValue { + if (Array.isArray(clause.value)) { + throw new AuthWhereError( + `operator "${clause.operator}" on "${clause.field}" takes a single value, got an array.`, + ) + } + return widen(clause.value, isBigInt) +} + +function list(clause: CleanedWhere, isBigInt = false): readonly AuthValue[] { + if (!Array.isArray(clause.value)) { + throw new AuthWhereError( + `operator "${clause.operator}" on "${clause.field}" takes an array, got ${typeof clause.value}.`, + ) + } + return clause.value.map((entry) => widen(entry, isBigInt)) +} + +function pattern(clause: CleanedWhere, build: (value: string) => string): string { + if (typeof clause.value !== 'string') { + throw new AuthWhereError( + `operator "${clause.operator}" on "${clause.field}" takes a string, got ${typeof clause.value}.`, + ) + } + return build(clause.value) +} + +function isInsensitive(clause: CleanedWhere): boolean { + return clause.mode === 'insensitive' && typeof clause.value === 'string' +} + +/** + * An empty list answers `undefined` rather than an empty disjunct list: `or()` + * with no arguments is not the `FALSE` the operator means, while the lanes' + * own `in([])` / `notIn([])` are. + */ +function insensitiveList(clause: CleanedWhere): readonly string[] | undefined { + if (clause.mode !== 'insensitive' || !Array.isArray(clause.value)) return undefined + if (clause.value.length === 0) return undefined + const strings = clause.value.filter((entry): entry is string => typeof entry === 'string') + return strings.length === clause.value.length ? strings : undefined +} + +/** One better-auth clause as an ORM-lane predicate on `field`. */ +function ormClause(field: AuthFieldProxy, clause: CleanedWhere, isBigInt: boolean): AnyExpression { + switch (clause.operator) { + case 'eq': + if (clause.value === null) return field.isNull() + if (isInsensitive(clause)) return field.ilike(pattern(clause, likeEqualsPattern)) + return field.eq(scalar(clause, isBigInt)) + case 'ne': { + if (clause.value === null) return field.isNotNull() + if (isInsensitive(clause)) return not(field.ilike(pattern(clause, likeEqualsPattern))) + return field.neq(scalar(clause, isBigInt)) + } + case 'lt': + return field.lt(scalar(clause, isBigInt)) + case 'lte': + return field.lte(scalar(clause, isBigInt)) + case 'gt': + return field.gt(scalar(clause, isBigInt)) + case 'gte': + return field.gte(scalar(clause, isBigInt)) + case 'in': { + const insensitive = insensitiveList(clause) + if (insensitive) return or(...insensitive.map((v) => field.ilike(likeEqualsPattern(v)))) + return field.in(list(clause, isBigInt)) + } + case 'not_in': { + const insensitive = insensitiveList(clause) + if (insensitive) return not(or(...insensitive.map((v) => field.ilike(likeEqualsPattern(v))))) + return field.notIn(list(clause, isBigInt)) + } + case 'contains': + return isInsensitive(clause) + ? field.ilike(pattern(clause, likeContainsPattern)) + : field.like(pattern(clause, likeContainsPattern)) + case 'starts_with': + return isInsensitive(clause) + ? field.ilike(pattern(clause, likeStartsWithPattern)) + : field.like(pattern(clause, likeStartsWithPattern)) + case 'ends_with': + return isInsensitive(clause) + ? field.ilike(pattern(clause, likeEndsWithPattern)) + : field.like(pattern(clause, likeEndsWithPattern)) + } +} + +function proxyFor( + model: AuthModelAccessor, + fieldKey: string, + clause: CleanedWhere, +): AuthFieldProxy { + const field = model[fieldKey] + if (!field) { + throw new AuthWhereError( + `the ORM lane exposes no field "${fieldKey}" (better-auth column "${clause.field}") on this model.`, + ) + } + return field +} + +/** + * Narrow `collection` by better-auth's flat clause list. + * + * better-auth's connectors group rather than nest: every `AND` clause holds, + * and the `OR` clauses hold as one disjunction alongside them. That is the + * shape its own reference adapters build, so it is the shape here. + */ +export function applyOrmWhere( + collection: AuthCollection, + clauses: readonly CleanedWhere[], + resolve: (column: string) => AuthFieldResolution, +): AuthCollection { + const predicate = (model: AuthModelAccessor, clause: CleanedWhere): AnyExpression => { + const field = resolve(clause.field) + return ormClause(proxyFor(model, field.key, clause), clause, field.isBigInt) + } + + let narrowed = collection + const disjuncts: CleanedWhere[] = [] + + for (const clause of clauses) { + if (clause.connector === 'OR') { + disjuncts.push(clause) + continue + } + narrowed = narrowed.where((model) => predicate(model, clause)) + } + + if (disjuncts.length === 1) { + const only = disjuncts[0] + return narrowed.where((model) => predicate(model, only)) + } + if (disjuncts.length > 1) { + return narrowed.where((model) => or(...disjuncts.map((clause) => predicate(model, clause)))) + } + return narrowed +} + +const BOOLEAN_CODEC = { codecId: 'pg/bool@1', nullable: false } as const + +/** + * The codec a LIKE/ILIKE pattern binds under. Postgres compares the pattern as + * text whatever the column's own type is, and Prisma's raw builder needs the + * codec named where it cannot infer one from the JS value alone. + */ +const PATTERN_CODEC = { codecId: 'pg/text@1' } as const + +/** + * One better-auth clause as a typed-SQL predicate. + * + * `like` has no builtin in Prisma's `fns` namespace — the Postgres target + * contributes `ilike` and nothing else, and there is no `not` — so a + * case-sensitive pattern and a negated insensitive one go through `fns.raw`, + * which is the documented seam for an expression fragment the builder does not + * model. The pattern itself is a bound parameter, never template text. + */ +function sqlClause( + column: Expression, + fns: AuthSqlFunctions, + clause: CleanedWhere, + isBigInt: boolean, +): Expression { + const bound = (build: (value: string) => string) => param(pattern(clause, build), PATTERN_CODEC) + + const like = (build: (value: string) => string): Expression => + fns.raw`${column} LIKE ${bound(build)}`.returns(BOOLEAN_CODEC) + + switch (clause.operator) { + case 'eq': + return isInsensitive(clause) + ? fns.ilike(column, pattern(clause, likeEqualsPattern)) + : fns.eq(column, scalar(clause, isBigInt)) + case 'ne': + return isInsensitive(clause) + ? fns.raw`${column} NOT ILIKE ${bound(likeEqualsPattern)}`.returns(BOOLEAN_CODEC) + : fns.ne(column, scalar(clause, isBigInt)) + case 'lt': + return fns.lt(column, scalar(clause, isBigInt)) + case 'lte': + return fns.lte(column, scalar(clause, isBigInt)) + case 'gt': + return fns.gt(column, scalar(clause, isBigInt)) + case 'gte': + return fns.gte(column, scalar(clause, isBigInt)) + case 'in': + return fns.in(column, list(clause, isBigInt)) + case 'not_in': + return fns.notIn(column, list(clause, isBigInt)) + case 'contains': + return isInsensitive(clause) + ? fns.ilike(column, pattern(clause, likeContainsPattern)) + : like(likeContainsPattern) + case 'starts_with': + return isInsensitive(clause) + ? fns.ilike(column, pattern(clause, likeStartsWithPattern)) + : like(likeStartsWithPattern) + case 'ends_with': + return isInsensitive(clause) + ? fns.ilike(column, pattern(clause, likeEndsWithPattern)) + : like(likeEndsWithPattern) + } +} + +/** + * better-auth's clause list as one typed-SQL predicate, grouped the same way. + * + * The two lanes address a column by different names because Prisma keys them + * differently: a Collection by the contract's model and field names (the + * derived Auth lists' own keys), the SQL builder by the table and column names + * the contract maps those to — which is what better-auth already hands over. + */ +export function sqlWhere( + fields: AuthSqlFieldProxy, + fns: AuthSqlFunctions, + clauses: readonly CleanedWhere[], + resolve: (column: string) => AuthFieldResolution, +): Expression { + const conjuncts: Expression[] = [] + const disjuncts: Expression[] = [] + + for (const clause of clauses) { + const column = fields[clause.field] + if (!column) { + throw new AuthWhereError( + `the typed-SQL lane exposes no column "${clause.field}" on this table.`, + ) + } + const predicate = sqlClause(column, fns, clause, resolve(clause.field).isBigInt) + if (clause.connector === 'OR') disjuncts.push(predicate) + else conjuncts.push(predicate) + } + + if (disjuncts.length > 0) { + conjuncts.push(disjuncts.length === 1 ? disjuncts[0] : fns.or(...disjuncts)) + } + if (conjuncts.length === 0) { + throw new AuthWhereError('a guarded update needs at least one where clause.') + } + return conjuncts.length === 1 ? conjuncts[0] : fns.and(...conjuncts) +} diff --git a/packages/auth/src/config/derive-auth-lists.ts b/packages/auth/src/config/derive-auth-lists.ts index 93a99935..80d830be 100644 --- a/packages/auth/src/config/derive-auth-lists.ts +++ b/packages/auth/src/config/derive-auth-lists.ts @@ -56,6 +56,12 @@ export type DerivedAuthLists = { /** The derived list configs, keyed by their derived list keys — base models and plugin tables alike. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo lists: Record> + /** + * Every better-auth model key — base models and plugin tables alike — mapped + * to the list key it was derived under. The Auth adapter resolves a model + * through this rather than re-deriving the naming rules (ADR-0060). + */ + registry: Record } /** better-auth's own fixed base model keys — independent of the stack's list-key overrides (`modelName`). Every other key `getAuthTables` returns is a plugin table. */ @@ -789,5 +795,5 @@ export function deriveAuthLists( }) } - return { keys, lists } + return { keys, lists, registry: Object.fromEntries(registry) } } diff --git a/packages/auth/src/config/plugin.ts b/packages/auth/src/config/plugin.ts index d117fe2b..f5cb37a1 100644 --- a/packages/auth/src/config/plugin.ts +++ b/packages/auth/src/config/plugin.ts @@ -60,7 +60,11 @@ export function authPlugin(config: AuthConfig): Plugin { // suffices for both: a list already declared by the app (or added by // an earlier iteration of this same loop) merges via `extendList`; // everything else registers via `addList`. - for (const [listName, listConfig] of Object.entries(authLists)) { + for (const [listName, derived] of Object.entries(authLists)) { + // ADR-0048's per-list pin, named for the Auth lists: every id the + // adapter hands better-auth is minted by the database, and it is the + // same strategy every other list gets. + const listConfig = { ...derived, db: { ...derived.db, idField: 'uuid7' as const } } if (context.config.lists[listName]) { // A list already exists under this derived key — merge auth fields // in only. Access control belongs to whoever owns the list (the diff --git a/packages/auth/src/lists/index.ts b/packages/auth/src/lists/index.ts index 638302cd..3b3013d9 100644 --- a/packages/auth/src/lists/index.ts +++ b/packages/auth/src/lists/index.ts @@ -109,3 +109,18 @@ export function getAuthLists( credentialFieldsConfig || {}, ).lists } + +/** + * The better-auth model key → derived list key registry for a resolved auth + * config. The Auth adapter resolves the model better-auth hands it through + * this, so the naming rules live in one place (ADR-0060). + * + * @param models - Resolved better-auth model config; defaults to the better-auth defaults + * @param plugins - The app's better-auth plugins (`authPlugin({ betterAuthPlugins })`), whose own tables are registered too + */ +export function getAuthListRegistry( + models: NormalizedAuthModels = DEFAULT_MODELS, + plugins?: BetterAuthPlugin[], +): Record { + return deriveAuthLists(models, {}, {}, plugins || []).registry +} diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts index cef660db..fa27a83d 100644 --- a/packages/auth/src/server/index.ts +++ b/packages/auth/src/server/index.ts @@ -1,9 +1,10 @@ import { betterAuth } from 'better-auth' -import { prismaAdapter } from 'better-auth/adapters/prisma' import { nextCookies } from 'better-auth/next-js' import type { Auth, BetterAuthOptions, BetterAuthPlugin } from 'better-auth' import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core' -import type { DatabaseConfig } from '@opensaas/stack-core/internal' +import type { UnsafeSurface } from '@opensaas/stack-core/unsafe' +import { opensaasAuthAdapter } from '../adapter/index.js' +import { getAuthListRegistry } from '../lists/index.js' import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js' /** @@ -49,12 +50,74 @@ function assertPluginTupleMatchesResolved( } } +/** + * Thrown when the context handed to `createAuth` carries no Unsafe surface, or + * cannot open the transaction `consumeOne` runs in. + * + * `AccessContext` deliberately does not name `unsafe` — the engine's own + * handle and the application's deliberate bypass are different things under + * different names (ADR-0038) — so the surface is read off the running request + * context and checked here rather than typed into the signature. + */ +export class AuthUnsafeSurfaceMissingError extends Error { + constructor() { + super( + '[@opensaas/stack-auth] The context passed to `createAuth()` / `buildBetterAuthOptions()` ' + + "carries no Unsafe surface. The Auth adapter runs on Prisma 8's own query lanes, so " + + 'pass the generated `rawOpensaasContext` (or a context from `getContext()`), not a ' + + 'hand-built double.', + ) + this.name = 'AuthUnsafeSurfaceMissingError' + } +} + +function isUnsafeSurface(value: unknown): value is UnsafeSurface { + if (typeof value !== 'object' || value === null) return false + return ( + typeof Reflect.get(value, 'query') === 'function' && + typeof Reflect.get(value, 'execute') === 'function' && + Reflect.get(value, 'orm') !== undefined && + Reflect.get(value, 'sql') !== undefined + ) +} + +/** + * The context's own interactive transaction, read structurally for the same + * reason `unsafe` is: `AccessContext` names neither (ADR-0038), and widening + * the public signature to reach them is what this check exists to avoid. The + * callback's context carries the transaction-bound Unsafe surface (ADR-0056). + */ +interface TransactionCapableContext { + transaction(body: (txContext: { readonly unsafe: unknown }) => Promise): Promise +} + +function isTransactionCapable(value: unknown): value is TransactionCapableContext { + return ( + typeof value === 'object' && + value !== null && + typeof Reflect.get(value, 'transaction') === 'function' + ) +} + function getDatabaseConfig( - dbConfig: DatabaseConfig, + opensaasConfig: OpenSaasConfig, + authConfig: NormalizedAuthConfig, context: AccessContext, ): BetterAuthOptions['database'] { - return prismaAdapter(context.ormHandle, { - provider: dbConfig.provider, + const unsafe = Reflect.get(context, 'unsafe') + if (!isUnsafeSurface(unsafe) || !isTransactionCapable(context)) { + throw new AuthUnsafeSurfaceMissingError() + } + + return opensaasAuthAdapter({ + config: opensaasConfig, + unsafe, + registry: getAuthListRegistry(authConfig.models, authConfig.betterAuthPlugins), + transaction: (body) => + context.transaction(async (txContext) => { + if (!isUnsafeSurface(txContext.unsafe)) throw new AuthUnsafeSurfaceMissingError() + return await body(txContext.unsafe) + }), }) } @@ -102,6 +165,34 @@ function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record[]> + first(): Promise> + aggregate(): Promise<{ n: number }> + create(): Promise> + update(): Promise> + updateAndCount(): Promise + delete(): Promise> + deleteAndCount(): Promise +} + +/** + * An Unsafe surface whose `Verification` collection resolves `row` and then + * deletes nothing — the state a consumer that lost the race observes between + * its own two statements. + */ +function lostRaceSurface(row: Record): UnsafeSurface { + const collection: CollectionDouble = { + where: () => collection, + select: () => collection, + orderBy: () => collection, + limit: () => collection, + offset: () => collection, + all: async () => [row], + first: async () => row, + aggregate: async () => ({ n: 1 }), + create: async () => row, + update: async () => row, + updateAndCount: async () => 1, + delete: async () => row, + deleteAndCount: async () => 0, + } + return { + sql: {}, + raw: {}, + orm: { public: { Verification: collection } }, + query: () => { + throw new Error('the double runs no plans') + }, + execute: () => { + throw new Error('the double runs no plans') + }, + } +} + +interface Harness { + database: TestDatabase + adapter: DBAdapter + config: OpenSaasConfig + registry: Record + betterAuthOptions: BetterAuthOptions +} + +async function standUp( + authConfig: AuthConfig, + betterAuthOptions: BetterAuthOptions, +): Promise { + const opensaasConfig: OpenSaasConfig = await defineConfig({ + plugins: [authPlugin(authConfig)], + db: { provider: 'postgresql' }, + lists: {}, + }) + const database = await createTestDatabase(opensaasConfig) + const normalized = opensaasConfig._pluginData?.auth as NormalizedAuthConfig + const context = database.context() + const registry = getAuthListRegistry(normalized.models, normalized.betterAuthPlugins) + const adapter = opensaasAuthAdapter({ + config: opensaasConfig, + unsafe: context.unsafe, + registry, + transaction: (body) => context.transaction((tx) => body(tx.unsafe)), + })(betterAuthOptions) + return { database, adapter, config: opensaasConfig, registry, betterAuthOptions } +} + +let plain: Harness +let mapped: Harness + +beforeAll(async () => { + plain = await standUp( + { + emailAndPassword: { enabled: true }, + betterAuthPlugins: [legacyIdPlugin], + extendUserList: { fields: { legacyId: bigInt() } }, + rateLimit: { enabled: true, storage: 'database' }, + }, + { rateLimit: { storage: 'database' }, plugins: [legacyIdPlugin] }, + ) + + mapped = await standUp( + { + emailAndPassword: { enabled: true }, + user: { fields: { name: 'full_name' } }, + verification: { fields: { identifier: 'ident' } }, + rateLimit: { enabled: true, storage: 'database', fields: { key: 'rate_key' } }, + }, + { + user: { fields: { name: 'full_name' } }, + verification: { fields: { identifier: 'ident' } }, + rateLimit: { storage: 'database', fields: { key: 'rate_key' } }, + }, + ) +}, BOOT) + +afterAll(async () => { + await plain?.database.close() + await mapped?.database.close() +}) + +beforeEach(async () => { + await plain.database.truncate() + await mapped.database.truncate() +}) + +async function seedToken(harness: Harness, identifier: string): Promise { + await harness.adapter.create({ + model: 'verification', + data: { identifier, value: 'token', expiresAt: new Date(Date.now() + 60_000) }, + }) +} + +describe('consumeOne is atomic', () => { + test('two racing consumers: exactly one gets the row', async () => { + await seedToken(plain, 'once') + + const consume = (): Promise<{ identifier: string } | null> => + plain.adapter.consumeOne<{ identifier: string }>({ + model: 'verification', + where: [{ field: 'identifier', value: 'once' }], + }) + + const [first, second] = await Promise.all([consume(), consume()]) + + const winners = [first, second].filter((row) => row !== null && row !== undefined) + expect(winners).toHaveLength(1) + expect(winners[0]?.identifier).toBe('once') + expect(await plain.adapter.count({ model: 'verification' })).toBe(0) + }) + + test('five racing consumers: exactly one gets the row', async () => { + await seedToken(plain, 'onlyonce') + + const consumed = await Promise.all( + Array.from({ length: 5 }, () => + plain.adapter.consumeOne({ + model: 'verification', + where: [{ field: 'identifier', value: 'onlyonce' }], + }), + ), + ) + + expect(consumed.filter((row) => row !== null && row !== undefined)).toHaveLength(1) + expect(await plain.adapter.count({ model: 'verification' })).toBe(0) + }) + + // The in-process database holds one connection, so the two transactions + // above run one after the other and the loser's own SELECT finds nothing. + // That is the outcome, not the mechanism: this drives the lane through a + // double whose SELECT resolves a row and whose DELETE claims none — the + // interleaving two overlapping transactions produce on a real pool. + test('a resolve that wins and a delete that claims nothing answers null', async () => { + const row = { id: randomUUID(), identifier: 'raced', value: 'token' } + const surface = lostRaceSurface(row) + const adapter = opensaasAuthAdapter({ + config: plain.config, + unsafe: surface, + registry: plain.registry, + transaction: (body) => body(surface), + })(plain.betterAuthOptions) + + expect( + await adapter.consumeOne({ + model: 'verification', + where: [{ field: 'identifier', value: 'raced' }], + }), + ).toBeNull() + }) + + test('answers null when nothing matches', async () => { + expect( + await plain.adapter.consumeOne({ + model: 'verification', + where: [{ field: 'identifier', value: 'never-issued' }], + }), + ).toBeNull() + }) +}) + +describe('the shipped id configuration', () => { + // The normal suite's own "not found" probes are disabled in + // `adapter-conformance.test.ts`: upstream hardcodes the id `"100000"` unless + // the options say `generateId: 'uuid'`, which is the key production refuses. + // These are those probes, with the well-formed id a uuid column takes. + test('findOne answers null for an id no row carries', async () => { + expect( + await plain.adapter.findOne({ + model: 'user', + where: [{ field: 'id', value: randomUUID() }], + }), + ).toBeNull() + }) + + test('findMany answers an empty array for an id no row carries', async () => { + expect( + await plain.adapter.findMany({ + model: 'user', + where: [{ field: 'id', value: randomUUID() }], + limit: 10, + }), + ).toEqual([]) + }) + + test('delete does not throw for an id no row carries', async () => { + await expect( + plain.adapter.delete({ model: 'user', where: [{ field: 'id', value: randomUUID() }] }), + ).resolves.toBeUndefined() + }) + + // Under `generateId: 'uuid'` — the configuration the harness used to run — + // better-auth drops a caller-supplied id even here, because `useUUIDs` plus + // `supportsUUIDs` answers `undefined` from the id transform. The shipped + // configuration writes it, so a plugin or `databaseHooks` create carrying + // its own id lands in the uuid column rather than being silently replaced. + test('a caller-supplied id is written when the caller forces it', async () => { + const id = randomUUID() + const created = await plain.adapter.create< + { id: string; email: string; name: string }, + { id: string } + >({ + model: 'user', + data: { id, email: `${id}@example.com`, name: 'Ada' }, + forceAllowId: true, + }) + expect(created.id).toBe(id) + }) +}) + +describe('both lanes address a mapped column the same way', () => { + test('the ORM lane resolves a mapped field', async () => { + const email = `${randomUUID()}@example.com` + await mapped.adapter.create({ model: 'user', data: { email, name: 'Ada' } }) + + const found = await mapped.adapter.findOne<{ name: string }>({ + model: 'user', + where: [{ field: 'name', value: 'Ada' }], + }) + expect(found?.name).toBe('Ada') + }) + + test('the typed-SQL lane resolves a mapped field', async () => { + await mapped.adapter.create({ + model: 'rateLimit', + data: { key: 'ip:9', count: 1, lastRequest: 10 }, + }) + + const bumped = await mapped.adapter.incrementOne<{ count: number }>({ + model: 'rateLimit', + where: [{ field: 'key', value: 'ip:9' }], + increment: { count: 2 }, + set: { lastRequest: 20 }, + }) + expect(bumped?.count).toBe(3) + }) + + test('consumeOne resolves a mapped field', async () => { + await mapped.adapter.create({ + model: 'verification', + data: { + identifier: 'mapped-once', + value: 'token', + expiresAt: new Date(Date.now() + 60_000), + }, + }) + + const consumed = await mapped.adapter.consumeOne<{ identifier: string }>({ + model: 'verification', + where: [{ field: 'identifier', value: 'mapped-once' }], + }) + expect(consumed?.identifier).toBe('mapped-once') + expect(await mapped.adapter.count({ model: 'verification' })).toBe(0) + }) +}) + +describe('values crossing the adapter', () => { + test('an undeclared int8 column keeps its precision', async () => { + const beyondSafe = 9007199254740993n + const email = `${randomUUID()}@example.com` + const created = await plain.adapter.create< + { email: string; name: string; legacyId: bigint }, + { id: string; legacyId: unknown } + >({ model: 'user', data: { email, name: 'Ada', legacyId: beyondSafe } }) + + expect(created.legacyId).toBe(beyondSafe) + + const found = await plain.adapter.findOne<{ legacyId: unknown }>({ + model: 'user', + where: [{ field: 'id', value: created.id }], + }) + expect(found?.legacyId).toBe(beyondSafe) + }) + + test('a declared bigint column still answers a number', async () => { + await plain.adapter.create({ + model: 'rateLimit', + data: { key: 'ip:big', count: 1, lastRequest: 1234 }, + }) + + const found = await plain.adapter.findOne<{ lastRequest: number }>({ + model: 'rateLimit', + where: [{ field: 'key', value: 'ip:big' }], + }) + expect(found?.lastRequest).toBe(1234) + }) +}) + +describe('where lowering', () => { + test('an insensitive pattern matches SQL metacharacters literally', async () => { + const literal = `100% ' OR 1=1 -- _x\\y` + await plain.adapter.create({ + model: 'user', + data: { email: 'meta@example.com', name: literal }, + }) + await plain.adapter.create({ + model: 'user', + data: { email: 'other@example.com', name: '100 OR 1=1 abc' }, + }) + + const matched = await plain.adapter.findMany<{ name: string }>({ + model: 'user', + where: [{ field: 'name', value: literal, operator: 'contains' }], + limit: 10, + }) + expect(matched.map((row) => row.name)).toEqual([literal]) + }) + + test('a case-sensitive pattern on the typed-SQL lane binds its metacharacters', async () => { + await plain.adapter.create({ + model: 'rateLimit', + data: { key: `ip:100% ' OR 1=1 --`, count: 1, lastRequest: 10 }, + }) + await plain.adapter.create({ + model: 'rateLimit', + data: { key: 'ip:1000', count: 1, lastRequest: 10 }, + }) + + const bumped = await plain.adapter.incrementOne<{ key: string; count: number }>({ + model: 'rateLimit', + where: [{ field: 'key', value: `100% ' OR 1=1 --`, operator: 'contains' }], + increment: { count: 1 }, + }) + expect(bumped?.key).toBe(`ip:100% ' OR 1=1 --`) + expect(bumped?.count).toBe(2) + }) + + test('an empty insensitive `in` matches nothing rather than throwing', async () => { + await plain.adapter.create({ model: 'user', data: { email: 'in@example.com', name: 'Ada' } }) + + expect( + await plain.adapter.findMany({ + model: 'user', + where: [{ field: 'name', value: [], operator: 'in', mode: 'insensitive' }], + limit: 10, + }), + ).toEqual([]) + }) + + test('an empty insensitive `not_in` matches everything rather than throwing', async () => { + await plain.adapter.create({ model: 'user', data: { email: 'notin@example.com', name: 'Ada' } }) + + const rows = await plain.adapter.findMany({ + model: 'user', + where: [{ field: 'name', value: [], operator: 'not_in', mode: 'insensitive' }], + limit: 10, + }) + expect(rows).toHaveLength(1) + }) + + test('a null guard on the typed-SQL lane compares with IS NULL', async () => { + await plain.adapter.create({ + model: 'verification', + data: { identifier: 'guarded', value: 'token', expiresAt: new Date(Date.now() + 60_000) }, + }) + await plain.adapter.create({ + model: 'rateLimit', + data: { key: 'ip:null-guard', count: 1, lastRequest: 10 }, + }) + + const guarded = await plain.adapter.incrementOne<{ count: number }>({ + model: 'rateLimit', + where: [ + { field: 'key', value: 'ip:null-guard' }, + { field: 'count', operator: 'ne', value: null }, + ], + increment: { count: 1 }, + }) + expect(guarded?.count).toBe(2) + + const blocked = await plain.adapter.incrementOne({ + model: 'rateLimit', + where: [ + { field: 'key', value: 'ip:null-guard' }, + { field: 'count', operator: 'eq', value: null }, + ], + increment: { count: 1 }, + }) + expect(blocked).toBeNull() + }) +}) diff --git a/packages/auth/tests/adapter-conformance.test.ts b/packages/auth/tests/adapter-conformance.test.ts new file mode 100644 index 00000000..4d8f6cbf --- /dev/null +++ b/packages/auth/tests/adapter-conformance.test.ts @@ -0,0 +1,219 @@ +// better-auth's own adapter conformance suites, run against the stack-authored +// Auth adapter over the Test context's in-process Postgres (ADR-0057, +// ADR-0060). Upstream's definition of correct, run against our translation: a +// better-auth release that adds another mandatory method fails a test here +// rather than a login in production. + +import { + caseInsensitiveTestSuite, + enableJoinTests, + normalTestSuite, + testAdapter, + uuidTestSuite, +} from '@better-auth/test-utils/adapter' +import { randomUUID } from 'node:crypto' +import { getAuthTables } from 'better-auth/db' +import { createTestDatabase, type TestDatabase } from '@opensaas/stack-core/testing' +import { config as defineConfig } from '@opensaas/stack-core' +import type { OpenSaasConfig } from '@opensaas/stack-core' +import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth' +import type { DBFieldAttribute } from 'better-auth/db' +import { authPlugin } from '../src/config/plugin.js' +import { getAuthListRegistry } from '../src/lists/index.js' +import type { NormalizedAuthConfig } from '../src/config/types.js' +import { opensaasAuthAdapter } from '../src/adapter/index.js' +import type { AuthConfig, AuthModelConfig } from '../src/config/types.js' + +// numberId is not run: the Auth lists are string-keyed by construction — +// `authPlugin` pins `db.idField: 'uuid7'` on every list it injects, and the +// adapter declares `supportsNumericIds: false` (ADR-0048, ADR-0060). +// +// joins is not run: the adapter implements no joins, and +// `advanced.database.joins` is refused at config time rather than left to +// better-auth's silent per-model fallback (ADR-0060). See +// `passthrough-keys.test.ts` for the refusal. + +/** + * Tests the adapter does not answer to, and why. + * + * - Every `join` test: the adapter implements none, and the flag that would + * turn better-auth's own fallback on is refused at config time (ADR-0060). + * - `generateId`: the database mints every auth id, so an app-supplied + * generator is ignored here and refused at config time (ADR-0048). + * - The issuer-scoped account key: better-auth declares that `@@unique` + * table-level, which `deriveAuthLists` does not yet emit (#986). A schema + * gap, not an adapter one — and a production one, stated as a known limit on + * `opensaasAuthAdapter` rather than only here. + * - The nullable foreign key: a `deriveAuthLists` gap tracked as + * [#1222](https://github.com/OpenSaasAU/stack/issues/1222), not an adapter + * one. + */ +const NOT_IMPLEMENTED: Record = { + ...Object.fromEntries(Object.keys(enableJoinTests).map((name) => [name, true])), + 'create - should use generateId if provided': true, + 'create - should enforce the issuer-scoped account identity key': true, + 'create - should return null for nullable foreign keys': true, +} + +/** + * The suite's own "no such row" probes, which are unrunnable against a `uuid` + * id column outside the `uuid` suite. + * + * Each hardcodes the id `"100000"` unless the running options say + * `advanced.database.generateId: 'uuid'` — the key production refuses — and + * Postgres rejects a malformed uuid outright rather than answering not-found. + * The `uuid` suite declares that key itself and runs them; the shipped + * configuration's own versions, with a well-formed id, are in + * `adapter-behaviour.test.ts`. + */ +const HARDCODED_NON_UUID_ID: Record = { + 'findOne - should not throw on record not found': true, + 'findMany - should return an empty array when no models are found': true, + 'delete - should not throw on record not found': true, +} + +const BASE_MODELS = ['user', 'session', 'account', 'verification'] as const + +type BaseModel = (typeof BASE_MODELS)[number] + +function modelOptionsOf(options: BetterAuthOptions, model: BaseModel): AuthModelConfig { + const declared = options[model] + const modelName = declared && 'modelName' in declared ? declared.modelName : undefined + const fields = declared && 'fields' in declared ? declared.fields : undefined + return { + ...(modelName !== undefined ? { tableName: modelName } : {}), + ...(fields !== undefined ? { fields } : {}), + } +} + +/** + * better-auth's `additionalFields`, as a plugin's own schema extension. + * + * `deriveAuthLists` reads better-auth's resolved tables through the model + * config and the plugin list; `additionalFields` reaches `getAuthTables` only + * through `options`, which the derivation does not take. A plugin's `schema` + * merges into the same resolved tables, so this is the seam that carries the + * suite's ad-hoc columns into the generated schema. + */ +function additionalFieldsPlugin(options: BetterAuthOptions): BetterAuthPlugin | undefined { + const schema: Record }> = {} + for (const model of BASE_MODELS) { + const declared = options[model] + const additional = + declared && 'additionalFields' in declared ? declared.additionalFields : undefined + if (additional && Object.keys(additional).length > 0) { + schema[model] = { fields: { ...additional } } + } + } + if (Object.keys(schema).length === 0) return undefined + return { id: 'conformance-additional-fields', schema } +} + +function authConfigFor(options: BetterAuthOptions): AuthConfig { + const plugins = [...(options.plugins ?? [])] + const additional = additionalFieldsPlugin(options) + if (additional) plugins.push(additional) + + return { + emailAndPassword: { enabled: true }, + betterAuthPlugins: plugins, + user: modelOptionsOf(options, 'user'), + session: modelOptionsOf(options, 'session'), + account: modelOptionsOf(options, 'account'), + verification: modelOptionsOf(options, 'verification'), + } +} + +async function opensaasConfigFor(options: BetterAuthOptions): Promise { + return await defineConfig({ + plugins: [authPlugin(authConfigFor(options))], + db: { provider: 'postgresql' }, + lists: {}, + }) +} + +/** + * What the schema depends on, and nothing else. + * + * The suites call `modifyBetterAuthOptions(options, false)` for changes that + * must NOT wipe the rows already inserted, so the database is rebuilt only + * when the resolved tables actually differ. + */ +function schemaFingerprint(options: BetterAuthOptions): string { + const tables = getAuthTables(options) + return JSON.stringify( + Object.entries(tables) + .map(([key, table]) => [ + key, + table.modelName, + Object.entries(table.fields) + .map(([field, attributes]) => [ + field, + attributes.fieldName ?? field, + attributes.type, + attributes.required ?? true, + attributes.unique ?? false, + attributes.references?.model ?? null, + ]) + .sort(), + ]) + .sort(), + ) +} + +let database: TestDatabase | undefined +let opensaasConfig: OpenSaasConfig | undefined +let fingerprint: string | undefined + +async function standUp(options: BetterAuthOptions): Promise { + const next = schemaFingerprint(options) + if (database !== undefined && next === fingerprint) return + + const built = await opensaasConfigFor(options) + const rebuilt = await createTestDatabase(built) + await database?.close() + database = rebuilt + opensaasConfig = built + fingerprint = next +} + +await testAdapter({ + adapter: async (options) => { + await standUp(options) + const current = database + const built = opensaasConfig + if (!current || !built) throw new Error('The conformance database was not stood up.') + const normalized = built._pluginData?.auth as NormalizedAuthConfig + const context = current.context() + return opensaasAuthAdapter({ + config: built, + unsafe: context.unsafe, + registry: getAuthListRegistry(normalized.models, normalized.betterAuthPlugins), + transaction: (body) => context.transaction((tx) => body(tx.unsafe)), + }) + }, + runMigrations: async (options) => { + await standUp(options) + }, + // The Auth lists' ids are `uuid7` columns (ADR-0048), so the suite's own + // fixtures have to mint UUIDs rather than better-auth's default nanoid, and + // its "no such row" probes have to use a well-formed id — Postgres rejects a + // malformed one outright rather than answering not-found. This is the whole + // of what the fixtures need: `advanced.database.generateId` is the key + // `assertNoUnsupportedPassthroughKeys` refuses, so setting it here would run + // every assertion against a configuration no shipped app can have — and a + // different branch of better-auth's own `initGetIdField` from the one + // production takes. The `uuid` suite declares that key itself, upstream, as + // the mode it exists to exercise. + customIdGenerator: () => randomUUID(), + onFinish: async () => { + await database?.close() + database = undefined + fingerprint = undefined + }, + tests: [ + normalTestSuite({ disableTests: { ...NOT_IMPLEMENTED, ...HARDCODED_NON_UUID_ID } }), + uuidTestSuite({ disableTests: NOT_IMPLEMENTED }), + caseInsensitiveTestSuite(), + ], +}).then((suite) => suite.execute()) diff --git a/packages/auth/tests/adapter-tripwire.test.ts b/packages/auth/tests/adapter-tripwire.test.ts new file mode 100644 index 00000000..ce92fad3 --- /dev/null +++ b/packages/auth/tests/adapter-tripwire.test.ts @@ -0,0 +1,207 @@ +// Every statement the Auth adapter issues carries the unsafe origin. +// +// The tripwire is installed unconditionally by the Test context and has no +// warn mode, so an unmarked statement is a thrown `UnmarkedQueryError` rather +// than a soft assertion (ADR-0059). Each method below therefore proves its own +// coverage by completing at all; the recorder is what proves it for *every* +// statement a multi-statement terminal issues — a single-row ORM `update()` or +// `delete()` is two. + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { randomUUID } from 'node:crypto' +import { config as defineConfig } from '@opensaas/stack-core' +import { createPlanRecorder, createTestDatabase } from '@opensaas/stack-core/testing' +import type { PlanRecorder, TestDatabase } from '@opensaas/stack-core/testing' +import type { OpenSaasConfig } from '@opensaas/stack-core' +import type { DBAdapter } from 'better-auth/adapters' +import type { BetterAuthOptions } from 'better-auth' +import { authPlugin } from '../src/config/plugin.js' +import { getAuthListRegistry } from '../src/lists/index.js' +import { opensaasAuthAdapter } from '../src/adapter/index.js' +import type { NormalizedAuthConfig } from '../src/config/types.js' + +const BOOT = 120_000 + +const betterAuthOptions: BetterAuthOptions = { + rateLimit: { storage: 'database' }, +} + +let database: TestDatabase +let opensaasConfig: OpenSaasConfig +let recorder: PlanRecorder +let adapter: DBAdapter + +beforeAll(async () => { + recorder = createPlanRecorder() + opensaasConfig = await defineConfig({ + plugins: [ + authPlugin({ emailAndPassword: { enabled: true }, rateLimit: { storage: 'database' } }), + ], + db: { provider: 'postgresql' }, + lists: {}, + }) + database = await createTestDatabase(opensaasConfig, { middleware: [recorder.middleware] }) + const normalized = opensaasConfig._pluginData?.auth as NormalizedAuthConfig + const context = database.context() + adapter = opensaasAuthAdapter({ + config: opensaasConfig, + unsafe: context.unsafe, + registry: getAuthListRegistry(normalized.models, normalized.betterAuthPlugins), + transaction: (body) => context.transaction((tx) => body(tx.unsafe)), + })(betterAuthOptions) +}, BOOT) + +afterAll(async () => { + await database?.close() +}) + +beforeEach(async () => { + await database.truncate() + recorder.clear() +}) + +async function seedUser(email = `${randomUUID()}@example.com`): Promise<{ id: string }> { + const created = await adapter.create<{ email: string; name: string }, { id: string }>({ + model: 'user', + data: { email, name: 'Ada' }, + }) + recorder.clear() + return created +} + +function origins(): (string | undefined)[] { + return recorder.plans.map((plan) => plan.origin) +} + +describe('every adapter method runs under the unsafe origin', () => { + test('create', async () => { + await seedUser() + // seedUser clears the recorder, so replay the statement it just proved. + await adapter.create({ model: 'user', data: { email: `${randomUUID()}@x.io`, name: 'Ada' } }) + expect(recorder.plans.length).toBeGreaterThan(0) + expect(origins()).toEqual(recorder.plans.map(() => 'unsafe')) + }) + + test('findOne and findMany', async () => { + const user = await seedUser() + await adapter.findOne({ model: 'user', where: [{ field: 'id', value: user.id }] }) + await adapter.findMany({ model: 'user', where: [], limit: 10 }) + expect(recorder.plans).toHaveLength(2) + expect(origins()).toEqual(['unsafe', 'unsafe']) + }) + + test('count', async () => { + await seedUser() + expect(await adapter.count({ model: 'user' })).toBe(1) + expect(origins()).toEqual(['unsafe']) + }) + + test('update covers every statement', async () => { + const user = await seedUser() + const updated = await adapter.update<{ name: string }>({ + model: 'user', + where: [{ field: 'id', value: user.id }], + update: { name: 'Grace' }, + }) + expect(updated?.name).toBe('Grace') + // A single-row ORM update resolves the identity, then writes it. + expect(recorder.plans.length).toBeGreaterThan(1) + expect(origins()).toEqual(recorder.plans.map(() => 'unsafe')) + }) + + test('updateMany', async () => { + await seedUser() + await adapter.updateMany({ + model: 'user', + where: [{ field: 'name', value: 'Ada' }], + update: { name: 'Grace' }, + }) + expect(recorder.plans.length).toBeGreaterThan(0) + expect(origins()).toEqual(recorder.plans.map(() => 'unsafe')) + }) + + test('delete covers every statement', async () => { + const user = await seedUser() + await adapter.delete({ model: 'user', where: [{ field: 'id', value: user.id }] }) + // Two statements, not one `DELETE … RETURNING`: Prisma resolves the + // identity first (a stated known limit of the ORM lane). + expect(recorder.plans.length).toBeGreaterThan(1) + expect(origins()).toEqual(recorder.plans.map(() => 'unsafe')) + }) + + test('deleteMany with a where', async () => { + await seedUser() + expect( + await adapter.deleteMany({ model: 'user', where: [{ field: 'name', value: 'Ada' }] }), + ).toBe(1) + expect(origins()).toEqual(recorder.plans.map(() => 'unsafe')) + }) + + test('deleteMany with an empty where runs the typed-SQL statement', async () => { + await seedUser() + expect(await adapter.deleteMany({ model: 'user', where: [] })).toBe(1) + expect(recorder.plans).toHaveLength(1) + expect(recorder.plans[0].kind).toBe('delete') + expect(origins()).toEqual(['unsafe']) + }) + + test('consumeOne covers every statement', async () => { + await adapter.create({ + model: 'verification', + data: { + identifier: 'once', + value: 'token', + expiresAt: new Date(Date.now() + 60_000), + }, + }) + recorder.clear() + + const consumed = await adapter.consumeOne<{ identifier: string }>({ + model: 'verification', + where: [{ field: 'identifier', value: 'once' }], + }) + expect(consumed?.identifier).toBe('once') + expect(recorder.plans.length).toBeGreaterThan(1) + expect(origins()).toEqual(recorder.plans.map(() => 'unsafe')) + + recorder.clear() + const second = await adapter.consumeOne({ + model: 'verification', + where: [{ field: 'identifier', value: 'once' }], + }) + expect(second).toBeNull() + expect(origins()).toEqual(recorder.plans.map(() => 'unsafe')) + }) + + test('incrementOne runs one typed-SQL statement under the origin', async () => { + await adapter.create({ + model: 'rateLimit', + data: { key: 'ip:1', count: 1, lastRequest: 10 }, + }) + recorder.clear() + + const bumped = await adapter.incrementOne<{ count: number }>({ + model: 'rateLimit', + where: [{ field: 'key', value: 'ip:1' }], + increment: { count: 2 }, + set: { lastRequest: 20 }, + }) + expect(bumped?.count).toBe(3) + expect(recorder.plans).toHaveLength(1) + expect(recorder.plans[0].kind).toBe('update') + expect(origins()).toEqual(['unsafe']) + }) + + test('incrementOne answers null when the guard matches no row', async () => { + await adapter.create({ model: 'rateLimit', data: { key: 'ip:2', count: 0, lastRequest: 10 } }) + const guarded = await adapter.incrementOne({ + model: 'rateLimit', + where: [ + { field: 'key', value: 'ip:2' }, + { field: 'count', operator: 'gt', value: 0 }, + ], + increment: { count: -1 }, + }) + expect(guarded).toBeNull() + }) +}) diff --git a/packages/auth/tests/adopt-better-auth-tables.test.ts b/packages/auth/tests/adopt-better-auth-tables.test.ts index 51ac85d6..c682e528 100644 --- a/packages/auth/tests/adopt-better-auth-tables.test.ts +++ b/packages/auth/tests/adopt-better-auth-tables.test.ts @@ -152,21 +152,29 @@ describe('adoptBetterAuthTables - clean-diff adoption with better-auth default t // List keys stay prefixed (no collision with the app's own User)... expect(result.lists).toHaveProperty('AuthUser') // ...but the physical table is better-auth's own default lowercase name. - expect(result.lists.AuthUser.db).toEqual({ timestamps: true, map: 'user', schema: 'auth' }) + expect(result.lists.AuthUser.db).toEqual({ + timestamps: true, + map: 'user', + schema: 'auth', + idField: 'uuid7', + }) expect(result.lists.AuthSession.db).toEqual({ timestamps: true, map: 'session', schema: 'auth', + idField: 'uuid7', }) expect(result.lists.AuthAccount.db).toEqual({ timestamps: true, map: 'account', schema: 'auth', + idField: 'uuid7', }) expect(result.lists.AuthVerification.db).toEqual({ timestamps: true, map: 'verification', schema: 'auth', + idField: 'uuid7', }) // The app's own domain User is untouched. @@ -228,21 +236,29 @@ describe('adoptBetterAuthTables - clean-diff adoption (Auth lists ≠ app User)' // Each Auth list is pinned to its live table name (@@map) and the `auth` // schema (@@schema), with auto-timestamps preserved (ADR-0004) — exactly the // shape that diffs CLEAN against a live separate-schema better-auth install. - expect(result.lists.AuthUser.db).toEqual({ timestamps: true, map: 'AuthUser', schema: 'auth' }) + expect(result.lists.AuthUser.db).toEqual({ + timestamps: true, + map: 'AuthUser', + schema: 'auth', + idField: 'uuid7', + }) expect(result.lists.AuthSession.db).toEqual({ timestamps: true, map: 'AuthSession', schema: 'auth', + idField: 'uuid7', }) expect(result.lists.AuthAccount.db).toEqual({ timestamps: true, map: 'AuthAccount', schema: 'auth', + idField: 'uuid7', }) expect(result.lists.AuthVerification.db).toEqual({ timestamps: true, map: 'AuthVerification', schema: 'auth', + idField: 'uuid7', }) // The app's own domain User is preserved: its field shape is intact, NOT diff --git a/packages/auth/tests/passthrough-keys.test.ts b/packages/auth/tests/passthrough-keys.test.ts new file mode 100644 index 00000000..a84912b6 --- /dev/null +++ b/packages/auth/tests/passthrough-keys.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest' +import { config as defineConfig } from '@opensaas/stack-core' +import type { AccessContext, OpenSaasConfig } from '@opensaas/stack-core' +import { authPlugin } from '../src/config/plugin.js' +import { buildBetterAuthOptions } from '../src/server/index.js' +import type { AuthConfig } from '../src/config/types.js' + +async function configWith(betterAuthOptions: AuthConfig['betterAuthOptions']) { + return (await defineConfig({ + plugins: [authPlugin({ emailAndPassword: { enabled: true }, betterAuthOptions })], + db: { provider: 'postgresql' }, + lists: {}, + })) as OpenSaasConfig +} + +/** + * The guard runs before the adapter is built, so a context that carries no + * Unsafe surface is enough to reach it — and any refusal that does not fire + * shows up as the surface's own error instead. + */ +const contextWithoutSurface = {} as AccessContext + +describe('refused passthrough keys', () => { + test('advanced.database.generateId throws at config time', async () => { + const config = await configWith({ advanced: { database: { generateId: () => 'nope' } } }) + await expect(buildBetterAuthOptions(config, contextWithoutSurface)).rejects.toThrow( + /advanced\.database\.generateId` is not supported/, + ) + }) + + test('advanced.database.joins throws at config time', async () => { + const config = await configWith({ advanced: { database: { joins: true } } }) + await expect(buildBetterAuthOptions(config, contextWithoutSurface)).rejects.toThrow( + /advanced\.database\.joins` is not supported/, + ) + }) + + test('database stays refused', async () => { + const config = await configWith({ database: { dialect: 'unused' } }) + await expect(buildBetterAuthOptions(config, contextWithoutSurface)).rejects.toThrow( + /`betterAuthOptions\.database` is not supported/, + ) + }) + + test('an unrelated advanced.database key passes through', async () => { + const config = await configWith({ advanced: { database: { defaultFindManyLimit: 25 } } }) + await expect(buildBetterAuthOptions(config, contextWithoutSurface)).rejects.toThrow( + /carries no Unsafe surface/, + ) + }) +}) diff --git a/packages/auth/tests/plugin-schema-placement.test.ts b/packages/auth/tests/plugin-schema-placement.test.ts index 1d851398..3d392f51 100644 --- a/packages/auth/tests/plugin-schema-placement.test.ts +++ b/packages/auth/tests/plugin-schema-placement.test.ts @@ -32,7 +32,7 @@ describe('authPlugin - schema placement (greenfield default)', () => { // Default keys, no @@schema, no @@map. Auth lists still opt into // auto-timestamps (ADR-0004), so db carries only `timestamps: true`. - expect(result.lists.User.db).toEqual({ timestamps: true }) + expect(result.lists.User.db).toEqual({ timestamps: true, idField: 'uuid7' }) expect(result.lists.User.db?.schema).toBeUndefined() expect(result.lists.User.db?.map).toBeUndefined() expect(result.lists.Session.db?.schema).toBeUndefined() @@ -67,21 +67,29 @@ describe('authPlugin - schema placement (adopt existing auth-schema install)', ( // Auth lists land in the `auth` schema, pinned to their live table names. // Auto-timestamps stay enabled (ADR-0004) alongside the @@map + @@schema. - expect(result.lists.AuthUser.db).toEqual({ timestamps: true, map: 'AuthUser', schema: 'auth' }) + expect(result.lists.AuthUser.db).toEqual({ + timestamps: true, + map: 'AuthUser', + schema: 'auth', + idField: 'uuid7', + }) expect(result.lists.AuthSession.db).toEqual({ timestamps: true, map: 'AuthSession', schema: 'auth', + idField: 'uuid7', }) expect(result.lists.AuthAccount.db).toEqual({ timestamps: true, map: 'AuthAccount', schema: 'auth', + idField: 'uuid7', }) expect(result.lists.AuthVerification.db).toEqual({ timestamps: true, map: 'AuthVerification', schema: 'auth', + idField: 'uuid7', }) // The app's own User is left in `public` (not extended/overwritten, not in `auth`) @@ -133,7 +141,11 @@ describe('authPlugin - RateLimit list schema placement', () => { lists: {}, }) - expect(result.lists.AuthRateLimit.db).toEqual({ map: 'AuthRateLimit', schema: 'auth' }) + expect(result.lists.AuthRateLimit.db).toEqual({ + map: 'AuthRateLimit', + schema: 'auth', + idField: 'uuid7', + }) expect(result.db.schemas).toContain('auth') }) diff --git a/packages/auth/tests/server.test.ts b/packages/auth/tests/server.test.ts index ce18ad99..31d85b57 100644 --- a/packages/auth/tests/server.test.ts +++ b/packages/auth/tests/server.test.ts @@ -4,17 +4,12 @@ import type { NormalizedAuthConfig } from '../src/config/types.js' import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core' const betterAuthMock = vi.fn(() => ({ api: { getSession: vi.fn(async () => null) } })) -const prismaAdapterMock = vi.fn((client: unknown, opts: unknown) => ({ client, opts })) const nextCookiesMock = vi.fn(() => ({ id: 'next-cookies' })) vi.mock('better-auth', () => ({ betterAuth: betterAuthMock, })) -vi.mock('better-auth/adapters/prisma', () => ({ - prismaAdapter: prismaAdapterMock, -})) - vi.mock('better-auth/next-js', () => ({ nextCookies: nextCookiesMock, })) @@ -65,8 +60,37 @@ function makeOpensaasConfig(authConfig: NormalizedAuthConfig): OpenSaasConfig { } function makeContext(): AccessContext { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture - return { ormHandle: { __mockPrisma: true } } as any + // Neither `unsafe` nor `transaction` is a member of `AccessContext` + // (ADR-0038) but the Auth adapter is built from both, so the double has to + // carry them. + return { + ormHandle: { __mockPrisma: true }, + transaction: () => { + throw new Error('no transaction is opened in this test') + }, + unsafe: { + sql: {}, + raw: {}, + orm: {}, + query: () => { + throw new Error('not queried in this test') + }, + execute: () => { + throw new Error('not executed in this test') + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture + } as any +} + +/** + * `database` is the Auth adapter factory — a fresh closure per build, so it + * compares by identity and never matches. Everything else is compared whole. + */ +function expectSameOptions(actual: BetterAuthOptions, expected: BetterAuthOptions): void { + expect(typeof actual.database).toBe('function') + expect(typeof expected.database).toBe('function') + expect({ ...actual, database: undefined }).toEqual({ ...expected, database: undefined }) } async function buildBetterAuthConfig(authConfig: NormalizedAuthConfig): Promise { @@ -81,7 +105,6 @@ async function buildBetterAuthConfig(authConfig: NormalizedAuthConfig): Promise< describe('createAuth', () => { beforeEach(() => { betterAuthMock.mockClear() - prismaAdapterMock.mockClear() nextCookiesMock.mockClear() }) @@ -310,7 +333,6 @@ describe('createAuth', () => { describe('betterAuthOptions passthrough', () => { beforeEach(() => { betterAuthMock.mockClear() - prismaAdapterMock.mockClear() nextCookiesMock.mockClear() }) @@ -324,7 +346,7 @@ describe('betterAuthOptions passthrough', () => { const built = await buildBetterAuthConfig(authConfig) expect(built).toEqual({ - database: { client: { __mockPrisma: true }, opts: { provider: 'sqlite' } }, + database: expect.any(Function), user: { modelName: 'User' }, session: { modelName: 'Session', expiresIn: 604800, updateAge: 86400 }, account: { modelName: 'Account' }, @@ -496,7 +518,6 @@ describe('betterAuthOptions passthrough', () => { describe('rateLimit option forwarding (issue #909)', () => { beforeEach(() => { betterAuthMock.mockClear() - prismaAdapterMock.mockClear() nextCookiesMock.mockClear() }) @@ -579,7 +600,7 @@ describe('buildBetterAuthOptions / createAuth parity', () => { await auth.api.getSession({}) expect(betterAuthMock).toHaveBeenCalledTimes(1) - expect(betterAuthMock.mock.calls[0][0]).toEqual(built) + expectSameOptions(betterAuthMock.mock.calls[0][0], built) }) it('createAuth with a plugin tuple constructs betterAuth with exactly what buildBetterAuthOptions returns for the same tuple', async () => { @@ -594,7 +615,7 @@ describe('buildBetterAuthOptions / createAuth parity', () => { await auth.api.getSession({}) expect(betterAuthMock).toHaveBeenCalledTimes(1) - expect(betterAuthMock.mock.calls[0][0]).toEqual(built) + expectSameOptions(betterAuthMock.mock.calls[0][0], built) }) it('createAuth rejects when its plugin tuple does not match the resolved betterAuthPlugins', async () => { @@ -616,7 +637,6 @@ describe('buildBetterAuthOptions / createAuth parity', () => { describe('buildBetterAuthOptions plugin-tuple argument', () => { beforeEach(() => { betterAuthMock.mockClear() - prismaAdapterMock.mockClear() nextCookiesMock.mockClear() }) diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index 1f4c2ba4..6a06bc49 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -64,6 +64,17 @@ export type { ImageTransformationResult, } from './config/index.js' +// The engine's LIKE-pattern escaping, shared with the packages that lower a +// substring predicate to `like`/`ilike` (ADR-0055, ADR-0060). One escaper. +export { + LIKE_ESCAPE_CHARACTER, + escapeLikeLiteral, + likeEqualsPattern, + likeContainsPattern, + likeStartsWithPattern, + likeEndsWithPattern, +} from './where/like.js' + // The lookup's provenance, which `opensaas dev` reads to tell the Database // escape (a URL in the environment) from a Dev database it should start for // the project itself (ADR-0063). The public accessor, `findDatabaseUrl`, diff --git a/packages/core/src/secured/vocabulary.ts b/packages/core/src/secured/vocabulary.ts index 52d7b543..99274256 100644 --- a/packages/core/src/secured/vocabulary.ts +++ b/packages/core/src/secured/vocabulary.ts @@ -9,6 +9,7 @@ import { checkAccess, getRelatedListConfig } from '../access/engine.js' import { isFieldReadableForPredicate } from '../access/field-access.js' import { resolveQueryField } from '../access/query-validation.js' import { ValidationError } from '../hooks/index.js' +import { likeContainsPattern } from '../where/like.js' import { RELATION_QUANTIFIERS, RELATION_QUANTIFIER_SET, @@ -229,15 +230,6 @@ function isWhereValue(value: unknown): value is WhereValue { ) } -/** - * Prisma 8 ships `like`/`ilike` with no `contains` and renders no `ESCAPE` - * clause, so the pattern is bound verbatim and the engine escapes the - * wildcards itself against Postgres's default backslash escape (ADR-0055). - */ -export function containsPattern(value: string): string { - return `%${value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%` -} - function comparison( listName: string, key: string, @@ -301,7 +293,7 @@ function resolveScalar(listName: string, key: string, condition: unknown): Scala if (typeof raw !== 'string') { throw malformedCondition(listName, key, 'takes a string for "contains"') } - steps.push({ op: 'contains', pattern: containsPattern(raw) }) + steps.push({ op: 'contains', pattern: likeContainsPattern(raw) }) break } } diff --git a/packages/core/src/where/like.test.ts b/packages/core/src/where/like.test.ts new file mode 100644 index 00000000..d5c8aab0 --- /dev/null +++ b/packages/core/src/where/like.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest' +import { + LIKE_ESCAPE_CHARACTER, + escapeLikeLiteral, + likeContainsPattern, + likeEndsWithPattern, + likeEqualsPattern, + likeStartsWithPattern, +} from './like.js' + +describe('escapeLikeLiteral', () => { + test('leaves an ordinary literal alone', () => { + expect(escapeLikeLiteral('alice@example.com')).toBe('alice@example.com') + }) + + test('escapes the wildcards and the escape character itself', () => { + expect(escapeLikeLiteral('100%_off')).toBe('100\\%\\_off') + expect(escapeLikeLiteral('a\\b')).toBe('a\\\\b') + }) + + test('leaves the empty string empty', () => { + expect(escapeLikeLiteral('')).toBe('') + expect(likeEqualsPattern('')).toBe('') + expect(likeContainsPattern('')).toBe('%%') + expect(likeStartsWithPattern('')).toBe('%') + expect(likeEndsWithPattern('')).toBe('%') + }) + + test('escapes the character it names', () => { + expect(escapeLikeLiteral(LIKE_ESCAPE_CHARACTER)).toBe( + `${LIKE_ESCAPE_CHARACTER}${LIKE_ESCAPE_CHARACTER}`, + ) + }) +}) + +describe('patterns', () => { + test('anchor where they say they do', () => { + expect(likeEqualsPattern('ada')).toBe('ada') + expect(likeContainsPattern('ada')).toBe('%ada%') + expect(likeStartsWithPattern('ada')).toBe('ada%') + expect(likeEndsWithPattern('ada')).toBe('%ada') + }) + + test('keep the caller wildcards and escape the value ones', () => { + expect(likeContainsPattern('50%')).toBe('%50\\%%') + expect(likeStartsWithPattern('_x')).toBe('\\_x%') + }) +}) diff --git a/packages/core/src/where/like.ts b/packages/core/src/where/like.ts new file mode 100644 index 00000000..226afd1d --- /dev/null +++ b/packages/core/src/where/like.ts @@ -0,0 +1,47 @@ +/** + * The engine's LIKE-pattern escaping. One escaper, engine-owned: the secured + * surface's Where vocabulary and the Auth adapter both lower substring + * predicates to `like`/`ilike`, and a second implementation is a second set of + * edge cases (ADR-0055, ADR-0060). + */ + +/** + * The escape character every pattern this module builds is written against. + * + * PostgreSQL's `LIKE` defaults to a backslash when the statement carries no + * `ESCAPE` clause, and neither Prisma's ORM lane nor its SQL builder emits + * one — the pattern travels as a bound parameter. So the patterns below must + * be backslash-escaped, and a caller writing its own `ESCAPE` clause must + * name this character. + */ +export const LIKE_ESCAPE_CHARACTER = '\\' + +/** + * Escape the `LIKE` metacharacters in a literal so it matches itself. + * + * Wildcards a caller adds around the result stay wildcards; `%` and `_` inside + * `value` become literal, and the escape character escapes itself. + */ +export function escapeLikeLiteral(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') +} + +/** `value` as a `LIKE` pattern matching it exactly — the pattern for an insensitive equality. */ +export function likeEqualsPattern(value: string): string { + return escapeLikeLiteral(value) +} + +/** `value` as a `LIKE` pattern matching anywhere in the column. */ +export function likeContainsPattern(value: string): string { + return `%${escapeLikeLiteral(value)}%` +} + +/** `value` as a `LIKE` pattern anchored to the start of the column. */ +export function likeStartsWithPattern(value: string): string { + return `${escapeLikeLiteral(value)}%` +} + +/** `value` as a `LIKE` pattern anchored to the end of the column. */ +export function likeEndsWithPattern(value: string): string { + return `%${escapeLikeLiteral(value)}` +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2780df7..fce4984d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -839,12 +839,18 @@ importers: '@better-auth/mcp': specifier: ^1.7.1 version: 1.7.1(ec783593f092731071710ba14f5eea74) + '@better-auth/test-utils': + specifier: 1.7.1 + version: 1.7.1(eed962069cece2d6a11c8b7932e1af6a) '@opensaas/stack-cli': specifier: workspace:* version: link:../cli '@opensaas/stack-core': specifier: workspace:* version: link:../core + '@prisma/orm-postgres': + specifier: 8.0.0-rc.8 + version: 8.0.0-rc.8(@prisma/cli-engine@0.3.0(@prisma/management-api-sdk@1.69.0)(magicast@0.5.4))(@typescript/typescript6@6.0.2)(magicast@0.5.4)(typanion@3.14.0)(vite@7.3.1(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.11)(yaml@2.9.0)) '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -1775,6 +1781,13 @@ packages: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 + '@better-auth/test-utils@1.7.1': + resolution: {integrity: sha512-Whb2s5EX9xwninIW70p+YVmcgzWUoYktKFJm0eNWXw/Noqv9+Op27ZUuKk8hMmCImnm6YuToSzkz3OBAP5OYPg==} + peerDependencies: + '@better-auth/core': ^1.7.1 + better-auth: ^1.7.1 + vitest: ^4.1.10 + '@better-auth/utils@0.5.0': resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} @@ -9738,6 +9751,12 @@ snapshots: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 + '@better-auth/test-utils@1.7.1(eed962069cece2d6a11c8b7932e1af6a)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260903.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2) + better-auth: 1.7.1(@cloudflare/workers-types@5.20260903.1)(@opentelemetry/api@1.9.1)(@prisma/client@7.10.0(@typescript/typescript6@6.0.2)(prisma@7.10.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(better-sqlite3@12.6.2)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(better-sqlite3@12.6.2)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1125.0))(mysql2@3.15.3)(next@16.2.10(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(prisma@7.10.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(better-sqlite3@12.6.2)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.2)(vite@7.3.1(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.11)(yaml@2.9.0)) + '@better-auth/utils@0.5.0': dependencies: '@noble/hashes': 2.2.0