Skip to content

fix(plugin-auth): convertWhere() 补齐 not_in / starts_with / ends_with,未识别算子改为响亮拒收 (#5813) - #5844

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-5813-convertwhere-missing-ops
Aug 6, 2026
Merged

fix(plugin-auth): convertWhere() 补齐 not_in / starts_with / ends_with,未识别算子改为响亮拒收 (#5813)#5844
baozhoutao merged 2 commits into
mainfrom
claude/issue-5813-convertwhere-missing-ops

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5813

前提复核(改之前)

按分诊要求,不按 issue 正文的旧行号,而是对 origin/main@f886f291a(#5710 的 PR #5812 合并之后)重读 convertWhere():

活体调用方也实测过,不是转述 issue:装好的 better-auth@1.7.0-rc.2
dist/plugins/admin/routes.mjs:311-322searchOperator 枚举是 contains | starts_with | ends_withfilterOperator 枚举就是 z.enum(whereOperators),而 :360 / :365 把它们直接推进 where。算子词表(@better-auth/core/db/adapter)运行时读出来确认是十一个:eq, ne, lt, lte, gt, gte, in, not_in, contains, starts_with, ends_with

改了什么

一、三个算子按表直译。 三个 ObjectQL 算子都在 FILTER_OPERATORS 里(五后端都必须求值):

better-auth ObjectQL
not_in $nin
starts_with $startsWith
ends_with $endsWith

二、链尾未识别算子响亮抛错。 错误信息带算子名、字段名、受支持算子清单和落点文件 —— 信息本身就是操作指引。这是 #3948 一族的 restore-invariant:丢谓词不是把结果变窄而是变宽,「悄悄回答另一个问题」比「拒绝回答」更坏。

if / else if 链改成 switch,顺带拿到第二道锁:default 臂里 operator 收敛为 never,所以 better-auth 长出第十二个算子而这里没加 case 时,pnpm --filter @objectstack/plugin-auth typecheck 会先于任何查询报错。运行时那一支拿到的仍是真实算子字符串,进错误信息。

关于 operator 缺省。 Where.operator 在 better-auth 侧声明为可选、@default eq,其 factory 的 transformWhereClause 会在适配器看到之前把默认值物化(CleanedWhere 因此是必填形态)。已废弃的裸 createObjectQLAdapter 收的是手搓 where,可能没走过 factory,所以缺省在这里显式补上 —— 这是生产方自己声明的契约,不是宽容别名;凡是写出来的算子,必须在词表里。

大小写语义

starts_with / ends_with 两侧同向,直译不开契约缝:better-auth 的 Where.mode 默认 "sensitive",$startsWith / $endsWith#5701 Q2=A 在契约层也是大小写敏感。

反向验证:方向是普通的「先红后绿」

把三个 case 删掉、default 改回静默 break(即修复前的语义),27 条里红 17 条,方向与预期一致。两条最有说服力的实测输出:

FAIL  a write selects its OWN row, not an arbitrary one
AssertionError: expected [ 'u_abcz', 'u_xabc', 'u_zed' ] to deeply equal [ 'u_abc1', 'u_abcz', 'u_xabc' ]
-   "u_abc1"      ← 应当留下的第一行,被删了
+   "u_zed"       ← 应当被删的目标行,还在

FAIL  `count` counts the matches, not the table
AssertionError: expected 4 to be 2

第一条是 #5813 最锋利的一半:对四行表执行「删除 namezed 开头的用户」,修复前删掉的是表里的第一行 u_abc1。第二条说明谓词被丢时 count 给出的不是「稍微偏大的数」,而是整张表的行数

测试:三面加一尾

新文件 packages/plugins/plugin-auth/src/auth-where-operator-coverage.test.ts,沿用 #5812auth-contains-filter.test.ts 建立的两面模式,并补了第三面:

  1. 契约面(spy engine):三个算子各自译出 $nin / $startsWith / $endsWith,并单独断言 not.toEqual({}) —— 空 filter 是本缺陷的确切形状;另有 count 路径与「与邻居条件共存而非互相覆盖」两条。
  2. 词表面(新增):从 @better-auth/core/db/adapter 运行时读出 whereOperators,逐个跑过适配器,断言每个都留下谓词;并把源码里的 SUPPORTED_WHERE_OPERATORS 与之钉成同一集合。这是唯一能看见上游长出新算子的一面 —— 另外两面只测得到有人记得写 case 的算子,而那正是 plugin-auth: convertWhere()not_in / starts_with / ends_with 没有分支,谓词被整条丢弃 —— 认证路径上的过滤放大 #5813 的成因。
  3. 行为面(真实 InMemoryDriver):每个算子一条有排除力的用例(starts_with 'abc' 不命中只是包含 abcx_abc;ends_with 'abc' 反过来只命中它;not_in 真的排除列出的行),外加 count 与前述写路径。
  4. 链尾拒收:编造算子 'fuzzy',factory 适配器与裸适配器两条路径都抛错,错误信息含算子名、字段名与受支持清单;并断言读根本没发生(拒收就是不回答,不是回答一个更宽的问题)。

行为面的 fake engine 只声明用得到的动词,deleteassertEngineDeleteDispatch(options) 开头(check:engine-double-contract 已确认收录为 pinned [delete])。

关于 #5814 的接缝(分诊指定必答项,报告里同答)

结论:变简单,不变难。 理由见下,并且本 PR 特意留了一条 pin 说明边界。

modeoperator兄弟字段,不是算子成员,所以链尾拒收读的是 operator不波及 mode —— 今天 mode: 'insensitive' 仍被忽略(那就是 #5814)。有一条测试专门钉住这个边界:带 mode: 'insensitive' 的查询不抛错、照常译出 $startsWith

接缝的形状是这样的:#5814 落地时,每个 case是「这个算子在 insensitive 下译成什么」的落点(例如 contains + insensitive → $icontains,#5701 已经把它加进算子词表),而 default是「这个 mode 在这个算子上无法表示」时该走的拒收位置 —— 同一条接缝,往里一层。改造前它是一串 if / else if 加一个隐形的静默出口,mode 只能靠再加一串平行分支来接;现在它是一个带穷尽性检查的 switch,加一维只需要在既有臂里展开,而且漏掉哪一臂会被 never 和词表面测试一起报出来。

唯一需要注意的顺序:#5814 的裁决若引入新的 ObjectQL 算子映射,SUPPORTED_WHERE_OPERATORS 与词表面那条集合相等断言不需要动(mode 不进算子表);要动的是各 case 臂内部。

范围与不做的事

验证

pnpm --filter @objectstack/plugin-auth test
  Test Files  36 passed (36)
        Tests  820 passed (820)

pnpm --filter @objectstack/plugin-auth typecheck
  (tsc --noEmit,无输出)

pnpm --filter @objectstack/runtime test        # plugin-auth 最重的下游消费者
  Test Files  101 passed (101)
        Tests  1458 passed (1458)

node scripts/check-engine-double-contract.mjs
  OK — 38 pinned, 165 in the DEBT ledger, 2 exempt
  pinned [delete]  packages/plugins/plugin-auth/src/auth-where-operator-coverage.test.ts

node scripts/check-type-check-coverage.mjs     # OK
node scripts/check-nul-bytes.mjs               # OK(5702 个文件,无裸控制字节)

注:plugin-auth 的 tsconfig.json 仍排除 **/*.test.ts(既有 TEST_DEBT 台账项,本 PR 未新增排除),所以上面的 typecheck 覆盖的是 objectql-adapter.ts。新测试文件另行单独跑过 tsc --noEmit 确认无类型错误。


🤖 Generated with Claude Code

https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv


Generated by Claude Code

…unknown operators (#5813)

`convertWhere()` covered eight of better-auth's eleven where operators. The
other three — `not_in` / `starts_with` / `ends_with` — fell off the end of its
`if / else if` chain: no key was written into `filter`, nothing was logged, and
a `where` carrying only such a condition compiled to `{}`.

A dropped predicate WIDENS a result set rather than narrowing it, on the
identity tables and through a mounted admin route:

  - `GET /api/v1/auth/admin/list-users?searchOperator=starts_with` answered with
    every user; `filterOperator`'s enum is the whole vocabulary, so `not_in`
    excluded nobody.
  - `update` / `delete` / `consumeOne` / `incrementOne` resolve their target
    with `findOne(filter)` first, so `{}` picked an arbitrary row and the write
    landed on the wrong record — measured: deleting "the user whose name starts
    with zed" removed the table's first row instead.

Two halves:

  1. The three operators translate to `$nin` / `$startsWith` / `$endsWith`, all
     members of the spec's `FILTER_OPERATORS`. Case semantics agree on both
     sides (better-auth's `Where.mode` defaults to `sensitive`; the `$startsWith`
     family is case-sensitive at the contract layer per #5701 Q2=A), so the
     direct translation opens no contract seam.
  2. The chain tail now THROWS instead of skipping — the #3948 restore-invariant
     discipline. The message names the operator, the field and the supported
     set. `never` narrowing in the `default` arm makes a twelfth upstream
     operator a compile error as well.

`Where.mode` is out of scope and deliberately not refused: it is a sibling field
of `operator`, not a member of the vocabulary (#5814).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 8:21am

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… with `as any`

`check:query-options-erasure` counts a test-side `find(o, { … } as any)` too
(#4674/#4918), and the ratchet is at its ceiling — the read-back in the
write-path case was on-contract all along, so it is spelled with a real
`QueryAST` rather than raising the count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: convertWhere()not_in / starts_with / ends_with 没有分支,谓词被整条丢弃 —— 认证路径上的过滤放大

2 participants