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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/auth-contains-literal-substring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/plugin-auth": patch
---

fix(plugin-auth): better-auth 的 `contains` 下译为 `$contains`,比较值不再当正则求值 (#5710)

`convertWhere()` 把 better-auth 的 `contains` 译成 `{ field: { $regex: value } }`,
于是一个**未转义、来自调用方**的比较值(`/admin/list-users` 的 `searchValue`、
SCIM 过滤值)坐进了正则的**模式位**。它的含义随后端分叉:

- `driver-memory` 用 `new RegExp(value)` 求值 —— `contains('a.b')` 命中 `axb`,
`^x` 变成锚定,而值里一个不配对的 `(` 让模式非法(mingo 查询路径直接抛
`SyntaxError`,参考匹配器则吞成静默零命中);
- `driver-sql` / `driver-sqlite-wasm` / `driver-turso` 编成子串
`LIKE '%value%'`(`%`/`_`/`\` 有转义、带显式 `ESCAPE`),元字符是字面量。

同一个认证查询,在应用测试常用的内存替身上和生产的 SQL 后端上给出**不同答案**,
且分叉发生在认证路径上。

现在这一支发出 `$contains` —— 协议 `FILTER_OPERATORS` 里的算子,五后端都必须按
**字面子串**求值,正是 better-auth `contains` 的本意(其 `Where.mode` 默认
`"sensitive"`,与 #5701 Q2=A 裁定的 `$contains` 大小写敏感契约同向)。

**对使用方的影响**:凭 `/admin/list-users?searchValue=…` 之类接口依赖「元字符按正则
生效」的调用会改变结果 —— 那是本次修复的缺陷本身,不是可依赖的行为。搜索
`a.b` 从此只命中含字面 `a.b` 的行,不再命中 `axb`;含非法正则字符的搜索值不再
报错或静默返回空,而是按字面子串匹配。
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"jose": "^6.2.5"
},
"devDependencies": {
"@objectstack/driver-memory": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@types/node": "^26.1.2",
"hono": "^4.12.34",
Expand Down
176 changes: 176 additions & 0 deletions packages/plugins/plugin-auth/src/auth-contains-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#5710] better-auth's `contains` is a LITERAL substring search — the adapter
* must not translate it into a bare `$regex`.
*
* `convertWhere()` used to emit `{ field: { $regex: condition.value } }`, which
* puts an unescaped, caller-supplied comparand (`/admin/list-users`'
* `searchValue`, a SCIM filter value) into a PATTERN position. What that value
* then means depended on the backend under the auth path:
*
* - driver-memory compiled it to `new RegExp(value)` — `a.b` matched `axb`,
* `^x` anchored, and an unbalanced `(` was an illegal pattern;
* - driver-sql / -sqlite-wasm / -turso compiled it to a substring
* `LIKE '%value%'` with `%`/`_`/`\` escaped — metacharacters literal.
*
* So one better-auth query answered differently on the memory double an app's
* tests run against and on the SQL backend production runs (#4706's shape, on
* the authentication path). These pins hold the operator at `$contains` — a
* member of the spec's `FILTER_OPERATORS`, i.e. one every backend is required
* to evaluate, as a literal substring.
*
* Two faces, deliberately: the first pins WHAT the adapter emits (the contract),
* the second pins what a real backend then ANSWERS (the behaviour). The first
* alone cannot see a translation that is spelled right and evaluated wrong; the
* second alone cannot say which operator earned the result.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { FILTER_OPERATORS } from '@objectstack/spec/data';
import type { QueryAST } from '@objectstack/spec/data';
import type { IDataEngine } from '@objectstack/core';
import { createObjectQLAdapterFactory } from './objectql-adapter';

/** Keeps the driver's own lifecycle logging out of the test output. */
const silentLogger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
} as any;

/**
* A read-only engine facade over a REAL `InMemoryDriver`.
*
* Only the three read verbs the `contains` path uses are declared. The write
* verbs are deliberately absent rather than stubbed: seeding goes through the
* driver directly (below), so a hand-written `delete`/`update` here would be a
* dispatch contract this test neither needs nor is able to honour
* (`check:engine-double-contract`, #4550).
*/
function memoryReadEngine(driver: InMemoryDriver): IDataEngine {
// The query bag is forwarded with its declared driver-side type and no `any`
// erasure: `query-options/no-any-erasure` (#4674/#4918) counts a test-side
// `find(obj, … as any)` too, and nothing here needs to be off-contract.
return {
find: (object: string, query: QueryAST) => driver.find(object, query),
findOne: (object: string, query: QueryAST) => driver.findOne(object, query),
count: (object: string, query?: QueryAST) => driver.count(object, query),
} as unknown as IDataEngine;
}

const NOW = new Date('2026-08-06T00:00:00.000Z').toISOString();

/** Rows whose `name`s differ only in how a regex would read the comparand. */
const SEED = [
{ id: 'u_literal', name: 'a.b', email: 'literal@example.com' },
{ id: 'u_wildcard', name: 'axb', email: 'wildcard@example.com' },
{ id: 'u_paren', name: 'x(y', email: 'paren@example.com' },
];

async function seededAdapter() {
const driver = new InMemoryDriver({ logger: silentLogger });
await driver.connect();
for (const row of SEED) {
await driver.create('sys_user', { ...row, emailVerified: false, createdAt: NOW, updatedAt: NOW });
}
const adapter: any = (createObjectQLAdapterFactory(memoryReadEngine(driver)) as any)({} as any);
return { driver, adapter };
}

/** `findMany` with a single better-auth `contains` condition on `name`. */
function containsQuery(value: string) {
return {
model: 'user',
where: [{ field: 'name', value, operator: 'contains', connector: 'AND' }],
limit: 100,
} as any;
}

describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => {
let engine: IDataEngine;

beforeEach(() => {
engine = {
insert: vi.fn().mockResolvedValue({ id: '1' }),
findOne: vi.fn().mockResolvedValue(null),
find: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
} as unknown as IDataEngine;
});

it('emits `$contains`, never a bare `$regex`, for a `contains` search', async () => {
const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any);
await adapter.findMany(containsQuery('a.b'));

const [object, query] = (engine.find as any).mock.calls[0];
expect(object).toBe('sys_user');
expect(query.where).toEqual({ name: { $contains: 'a.b' } });
// Spelled out separately: `toEqual` above would still pass if a second,
// regex-shaped limb were added under a different field.
expect(JSON.stringify(query.where)).not.toContain('$regex');
});

it('emits an operator every backend is required to evaluate', () => {
// The whole point of the flip: `$contains` is in the protocol's runtime
// allowlist, `$regex` never was — it survived only because this adapter
// produced it (driver-memory's `filter-refusal.ts` says so in as many
// words), which is why #5702's loud refusal is ordered after this PR.
expect(FILTER_OPERATORS).toContain('$contains');
expect(FILTER_OPERATORS).not.toContain('$regex');
});

it('leaves the other operators untouched', async () => {
const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any);
await adapter.findMany({
model: 'user',
where: [
{ field: 'email', value: 'a@b.com', operator: 'eq', connector: 'AND' },
{ field: 'name', value: ['x', 'y'], operator: 'in', connector: 'AND' },
],
limit: 100,
} as any);

const [, query] = (engine.find as any).mock.calls[0];
expect(query.where).toEqual({ email: 'a@b.com', name: { $in: ['x', 'y'] } });
});
});

describe('[#5710] the comparand is a literal substring on a real backend', () => {
it('does not read `.` as a wildcard — `a.b` matches `a.b`, not `axb`', async () => {
const { adapter } = await seededAdapter();
const rows: any[] = await adapter.findMany(containsQuery('a.b'));

// The pin, stated in both directions: the metacharacter row is NOT matched
// (a bare `$regex` matched it through `.`), and the literal row still is.
expect(rows.map((r) => r.name)).toEqual(['a.b']);
});

it('does not read `^` as an anchor', async () => {
const { adapter } = await seededAdapter();
const rows: any[] = await adapter.findMany(containsQuery('^a'));

// As a pattern, `^a` matched `a.b` and `axb`. As a substring, nothing here
// contains the two characters `^a`.
expect(rows).toEqual([]);
});

it('matches a value that is not a legal regex, instead of failing on it', async () => {
const { adapter } = await seededAdapter();
const rows: any[] = await adapter.findMany(containsQuery('('));

// `new RegExp('(')` throws — under `$regex` this comparand could not
// produce an answer at all (the mingo path threw, the reference matcher
// swallowed it into a silent no-match). It is just a character now.
expect(rows.map((r) => r.name)).toEqual(['x(y']);
});

it('answers an ordinary metacharacter-free search unchanged', async () => {
const { adapter } = await seededAdapter();
const rows: any[] = await adapter.findMany(containsQuery('xb'));

expect(rows.map((r) => r.name)).toEqual(['axb']);
});
});
29 changes: 28 additions & 1 deletion packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,34 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };
// [#5710] `$contains`, NOT `$regex`. better-auth's `contains` is a
// LITERAL substring search (`Where.mode` defaults to `"sensitive"`, and
// the value comes straight from a caller — `/admin/list-users`'
// `searchValue`), while `$regex` puts that value in a PATTERN position.
//
// What the bare `$regex` did, per backend, to one `contains('a.b')`:
// - driver-memory: `new RegExp('a.b')` — `.` is a wildcard, so it
// matched `axb`; an unbalanced `(` in the value made the pattern
// illegal (a throw on the mingo path, a silent no-match on the
// reference matcher's).
// - driver-sql / -sqlite-wasm / -turso: compiled to a substring
// `LIKE '%a.b%'` — metacharacters literal.
// One operator, two answers, and the divergence only shows up when the
// app's tests run on the memory double and production runs SQL (#4706).
//
// `$contains` is in the spec's `FILTER_OPERATORS` and every backend
// evaluates it as a literal substring (SQL side escapes `%`/`_`/`\` and
// emits an explicit `ESCAPE`), which is exactly better-auth's meaning.
// Case semantics follow the #5701 Q2=A ruling (`$contains` is
// case-SENSITIVE at the contract layer; the per-driver alignment is
// #5702's budget), which matches better-auth's own `mode: 'sensitive'`
// default.
//
// This is also the last live producer of `$regex` — it is what
// `driver-memory/src/filter-refusal.ts` means by "refusing it here would
// break a live producer", and the reason #5702's loud refusal is ordered
// after this flip.
filter[fieldName] = { $contains: condition.value };
}
}

Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading