Skip to content

The Auth adapter over the Unsafe surface with the conformance suites - #1221

Merged
borisno2 merged 3 commits into
prisma-8from
claude/issue-1161-auth-adapter
Sep 6, 2026
Merged

The Auth adapter over the Unsafe surface with the conformance suites#1221
borisno2 merged 3 commits into
prisma-8from
claude/issue-1161-auth-adapter

Conversation

@borisno2

@borisno2 borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Implements #1161. Part of #1126. Decision record: ADR-0060.

@opensaas/stack-auth stops handing better-auth prismaAdapter — which is written against Prisma Client delegates a Prisma 8 client does not have — and builds its own adapter with better-auth's createAdapterFactory, driving the Unsafe surface.

What landed

packages/auth/src/adapter/ — the Auth adapter.

  • Eight methods on the ORM lane (context.unsafe.orm): create, findOne, findMany, update, updateMany, delete, deleteMany, count.
  • incrementOne is one typed-SQL UPDATE … SET n = n + δ … RETURNING through the surface's executors. Prisma's fns namespace has no arithmetic builtin, so only the n + δ fragment is fns.raw, carrying the column's own codec via codecOf; the guard predicate and the RETURNING list stay typed.
  • empty-where deleteMany is one typed-SQL unconditional DELETE — a Collection's deleteAndCount is checked against a prior .where(), and better-auth's own cleanup issues this on every model.
  • 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.
  • Ids minted by the database: disableIdGeneration: true, supportsUUIDs: true, supportsNumericIds: false, and authPlugin pins db.idField: 'uuid7' on every list it injects (ADR-0048).
  • Addressing: models resolve through the derived-auth-lists registry (deriveAuthLists now surfaces the model → list-key map it already built), fields through better-auth's default field names, and the contract applies the column map. A mapped table name never reaches a Collection — getDefaultModelName maps it back first.
  • Refused passthrough keys: advanced.database.generateId and advanced.database.joins throw at config time in assertNoUnsupportedPassthroughKeys. betterAuthOptions.database stays refused.
  • createAuth(config, rawOpensaasContext) keeps its signature. getDatabaseConfig builds the adapter from the resolved context's Unsafe surface where it called prismaAdapter before. AccessContext deliberately does not name unsafe (ADR-0038), so the surface is read off the running request context through a structural check and refused loudly (AuthUnsafeSurfaceMissingError) when absent, rather than widening the public signature.

The or() decision

No internal import was needed. ADR-0060 anticipated importing the combinators from an internal package and accepted that, with typed SQL throughout as the fallback. At 8.0.0-rc.8 or, and and not are exported from @prisma/orm-postgres/orm-client, a published subpath of the package the stack already pins — the "internal" caveat in Prisma's own skill docs is about the @internal/… workspace alias, not the published surface. AnyExpression, OrderByItem, Expression, CodecRef, codecOf and param are likewise public on @prisma/orm-postgres/relational-core, so the adapter's lane types are Prisma's own rather than hand-declared stand-ins. @prisma/orm-postgres is added as a peer + dev dependency of packages/auth.

The LIKE escaper

ADR-0060 says the pattern operators "reuse the engine-owned LIKE escaping ADR-0055 introduced … no second escaper". That helper did not exist on prisma-8 — ADR-0055's Where vocabulary is #1147, still open. So it is added here, in packages/core/src/where/like.ts, exported from @opensaas/stack-core/internal: LIKE_ESCAPE_CHARACTER, escapeLikeLiteral, and the four pattern builders (likeEqualsPattern / likeContainsPattern / likeStartsWithPattern / likeEndsWithPattern). Nothing private lives in packages/auth.

#1147 must adopt this helper rather than write a second one.

Tripwire coverage

packages/auth/tests/adapter-tripwire.test.ts drives all ten methods over the Test context with the tripwire installed (throw mode, no warn mode) plus a plan recorder. Every recorded plan's origin is 'unsafe'. update, delete and consumeOne each assert more than one recorded plan — a single-row ORM update()/delete() is two statements, not one … RETURNING — and every one of them is marked. incrementOne and empty-where deleteMany each assert exactly one plan, of the expected AST kind.

Conformance

packages/auth/tests/adapter-conformance.test.ts runs @better-auth/test-utils@1.7.1's testAdapter over createTestContext's in-process Postgres, rebuilding the database whenever the resolved better-auth tables change. 159 passed, 73 skipped, 0 failed.

Suites run: normal, uuid, caseInsensitive.

Stated skips, all in the test file with their reasons:

  • numberId — the Auth lists are string-keyed by construction; the adapter declares supportsNumericIds: false.
  • joins (the whole joinsTestSuite, plus every join-named test inside normal/uuid) — the adapter implements none, and advanced.database.joins is refused at config time rather than left to better-auth's silent per-model fallback.
  • create - should use generateId if provided — the database mints every auth id; an app-supplied generator is ignored here and refused at config time.
  • create - should enforce the issuer-scoped account identity key — better-auth declares that @@unique table-level, which deriveAuthLists does not yet emit (auth: upgrade better-auth to 1.7.1 — new required account.issuer column, new @@unique([issuer, accountId]), and a stale ^1.3.29 peer floor #986). A schema gap, not an adapter one.
  • create - should return null for nullable foreign keys — a reference field whose name does not end in Id derives a relation field and its own FK column under the same name, and the relation shadows the column on the returned row. Also deriveAuthLists; better-auth's own tables all name theirs <target>Id.

Two behaviours the suites forced into the open, both handled in the adapter rather than papered over in the harness:

  • Prisma's timestamptz codec decodes to a string at rc.8, so the adapter declares supportsDates: false and lets better-auth's own string↔Date conversion keep its contract true.
  • Prisma's pg/int8 codec refuses a JS number, which is how better-auth carries a bigint: true field. The adapter widens on the way in and narrows on the way out, keyed off the field attribute's own flag.

Out of scope (left to #1162)

The factory's transaction option, the transactions and authFlow suites, the plain-Node anchor, and the package-manifest / guidance sweep (dropping the @prisma/client peer, de-naming prismaAdapter in the CLAUDE.md files). transaction: false is declared and stated as a known limit.

Verification

  • pnpm lint — 0 errors (2 pre-existing warnings, untouched files)
  • pnpm manypkg fix, pnpm format — clean
  • pnpm build — 11/11 tasks
  • packages/auth — 472 passed, 77 skipped
  • packages/core — 1555 passed, 1 skipped
  • packages/cli — 335 passed

🤖 Generated with Claude Code

…he Unsafe surface

The auth plugin no longer hands better-auth `prismaAdapter`. It builds its own
adapter with `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()`.

`authPlugin` pins `db.idField: 'uuid7'` on every list it injects and the adapter
declares `disableIdGeneration`, so the database mints auth ids.
`advanced.database.generateId` and `advanced.database.joins` join the refused
passthrough keys; `createAuth(config, rawOpensaasContext)` keeps its signature.

core gains the engine-owned LIKE-pattern escaping the adapter lowers `contains`
/ `starts_with` / `ends_with` and insensitive `eq` through — one escaper, for
ADR-0055's Where vocabulary to adopt rather than re-write.

better-auth's normal, uuid and caseInsensitive suites run over the Test
context; numberId and joins are skipped with the reason stated.

Implements #1161. Part of #1126. See ADR-0060.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 6, 2026 9:38pm UTC

@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fc27dd8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-auth Minor
@opensaas/stack-core Minor
@opensaas/stack-cli Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.27% (🎯 65%) 2815 / 2986
🟢 Statements 92.97% (🎯 65%) 3083 / 3316
🟢 Functions 96.71% (🎯 62%) 559 / 578
🟢 Branches 87.76% (🎯 50%) 2167 / 2469
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/secured/vocabulary.ts 86.75% 82.53% 94.44% 87.59% 102, 240, 252, 262, 273, 278, 294, 302, 308, 311, 323, 340, 347, 365, 367, 372-376, 383, 384, 386, 501
packages/core/src/where/like.ts 100% 100% 100% 100%
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 73.64% 1646 / 2235
🔵 Statements 73.23% 1765 / 2410
🔵 Functions 82.42% 286 / 347
🔵 Branches 60.62% 819 / 1351
File CoverageNo changed files found.
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 91.2% 280 / 307
🔵 Statements 89.94% 313 / 348
🔵 Functions 96.05% 73 / 76
🔵 Branches 82.38% 262 / 318
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/auth/src/adapter/surface.ts 59.25% 45% 88.88% 63.63% 129-135, 141, 147, 174-175, 185, 196
packages/auth/src/adapter/where.ts 81.13% 69.56% 91.3% 79.54% 27-28, 49-51, 58-60, 67-69, 142-144, 178-179, 226-228, 232-236, 242-248, 272-274, 277, 282, 285
packages/auth/src/config/derive-auth-lists.ts 97.38% 90.05% 100% 99.27% 252, 253, 440-442, 780
packages/auth/src/config/plugin.ts 100% 100% 100% 100%
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 79.66% 235 / 295
🔵 Statements 81.17% 263 / 324
🔵 Functions 87.91% 80 / 91
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 54.38% 397 / 730
🔵 Statements 53.85% 419 / 778
🔵 Functions 64.06% 82 / 128
🔵 Branches 47.25% 198 / 419
File CoverageNo changed files found.
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #2015 for commit fc27dd8 by the Vitest Coverage Report Action

Comment thread packages/auth/src/adapter/index.ts Outdated
// Prisma resolves the first matching identity and deletes by it with
// `RETURNING`, so of two racing consumers exactly one gets the row —
// the at-most-one guarantee better-auth asks of this method.
const consumed = await narrow(model, where).delete()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

consumeOne is not atomic — a one-time token can be consumed twice.

The tripwire test for this method asserts recorder.plans.length is greater than one, i.e. Prisma lowers where(…).delete() to a SELECT that resolves the identity followed by a DELETE. Those two statements run on the surface's executors with no transaction around them (transaction: false), and the result is returned without checking that the DELETE actually affected a row.

Scenario: two requests replay the same magic-link / email-OTP / reset token concurrently. Both SELECTs resolve the same verification row before either DELETE runs. If Prisma's delete() answers with the row it resolved rather than the DELETE's own RETURNING, both callers get a non-null row back and the token is accepted twice — exactly the guarantee consumeOne exists to provide.

better-auth's own reference adapter guards this explicitly: @better-auth/prisma-adapter wraps the pair in db.$transaction(...) and returns target only when deleteMany(...).count > 0. This adapter does neither, and no test exercises the concurrent case (the second consumeOne in adapter-tripwire.test.ts is sequential, which the SELECT alone already answers).

Either wrap the pair in context.transaction / the surface's transaction scope, or make this method a single typed-SQL DELETE … WHERE … RETURNING the way incrementOne already is.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 300b17d. consumeOne now resolves the row, then deletes by its identity, both inside one transaction on the Unsafe surface's transaction-bound lanes, and answers the row only when the delete claimed it (deleteAndCount() > 0) — the shape @better-auth/prisma-adapter uses. The transaction arrives as an adapter option; getDatabaseConfig reads the context's own transaction structurally, beside unsafe, so no public signature widened and none of #1162's factory-transaction work is pulled in.

Three tests in the new packages/auth/tests/adapter-behaviour.test.ts. Two drive concurrency: two (and five) concurrent consumeOne calls against one row — exactly one winner, zero rows left. Those pin the outcome but not the mechanism: the in-process database holds a single connection, so the transactions serialise and the loser's own SELECT finds nothing, which would pass without a gate. So the third 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 — and asserts null. That one fails against the previous where(…).delete().

ADR-0060 carries a dated amendment at the top: the decision is unchanged, the mechanism sentence is corrected to "two statements under a transaction, gated on the delete's own count".

Comment thread packages/auth/src/adapter/where.ts Outdated
isBigInt: boolean,
): Expression<ScopeField> {
const like = (build: (value: string) => string): Expression<ScopeField> =>
fns.raw`${column} LIKE ${pattern(clause, build)}`.returns(BOOLEAN_CODEC)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The raw LIKE fragment interpolates a user-controlled string without param(), and nothing exercises it.

Every other value this PR puts inside fns.raw is wrapped first — incrementOne builds its set assignments as fns.raw`${param(widened, { codecId: codec.codecId })}` . Here the pattern (an attacker-supplied contains / starts_with / ends_with value, escaped for LIKE metacharacters but otherwise raw) goes straight into the template. If RawSqlBuilder inlines non-Expression template values as SQL text rather than binding them, this is either a syntax error on every call or an injection point; escapeLikeLiteral escapes %/_, not quotes, so it is no defence.

This is undetectable from the test suite: sqlWhere is reachable only from incrementOne, and better-auth only ever issues incrementOne with eq/comparison guards on the rate-limit key — so the whole like branch (and the hardcoded BOOLEAN_CODEC = 'pg/bool@1' it returns) never runs in conformance or tripwire. Bind the pattern the same way incrementOne binds its set values.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Parameterised in 300b17d: the pattern is now param(pattern, { codecId: 'pg/text@1' }), matching what the set branch of incrementOne does — and incrementOne's increment fragment is bound the same way (your summary finding 2).

One correction to the diagnosis, with evidence, since it changes how urgent this was: it was not an injection point at rc.8. Prisma's raw tag binds bare interpolations rather than inlining them — expression-PL-Tra28.d.mts: "Bare RawSqlLiteral interpolations are wrapped as ParamRef nodes with the codec resolved via adapter.inferCodec(value). Use param when the codec cannot be inferred from the value alone (e.g. Date)." So the string travelled as $n, and param() here removes the reliance on that inference rather than closing a hole.

The branch is now exercised: adapter-behaviour.test.ts drives incrementOne with a contains guard whose value is 100% ' OR 1=1 -- and asserts it matches the row holding that literal and not the neighbouring one, and the ORM lane's contains gets the same treatment with a value carrying quotes, %, _ and a backslash.

Comment thread packages/auth/src/adapter/where.ts Outdated
? fns.ilike(column, pattern(clause, likeEqualsPattern))
: fns.eq(column, scalar(clause, isBigInt))
case 'ne':
return fns.ne(column, scalar(clause, isBigInt))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The typed-SQL lane loses the null handling the ORM lane has.

ormClause maps eq/ne with value === null to isNull() / isNotNull(). sqlClause does not: fns.eq(column, null) and fns.ne(column, null) lower to col = NULL / col <> NULL, which are NULL in SQL and therefore never true.

Scenario: a better-auth plugin issues incrementOne with a guard like { field: 'consumedAt', operator: 'eq', value: null } (the natural "only bump a row that hasn't been claimed" shape). The UPDATE silently matches zero rows and the adapter answers null — the caller reads it as "no such row" rather than "guard is broken". mode: 'insensitive' is likewise honoured for eq here but dropped for ne, which the ORM lane handles.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Half of this is a false positive, half is real, and both now have tests.

Null handling — false positive at rc.8. fns.eq/fns.ne already lower a null operand to IS NULL/IS NOT NULL themselves. From @prisma/orm-family-sql/dist/builder__runtime.mjs (the builtin fns set, createBuiltinFunctionseq/ne):

function eq(a, b) {
  if (b === null) return boolExpr(NullCheckExpr.isNull(resolve(a)));
  ...
function ne(a, b) {
  if (b === null) return boolExpr(NullCheckExpr.isNotNull(resolve(a)));

So the consumedAt guard you describe works. adapter-behaviour.test.ts now pins it end-to-end through incrementOne: an ne: null guard bumps the row, an eq: null guard answers null. I left the code as it is rather than adding branches that would restate what the builtins do.

Insensitive ne — real, fixed in 300b17d. The SQL lane dropped mode for ne where the ORM lane wraps its ilike in not(). There is no not in the builtins and the Postgres target contributes only ilike, so it is now fns.raw with a NOT ILIKE and the pattern bound through param().

function insensitiveList(clause: CleanedWhere): readonly string[] | undefined {
if (clause.mode !== 'insensitive' || !Array.isArray(clause.value)) return undefined
const strings = clause.value.filter((entry): entry is string => typeof entry === 'string')
return strings.length === clause.value.length ? strings : undefined

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

An empty insensitive in list reaches or() with zero arguments.

For { operator: 'in', value: [], mode: 'insensitive' } this returns []strings.length === clause.value.length is 0 === 0 — rather than undefined. ormClause then calls or(...[]), i.e. or() with no disjuncts (and not(or()) for not_in), which either throws or emits a degenerate predicate instead of the correct FALSE / TRUE.

Guard the empty case: if (strings.length === 0) return undefined, or handle a zero-length disjunct list in ormClause alongside the field.in([]) path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 300b17d: insensitiveList returns undefined for a zero-length list, so in/not_in fall through to the lane's own field.in([]) / field.notIn([]), which are the FALSE/TRUE the operators mean. Two tests in adapter-behaviour.test.ts: an empty insensitive in answers [], an empty insensitive not_in answers every row.

// 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 } }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The uuid7 pin overrides an app-level db.idField with no escape hatch, and it does not reach the extendList branch.

uuid7 is already the global default (listConfig.db?.idField ?? config.db.idField ?? 'uuid7' in contract/derive.ts), so this line only changes behaviour for an app that deliberately set db.idField to something else — and for that app it silently wins.

The case that breaks is adoption. adopt-better-auth-tables.test.ts was updated to expect idField: 'uuid7' on AuthUser/AuthSession/AuthAccount/AuthVerification, but those lists exist to diff clean against a live better-auth install whose id columns hold nanoids in text. An app that set db.idField to a text strategy to match that live schema can no longer keep the auth lists on it; the generator emits a pg/uuid column and the diff (and any migration over existing rows) fails.

Second half of the same asymmetry: the extendList branch below forwards only fields/hooks/mcp, so a list the app declared itself keeps whatever idField it had — while the adapter unconditionally declares supportsUUIDs: true and supportsNumericIds: false for every model. An app-declared User on int autoincrement gets ids stringified by better-auth's id output transform and then handed back to a numeric column on the next where.

Consider honouring an explicit per-list or app-level idField and only defaulting when none was declared.

typeof advancedDatabase === 'object' &&
!Array.isArray(advancedDatabase)
) {
if ('generateId' in advancedDatabase) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

'generateId' in advancedDatabase also refuses the two settings that mean what this adapter wants.

better-auth's generateId takes false ("let the database mint it") and 'uuid' alongside a custom function. Both of the first two are exactly the behaviour the adapter asks for — initGetIdField returns undefined for the id in either case — yet the presence check rejects them with a message about "an app-supplied generator ... would write a non-UUID into a uuid column", which is false for false/'uuid'. Same for a config that spells out { generateId: undefined }.

Narrow the check to the values that are actually incompatible (typeof v === 'function' and v === 'serial') rather than key presence.

...options,
advanced: {
...options.advanced,
database: { ...options.advanced?.database, generateId: 'uuid' },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The conformance suite runs a configuration production refuses, and the difference is not cosmetic.

This override sets advanced.database.generateId: 'uuid' — the exact key assertNoUnsupportedPassthroughKeys now throws on. So all 159 passing assertions exercise a config no shipped app can have.

The two configs take different branches of better-auth's initGetIdField. Under the tested one, useUUIDs is true, so transform.input for id returns undefined whenever supportsUUIDs is set — a caller-supplied id is always dropped and the database mints. Under the shipped one (disableIdGeneration: true, no generateId), useUUIDs is false and transform.input falls through to return value, so a caller-supplied id is written to the column. That path — a plugin or databaseHooks create carrying its own id into a uuid7 column — is the one production takes and the one nothing here covers.

Either drop the override and give the suite's fixtures UUIDs some other way, or add a case that runs the shipped id configuration.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 300b17d. The override is gone: customIdGenerator: () => randomUUID() alone gives the fixtures their UUIDs, so normal and caseInsensitive now run disableIdGeneration: true with no generateId — the shipped branch of initGetIdField, where a caller-supplied id is written rather than dropped. The uuid suite still declares that key itself, upstream, as the mode it exists to exercise; the harness no longer declares it for everyone.

That exposed three upstream probes that cannot run this way: findOne/findMany/delete "not found" each hardcode the id "100000" unless the options say generateId: 'uuid', and Postgres rejects a malformed uuid outright instead of answering not-found. They are disabled in the normal suite only, with that reason in the file, and re-covered under the shipped configuration in adapter-behaviour.test.ts with a well-formed id — plus a case asserting a caller-supplied id survives (forceAllowId, which is where the factory lets one through at all; without it the factory itself drops the id before the transform, in both configurations).

156 passed, 76 skipped, 0 failed across normal, uuid and caseInsensitive (was 159/73 under the impossible config; the delta is those three probes).

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Code review — /code-review high (verdict: REQUEST CHANGES)

Seven findings are already posted as inline comments on the diff. This summary adds a second pass over the risk areas that weren't covered there, and states the verdict. GitHub blocks a formal Request changes from the PR author's own account, so this is submitted as a Comment review — treat it as request changes.

The adapter itself is good work: the addressing layer, the AND/OR grouping, the incrementOne single-statement lowering, and the structural surface guards are all sound. What blocks is a small set of correctness and test-integrity issues.

Verified clean (the specific claims this PR makes)

  • @prisma/orm-postgres dependency. Exact pin 8.0.0-rc.8 as peer + dev, which is exactly how packages/core depends on it (required peer, not optional — correct: the adapter imports codecOf/param at module scope). No version skew.
  • The published-subpath claim holds. I unpacked the published tarball rather than taking it on trust: ./orm-client and ./relational-core are both in exports; ./orm-client re-exports @prisma/orm-family-sql/orm-client (a real dependencies entry, itself exporting ./orm-client), whose .d.mts declares and, or, not in its export list. Nothing here reaches an @internal/… alias.
  • The LIKE escaper is correct. escapeLikeLiteral escapes the backslash first, then % and _ — the escaped-escape ordering is right, and LIKE_ESCAPE_CHARACTER correctly documents Postgres' implicit default. No second escaper exists in packages/auth (grepped). Only gap: no test for the empty string.
  • The structural unsafe read is type-safe. isUnsafeSurface / isCollection / isSqlTable use Reflect.get behind type predicates — no any, no cast, unknown stays internal. Note isUnsafeSurface only probes query/execute while the adapter also needs orm/sql; that's survivable because AuthModelUnreachableError catches it downstream, but the guard is weaker than its error message claims.
  • The tripwire is not trivially passing. origins() vs plans.map(() => 'unsafe') would pass vacuously on an empty recorder, but update, delete and consumeOne each assert plans.length > 1 first, so the multi-statement claim is genuinely asserted.
  • Skips are all justified in the file, and the count is dominated by enableJoinTests. Changeset present and accurate.

Additional findings

  1. outward() narrows every bigint to Number() unconditionallypackages/auth/src/adapter/index.ts (const outward = …). The inbound direction is careful (isBigInt from getFieldAttributes), but the outbound direction is a blanket typeof value === 'bigint' ? Number(value) : value over every column of every model. Above Number.MAX_SAFE_INTEGER that silently mangles the value. lastRequest won't hit it, but an app column added through extendUserList will. Narrow the same way you widen — by the declared bigint attribute — and at minimum don't lose precision silently.

  2. incrementOne's increment fragment is not parameterisedfns.raw\${target} + ${delta}`. The setbranch three lines below wraps its value inparam(widened, { codecId }); the increment branch interpolates the raw JS delta. The author clearly knew param()was the right call here, which makes the omission look accidental — and it's the same class of issue as the unparameterisedLIKEpattern flagged inline inwhere.ts:197`.

  3. The two lanes disagree on how a column is addressed. applyOrmWhere resolves through getDefaultFieldName (field keys), while sqlWhere indexes fields[clause.field] by better-auth's column name and codecFor/columnsOf do the same. Both assumptions can't hold once an app supplies a fields mapping on a model — the ORM path resolves and the typed-SQL path throws AuthWhereError. Reachable via incrementOne on a remapped rateLimit. Nothing tests a mapped field on the SQL lane.

  4. widen() throws the wrong error for a non-integer. BigInt(1.5) raises a bare RangeError, escaping the AuthWhereError vocabulary the rest of the module is careful to use.

  5. Two type casts, against the repo's no-casting ruleasAdapterResult's row as T (documented, arguably the one unavoidable seam against better-auth's typing) and inward(model, { ...(update as Record<string, unknown>) }) in update(), which is undocumented and is a second place, contradicting asAdapterResult's own "this is the one place ours does".

  6. Two skips hide production gaps, not adapter gaps. should enforce the issuer-scoped account identity key and should return null for nullable foreign keys are both deferred to #986. The first means duplicate accounts are possible in a shipped app until that lands. That deserves a stated known limit in the adapter docblock alongside the joins/createSchema/transaction ones, not only a test comment.

Must fix before merge

  • consumeOne atomicity (inline r3944014739) — a token can be consumed twice.
  • The conformance harness runs a config production refuses (inline r3944017230) — the shipped id path is the untested one.
  • Unparameterised raw values: where.ts:197 (inline) and finding 2 above.
  • sqlClause dropping null handling (inline r3944015510) and or() with zero arguments (inline r3944015825).

…config

Addresses the review on #1221.

- `consumeOne` runs its resolve-then-delete pair in one transaction on the
  Unsafe surface's transaction-bound lanes and answers the row only when the
  delete claimed it. The adapter takes the transaction as an option; the
  server reads the context's own `transaction` structurally, beside `unsafe`.
- The conformance harness drops the `advanced.database.generateId: 'uuid'`
  override, so normal and caseInsensitive now run the id path production
  takes. The three upstream probes that hardcode a non-UUID id are disabled in
  the normal suite with their reason and re-covered under the shipped config.
- The typed-SQL LIKE pattern and `incrementOne`'s increment fragment are bound
  through `param()`; insensitive `ne` no longer loses its mode on that lane.
- An empty insensitive `in`/`not_in` lowers through the lane's own `in([])`
  rather than a zero-argument `or()`.
- `outward()` narrows a bigint to a number only for a column better-auth
  declares as `bigint`, matching the inbound gate.
- `update`'s payload is narrowed by a predicate rather than cast; the
  issuer-scoped account gap is a stated known limit; ADR-0060 carries a dated
  amendment correcting the `consumeOne` mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Review addressed — 300b17d

Replies are on the five inline threads this changes; here are the summary findings and the two I did not act on.

Summary findings

  1. outward() narrowing every bigint — fixed. It now narrows only where getFieldAttributes(...).bigint is true, the same gate inward widens on, so an application's own int8 column keeps its value. Test: a legacyId column declared to better-auth as a plain number and to the stack as bigInt() round-trips 9007199254740993n exactly, while rateLimit.lastRequest (declared bigint) still answers a JS number.

  2. incrementOne's increment fragment unparameterised — fixed, param(widened, { codecId }) like the set branch three lines below, with the same bigint widening applied to the delta. See the where.ts:197 thread for why this was a consistency fix rather than a hole: Prisma's raw tag binds bare interpolations as ParamRef nodes.

  3. The two lanes addressing columns differently — false positive, now covered by a test. They differ because Prisma keys them differently: a Collection by the contract's model/field names (the derived Auth lists' own keys, which is why the ORM lane resolves through getDefaultFieldName), the SQL builder by the table/column names the contract maps those to — which is what better-auth already hands over. I made both lanes resolve through the field key, and the new mapped-model test failed with the typed-SQL lane exposes no column "key" (better-auth column "rate_key"): the SQL lane exposes the physical name. So I reverted to the original addressing, which is correct, and kept the test. adapter-behaviour.test.ts stands up a second database whose user, verification and rateLimit models all carry fields mappings, and exercises findOne (ORM lane), incrementOne (typed-SQL lane) and consumeOne (both) through it. Nothing tested a mapped field before; something does now.

  4. widen() throwing a bare RangeError on a non-integer — not changed. It converts only where the declared attribute says bigint, and better-auth carries those as integers, so I would rather not add a branch and a test for a value no caller produces. Happy to wrap it if you disagree.

  5. The two casts — one removed, one kept and documented. update as Record<string, unknown> is gone: the payload goes through an isColumnValues predicate that refuses a non-object with an AuthAdapterError instead of asserting one. asAdapterResult's row as T stays, and its docblock now names the constraint precisely — T is a free type parameter on every row-returning method of better-auth's CustomAdapter, so no value the database produces can satisfy it, and its own reference adapters widen at the same seam. That is the one cast left in the adapter.

  6. The skips that hide production gaps — the issuer-scoped account key is now a stated known limit in opensaasAuthAdapter's docblock, alongside joins / createSchema / transactions, naming what nothing in the database prevents and that auth: upgrade better-auth to 1.7.1 — new required account.issuer column, new @@unique([issuer, accountId]), and a stale ^1.3.29 peer floor #986 is what closes it. The nullable-FK skip now points at deriveAuthLists: a reference name not ending in Id makes the relation field shadow its own FK column #1222 rather than restating the problem.

LIKE escaper empty-string test — added in packages/core/src/where/like.test.ts (the empty string through the escaper and all four builders).

Not addressed here

The two remaining inline threads — the uuid7 pin overriding an app-level db.idField (config/plugin.ts:67) and narrowing the generateId refusal to the values that are actually incompatible (server/index.ts:150) — are untouched. Both look real to me; both change what an app can configure rather than what the adapter does, and they sit outside the scope of this pass. They want their own issue, together with the question of supportsUUIDs/supportsNumericIds being declared unconditionally.

Verification

  • pnpm build 11/11; pnpm lint 0 errors (2 pre-existing warnings in untouched files); pnpm manypkg check and pnpm format clean.
  • packages/auth — 487 passed, 80 skipped, 0 failed.
  • packages/core — 1556 passed, 1 skipped. packages/cli — 335 passed.
  • Conformance over the in-process Postgres, under the shipped id configuration: 156 passed, 76 skipped, 0 failed — suites normal, uuid, caseInsensitive.

The existing changeset is updated in place; no second one.

…e.ts

The base moved under this branch while it was in review, so GitHub could
compute no merge ref and CI never ran for 300b17d. The one content conflict
was `packages/core/src/internal.ts`, where this branch's `where/like.ts`
re-exports and the base's `findDatabaseConnection` (#1218) were added at the
same point; both are kept.

#1220 landed the Where vocabulary from its own branch rather than adopting
this branch's engine-owned escaper, leaving a second implementation:
`containsPattern()` in `secured/vocabulary.ts`. It performed the same three
replacements in the same order with the same wrapping as `likeContainsPattern`,
so it is deleted and the `contains` case now calls the shared builder. One
escaper, as ADR-0060 requires. #1220's Where-vocabulary and filter tests pass
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Merged origin/prisma-8 into the branch (merge, not rebase — the review threads here are anchored to these commits). The branch was conflicted, so GitHub could compute no merge ref and CI had not run at all for 300b17d9; it is running now.

One content conflict, in packages/core/src/internal.ts: this branch's where/like.ts re-exports and the base's findDatabaseConnection (#1218) landed at the same point in the file. Both kept.

Duplicate escaper consolidated. #1220 landed the Where vocabulary from its own branch rather than adopting this branch's engine-owned escaper, so the base carried a second implementation — containsPattern() in packages/core/src/secured/vocabulary.ts. It performed the same three replacements in the same order with the same wrapping as likeContainsPattern, so it is deleted and the contains case now calls the shared builder from packages/core/src/where/like.ts. That module already documents the ESCAPE-clause constraint the removed comment explained, and it cites ADR-0055 and ADR-0060, so nothing was carried across. There is now exactly one implementation of the escaping logic in the repository, as ADR-0060 requires.

#1220's own Where-vocabulary and filter tests pass unchanged (src/secured, src/filter, src/where: 5 files, 117 passed) — that is the proof the consolidation changed no behaviour.

Full build green. packages/core 1570 passed / 1 skipped; packages/auth 487 passed / 80 skipped (the skips are the env-gated e2e suites and the conformance cases the branch already marks skipped — unchanged); packages/cli 372 passed. pnpm lint 0 errors (2 pre-existing warnings), manypkg fix and format clean.

The existing changeset already describes the escaper as shared with the Where vocabulary, which the consolidation now makes true, so it is left as-is.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re-review of 300b17d — verdict: APPROVE (submitted as a Comment review; GitHub blocks a formal Approve from the PR author's own account)

I audited the two rebuttals against the installed 8.0.0-rc.8 source rather than either party's characterisation, then went over the new code. The author is right on all three disputed findings, and the four fixes that did land are sound. Nothing blocks.

Rebuttal 1 — "unparameterised SQL was not an injection point": author is right

Not just the docblock the author cited — the implementation and the renderer both.

@prisma/orm-family-sql/dist/expression-DmQbErXU.mjs, resolveInterpolation:

if (typeof value === "number" || typeof value === "bigint" || typeof value === "string"
    || typeof value === "boolean" || value instanceof Uint8Array)
  return ParamRef.of(value, { codec: { codecId: adapter.inferCodec(value) } });
throw runtimeError("RUNTIME.RAW_SQL_UNSUPPORTED_INTERPOLATION", )

templateParts only ever pushes the tag's own static strings as SQL text; every interpolation becomes an AST node. And @prisma/orm-target-postgres/dist/sql-renderer-*.mjs closes it: renderRawExprrenderPartstypeof part === "string" ? part : renderExpr(part, …), and renderExpr's case "param-ref"renderParamRefrenderTypedParam(index, codecId, …), i.e. a $n placeholder. A bare string interpolation is bound, never concatenated. The original finding's severity was overstated; there was no injection point on the LIKE or incrementOne paths.

The param() change is still worth having — inferCodec throws for anything outside {number, bigint, string, boolean, Uint8Array} — but as a robustness/consistency fix, not a security one.

Rebuttal 2 — "fns.eq(x, null) already lowers to IS NULL": author is right

@prisma/orm-family-sql/dist/builder__runtime.mjs L219–228, reached from createBuiltinFunctions L301–302:

function eq(a, b) { if (b === null) return boolExpr(NullCheckExpr.isNull(resolve(a))); 
function ne(a, b) { if (b === null) return boolExpr(NullCheckExpr.isNotNull(resolve(a))); 

So the consumedAt: null guard scenario in the original comment does not occur. The other half of that comment — insensitive ne dropping its mode on the SQL lane — was real and is fixed.

While I was in there: the empty-or() finding was also a false positive. OrExpr.of([]) is exactly OrExpr.false(), and the Postgres renderer has case "or": if (node.exprs.length === 0) return "FALSE". The insensitiveList guard is behaviour-neutral. Harmless and slightly clearer, so keep it.

Rebuttal 3 — "lane addressing was a false positive": author is right, and the retained test is not vacuous

The reverted addressing is internally consistent for a model carrying a fields mapping. Traced end to end for rateLimit.fields.key = 'rate_key' (stack field key key, physical column rate_key, better-auth field name key):

  • sqlWhere gets clause.field === 'rate_key' (better-auth's factory maps it before the custom adapter sees it) and indexes the SQL proxy by that physical name — correct.
  • resolveFieldgetDefaultFieldName('rate_key')'key', which is both the ORM contract's field key and the name getFieldAttributes wants — so isBigInt and applyOrmWhere are correct.
  • inward keys by column → field key; outward narrows on the field key and renames via getFieldName back to the column; columnsOf is column-named, matching what the SQL lane returns.

The test discriminates: findOne on the mapped user.name → full_name exercises the ORM lane, incrementOne on the mapped rateLimit.key → rate_key exercises sqlWhere (it would throw AuthWhereError if the addressing were wrong), and consumeOne on mapped verification.identifier → ident crosses both.

Minor, non-blocking: the SQL lane's mapped coverage runs only through sqlWhere. Nothing exercises codecFor with a mapped increment/set column — only count/lastRequest, which are unmapped. A mapped counter column would be the remaining untested corner.

consumeOne — the fix holds

  • Gate is correct. deleteAndCount() > 0 under Read Committed: the loser's DELETE blocks on the row lock, re-evaluates after the winner commits, claims zero, answers null.
  • The transaction is genuinely the surface's. packages/core/src/context/index.ts builds the child context's unsafe from createUnsafeTransactionSurface(client, _unsafeTransaction) inside client.transaction, so tx.unsafe really is transaction-bound — not the outer client. The narrow(…, lane) / collectionFor(model, lane) threading is complete; no path in consumeOne falls back to the ambient unsafe.
  • The structural read is type-safe. isTransactionCapable is a Reflect.get predicate over a declared TransactionCapableContext, and the tx context's unsafe is re-validated by isUnsafeSurface inside the callback. No any, no cast. Tightening isUnsafeSurface to also probe orm/sql closes the gap the prior review noted.
  • The third test is the one that pins it, and it is not tautological. The double answers first() → row and deleteAndCount() → 0. Against the previous where(…).delete() shape the double's delete() returns row, the adapter answers non-null, and the test fails. It genuinely discriminates old from new.

The author's own caveat is the honest one and I'd leave it as stated: the two concurrency tests assert the outcome, not the mechanism, and no test drives the pair through a real transaction on a real pool. That is a residual, not a defect.

Conformance harness — the skips are legitimate

Verified against @better-auth/test-utils@1.7.1 dist/adapter/suites/basic.mjs. Five sites hardcode "100000", each gated on getBetterAuthOptions().advanced?.database?.generateId === "uuid" — exactly the gate described. Three are the disabled tests, named identically to HARDCODED_NON_UUID_ID:

  • L276 findOne - should not throw on record not found
  • L1214 findMany - should return an empty array when no models are found
  • L1894 delete - should not throw on record not found

The other two (L697, L1236) are inside join tests already disabled via enableJoinTests. So 159→156 / 73→76 accounts for the delta exactly, with no other test silently flipping to skipped.

The replacement coverage is real, not a stub: the three probes are re-run in adapter-behaviour.test.ts under the shipped config with well-formed UUIDs, plus a forceAllowId case the old override could not have run at all (under generateId: 'uuid' the id transform drops a caller-supplied id). Net: the suite lost three probes and gained the id branch production actually takes. That is a stronger suite at a lower pass count.

ADR-0060 amendment

Accurate, dated, PR-linked, stacked above the 2026-09-05 amendment in the same blockquote style, and the decision bullet is cross-referenced rather than rewritten. Correct handling.

One nit, non-blocking: the decision bullet "config.transaction is implemented" is still contradicted by the shipped transaction: false (a stated known limit deferred to #1162). The amendment corrects the consumeOne mechanism but leaves that one standing. Worth folding into #1162's amendment rather than this PR.

Remaining items

  • asAdapterResult's row as T — the one cast left, and it is the right one to keep. T is a free type parameter on every row-returning CustomAdapter method, so no database value can satisfy it. The docblock now names that external constraint precisely, which is what the comments rule asks of a kept comment. update's cast is genuinely gone, replaced by isColumnValues with a real error.
  • Bigint outbound narrowing — now the exact mirror of inward's gate, and the legacyId round-trip of 9007199254740993n alongside rateLimit.lastRequest still answering a JS number covers both sides.
  • Empty insensitive in/not_in — fine (see above; behaviour-neutral).
  • House rules: .js extensions throughout, no any in new code, no new undocumented comments.

What I did not re-review

The two threads the author deliberately left open — the uuid7 pin overriding an app-level db.idField, and narrowing the generateId refusal to the values that are actually incompatible. I agree with the author's read that both are real, both change what an app can configure rather than what the adapter does, and both belong in their own issue alongside the unconditional supportsUUIDs/supportsNumericIds declarations. They should not hold this PR.

Approving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant