Repository navigation
Commit 95ab93f
Fixes #20061
Fixes #20062
Clause-②: no (narrowing)
## What
Four published REST doors read `?limit=` with a bare `Number()`. They
substituted, clamped or dropped the result and answered `200`:
- `GET /data/import/jobs`
- `GET /data/:object/export`
- `GET /meta/:type/:name/history`
- `GET /search`
Each door now reads `limit` against its own declaration and refuses a
value outside it. `GET /data/import/jobs` does the same for `offset`,
because its declaration bounds `offset` too. The refusal is `400` with
the data surface's existing `VALIDATION_FAILED` + `fields[]` envelope,
and the service is never called. Nothing a conforming caller sends
changes its answer.
The change is one private reader in `packages/rest/src/rest-server.ts`,
`readDeclaredQueryNumber`, with no new package export:
- **import jobs** read through `ListImportJobsRequestSchema.shape.limit`
/ `.offset`;
- **history** reads through `HistoryMetaItemRequestSchema.shape.limit`;
- **export** and **search** declare no request schema, so they read
through a module-private `z.number().int().optional()`: a whole number,
nothing about range.
A non-blank numeric string is coerced with `Number()` and handed to the
schema. Anything else (`abc`, a blank string, a structured value) goes
to the schema as it came, so the declaration refuses it by type.
`Number()` never gets to invent a `0` or a `NaN` for it.
## Before and after, measured on the real `RestServer` routes (spy
protocol)
Every row answered `200` before this PR.
| door | request | answered before | answers now |
|:--|:--|:--|:--|
| import jobs | `?limit=0` / `?limit=-3` | limit 50 / 1 | `400`,
`fields[0]` = `limit` / `min_value` |
| import jobs | `?limit=201` / `?limit=500` | limit 200 | `400`,
`max_value` |
| import jobs | `?limit=abc` / `1.5` / `Infinity` / blank | limit 50 /
1.5 / 200 / 50 | `400`, `invalid_type` |
| import jobs | `?offset=-1` / `abc` / `1.5` | offset 0 / 0 / 1.5 |
`400`, `min_value` / `invalid_type` |
| export | `?limit=abc` / empty / blank | `X-Export-Limit: 1` (a one-row
export) | `400`, `invalid_type` |
| export | `?limit=1.5` / `Infinity` | `X-Export-Limit: 1.5` / `50000` |
`400`, `invalid_type` |
| meta history | `?limit=abc` / `Infinity` | limit dropped: the whole
change log | `400`, `invalid_type` |
| meta history | `?limit=` (empty) / blank | limit 0: zero events |
`400`, `invalid_type` |
| search | `?limit=abc` | `limit: NaN` handed to `searchAll`: no overall
cap | `400`, `invalid_type` |
| search | `?limit=1.5` / `Infinity` / blank | 1.5 / clamped to 100 / 0
| `400`, `invalid_type` |
Unchanged and pinned by the lit controls:
- **Import jobs:** absent or empty `limit` → 50, absent or empty
`offset` → 0. Conforming values reach `findData` unchanged.
- **Export:** absent → 10000. `?limit=25` → 25. The door's own range
handling is untouched: `?limit=0` → 1 and `?limit=60000` → 50000.
- **History:** absent → no `limit` member. `5`, `0` and `1.5` are
forwarded unchanged, because the declaration is `z.number()` with no
`int()` and no bounds.
- **Search:** absent or empty → `undefined`. `20`, `0` and `500` reach
`searchAll` unchanged, and its `[1, 100]` clamp is its own business.
The refusal body carries:
- `code: 'VALIDATION_FAILED'`;
- `fields: [{ field: 'limit', code, message }]`;
- an `error` sentence naming the parameter;
- `object`, on the export door only. The handler throws
`validationFailure` (`@objectstack/types`), and each door's existing
catch (`handleRouteError` / `mapDataError`) writes the 400. So
`rest-server.ts` gains no response write site, and
`check:route-envelope`'s `siblingCode` count on it is unchanged (green).
## PM mechanism assumptions, measured
1. **The `parseIntegerParam` cycle: confirmed.**
`packages/runtime/package.json` depends on `@objectstack/rest`. rest's
dependencies are `core`, `metadata-core`, `observability`,
`platform-objects`, `service-package`, `spec`, `types` (plus `exceljs`,
`zod`). Nothing was moved; `packages/runtime/**` and `packages/spec/**`
are untouched.
2. **The declared-schema idiom holds, with one measured difference in
`fields[].code`.** zod 4.6.1 against the declared schemas, mapped
through `zodIssuesToFields`:
- `NaN`, a string, `Infinity`, and `1.5` against `int()` all give
`invalid_type`;
- below `min` gives `min_value`; above `max` gives `max_value`.
These are all ADR-0114 catalog members, never zod's own codes. Runtime's
`parseIntegerParam` answers `invalid_number` for the same `abc` / `1.5`;
see the open question in the report.
3. **The doors match the card, with two corrections.**
- Export serves no declared request schema.
`CreateExportJobRequestSchema` is the contract of `POST
/api/v1/data/:object/export`, which rest does not mount.
`ExportRequestSchema` (`contract.zod.ts`) is bound to no route.
- The search door holds: the only production caller of `searchAll` is
the REST door (`git grep`, tests excluded). So
`packages/metadata-protocol/src/protocol.ts` is not in this diff.
4. **The empty-string premise is partly falsified.** Only import jobs
and search answered `?limit=` with their default. Export answered a
one-row export (`Number('') || 0` → `Math.max(1, 0)`), and history
answered zero events (`Number('')` = 0, forwarded). The rule applied is
the one `parseEnumParam` (runtime `query-param.ts`) states for the same
spelling:
- Where the old answer already was the absent answer, empty stays
absent.
- Where the old answer was an invented `0`, empty is refused.
Reading it as absent on export and history would have turned those two
into a 10000-row export and the whole change log. That grows the window,
the widening this claim stops on, so the reader carries an explicit
per-door `emptyIsAbsent`.
No door accepts anything it refused before: none refused any
single-valued `limit` before this PR. No conforming value's answer
changes either, so the declaration stays `no (narrowing)`.
## Tests
The new file is
`packages/rest/src/rest-server-limit-param-parsing.test.ts` (43 cases).
Each refusal pins `status` 400, `code` `VALIDATION_FAILED`,
`fields[0].field` and `fields[0].code`, and that the service spy was
never called. Each lit control pins the argument the service received.
- Against unfixed `6780e34a`: 24 failed (every refusal row answered
200), 19 passed (every lit control).
- Against the fix: 43 passed.
One existing fixture was corrected in
`rest-server-closed-query-params.test.ts`. Its search closed-set loop
sent `limit: 'x'` and expected 200; it now takes a valid value per name,
as its export twin in the same file already does.
Runs at head `74898ed5`, after merging `origin/main`:
- **Rest suite:** `pnpm --filter @objectstack/rest exec vitest run
--project local --maxWorkers=2` → `Test Files 196 passed (196)`, `Tests
3327 passed | 1 skipped (3328)`.
- **Typecheck:** `pnpm --filter @objectstack/rest run typecheck` → exit
0. `tsc --noEmit` passes, and `check:test-typecheck: OK — ... 0 file(s)
/ 0 error(s)`.
Ablations went through `scripts/ablation-replace.mjs` in WRAP mode, run
after the fix was committed. Vitest reads `src/` through the relative
import, so no dist was involved.
- **Import jobs:** restoring `Math.min(200, Math.max(1, Number(q.limit)
|| 50))` landed on disk (anchor 1 → 0, blob `02ceccd1` → `cab07300`).
Result: `Tests 8 failed | 35 passed`, exactly the 8 import-jobs `limit`
refusal rows.
- **Search:** restoring `req.query?.limit ? Number(req.query.limit) :
undefined` landed (blob `02ceccd1` → `9b589c00`). Result: `Tests 4
failed | 39 passed`, exactly the 4 search refusal rows.
- Both restores were proven: blob == HEAD `02ceccd1`, `git diff HEAD`
empty.
Public surface: rest's `dist/index.d.ts` and `dist/index.d.cts`, built
from merge-base `bc80e162`'s `rest-server.ts` and from HEAD's, are
byte-identical (`cmp`). The restore was proven by blob hash. So no
importing package's types can move, and only rest's own tests are owed.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on the real diff gives 61 commands,
identical to the PM's list. All ran at `74898ed5` with exit codes
captured before any pipe. `--ran` reconciliation: `61 derived, 59 run, 2
NOT-MEASURED, 0 UNRUN`.
- **59 exit 0.** This includes `check:adr-0087-registration --base
origin/main` (`not-required (no-migration-prescription)` accepted),
`check:changeset-no-major`, `check:empty-changeset`,
`check:route-envelope`, `check:nul-bytes`, `check:doc-authoring`,
`check:published-files`, `check:issue-citations` and
`check:undeclared-dep-imports`.
- **NOT MEASURED: `check:dual-build-cjs-loads`**, exit 3 `PREREQUISITE
NOT MET`: 43 workspace packages have no `dist/`. Declared narrowing:
`require('./dist/index.cjs')` and `import('@objectstack/rest')` both
load and expose `RestServer`.
- **NOT MEASURED: `check:type-check-debt`**, exit 3 `PREREQUISITE NOT
MET`: the full closure is not built. See the `.d.ts` identity above; CI
builds the closure before this step.
- **`pnpm lint`** (the lane addition) ran as the full union, `eslint .
--no-inline-config`, at `74898ed5`: exit 0.
## First-party callers, measured
- `@objectstack/client` sends `limit` as written: `data.listImportJobs`,
`data.export`, `meta.getHistory`, `search`.
- objectui at its pin `f8a9d0fb`: `ImportWizard` sends `limit: 50`,
`ResourceHistoryPage` sends `limit: 100`, and the search and export
emitters send `limit` only when it is a number greater than 0. None
sends a value this PR refuses.
## Acceptance notes
- **Same-family reads left out of this diff.** Triage's note 3 keeps
them out, and the file is also held by another claim (PR #20125).
- Measured on the real routes with a spy protocol, each answering 200:
- `GET /meta/:type/:name/history?sinceSeq=abc` drops `sinceSeq` and
reads the log from the start (`rest-server.ts:8139`);
- `GET /meta/:type/:name/audit?limit=abc` drops `limit`, so the
producer's default 100 is served (`:8318`; declared `z.number()`, which
refuses `NaN`);
- `GET /search?perObject=abc` hands `perObject: NaN` to `searchAll`
(`:10709`).
- Read only, not measured: `GET /data/approvals/requests` `limit` /
`offset` drop a non-numeric value and serve the unpaged list (`:13601`).
Export `?page=` falls back to the 500-row chunk (`:10297`), which
changes chunking only, not the rows returned.
- **History's declared `limit` admits `1.5`, `0` and negatives.** The
door forwards them as before. Whether
`HistoryMetaItemRequestSchema.limit` should declare `int()` / `min()` is
the spec seat's question, and this PR takes no position.
- **Export and search range stay as they were:** the export floor of 1
and cap of 50000, and search's `[1, 100]` clamp. Both cards take no
position on bounds.
- **`GET /data/import/jobs?status=` is not checked against the declared
`ImportJobStatus`.** Read only, not measured, out of scope.
- **Merge:** `origin/main` was merged (no rebase) after PR #20128 landed
on `rest-server.ts`, with no conflict. PR #20125's hunks do not overlap
these doors.
- **CI-owned, not run locally:** the full `Test Core` shards, `Dogfood`,
`Build Core`, `Temporal Conformance`, and the workspace type-check
lanes.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe677ae commit 95ab93f
4 files changed
Lines changed: 545 additions & 10 deletions
File tree
- .changeset
- packages/rest/src
| 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 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
426 | 426 | | |
427 | 427 | | |
428 | 428 | | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
429 | 433 | | |
430 | 434 | | |
431 | | - | |
| 435 | + | |
432 | 436 | | |
433 | 437 | | |
434 | 438 | | |
| |||
0 commit comments