Repository navigation
Commit 04141a6
fix(rest)!: the public form submit door refuses a value outside a form row's declared options subset (#27766)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DGPE5j6DCgVYUDTiGjVBjhW)_
Fixes #27606
domain:server#4 · os-elon-musk · session_016WV4czUVLWPn8CY8QLDrjQ
Scope: the submit door only, per triage 6500544109. The renderer half
(`ui/apps/console/src/components/FormPage.tsx` listing every option) is
the `domain:ui` card and is not touched here.
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none (no published skill describes how the public form
submit door judges a select value; `objectstack-api` names the door as a
whitelist-filtered insert, which stays true)
## 维护者速读
- 改了什么:公开表单提交口 `POST /forms/SLUG/submit` 现在拒收不在表单行 `options` 子集里的值,返回
400 `VALIDATION_FAILED`,`fields[]` 点名字段(代码
`invalid_option`,只列出子集内的可选值);子集内的值照常保存。
- 为什么改:表单作者给字段只开放部分选项(如 HotCRM 工单表单只给访客
low/medium/high),读取口已只发子集,但提交口不判,访客或脚本照样能存下 critical。
- 风险与回滚:只收窄一个匿名入口;没列 `options`
的行、同一字段另有不带子集的行、空值都不受影响。控制台公开表单页目前仍列出全部选项(另一张 ui 卡修),访客若选了子集外的值会看到点名字段的
400。回滚即还原 `form-routes.ts` 两处改动。
- 先看:`packages/rest/src/form-routes.ts` 的 `optionSubsets` 收集段与 `refused`
信封里新增的子集判断循环;`packages/rest/src/public-form-option-subset.test.ts`。
- 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run
src/public-form-option-subset.test.ts`,预期 11/11 通过。
- 需要你做的:无,席位落地。
## What changes
- While it reads the form's entries for the allow-list and `required`,
the submit handler now also collects each field's offered `options`
subset: the union of every entry that lists `options`. An entry that
lists none, or a bare-string entry, offers the object's full set and
marks the field not narrowed, whatever another entry lists.
- Inside the existing refusal envelope (one `validationFailure`, the
flat `400 VALIDATION_FAILED` with `fields[]`, already shared by the
`required` and file judgements), a value outside a field's subset is
refused before the write with the closed ADR-0114 catalog's
`invalid_option`. The message is the catalog's (`invalid_option`, or
`invalid_option_value` naming the element for a multi-valued field), the
field is named by the object's label, and `constraint.allowed` lists the
subset's values only. No second envelope and no new code.
- Judged on what the door writes, with the same emptiness rule as
`required` (`isEmptyForRequired`): an empty or absent value is not
judged. A multi-valued value is judged element by element, the first
outside element named, as the engine judges the object's set.
- Changeset: `@objectstack/rest` patch, with the BREAKING note and the
ADR-0087 `not-required (no-migration-prescription)` marker.
### Deviation from the dispatch's mechanism assumption
The order guessed the refusal would join the envelope as
`invalid_value`. Measured: the catalog has `invalid_option` ("not a
member of the field's declared options"), and it is what the engine
answers for a value outside the object's options on this same route. The
row subset is a declared option set, so the door uses `invalid_option`
and the catalog's own sentences, keeping one vocabulary for "not an
offered option" on both levels.
## Tests
`packages/rest/src/public-form-option-subset.test.ts`, through the real
route (`RestServer` → `ObjectStackProtocolImplementation` → `ObjectQL` →
`SqlDriver` sqlite in memory; only the metadata read is stubbed):
- refused: a value outside the subset (`critical` on a `[low, medium,
high]` row) answers 400 naming `priority` with `invalid_option`,
`constraint.allowed` is `low, medium, high`, the excluded member appears
nowhere in the body, the write is never attempted and no row is stored;
a value that is no member of the object is refused the same way before
the write; a multi-valued element outside the subset is refused naming
it; with a missing required field, both are told in one envelope;
- control: a value inside the subset saves; absent, `null`, `''` and
`[]` submit;
- boundary: a row with no `options` keeps the object's full set; a field
another row offers without a subset is not narrowed.
## Acceptance notes
- The console's public form page still offers every option of the field
until the `domain:ui` half reads the row's subset; until then a visitor
who picks an outside value gets the 400 naming the field and the allowed
values.
- An entry's `options: []` is read as listing no subset (the object's
full set), not as offering nothing.
- An entry that is `hidden: true` or conditionally visible is still
judged: the door does not evaluate `visibleWhen`, and a value outside
every subset the form lists is never one the form offers.
## Verification
Read at head `09cd48e0`.
<details><summary>Premise on main, ablation, package and gate
readings</summary>
- Premise on `main` (`81d00a4e`), the new test before the fix: 4 failed,
7 passed. `critical` on the `[low, medium, high]` row answered `201` and
stored the row; `["billing", "security"]` on a `[billing, outage]` row
answered `201`. All 7 controls were already green.
- After the fix: the new file 11/11, and the six public-form test files
(option subset, required, file field, routes, stored row, submit answer)
65/65.
- Ablation (`scripts/ablation-replace.mjs`, the anchor `if (!subset)
continue;` replaced by a bare `continue`, 1 hit to 0, blob `acba7d5b` to
`95a39697`): 4 failed, 7 passed, the four refusal cases. Restored: blob
equals HEAD (`acba7d5b`), `git diff HEAD` empty. The test imports the
route from `src`, so no build sat between the mutation and the run.
- `pnpm --filter @objectstack/rest build`: exit 0. `pnpm --filter
@objectstack/rest typecheck`: exit 0. `pnpm --filter @objectstack/rest
test`: exit 0, 343/343 files, 6706 passed, 326 skipped.
- `eslint` on the two changed source files, `--no-inline-config`: exit
0. The repo-wide `pnpm lint` is left to CI's `Lint & Repo Gates`.
- `dispatch-gates --commands` derived 59 families; 58 exited 0.
`check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (it reads
every package's dist and needs a whole-tree build), so it is NOT
MEASURED locally and CI carries it. `--ran` with exit codes: 59 derived,
58 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.
- `check:commit-card-trailers --range origin/main..HEAD`: exit 0.
`check:nul-bytes`: exit 0.
- Dogfood battery (`(narrowing)` arm): running on this head under the
verification lock; its reading lands in the `os-dev-report` comment on
#27606.
- No open PR touches `form-routes.ts` (the claim's read of the open
PRs).
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_016WV4czUVLWPn8CY8QLDrjQ
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 308f826 commit 04141a6
4 files changed
Lines changed: 396 additions & 9 deletions
File tree
- .changeset
- content/docs/ui
- 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
| 47 | + | |
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
460 | 460 | | |
461 | 461 | | |
462 | 462 | | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
463 | 469 | | |
464 | 470 | | |
465 | 471 | | |
466 | | - | |
467 | | - | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
468 | 476 | | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
469 | 491 | | |
470 | 492 | | |
471 | 493 | | |
| |||
616 | 638 | | |
617 | 639 | | |
618 | 640 | | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
619 | 658 | | |
620 | | - | |
621 | | - | |
622 | | - | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
623 | 663 | | |
624 | 664 | | |
625 | | - | |
| 665 | + | |
626 | 666 | | |
627 | 667 | | |
628 | 668 | | |
629 | 669 | | |
630 | 670 | | |
631 | 671 | | |
632 | | - | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
633 | 680 | | |
634 | 681 | | |
635 | 682 | | |
| |||
647 | 694 | | |
648 | 695 | | |
649 | 696 | | |
650 | | - | |
| 697 | + | |
651 | 698 | | |
652 | 699 | | |
653 | 700 | | |
| |||
660 | 707 | | |
661 | 708 | | |
662 | 709 | | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
663 | 744 | | |
664 | 745 | | |
665 | 746 | | |
| |||
0 commit comments