Repository navigation
Commit 08610a9
fix(rest)!: /meta list refuses a bracketed object key on views and a bracketed package key on the list and book tree (#29298)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9EdXbpw3tnxagrfuK2otRVU)_
Fixes #29199
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill or doc page teaches a bracketed `object` or
`package` key (`git grep` over `skills/`, `content/docs/` and
`apps/docs/` finds none); the pages that name `?object=` and `?package=`
teach the plain spelling, which is unchanged.
## 维护者速读
- **改了什么**:视图列表 `GET /meta/view` 收到括号写法的
`object`(`?object[]=NAME`、`?object[0]=NAME`)时答 400 `VALIDATION_ERROR`
并点名该键;`/meta` 列表与 book tree 收到括号写法的
`package`(`?package[]=ID`、`?package[0]=ID`)时同样答
400。以前这些键被忽略,返回全部视图或跨全部包的列表。
- **为什么改**:按 qs 风格序列化数组的客户端只想取一个对象的视图或一个包的内容,却拿回全部,还会当作它要的那一份来用;和 #29046
修好的 `?id[]=` 是同一类静默放宽。
- **风险与回滚**:只动 `packages/rest`(列表链一步 + 两个读门入口的同一判定,两条传输共用),`patch` 带
BREAKING 横幅;只影响发括号键的 API 调用方,`@objectstack/client`
与控制台都不发。所有写门本来就以未知参数拒收 `package[]`,本 PR 不改写行为。回滚即还原本 PR。
-
**先看**:`packages/rest/src/meta-item-read-gate.ts`(`refuseBracketedListFilter`、`metaBracketedPackageRefusal`
与第 4 步的调用);`packages/rest/src/meta-collection-routes.ts`
列表门入口新增的几行;`.changeset/29199-bracketed-object-package-refused.md`。
- **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run
src/meta-app-publish-gate.test.ts
src/meta-query-arity-doors.test.ts`,预期全绿,其中 `BRACKETED view object[…]` 与
`BRACKETED package` 用例断言 400。
- **需要你做的**:无,席位落地。
## What changes
`?object[]=lead`, `?object[0]=lead`, `?package[]=ID` and
`?package[0]=ID` reach the `/meta` doors under the key as spelled: every
adapter hands query keys through unparsed (`readQuery` in
`plugin-hono-server`, `readDispatchQuery` in `@objectstack/hono`). The
view switcher reads `query.object` and the package binding reads
`query.package`, so neither saw them, and the caller got every view, or
the list across every package, with 200.
- `packages/rest/src/meta-item-read-gate.ts`
- #29046's `refuseBracketedAppIdFilter` becomes one helper keyed by
parameter, `refuseBracketedListFilter(query, param, names, lists)`. Step
3 asks it for `id` on `app` and step 4 now asks it for `object` on
`view`. The `id` answer is byte-identical; the sentence builder is now
`listFilterRefusalMessage`, and the empty `?id=` / `?object=` messages
are byte-identical too (their existing pins pass unchanged).
- `object` (new): `Query parameter 'object[]' names no object; the
'object' filter takes exactly one object name, spelled ?object=NAME. The
list was not served: a bracketed 'object' key is not a filter. Name one
object, or leave the parameter out to list every view you can see.`
- `metaBracketedPackageRefusal(door, query)` (new, module-level, not
added to the package index): on the two arity doors that read `package`
(the list and the book tree) a key that opens `package[` is refused as
data, rule `bracketed-parameter`, in the same sentence frame.
`metaQueryArityRefusal` asks it after the multiplicity rule, so the
runtime dispatcher's list and tree get it with no runtime source change.
- list: `Query parameter 'package[]' names no package; the 'package'
filter takes exactly one package id, spelled ?package=ID. The list was
not served: a bracketed 'package' key is not a filter. Name one package,
or leave the parameter out to list across every package.` The tree says
`The tree was not served` and `read the tree across every package`.
- `packages/rest/src/meta-collection-routes.ts` (list) and
`packages/rest/src/meta-item-read-routes.ts` (book tree): `RestServer`'s
two doors ask the same decision right after `refuseRepeatedQueryParams`,
before the binding is read, and write it in the nested envelope their
arity gate writes.
- A bracketed key beside the plain one is refused on all three (two
spellings of one filter).
**Measured: which doors does each seam serve (order item 2, 3).**
| Door | `?object[]=` | `?package[]=` on main | This PR |
|:--|:--|:--|:--|
| `GET /meta/view`, `/meta/views` (list chain step 4) | 200, every view
| 200, every package | 400 / 400 |
| `GET /meta/:type`, any other type | not read (like `?object=`) | 200,
every package | unchanged / 400 |
| `GET /meta/book/:name/tree` | n/a | 200, every package | 400 |
| `GET /meta/:type/:name`, `/layers` | n/a | 400 unknown parameter
(closed set) | unchanged |
| `PUT` / `DELETE /meta/:type/:name`, `POST …/publish` (write doors) |
n/a | 400 unknown parameter (closed set), protocol never called |
unchanged |
| `GET /meta/diagnostics` | n/a | 400 unknown parameter (closed set) |
unchanged |
Step 4 is the only list step that reads `object`, keyed on the folded
`view` (`views` is covered). The write doors were measured before any
change (a scratch probe driving each `RestServer` route with
`?package[]=`; the dispatcher's save and item read ask the same closed
sets through `metaItemQueryRefusal`), so no write behaviour changes
here. The order's "at the binding's one seam" could not be taken
literally: `metaItemPackageBinding(raw)` receives the VALUE of
`query.package`, which is `undefined` for a bracketed key, so it cannot
see the key. The seam both transports ask before every arity-door read
of `package` is the arity decision, which is where the refusal sits;
`rest-server.ts` (the binding's home) is untouched.
**Producer census (order item 5).** `@objectstack/client` builds
`?package=` with `URLSearchParams.set` (both `meta.getItems` surfaces)
and sends no `object` on the list; the console (`ui/**`, read only) has
no qs library and no bracketed `object`/`package` builder; no docs or
skill example spells either key bracketed. No real producer sends them.
## Tests
- `packages/rest/src/meta-app-publish-gate.test.ts`:
`it.each(['object[]', 'object[0]', 'object[name]'])` on `view` and
`views` for three caller postures asserts 400, `code`, the full sentence
and no list; a bracketed key beside a plain `object` is 400; controls:
`?object=lead` switches, no `object` lists all, and a bracketed `object`
key on `app` is not read (200).
- `packages/rest/src/meta-query-arity-doors.test.ts`: the decision as
data for `package[]`, `package[0]`, `package[id]` on the list and the
tree (beside a plain `package` too); a repeated plain `package` still
answers the multiplicity sentence first; not judged on `_drafts`,
`state` or `packages[`; `RestServer`'s list and tree answer the nested
400 and `getMetaItems` is never called; controls: the plain `package`
still scopes, none reads every package.
- `packages/runtime/src/domains/dispatcher-query-arity.test.ts`: the
dispatcher's list and tree refuse `package[]` / `package[0]` with the
same code and sentence before any read, and its list chain refuses
`object[]`.
- `packages/runtime/src/domains/meta-list-projection-parity.test.ts`:
probes `?object[]=lead`, `?object[0]=lead`, `?package[]=crm` on the list
census (every type × caller, both transports), `?package[]=crm` on the
item, tree and layers censuses; the "reference moves" test asserts
`RestServer`'s 400 for `object[]` and `package[]`.
- `packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts`
(booted showcase over Hono), appended `describe`: `/meta/view?object[]=`
/ `?object[0]=showcase_task` and `/meta/object?package[]=` /
`?package[0]=com.example.showcase` answer 400 `VALIDATION_ERROR` naming
the key, no items, as the admin and as a member; controls:
`?object=showcase_task` is that object's views and fewer than all,
`?package=com.example.showcase` is narrower than no package.
No existing test was edited to pass; every hunk in a test file is an
addition.
<details><summary>Red first on main (45e9b2f)</summary>
The pins were committed before the fix (919e71b) and run against main's
source and dist:
- rest: `Tests 13 failed | 58 passed (71)` — the three `BRACKETED view
object[…]` cases (`expected 200 to be 400`) and the ten
bracketed-`package` cases. Every control passed.
- runtime: `Tests 6 failed | 1174 passed (1180)` — the five dispatcher
cases and "the reference moves". The census rows stayed green, as
expected: on main both transports gave the same unfiltered answer.
- dogfood: the reds are read off the ablation legs below (the same code
as main for each arm).
</details>
<details><summary>Ablation: each refusal, put back, reds only its own
pins</summary>
Through `scripts/ablation-replace.mjs` (wrap mode), `pnpm --filter
@objectstack/rest build`, then `ablation-dist-preflight.mjs` before
reading any run.
1. **object arm.** The step-4 call `if (metaType === 'view')
refuseBracketedListFilter(query, 'object', 'object', 'view');` replaced
by a marker statement. Anchor 1 → 0, blob `894bc70c` → `aae79d48`;
marker present in 2 built files. rest `3 failed | 32 passed (35)` (the
three `object[…]` cases); runtime `2 failed | 1178 passed (1180)` (the
dispatcher `object[]` case and the reference test); dogfood `2 failed |
17 passed (19)` (`/meta/view?object[]=` and `?object[0]=`).
2. **package arm.** `metaBracketedPackageRefusal`'s door check replaced
by an unconditional `return undefined`. Anchor 1 → 0, blob `894bc70c` →
`2a7a9483`; marker present in 2 built files. rest `10 failed | 26 passed
(36)` (the ten bracketed-`package` cases); runtime `5 failed | 1175
passed (1180)` (the four dispatcher list/tree cases and the reference
test); dogfood `2 failed | 17 passed (19)` (`/meta/object?package[]=`
and `?package[0]=`).
Each restore: blob back to HEAD `894bc70c`, `git diff HEAD` empty,
rebuild exit 0, marker absent from all 6 built files, whole tree clean.
</details>
## Acceptance notes
- **Premise.** Holds as the card states it, and wider: the bracketed
`package` key also reads as "every package" on the list (every type) and
on the book tree.
- **File surface vs the claim.** The claim named
`meta-item-read-gate.ts` and, if `package[]` red, the binding's home
(`rest-server.ts`). The fix lives in `meta-item-read-gate.ts` plus
three-line door hand-offs in `meta-collection-routes.ts` and
`meta-item-read-routes.ts`; `rest-server.ts` is untouched (see
"Measured" for why the binding cannot see the key). The runtime parity
and dispatcher arity tests gained pins; no runtime source changed.
- **Two dialects, as before.** The view `object[]` refusal is thrown
inside the list chain after the per-caller gate, so `RestServer` writes
the flat `{ error, code }` body #29046's `id[]` refusal writes. The
`package[]` refusal is answered at the door entry, where `package` is
read, so `RestServer` writes the nested `{ error: { code, message } }`
its arity gate writes. Status and `code` are identical in both.
- **Same class, not fixed here (report for the seat).** `GET
/meta/_drafts` reads `?packageId=` and `?type=` with no closed set, so a
bracketed `packageId[]` or `type[]` very likely reads as every package
or every type; `?preview[]=draft` on the list reads as no preview (the
published list) and `?include[]=` as no inclusion. Not measured here;
they are outside the card's keys.
## Verification
Every local reading below is at HEAD `1ee7e4f5` (the fix `34997b8f` plus
a merge of `origin/main`), in a worktree on
`claude/project-thread-9szbaq`. Families CI runs are cited from this
PR's check-runs in the report, not re-run here.
<details><summary>Package suites and typecheck (HEAD 1ee7e4f)</summary>
- `pnpm --filter @objectstack/rest test`: `VERDICT command-exit 0` ·
`Test Files 367 passed (367)` · `Tests 6980 passed | 332 skipped (7312)`
- `pnpm --filter @objectstack/rest typecheck`: exit 0
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2
src/domains/dispatcher-query-arity.test.ts
src/domains/meta-list-projection-parity.test.ts`: exit 0 · `Tests 1180
passed (1180)`
- `pnpm --filter @objectstack/runtime typecheck`: exit 0; `pnpm --filter
@objectstack/dogfood typecheck`: exit 0
- lint: root eslint with `--no-inline-config` over `packages/rest`,
`packages/runtime`, `packages/qa/dogfood`: exit 0, no findings
</details>
<details><summary>Full dogfood battery (the narrowing owes it)</summary>
`pnpm --filter @objectstack/dogfood test` under the verify lock, after
the `@objectstack/dogfood^...` closure build on this head: `VERDICT
command-exit 0` · `Test Files 389 passed | 3 skipped (392)` · `Tests
3220 passed | 28 skipped (3248)`.
</details>
<details><summary>Derived gate set</summary>
`node scripts/pm/dispatch-gates.mjs --commands` (from the merge-base, at
`1ee7e4f5`): 64 commands, all exit 0. `--ran` reconciliation: `64
derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`. Three
first-pass exit 3s were prerequisites, not reds, each re-run green:
`check-plugin-teardown-shape.mjs --self-test` (its pinned fixture commit
fetched on this shallow clone), `check:dual-build-cjs-loads` (7 packages
outside the dogfood closure had no `dist/`; built, then `115 published
require entry point(s) across 70 package(s)` load) and
`check:type-check-debt` (its closure build queued behind the dogfood
battery on the shared lock; re-run under the lock, `0 raw tsc
error(s)`).
</details>
- **Not run locally:** `test:repo` (the vitest `repo` projects, CI `Test
Core`). The one `repo`-project file this diff edits,
`meta-list-projection-parity.test.ts`, was run directly (above).
- `origin/main` has moved 22 commits since the merge; none touches a
file this PR edits (`git diff --name-only` over the range), so the
queue's re-run against current main is the remaining check.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmPfJsJeWNxeaZkvyEM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3ec2647 commit 08610a9
9 files changed
Lines changed: 342 additions & 31 deletions
File tree
- .changeset
- packages
- qa/dogfood/test
- rest/src
- runtime/src/domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
221 | 267 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
374 | 374 | | |
375 | 375 | | |
376 | 376 | | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
377 | 423 | | |
378 | 424 | | |
379 | 425 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
648 | 648 | | |
649 | 649 | | |
650 | 650 | | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
651 | 661 | | |
652 | 662 | | |
653 | 663 | | |
| |||
0 commit comments