fix(spec,rest,runtime): request bodies are checked against the schemas the catalog declares (#3899) - #4322
Merged
Merged
Conversation
…s the catalog declares (#3899) The API catalog (plugin-rest-api.zod.ts) declared requestSchema on 16 routes while almost no mounted entry point validated its body — a malformed request did not 400, it executed different semantics (an unfiltered full read on /query, markRead(userId, []) on a misnamed key, a flow registered under the key `undefined`, a one-letter-off toggle body ENABLING the flow it meant to disable). Wire the declared schemas at the real entry points, make the catalog stop promising what nothing performs, and gate both directions: - rest: POST /data/:object/query, POST /data/:object, PATCH /data/:object/:id, POST /data/:object/batch and POST /data/:object/createMany now safeParse the declared contract and answer 400 VALIDATION_FAILED + fields[] (the #3933/#3944 pattern); the query route also pins the PATH object into the forwarded query. - runtime: POST /notifications/read validates MarkNotificationsReadRequestSchema; the automation registerFlow / updateFlow / toggle bodies get strict hand-written guards (keys.ts pattern); the analytics-entry validationFailure helper is hoisted to validation-failure.ts and shared. - spec: the catalog drops the four ghost notification endpoints (#3612 removed the routes), fixes the automation trigger path to /trigger/:name and drops its never-true request schema, adds the POST /:object/query entry, repoints create/update at the schemas the routes actually validate, and drops requestSchema from bodyless GET/DELETE entries. - gates: schema-name references must resolve to real exports and sit on body-carrying methods (spec); every declared requestSchema on a mounted route has a violating-body -> 400 conformance case with a completeness ratchet (rest + runtime request-schema-gate suites). Suites: spec 7132, rest 536, runtime 977 all green; check:generated 8/8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 5 package(s): 116 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…chema-validation-o9ltjz
… serves The first CI run of the #3899 request-schema gate rejected the dogfood search proof's own wire shape: `{ search: 'retail', searchFields: ['industry'] }` answered 400. The schema was the wrong half — QuerySchema declared only the structured FullTextSearchSchema form while ADR-0061 D1 ("the client sends only the query text"), the engine executor (search-filter.ts), and the search-conformance ledger all serve the bare string plus the validated `searchFields` narrowing. - `search` becomes `string | FullTextSearch` (string is the canonical Tier-1 spelling; the object form keeps the declared Tier-2 knobs) - `searchFields` is formally declared (the ADR's own P1 item), noted as server-intersected — can only narrow, never widen - rest request-schema gate pins the ADR-0061 wire shape as a positive case so entry validation can never 400 it again - regenerated: references docs + authorable-surface (adds `data/Query:searchFields`); all 8 artifact gates green spec suite 7145 green; both previously-failing dogfood files pass locally (showcase-search 4/4, two-factor-lockout 5/5 — the 2FA pair does not reproduce here and is green on main @ 5d21a48's identical job). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
…hFields in the liveness ledger The search-contract repair turned `search` into a union the liveness walker does not descend, and declared `searchFields` — both landed UNCLASSIFIED on the freshly-seeded query ledger (#4286). `search` becomes a leaf entry (the object form's experimental flags stay audited by their own describe markers inside FullTextSearchSchema); `searchFields` gets its live entry pointing at the resolveSearchFields intersection and the ADR-0061 dogfood proof. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
…e demands `QUERY_AST_KEYS` is typed `Record<keyof QueryAST, true>` precisely so a key added to the spec is a compile error here rather than silent drift at the REST boundary — and it did its job: declaring `searchFields` on QuerySchema turned the whole build red until the key was listed. The hand-maintained `'searchFields'` entry in RESERVED_LIST_QUERY_PARAMS goes with it. It was there as a "transport-only extra the AST does not name"; the AST names it now, so it arrives through the `QUERY_AST_KEYS` spread and the type-level pin covers it. Keeping both would have been the second source that list exists to avoid. No behavior change on either path: the GET normalizer already accepted `?searchFields` / `$searchFields` and the engine already read `ast.searchFields` (ADR-0061). metadata-protocol 122, objectql 1373, full build 71/71 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
…hand-written docs
Both hand-written query pages taught only the structured `search: {query,
fields}` form — `query-syntax.mdx` even typed it `search?: FullTextSearch`,
which is now literally wrong. The bare string is the canonical ADR-0061
spelling (D1: the client says what to search for, the server decides which
fields), it is what every surface sends, and it is what the dogfood proof
asserts; the top-level `searchFields` narrowing was undocumented entirely.
Same gap this PR exists to close, one layer up: a reader following these
pages learned half the contract. Both forms are now shown, with the
structured one named as equivalent for the two members that drive the
expansion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
…instead of stopping Test Core These three suites start a real mongod via mongodb-memory-server, which fetches a ~123 MB binary from fastdl.mongodb.org at module load. Each already anticipated that fetch FAILING — try/catch, warn, skip — precisely so a blocked download costs a skipped suite rather than the monorepo test job. A catch cannot express the other half. When the fetch HANGS, the top-level await never settles: the suite neither runs nor skips, it stops. Observed twice on this branch (Test Core attempts 1 and 2 of run 30610017310), both frozen at the identical point with zero output from this package and no skip warning, both force-killed 10 minutes later by the #4250 stall guard with `@objectstack/driver-mongodb#test` as the surviving task. `instance .launchTimeout` does not cover it — that bounds spawning mongod once the binary is on disk, a later phase than the fetch. `createTestMongod` puts a 120s deadline on the wait so a hang lands in the skip branch the suites already had. Not a workaround for this PR's diff: the same task on main 40 minutes earlier logged `Downloading MongoDB "8.2.6": 0% … 100%` and passed on a cache miss, so this is the network changing under an unbounded wait. Any PR touching @objectstack/spec invalidates the cache and runs straight into it. Verified: 160/160 still pass locally on the success path (binary cached), and a mocked never-settling create() resolves to undefined at the deadline rather than hanging. The helper stays out of dist — tsup entry is src/index.ts only, which does not reference it (mongodb-memory-server is a devDependency). Refs #4250. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
…checked source `src/test-mongod.ts` uses setTimeout / clearTimeout / console and turned the package's `tsc --noEmit` red. The `.test.ts` files that call it have always used the same globals, but tsconfig excludes `**/*.test.ts`, so this package never needed `types: ["node"]` until a shared helper became typechecked source — and it cannot be named `*.test.ts`, or vitest would collect it as a suite with no tests. Same one-line declaration a dozen sibling packages already carry (client, core, lint, cli, metadata-protocol, …); @types/node is already a devDep here. I missed this locally by verifying with build + eslint + vitest: tsup's dts step only covers the `src/index.ts` entry graph, which this helper is deliberately outside of, so nothing I ran typechecked it. Re-verified with the command CI actually uses — `turbo run build --filter='./packages/*'`, 57/57 — plus `tsc --noEmit` in this package and its 160 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
…chema-validation-o9ltjz # Conflicts: # content/docs/protocol/objectql/query-syntax.mdx
os-zhuang
marked this pull request as ready for review
July 31, 2026 08:02
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3899(建议 ①②④;建议 ③ 的 filter-normalizer 已由 #4128 一轮修完,
$or/$not现为树节点、未知操作符直接 throw,本 PR 无需改动)。范围核对(issue 核对于 93f267f,此后已落地的部分)
Issue 表中 7 个 schema,有 4 个已被单独 issue 接线:
DeleteManyDataRequestSchema(#3897)、UpdateManyDataRequestSchema(#3933)、批量上限与单一 Zod 源(#3939/#3944)、SharingRuleSchema(#3896);AnalyticsQueryRequestSchema也已在 #3878/#3891 之后接上(domains/analytics.ts的assertAnalyticsQueryBody,信封形状已退役、schema 现为.strict()+ retiredKey 墓碑)。本 PR 收掉剩余的一半,并补上把"声明=生效"钉死的门。① 目录停止承诺没做的事(
plugin-rest-api.zod.ts)/devices×2、/preferences×2):服务端路由 21 client SDK methods target routes no server surface mounts — delete on the open major train, or build the servers (#3587 finding) #3612 已删(实际上从未建成,packages/client/src/index.ts有原话),目录却一直连requestSchema一起声明着——与 The SDK's entireainamespace is dead, and the entire real AI surface is unreachable from the SDK — the two sets are disjoint #3718 删DEFAULT_AI_ROUTES同病同药。/trigger→/trigger/:name,并摘掉AutomationTriggerRequestSchema声明——该 schema 描述的{trigger, payload}形状与真实 wire(name 在路径、body 是 params 包)从未一致。POST /:object/query条目(挂载已久、目录一直缺席),declareFindDataRequestSchema。CreateDataRequestSchema/UpdateDataRequestSchema(旧名描述的{ data }信封不是 wire 形状)。GetMetaItemRequestSchema/IdRequestSchema保留为协议方法契约)。SaveMetaItemRequestSchema同摘:item: z.unknown()在入口永不可能失败,真正的门是 metadata 层的按类型校验(422 + issues)。② 声明的 schema 接到真实入口
rest-server(沿用 #3933/#3944 的"路径参数最后合并 +
zodIssuesToFields"范式,400 信封与 updateMany/deleteMany 完全一致):POST /data/:object/queryquery: req.body || {}原样透传,畸形 body 静默变全量读FindDataRequestSchema校验;路径 object 最后写入并随查询下传(bodyobject不能再与路径矛盾);校验后转发合并后的原始 body,不注入 parse 产物POST /data/:objectdata: req.body裸传CreateDataRequestSchema(非 record body → 400)PATCH /data/:object/:idUpdateDataRequestSchemaPOST /data/:object/batchBatchUpdateRequestSchema;上限检查移到形状检查之后(#3944 同序)POST /data/:object/createManyrecords: req.body || []CreateManyDataRequestSchema(裸数组 wire 不变;发{records:[...]}信封会拿到带指引的 400)runtime dispatcher(analytics 入口的 throw-duck-shape 范式,两个错误出口都映射为 400,#3918 已钉):
POST /notifications/read:接MarkNotificationsReadRequestSchema。旧读法Array.isArray(body?.ids) ? … : []把{"notificationIds":[...]}/{"ids":"n1"}静默变成markRead(userId, [])——200、readCount: 0、角标永不清零(issue ③ 原例)。POST /automation(registerFlow):name必须为非空字符串——拼错 name 曾把流程注册到undefined键下并回显 200。POST /automation/:name/toggle:严格{ enabled?: boolean }——{"enable": false}(少个 d)曾启用流程并返回 200{enabled: true};现在 400 并点名错键。空 body = enable 的既有语义保留。PUT /automation/:name:定义必须是对象(definition信封是既有双方言,原样保留、未新增)。validationFailure/fieldsFromZodIssues从 analytics 内联提升到validation-failure.ts(与识别器同文件,构造与识别不可能漂移),analytics/notifications/automation 三处共用。④ 门
plugin-rest-api.schema-refs.test.ts):每个requestSchema/responseSchema字符串必须解析到真实导出的 Zod schema(fix(spec,rest): enforce the batch-size cap, and give each bulk endpoint one Zod source (#3939) #3944 记过的悬空名CreateManyRequestSchema这一类从此结构性不可能);requestSchema只允许出现在 POST/PUT/PATCH 上;请求体从不与声明它的 schema 对照(#3877 的请求侧对偶):7 个 schema 定义了从未启用,而 API 目录已宣称生效 #3899 接线的 7 条声明有防回退底线。request-schema-gate.conformance.test.ts):枚举目录中声明requestSchema的已挂载路由,逐条发违反 schema 的 body,断言 400VALIDATION_FAILED+fields[]且协议层/服务从未被调用;每条配合规 body 的阳性对照(证明 400 来自 schema 而非路由坏了)。完整性棘轮:新声明一条requestSchema而不加违规用例,套件直接红——声明从此跑不赢生效。automation-body-validation.test.ts,不在目录门内但钉死 issue ③ 的语义翻转)。门当场抓到的两处真实漂移(本 PR 顺带修掉)
这两处都不是我改出来的,是新门第一次运行就照出来的既有"声明 ≠ 实际",与本 issue 是同一种病:
QuerySchema的 search 契约与 ADR-0061 服务的形状不符。schema 只声明了结构化的FullTextSearchSchema,而 ADR-0061 D1 的规范拼写是裸字符串(客户端只说搜什么,服务端决定搜哪些字段),顶层searchFields窄化更是完全没声明——可执行器、conformance ledger、dogfood HTTP 证明用的都是后者。入口一开始校验 body,dogfood 的showcase-search立刻红。按 contract-first 修在 spec 侧(search: string | FullTextSearch、正式声明searchFields),并在 rest 门里钉了该 wire 形状的阳性用例。连带:QUERY_AST_KEYS(Record<keyof QueryAST, true>,存在意义就是"spec 加键、REST 边界没跟上就编译不过")如设计般把构建拦红,补键后顺手清掉RESERVED_LIST_QUERY_PARAMS里那份手写的'searchFields'副本。query-syntax.mdx的类型签名search?: FullTextSearch已是字面错误,searchFields一字未提。同一种病上移一层,一并校正。一处 CI 稳定性修复(
354caba,与请求体校验无关,但本 PR 被它挡住)Test Core在本分支连续两次停摆在完全相同的位置,被杀任务都是@objectstack/driver-mongodb#test。取证后根因明确:该包三个套件在模块加载期从 fastdl.mongodb.org 抓 ~123MB 的 mongod 二进制,代码只防了"下载失败"(try/catch → skip,文件头注释写明意图正是"别让它拖垮整个 Test Core"),没防"下载卡住"——挂起时 promise 永不 settle,套件既不跑也不跳,就地停住。instance.launchTimeout不覆盖这一段(它约束的是二进制就位后 spawn mongod)。不是本 PR 的 diff 引起:同一任务在 main(40 分钟前)是 cache miss 真跑的,日志有完整的
Downloading MongoDB "8.2.6": 0% → 100%并通过;是网络侧在变而等待没有上界。但任何改动@objectstack/spec的 PR 都会让全仓缓存失效、让这个任务真跑,于是必然撞上,重跑绕不过去。修法是给等待加 120s 截止时间,让"卡住"落进套件本来就有的 skip 分支——降级结果与可见性不变(warn + 跳过),只是从"永不发生"变成"有界发生"。取证与讨论记在 #4250(该 issue 已记录的三次机制不同,是另一条线索,未受影响)。
行为变更
违反已声明契约的 body 从"静默换一种语义执行"变为
400 VALIDATION_FAILED + fields[]。合规调用方(SDK 全部方法已逐一核对发送形状)不受影响;曾依赖畸形 body 被静默接受的调用按fields[]指引改正即可。细节见 changeset。验证
spec7145 /rest536 /runtime977 /objectql1373 /metadata-protocol122 /driver-mongodb160 全绿;全量构建 71/71;check:generated8/8;check:liveness绿(新声明的query/searchFields与转为 union 叶子的query/search均已登记台账)。client.data.query/create/update/batch/createMany、client.notifications.markRead、client.automation.toggle/execute发送形状全部合规;既有集成用例发送的都是合规 body,全数通过。🤖 Generated with Claude Code
https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC