You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 93c0a63
Browse filesBrowse the repository at this point in the historyBrowse files
feat(rest): the public form submit answer carries the engine-generated fields its thank-you panel names (#28479)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao5tKEFLNaFYifAyKCfRo1qW)_Fixes#28443. This is the server step of #28005 (ruling B, record
6545210989; names approved in record 6548758413). The spec half landed
in #28440; the ui half (#28444) follows this one.
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:匿名公开表单提交接口 `POST /api/v1/forms/:slug/submit` 的回包,除了新记录的
`id`,现在还会带上 `fields`:表单「感谢页」里用 `{{record.字段名}}`
点名的、由引擎在插入时生成的字段值(今天只有自动编号 `autonumber`,比如工单号)。感谢页没点名任何字段的表单,回包仍然只有 `{
id }`。
- 为什么改:web-to-case 这类公开表单,访客提交后需要看到自己的工单号。#28005 裁决 B 定了顺序:spec 先、server
次、ui 后;spec
的名字(`PublicFormSubmitResponseSchema`、`thankYouRecordFieldNames`)已在
#28440 合入,本 PR 是 server 这一步。
-
风险与回滚:这是一次放宽:匿名访客能多看到被点名的自动编号值。插入后存下的其它值(包括提权钩子从访客无权读的记录里算出的值)一律不回。服务端在回包时再按字段的当前类型核对一次,不是自动编号的名字直接丢弃,所以发布后把字段改成文本类型也不会泄露。回滚即
revert 本 PR,回包退回 `{ id }`。
- 先看:`packages/rest/src/form-routes.ts` 的
`thankYouAnswerFields`(两条丢弃规则)和提交处理末尾组装 `answer`
的几行;`packages/rest/src/public-form-submit-answer.test.ts` 新增的 `[#28005]`
一组用例。
- 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2
src/public-form-submit-answer.test.ts`,预期 10 条全绿,其中点名自动编号的表单回包恰为 `{ id,
fields: { case_number } }`。
- 需要你做的:无,席位落地。
## What changes
**The answer.** After the insert, the submit handler reads
`thankYouRecordFieldNames(match.form.submitBehavior)`
(`@objectstack/spec/ui`), the one reading of which names a thank-you
panel holds. When it returns at least one name, `thankYouAnswerFields`
builds `fields` from the row `createData` answered, and the body is
typed `PublicFormSubmitResponse`. A panel with no token, or a form with
any other `submitBehavior`, answers `{ id }` exactly as before.
**Two drop rules, applied at answer time.** A named field reaches the
answer only if both hold:
1. **Type.** The object's current declaration has the name as a field
whose `type` is in `RUNTIME_OWNED_FIELD_TYPES`
(`@objectstack/spec/data`, today `autonumber`). A field retyped after
publish fails this rule. So does a name that is no declared field: a
system column such as `created_at`, a lookup such as `owner_id`, or a
prototype key such as `constructor`.
2. **Value.** The answered row carries the name as a string or a number.
A name the row lacks is dropped, never answered as `null`: a field
masked by the grant's result masker, one stripped as `internal: true`,
or one never generated. A `null` or any other shape is dropped too.
`fields` is left out when no name survives. The row is the one
`createData` already answered, so this adds no second read. It is
stripped of `internal: true` fields and masked for the grant. The object
declaration is read at most once per submission: the read the `required`
/ file / option judgements already make is now shared with the answer
through a memo.
**Route ledger.** The `POST /api/v1/forms/:slug/submit` row names
`responseSchema: 'PublicFormSubmitResponseSchema'`, with its conformance
test cited in the note.
**Comments and docs that stated the answer.** These now say `{ id }`
plus the panel-named engine-generated fields:
- the `[#22437]` comment in `form-routes.ts` and the private
`registerFormEndpoints` docblock in `rest-server.ts`;
- the internal-fields tripwire disposition. It moves from
`no-record-echo` to `protocol-ingress`, because the values come off
`createData`'s stripped record;
- `content/docs/ui/forms.mdx` §4 (the success response and the minimal
client) and the masker bullet, and
`content/docs/ui/public-data-collection.mdx`.
## Tests
`packages/rest/src/public-form-submit-answer.test.ts`, new `[#28005]`
block. Every answer is parsed with `PublicFormSubmitResponseSchema`
(strict):
- A panel naming an `autonumber` field answers `{ id, fields: {
case_number } }`. No other stored value (derived, defaulted, owner,
email, timestamps) appears in the serialized body.
- A name in both title and message is answered once.
- A panel with no token answers `{ id }` only.
- Type rule: a field retyped to `text` after publish is dropped. Names
that are no declared field (`created_at`), a lookup (`owner_id`) or
`constructor` are dropped, and the autonumber beside them is kept.
- Value rule: a name the answered row does not carry is dropped, and so
is a stored `null`.
- The ledger row names `PublicFormSubmitResponseSchema`, and that name
resolves to the very schema the cases parse with.
`rest-write-response-internal-fields.tripwire.test.ts`: the disposition
change above.
Whole package on `ab25d2a7`: `pnpm --filter @objectstack/rest exec
vitest run`: `Test Files 363 passed (363)`, `Tests 7166 passed | 352
skipped (7518)`. `pnpm --filter @objectstack/rest typecheck`: exit 0
(tsc, plus the test-layer check). `packages/client`
`route-ledger-response-schema.test.ts` +
`client-url-conformance.test.ts`: 10/10.
<details><summary>Ablations</summary>
Each mutation went through `scripts/ablation-replace.mjs` on
`packages/rest/src/form-routes.ts`. The anchor landed (count 1 → 0), and
the file was restored with the blob equal to HEAD (`bd027a8f4c2a`). The
suite imports `./rest-server` from `src`, so no dist was involved.
| mutation | red (of 10) |
|:--|:--|
| answer the whole stored row as `fields` (`answer.fields = {
...result.record }`) | 3: the autonumber case, the title+message case,
the system-column case |
| type rule removed (`if (typeof type !== 'string' \|\|
!RUNTIME_OWNED_FIELD_TYPES.has(type)) continue;` → `void type;`) | 2:
the retyped-to-text case and the system-column case |
| value rule removed (`if (typeof value !== 'string' && typeof value !==
'number') continue;` → `void 0;`) | 2: the absent-from-row case and the
stored-`null` case |
A first version had a third check (the row has the name as an own key).
Its ablation stayed green, because the value rule already drops an
absent name. It was folded into the value rule (commit `ba54dc43`), so
each remaining check is one an ablation can red.
</details>
<details><summary>Live measure (showcase, --fresh)</summary>
Premise, on `9b1dc992` (main after #28440): an admin authored
`probe_case` (a `case_number` autonumber `CASE-{0000}`) and a public
form view whose thank-you message names `{{record.case_number}}`. The
anonymous submit answered `{"id":"AVTOnpdO_UpgsK3G"}` with HTTP 201,
while the stored row read back by the admin carried
`"case_number":"CASE-0001"`. The showcase `contact-us` form answered
`{"id":…}` too.
After, on `ab25d2a7` (same recipe, fresh boot):
- token form →
`{"id":"2nE-CA41J2muTSu-","fields":{"case_number":"CASE-0001"}}` · 201
- `contact-us` (no token) → `{"id":"65dfSz6YqZFzzpvC"}` · 201
- `case_number` retyped to `text` (`PUT
/meta/object/probe_case?force=true`), then submitted with
`"case_number":"FORGED"` in the body → `{"id":"H1HHBhQH941n-pjM"}` · 201
(the name was dropped)
</details>
## Acceptance notes
- **`fields` is omitted, not `{}`, when every named field is dropped.**
The card says `fields` appears "only when it returns at least one". This
PR reads that as at least one surviving value, so the body never carries
an empty map. A renderer reading `fields?.[name]` sees the same thing
either way.
- **A retype's publish gate.** Retyping an `autonumber` to `text` is
refused with `409 DESTRUCTIVE_CHANGE` unless forced. After a forced
retype, the view that names the field is not re-judged by
`thank-you-record-field`, because that rule runs on a `view` or `page`
write. The answer-time type rule is what holds that case, as the card
intended.
- **Not touched, outside the file surface:** the docblock of
`packages/verify/src/handle.ts` (`run`) still says the public form route
"answers `{ id }`". It is true for every form whose panel names no
field. `content/docs/releases/v18/18-0.mdx` says the same thing and is
release-owned.
- **Docs for the panel token itself** (that a thank-you panel may name
an autonumber field and the console fills it) belong to the ui half
#28444. The console does not fill the tokens yet, so this PR documents
only the wire answer.
- Branch `claude/project-thread-c1oruc` is the card thread's
platform-assigned branch; claim amendment 6549411063 names it.
## Verification
Local, on `ab25d2a7`: `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 91 families. Each was run
with stdin from `/dev/null`, and its exit was captured by redirect
before any pipe. All 91 exit 0. Four first exited non-zero on build
state, not on the diff. `check:plugin-teardown-shape --self-test` (exit
3, a shallow clone) was re-run after fetching its pinned fixture commit.
`check:skill-examples` (exit 3, no declarations) was re-run after
building `@objectstack/client{,-react}`. `check:dts-closure` and
`check:dual-build-cjs-loads` (exit 1, packages built with
`OS_SKIP_DTS=1`) were re-run after a forced full declarations build of
every non-ui package. `--ran`: `91 derived famil(ies) accounted for — 91
run, 0 NOT-MEASURED`, exit 0. Scoped lint, the repo's eslint with
`--no-inline-config` over `packages/rest/src`: exit 0. Run record:
`server-seat-1/r2026-10-31/out/28443-gates-ran-ab25d2a7.list`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibzzJHmF6J8Rdez8TWu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(rest): the public form submit answer carries the engine-generated fields its thank-you panel names
6
+
7
+
Clause-②: yes (widening)
8
+
Metadata-protocol: none
9
+
10
+
`POST /api/v1/forms/:slug/submit` answers `PublicFormSubmitResponseSchema` (`@objectstack/spec/api`): the created record's `id`, as before, plus `fields` when the form's `thank-you` panel names fields of the record as `{{record.field_name}}`. `fields` holds the stored values of exactly the names `thankYouRecordFieldNames(form.submitBehavior)` returns, so a web-to-case visitor can be shown the case number the engine just generated. A form whose panel names no field still answers `{ id }` alone.
11
+
12
+
Nothing else the insert stored is answered: a `beforeInsert` / `afterInsert` hook, an elevated one included, may have derived a stored value from records the anonymous form grant cannot read. So the route re-checks every named field when it answers, and drops a name unless the object declares it with a type in `RUNTIME_OWNED_FIELD_TYPES` (today `autonumber`) and the row the insert answered carries it as a string or a number. That covers an object retyped after the form was published, which the `thank-you-record-field` authoring rule cannot see. `fields` is left out when no name survives.
13
+
14
+
The route ledger now names `PublicFormSubmitResponseSchema` as this route's `responseSchema`.
Copy file name to clipboardExpand all lines: content/docs/ui/forms.mdx
+11-2Lines changed: 11 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -118,7 +118,8 @@ name reaches one thing only:
118
118
permissions (`allowCreate`, `allowRead`, …) are not read on this path, so they
119
119
neither grant nor limit the insert.
120
120
-**The visitor never sees that record.** The submit answers with the new
121
-
record's `id` only (section 4). Register a `guest_portal` set only if you want
121
+
record's `id`, plus only the engine-generated fields its thank-you panel
122
+
names (section 4). Register a `guest_portal` set only if you want
122
123
fields masked on the record the route gets back; nothing else needs one.
123
124
-**No object hook can read it.** A hook's `ctx.session` and `ctx.user` carry
124
125
neither `permissions` nor `anonymous`, and on a public submission both are
@@ -213,6 +214,14 @@ Response on success (HTTP `201 Created`) — the created record's id, and nothin
213
214
{ "id": "r7p8cUZoBJbFWudt" }
214
215
```
215
216
217
+
The one addition: when the form's `thank-you` panel names fields of the record as `{{record.field_name}}`, the answer also carries `fields`, holding the stored values of exactly those fields. A panel may name only a field the engine generates on insert (an `autonumber` field, such as a case number) — the `thank-you-record-field` rule refuses any other at `os validate` and at publish, and the route re-checks each name's type when it answers, so a field retyped after publish is left out:
Any other value the insert stored never reaches the visitor: a `beforeInsert` / `afterInsert` hook may have derived it from records the anonymous grant cannot read. The body's shape is `PublicFormSubmitResponseSchema` (`@objectstack/spec/api`).
224
+
216
225
The anonymous caller is never shown the stored row. It already knows what it submitted, and everything else on the row — a `defaultValue`, a field a `beforeInsert` / `afterInsert` hook stamped, a value an elevated hook derived from existing records — belongs to readers the form grants nothing. So the whitelist-stripped `status` and the hook defaults in the example above land on the row but are not in the answer. A host that needs the stored record reads it through an authenticated read (`GET /api/v1/data/:object/:id`) with a principal allowed to see it.
Copy file name to clipboardExpand all lines: content/docs/ui/public-data-collection.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -48,7 +48,7 @@ Only the fields listed in `sections[].fields` are accepted on submit; everything
48
48
49
49
The whitelist cannot hold a file field (`file`, `image`, `avatar`, `video`, `audio`): a visitor has no upload route, so a public form declaring one is refused at `os validate` / `os build` and at publish (`public-form-file-field`). Collect files from registered, signed-in users on an internal form instead.
50
50
51
-
The submit's execution context also names a `guest_portal` permission set. That name authorizes nothing: the grant is decided before any permission set resolves, so the form works the same whether or not a set of that name exists, and such a set's object permissions (`allowCreate`, `allowRead`, …) are not read on this path. Where a deployment registers one, only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, are read, to mask the created record the server hands back to the route. The visitor never sees that record: the submit answers with the new record's `id`only.
51
+
The submit's execution context also names a `guest_portal` permission set. That name authorizes nothing: the grant is decided before any permission set resolves, so the form works the same whether or not a set of that name exists, and such a set's object permissions (`allowCreate`, `allowRead`, …) are not read on this path. Where a deployment registers one, only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, are read, to mask the created record the server hands back to the route. The visitor never sees that record: the submit answers with the new record's `id`, plus only the engine-generated fields (an `autonumber` case number) the form's thank-you panel names.
52
52
53
53
System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022).
0 commit comments