Skip to content

Commit 186a83a

Browse files
fix(rest)!: /meta/_drafts refuses a bracketed packageId or type key instead of listing every draft (#29440)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi96RirUSzHMtPHuFBWpUKa7o)_ Fixes #29319 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no skill or doc page teaches a bracketed `packageId` or `type` key on `_drafts` (`git grep` over `skills/`, `content/docs/` and `apps/docs/` finds none); the pages that name `_drafts` teach no query spelling at all. ## 维护者速读 - **改了什么**:`GET /meta/_drafts` 收到括号写法的 `packageId`(`?packageId[]=ID`、`?packageId[0]=ID`)或 `type`(`?type[]=TYPE`、`?type[0]=TYPE`)时答 400 `VALIDATION_ERROR` 并点名该键;以前这些键被忽略,返回全部包、全部类型的草稿。 - **为什么改**:按 qs 风格序列化数组的客户端只想看一个包或一种类型的草稿,却拿回全部草稿并当成过滤结果;这是 #29046、#29298 修过的同一类静默放宽在 `/meta` 上的最后一处。 - **风险与回滚**:只动 `packages/rest`(#29298 的括号判定改成按门查表的一个函数,`_drafts` 门入口加同一判定,两条传输共用),`patch` 带 BREAKING 横幅;只影响发括号键的 API 调用方,`@objectstack/client` 与控制台都不发。回滚即还原本 PR。 - **先看**:`packages/rest/src/meta-item-read-gate.ts`(`META_BRACKETED_FILTERS` 与 `metaBracketedFilterRefusal`);`packages/rest/src/meta-collection-routes.ts` 的 `_drafts` 门入口新增几行;`.changeset/29319-bracketed-drafts-filter-refused.md`。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/meta-query-arity-doors.test.ts`,预期全绿,其中 `a BRACKETED packageId or type key on _drafts` 用例断言 400。 - **需要你做的**:无,席位落地。 ## What changes `?packageId[]=ID`, `?packageId[0]=ID`, `?type[]=TYPE` and `?type[0]=TYPE` reach `GET /meta/_drafts` under the key as spelled: both Hono adapters hand query keys through unparsed (`readQuery` in `plugin-hono-server`, `readDispatchQuery` in `@objectstack/hono`). Both transports read only `query.packageId` and `query.type`, so `listDrafts` was handed no package and no type, and an author who named one package's (or one type's) drafts got every draft with 200. - `packages/rest/src/meta-item-read-gate.ts`: #29298's `metaBracketedPackageRefusal` becomes `metaBracketedFilterRefusal(door, query)`, driven by one per-door table, `META_BRACKETED_FILTERS`: the list and the book tree judge `package` (as before), `_drafts` judges `packageId` and then `type`. It is the same function, generalised and renamed, not a second copy; it was module-level and never on the package index, so no export moves. The two `package` sentences are byte-identical (#29298's pins pass unchanged). `metaQueryArityRefusal` already asks it after the multiplicity rule, so the runtime dispatcher's `_drafts` gets the refusal with no runtime source change. - `packageId`: `Query parameter 'packageId[]' names no package; the 'packageId' filter takes exactly one package id, spelled ?packageId=ID. The list was not served: a bracketed 'packageId' key is not a filter. Name one package, or leave the parameter out to list the drafts of every package.` - `type`: `Query parameter 'type[]' names no metadata type; the 'type' filter takes exactly one metadata type, spelled ?type=TYPE. The list was not served: a bracketed 'type' key is not a filter. Name one metadata type, or leave the parameter out to list the drafts of every type.` - `packages/rest/src/meta-collection-routes.ts`: `RestServer`'s `_drafts` door asks the same decision right after `refuseRepeatedQueryParams`, before `listDrafts`, and writes it in the nested envelope its arity gate writes (as the list door does). - `packages/rest/src/meta-item-read-routes.ts`: the book tree's call follows the rename (one line). - The order is unchanged on both transports: capability gate (403), protocol probe (501), multiplicity, then the bracketed key. A bracketed key beside the plain one is refused (two spellings of one filter). `MetaItemQueryRefusal.rule` gains no member: the refusal reuses `bracketed-parameter`, so `Clause-②: no (narrowing)`. **Measured on main before any edit (27c1c55).** Real Hono request parsing on both transports (`RestServer` on a `HonoHttpServer`, driven with `app.fetch`; the dispatcher on a raw Hono app with `readDispatchQuery`'s exact query shape), an author caller, four seeded drafts (two packages × two types), brackets sent raw and percent-encoded: | Request | Both transports on main | |:--|:--| | no query | 200, 4 drafts (control) | | `?packageId=crm` / `?type=view` / both | 200, 2 / 2 / 1 drafts (controls) | | `?packageId[]=crm`, `?packageId[0]=crm` | 200, 4 drafts, `listDrafts({})` | | `?type[]=view`, `?type[0]=view` | 200, 4 drafts, `listDrafts({})` | | `?packageId[]=crm&type[]=view` | 200, 4 drafts | | `?packageId=crm&packageId[]=hr` | 200, 2 drafts: the bracketed key silently dropped | **Producer census.** `@objectstack/client`'s `listDrafts` builds both keys with `URLSearchParams.set`; the console builds `?packageId=${encodeURIComponent(…)}` (`usePendingDrafts.ts`, `PackagesPage.tsx`); neither has a qs library; no docs or skill example spells either key bracketed. No real producer sends them. ## Tests - `packages/rest/src/meta-query-arity-doors.test.ts` (appended `describe`): the decision as data for `packageId[]`, `packageId[0]`, `packageId[id]`, `type[]`, `type[0]`, `type[name]` (and beside the plain key); a repeated plain `packageId` still answers the multiplicity sentence first; not judged on the list or the tree, nor for `types[]`, `packageIds[]` or `package[]` on `_drafts`; `RestServer`'s `_drafts` answers the nested 400 and `listDrafts` is never called; controls: the plain spellings still narrow, none reads every draft. - `packages/runtime/src/domains/dispatcher-query-arity.test.ts` (appended `describe`): the dispatcher's `_drafts` refuses `packageId[]`, `packageId[0]`, `type[]`, `type[0]` with the same code and sentence before `listDrafts`; beside the plain key too; control: the plain spellings reach `listDrafts`. - `packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts` (booted showcase over Hono, appended `describe`): `/meta/_drafts?packageId[]=` / `?packageId[0]=com.example.showcase` and `?type[]=` / `?type[0]=view` answer 400 `VALIDATION_ERROR` naming the key, no list, as the admin; controls: `?packageId=` and `?type=` answer 200 with only matching rows, and a member is still refused 403 at the capability gate first. No existing test was edited; every hunk in a test file is an addition. <details><summary>Red first on main (a2fcede, pins at b748eaa)</summary> The pins were committed before the fix and run against main's source and dist: - rest: `Tests 10 failed | 39 passed (49)`: the six as-data cases and the four `RestServer` door cases. Every control and every #29298 pin passed. - runtime: `Tests 5 failed | 38 passed (43)`: the four dispatcher cases and the beside-the-plain-key case. - dogfood: the reds are read off the ablation legs below. </details> <details><summary>Ablation: each refused key, and the RestServer hand-off, 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 packages/rest MARKER` (marker present in 2 built files) before reading any run. Baseline on the fixed head first: rest `49 passed`, runtime `43 passed`, dogfood file `24 passed`. 1. **packageId.** The `_drafts` table row's `param: 'packageId'` renamed to a marker, so the key is not judged. Anchor 1 → 0, blob `977baa3c` → `69581359`. rest `5 failed | 44 passed (49)` (the three `packageId` as-data cases, the two `RestServer` `packageId` cases); runtime `3 failed | 40 passed (43)` (`packageId[]`, `packageId[0]`, beside-the-plain-key); dogfood `2 failed | 22 passed (24)` (`packageId[]`, `packageId[0]`). 2. **type.** The row's `param: 'type'` renamed to a marker. Anchor 1 → 0, blob `977baa3c` → `36e7b636`. rest `5 failed | 44 passed (49)` (the three `type` as-data cases, the two `RestServer` `type` cases); runtime `2 failed | 41 passed (43)` (`type[]`, `type[0]`); dogfood `2 failed | 22 passed (24)` (`type[]`, `type[0]`). 3. **RestServer hand-off.** The `_drafts` door's call asks an unknown door instead of `'drafts'`. Anchor 1 → 0, blob `824a9f54` → `2d929242`. rest `4 failed | 45 passed (49)` (the four `RestServer` door cases); runtime `43 passed (43)` (the dispatcher asks the decision through `metaQueryArityRefusal`, untouched); dogfood `4 failed | 20 passed (24)` (the showcase is served by `RestServer`). Each restore: blob back to HEAD, `git diff HEAD` empty; then a rebuild, all three markers absent from all 6 built files, whole tree clean (`git status --porcelain` empty). </details> ## Acceptance notes - **Premise.** Holds as the card states it, on both transports, raw and percent-encoded; and beside a plain `?packageId=` the bracketed key was silently dropped. - **File surface vs the claim.** As claimed, plus the one-line rename at the book tree's call in `meta-item-read-routes.ts`, the other caller of the function this PR generalises. `packages/runtime/src/domains/meta.ts` needed no edit: its `_drafts` already asks `metaQueryArityRefusal('drafts', …)`. No `ui/**`, no `packages/spec/**`. - **Main-red seen on the final head, not this change.** `flow-runas.dogfood.test.ts` (3) and `declarative-endpoint-anonymous-guest.dogfood.test.ts` (1) red with this PR's source set back to main's (see Verification); reported to the seat. - **Same class, still not fixed here.** `?preview[]=draft` on the list reads as no preview and `?include[]=` as no inclusion (#29298's note). Not measured here; outside this card's keys. ## Verification Every local reading below is at HEAD `ee0e30c9` (the fix `359c7496` plus two merges of `origin/main`, the last taken after `dfbe34d8`), in a worktree on `claude/project-thread-ta4g7t`. 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 ee0e30c)</summary> - `pnpm --filter @objectstack/rest test`: `VERDICT command-exit 0` · `Test Files 370 passed (370)` · `Tests 7043 passed | 332 skipped (7375)` - `pnpm --filter @objectstack/rest typecheck && … runtime typecheck && … dogfood typecheck`: `VERDICT command-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`: `VERDICT command-exit 0` · `Tests 1185 passed (1185)` - lint: root eslint with `--no-inline-config` over `packages/rest`, `packages/runtime`, `packages/qa/dogfood`: exit 0, no findings - `node scripts/check-platform-checklist.mjs --changed-from origin/main` (a module export was renamed): OK, 12 anchors judged, 0 lost </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: - HEAD `ee0e30c9`: `Test Files 2 failed | 391 passed | 3 skipped (396)` · `Tests 4 failed | 3244 passed | 28 skipped (3276)`. The four reds are `flow-runas.dogfood.test.ts` (three: the member's flow trigger answers `404 RECORD_NOT_FOUND`) and `declarative-endpoint-anonymous-guest.dogfood.test.ts` (one). They are not this change: with this PR's three `packages/rest/src` files set back to `origin/main`'s (blob-identical to the merged main), `rest` rebuilt and the fix's sentence absent from `dist/`, the same two files answer the same `Tests 4 failed | 8 passed (12)`; restored, `git status --porcelain` empty. Neither file touches `/meta`; between the two heads main took `dfbe34d8`, which changed the flow start doors (not bisected). Reported to the seat as a main-red, not fixed here. - HEAD `88dc5779` (the same diff on the previous main): `VERDICT command-exit 0` · `Test Files 393 passed | 3 skipped (396)` · `Tests 3248 passed | 28 skipped (3276)`. </details> <details><summary>Derived gate set</summary> `node scripts/pm/dispatch-gates.mjs --commands` (from the merge-base, at `ee0e30c9`): 64 commands, all exit 0. `--ran` reconciliation with each exit code recorded: `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero)`. On the previous head two first-pass exit 3s were prerequisites, each re-run green: `check-plugin-teardown-shape.mjs --self-test` (its pinned fixture commit fetched on this shallow clone) and `check:dual-build-cjs-loads` (7 packages outside the dogfood closure built). </details> - **Not run locally:** `test:repo` (the vitest `repo` projects, CI `Test Core`). The one `repo`-project file this diff could reach, `meta-list-projection-parity.test.ts`, was run directly (above). --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmSq83zsshBxiLpFJdE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29c70ca commit 186a83a

7 files changed

Lines changed: 232 additions & 32 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: a bracketed `packageId` or `type` key on `GET /meta/_drafts` (`?packageId[]=ID`, `?packageId[0]=ID`, `?type[]=TYPE`, `?type[0]=TYPE`) is refused 400 naming the key instead of being ignored and answered with every package's or every type's drafts
6+
7+
Clause-②: no (narrowing) — `GET /meta/_drafts` with any query key that opens `packageId[` or `type[` (on `RestServer` and on the runtime dispatcher, which share the arity decision) is refused with `400 VALIDATION_ERROR`; it answered 200 with every draft the caller may read before. No package export, answer member, error code, status or `rule` tag is added: the refusal reuses the `bracketed-parameter` rule the list and the book tree already answer.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: a drafts request that spells its one-package or one-type filter as an array key is refused instead of served unfiltered. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing on the `/meta/_drafts` read (`@objectstack/rest`).
13+
14+
- **Before.** `?packageId[]=ID`, `?packageId[0]=ID`, `?type[]=TYPE` and `?type[0]=TYPE` (the qs-style array spellings) reached the door under the key as spelled, which neither filter read, so an author who asked for one package's or one type's drafts got every draft, as if it were the filtered list. Beside a plain `?packageId=` the bracketed key was silently dropped.
15+
- **Now.** The door refuses a key that opens `packageId[` or `type[` after its capability gate and its multiplicity gate, before any read: `400 VALIDATION_ERROR`, message `Query parameter 'packageId[]' names no package; the 'packageId' filter takes exactly one package id, spelled ?packageId=ID. …` or `Query parameter 'type[]' names no metadata type; the 'type' filter takes exactly one metadata type, spelled ?type=TYPE. …`, and no list. A bracketed key beside the plain one is refused too.
16+
- **Unchanged.** `?packageId=ID` and `?type=TYPE` narrow as before, and leaving them out lists every draft; a repeated `packageId` or `type` keeps its 400; a caller with no authoring capability is still refused 403 first.
17+
- **Who sees it.** An API client that serialises arrays qs-style. `@objectstack/client`'s `listDrafts` builds both keys with `URLSearchParams` (no brackets), and the console sends `?packageId=` plain.
18+
19+
**The remedy.** FROM `?packageId[]=ID` (or `?packageId[0]=ID`) TO `?packageId=ID`; FROM `?type[]=TYPE` (or `?type[0]=TYPE`) TO `?type=TYPE`; leave the key out to list every draft.

‎packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,4 +264,41 @@ describe('[#26572] the v18 /meta read doors, over HTTP on a booted showcase', ()
264264
expect(objects.length).toBeLessThan(everyPackage.length);
265265
});
266266
});
267+
268+
// The same class on `_drafts`' own filters: `?packageId[]=` / `?type[]=`
269+
// reached the door under the key as spelled, so the list was not narrowed
270+
// and an author who asked for one package's (or one type's) drafts got
271+
// every draft. Each is refused: 400 VALIDATION_ERROR naming the key, no list.
272+
describe('GET /meta/_drafts with a bracketed `packageId` or `type` key is refused', () => {
273+
const draftsOf = (body: any): any[] => body?.drafts ?? body?.data?.drafts ?? [];
274+
275+
for (const [key, value, names] of [
276+
['packageId[]', PKG, 'package'], ['packageId[0]', PKG, 'package'],
277+
['type[]', 'view', 'metadata type'], ['type[0]', 'view', 'metadata type'],
278+
] as const) {
279+
it(`/meta/_drafts?${key}=…: 400 VALIDATION_ERROR naming the key, for the admin`, async () => {
280+
const res = await stack.apiAs(admin, 'GET', `/meta/_drafts?${key}=${encodeURIComponent(value)}`);
281+
const body: any = await res.json();
282+
expect({ status: res.status, code: codeOf(body) }, JSON.stringify(body).slice(0, 400))
283+
.toEqual({ status: 400, code: 'VALIDATION_ERROR' });
284+
expect(messageOf(body)).toMatch(new RegExp(`^Query parameter '${key.replace(/[[\]]/g, '\\$&')}' names no ${names};`));
285+
expect(draftsOf(body)).toEqual([]);
286+
});
287+
}
288+
289+
it('CONTROLS — the plain spellings narrow, and a member is still refused at the capability gate first', async () => {
290+
const scoped = await stack.apiAs(admin, 'GET', `/meta/_drafts?packageId=${encodeURIComponent(PKG)}`);
291+
expect(scoped.status).toBe(200);
292+
const inPackage = draftsOf(await scoped.json());
293+
expect(inPackage.every((d: any) => d?.packageId === PKG), JSON.stringify(inPackage).slice(0, 400)).toBe(true);
294+
295+
const typed = await stack.apiAs(admin, 'GET', '/meta/_drafts?type=view');
296+
expect(typed.status).toBe(200);
297+
const views = draftsOf(await typed.json());
298+
expect(views.every((d: any) => d?.type === 'view'), JSON.stringify(views).slice(0, 400)).toBe(true);
299+
300+
const member = await stack.apiAs(liaison, 'GET', `/meta/_drafts?packageId[]=${encodeURIComponent(PKG)}`);
301+
expect(member.status).toBe(403);
302+
});
303+
});
267304
});

‎packages/rest/src/meta-collection-routes.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,16 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop
465465
// [#23214] The list lives beside the shared decision, so the
466466
// runtime dispatcher's twin refuses the same names.
467467
if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.drafts)) return;
468+
// A bracketed `packageId` or `type` key (`?type[]=`) is read
469+
// by neither filter below, so it narrowed nothing: refused
470+
// here, by the decision the dispatcher's twin asks too.
471+
const bracketedFilter = metaReadGate.metaBracketedFilterRefusal('drafts', req.query);
472+
if (bracketedFilter) {
473+
res.status(bracketedFilter.status).json({
474+
error: { code: bracketedFilter.code, message: bracketedFilter.message },
475+
});
476+
return;
477+
}
468478
// [#29180] A `type` that names no metadata type narrowed the
469479
// list to nothing and answered `200 {"drafts":[]}`, a
470480
// confident "nothing pending". Refused by the list door's own
@@ -651,7 +661,7 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop
651661
// A bracketed `package` key (`?package[]=`) is read by no
652662
// binding below, so it scoped nothing: refused here, by the
653663
// decision the dispatcher's twin asks too.
654-
const bracketedPackage = metaReadGate.metaBracketedPackageRefusal('list', req.query);
664+
const bracketedPackage = metaReadGate.metaBracketedFilterRefusal('list', req.query);
655665
if (bracketedPackage) {
656666
res.status(bracketedPackage.status).json({
657667
error: { code: bracketedPackage.code, message: bracketedPackage.message },

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2417,7 +2417,7 @@ const LAYERED_VIEW_MEMBER_MESSAGE =
24172417
* everywhere; `rule` names which of the three refused, because `RestServer`'s
24182418
* item read answers the third at a different point (see
24192419
* {@link metaItemQueryRefusal}). The arity doors answer a fourth,
2420-
* `bracketed-parameter` ({@link metaBracketedPackageRefusal}).
2420+
* `bracketed-parameter` ({@link metaBracketedFilterRefusal}).
24212421
*/
24222422
export interface MetaItemQueryRefusal {
24232423
readonly status: 400;
@@ -2558,50 +2558,88 @@ export const META_QUERY_ARITY_DOORS: Readonly<Record<MetaQueryArityDoor, readonl
25582558
* door's {@link META_QUERY_ARITY_DOORS} entry that was supplied more than once,
25592559
* as `400 VALIDATION_ERROR` with `repeatedQueryParamMessage`'s sentence, which
25602560
* is the answer `RestServer`'s door gives through `refuseRepeatedQueryParams`;
2561-
* then a bracketed `package` key ({@link metaBracketedPackageRefusal}), which
2561+
* then a bracketed narrowing filter ({@link metaBracketedFilterRefusal}), which
25622562
* `RestServer`'s door asks right after its multiplicity gate. It returns
25632563
* `undefined` otherwise, and has then unwrapped a one-element array in place.
25642564
* ⛔ Ask it before any read of the door's query.
25652565
*/
25662566
export function metaQueryArityRefusal(door: MetaQueryArityDoor, query: unknown): MetaItemQueryRefusal | undefined {
25672567
if (!query || typeof query !== 'object') return undefined;
25682568
return repeatedOnceRefusal(query as Record<string, unknown>, META_QUERY_ARITY_DOORS[door])
2569-
?? metaBracketedPackageRefusal(door, query);
2569+
?? metaBracketedFilterRefusal(door, query);
25702570
}
25712571

25722572
/**
2573-
* The answer of the two arity doors that read `?package=` — the list and the
2574-
* book tree — to a BRACKETED spelling of it: `?package[]=ID`, `?package[0]=ID`,
2575-
* any key that opens `package[`. A client that serialises arrays qs-style sends
2576-
* it; every adapter hands the key through as spelled, so the door's
2577-
* `metaItemPackageBinding(query.package)` read no package, and a caller who
2578-
* named ONE package was answered across every package. `400
2579-
* VALIDATION_ERROR`, in the list chain's narrowing-filter frame
2580-
* ({@link listFilterRefusalMessage}), naming the key as sent; beside a plain
2581-
* `?package=` too (two spellings of one scope). Asked at the door's entry,
2582-
* before any read — where the binding is read — by both transports:
2573+
* One filter an arity door judges for a BRACKETED spelling: the name as the
2574+
* door reads it, what one value of it names, how one value is spelled, what the
2575+
* door serves, and what leaving the parameter out reads. Every field goes into
2576+
* {@link listFilterRefusalMessage}'s one sentence frame.
2577+
*/
2578+
interface MetaBracketedFilter {
2579+
readonly param: string;
2580+
readonly names: string;
2581+
readonly takes: string;
2582+
readonly served: 'list' | 'tree';
2583+
readonly unscoped: string;
2584+
}
2585+
2586+
const PACKAGE_ID_TAKES = 'one package id, spelled';
2587+
2588+
/**
2589+
* The single-valued filters each arity door judges for a bracketed spelling, in
2590+
* the order asked: the names these doors read at their entry whose absence
2591+
* WIDENS the answer (no package is every package, no type is every type). The
2592+
* list's `id` and `object` are judged a step later, in the list chain
2593+
* ({@link refuseBracketedListFilter}).
2594+
*/
2595+
const META_BRACKETED_FILTERS: Readonly<Partial<Record<MetaQueryArityDoor, readonly MetaBracketedFilter[]>>> = Object.freeze({
2596+
list: Object.freeze([
2597+
{ param: 'package', names: 'package', takes: `${PACKAGE_ID_TAKES} ?package=ID`, served: 'list', unscoped: 'list across every package' },
2598+
] as const),
2599+
'book-tree': Object.freeze([
2600+
{ param: 'package', names: 'package', takes: `${PACKAGE_ID_TAKES} ?package=ID`, served: 'tree', unscoped: 'read the tree across every package' },
2601+
] as const),
2602+
drafts: Object.freeze([
2603+
{ param: 'packageId', names: 'package', takes: `${PACKAGE_ID_TAKES} ?packageId=ID`, served: 'list', unscoped: 'list the drafts of every package' },
2604+
{ param: 'type', names: 'metadata type', takes: 'one metadata type, spelled ?type=TYPE', served: 'list', unscoped: 'list the drafts of every type' },
2605+
] as const),
2606+
});
2607+
2608+
/**
2609+
* The answer of an arity door to a BRACKETED spelling of one of its narrowing
2610+
* filters ({@link META_BRACKETED_FILTERS}): `?package[]=ID` or `?package[0]=ID`
2611+
* on the list and the book tree, `?packageId[]=ID` or `?type[]=TYPE` on
2612+
* `_drafts` — any key that opens `param[`. A client that serialises arrays
2613+
* qs-style sends it; every adapter hands the key through as spelled
2614+
* (`readQuery` in `plugin-hono-server`, `readDispatchQuery` in
2615+
* `@objectstack/hono`), so the door, which reads `query[param]`, read no
2616+
* filter, and a caller who named ONE package (or one type) was answered across
2617+
* every package (or type). `400 VALIDATION_ERROR`, rule `bracketed-parameter`,
2618+
* in the list chain's narrowing-filter frame ({@link listFilterRefusalMessage}),
2619+
* naming the key as sent; beside a plain spelling too (two spellings of one
2620+
* filter). Asked at the door's entry, before any read, by both transports:
25832621
* {@link metaQueryArityRefusal} for the dispatcher, after
25842622
* `refuseRepeatedQueryParams` for `RestServer`.
25852623
*
25862624
* Every by-name `/meta` door (the item read, `/layers`, the save, the reset,
2587-
* `/publish`) and the diagnostics sweep already refuse the key: it is not in
2588-
* their closed set (route rule 5). `_drafts` and `state` read no `package`.
2625+
* `/publish`) and the diagnostics sweep already refuse such a key: it is not in
2626+
* their closed set (route rule 5).
25892627
*/
2590-
export function metaBracketedPackageRefusal(door: MetaQueryArityDoor, query: unknown): MetaItemQueryRefusal | undefined {
2591-
if (!META_QUERY_ARITY_DOORS[door].includes('package')) return undefined;
2592-
const key = bracketedKeyOf(query, 'package');
2593-
if (key === undefined) return undefined;
2594-
const tree = door === 'book-tree';
2595-
return {
2596-
status: 400,
2597-
code: 'VALIDATION_ERROR',
2598-
message: listFilterRefusalMessage(
2599-
key, 'package', "the 'package' filter takes exactly one package id, spelled ?package=ID",
2600-
"a bracketed 'package' key", tree ? 'tree' : 'list',
2601-
tree ? 'read the tree across every package' : 'list across every package',
2602-
),
2603-
rule: 'bracketed-parameter',
2604-
};
2628+
export function metaBracketedFilterRefusal(door: MetaQueryArityDoor, query: unknown): MetaItemQueryRefusal | undefined {
2629+
for (const filter of META_BRACKETED_FILTERS[door] ?? []) {
2630+
const key = bracketedKeyOf(query, filter.param);
2631+
if (key === undefined) continue;
2632+
return {
2633+
status: 400,
2634+
code: 'VALIDATION_ERROR',
2635+
message: listFilterRefusalMessage(
2636+
key, filter.names, `the '${filter.param}' filter takes exactly ${filter.takes}`,
2637+
`a bracketed '${filter.param}' key`, filter.served, filter.unscoped,
2638+
),
2639+
rule: 'bracketed-parameter',
2640+
};
2641+
}
2642+
return undefined;
26052643
}
26062644

26072645
/**

‎packages/rest/src/meta-item-read-routes.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M
386386
// A bracketed `package` key (`?package[]=`) is read by no
387387
// binding below, so it scoped nothing: refused here, by the
388388
// decision the dispatcher's twin asks too.
389-
const bracketedPackage = metaReadGate.metaBracketedPackageRefusal('book-tree', req.query);
389+
const bracketedPackage = metaReadGate.metaBracketedFilterRefusal('book-tree', req.query);
390390
if (bracketedPackage) {
391391
res.status(bracketedPackage.status).json({
392392
error: { code: bracketedPackage.code, message: bracketedPackage.message },

‎packages/rest/src/meta-query-arity-doors.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,64 @@ describe('a BRACKETED `package` key on the two arity doors that read `package`',
198198
}
199199
});
200200
});
201+
202+
/**
203+
* The same class on `_drafts`' own two filters. `?packageId[]=ID`,
204+
* `?packageId[0]=ID`, `?type[]=TYPE` and `?type[0]=TYPE` reached the door under
205+
* the key as spelled, so `listDrafts` was handed no package and no type, and a
206+
* caller who named one package's (or one type's) drafts got every draft it may
207+
* read. Refused at the door's arity step, before `listDrafts`, in the same
208+
* sentence frame as the list's bracketed `package`.
209+
*/
210+
const bracketedDraftsMessage = (key: string, param: 'packageId' | 'type'): string => (param === 'packageId'
211+
? `Query parameter '${key}' names no package; the 'packageId' filter takes exactly one package id, `
212+
+ `spelled ?packageId=ID. The list was not served: a bracketed 'packageId' key is not a filter. `
213+
+ `Name one package, or leave the parameter out to list the drafts of every package.`
214+
: `Query parameter '${key}' names no metadata type; the 'type' filter takes exactly one metadata type, `
215+
+ `spelled ?type=TYPE. The list was not served: a bracketed 'type' key is not a filter. `
216+
+ `Name one metadata type, or leave the parameter out to list the drafts of every type.`);
217+
const DRAFTS_KEYS = [
218+
['packageId[]', 'packageId'], ['packageId[0]', 'packageId'], ['packageId[id]', 'packageId'],
219+
['type[]', 'type'], ['type[0]', 'type'], ['type[name]', 'type'],
220+
] as const;
221+
222+
describe('a BRACKETED `packageId` or `type` key on `_drafts`', () => {
223+
it.each(DRAFTS_KEYS)('`%s` is refused as data, naming the key', (key, param) => {
224+
expect(metaQueryArityRefusal('drafts', { [key]: 'x' })).toEqual({
225+
status: 400,
226+
code: 'VALIDATION_ERROR',
227+
message: bracketedDraftsMessage(key, param),
228+
rule: 'bracketed-parameter',
229+
});
230+
// Beside the plain key it is two spellings of one filter.
231+
expect(metaQueryArityRefusal('drafts', { [param]: 'x', [key]: 'y' })?.rule).toBe('bracketed-parameter');
232+
});
233+
234+
it('a repeated plain `packageId` still answers the multiplicity sentence first', () => {
235+
expect(metaQueryArityRefusal('drafts', { packageId: ['a', 'b'], 'type[]': 'view' })?.message)
236+
.toBe(repeatedQueryParamMessage('packageId', 2));
237+
});
238+
239+
it('is judged only on `_drafts`, and not for a key that merely starts alike', () => {
240+
expect(metaQueryArityRefusal('list', { 'packageId[]': 'x', 'type[]': 'view' })).toBeUndefined();
241+
expect(metaQueryArityRefusal('book-tree', { 'type[]': 'view' })).toBeUndefined();
242+
expect(metaQueryArityRefusal('drafts', { 'types[]': 'view', 'packageIds[]': 'x', 'package[]': 'x' })).toBeUndefined();
243+
});
244+
245+
it.each(DRAFTS_KEYS.filter(([key]) => key.endsWith('[]') || key.endsWith('[0]')))(
246+
'RestServer: `%s` answers the nested 400 before `listDrafts`',
247+
async (key, param) => {
248+
const { protocol, status, body } = await drive('drafts', { [key]: 'x' });
249+
expect(status).toBe(400);
250+
expect(body).toEqual({ error: { code: 'VALIDATION_ERROR', message: bracketedDraftsMessage(key, param) } });
251+
expect(protocol.listDrafts).not.toHaveBeenCalled();
252+
},
253+
);
254+
255+
it('RestServer: the plain spellings still narrow, and absent ones read every draft (controls)', async () => {
256+
expect((await drive('drafts', { packageId: 'com.acme', type: 'view' })).protocol.listDrafts)
257+
.toHaveBeenCalledWith({ packageId: 'com.acme', type: 'view' });
258+
expect((await drive('drafts', {})).protocol.listDrafts)
259+
.toHaveBeenCalledWith({ packageId: undefined, type: undefined });
260+
});
261+
});

0 commit comments

Comments
 (0)