Skip to content

Commit 93c0a63

Browse 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>
1 parent f5e6953 commit 93c0a63

8 files changed

Lines changed: 241 additions & 19 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
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`.

‎content/docs/ui/forms.mdx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ name reaches one thing only:
118118
permissions (`allowCreate`, `allowRead`, …) are not read on this path, so they
119119
neither grant nor limit the insert.
120120
- **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
122123
fields masked on the record the route gets back; nothing else needs one.
123124
- **No object hook can read it.** A hook's `ctx.session` and `ctx.user` carry
124125
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
213214
{ "id": "r7p8cUZoBJbFWudt" }
214215
```
215216

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:
218+
219+
```json
220+
{ "id": "r7p8cUZoBJbFWudt", "fields": { "case_number": "CASE-0042" } }
221+
```
222+
223+
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+
216225
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.
217226

218227
Errors:
@@ -276,7 +285,7 @@ async function submit(slug: string, payload: Record<string, unknown>) {
276285
body: JSON.stringify(payload),
277286
});
278287
if (!r.ok) throw new Error(await r.text());
279-
return r.json(); // { id } — the created record's id only
288+
return r.json(); // { id, fields? } — the created id, plus the panel-named engine-generated fields
280289
}
281290
```
282291

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Only the fields listed in `sections[].fields` are accepted on submit; everything
4848

4949
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.
5050

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.
5252

5353
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).
5454

‎packages/rest/src/form-routes.ts‎

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,12 @@ import {
7373
anonymousFormObjectName,
7474
} from '@objectstack/metadata-core';
7575
import { isEmptyForRequired, isMissing } from '@objectstack/core';
76-
import type { CreateDataRequest, GetMetaItemsRequest } from '@objectstack/spec/api';
76+
import type { CreateDataRequest, GetMetaItemsRequest, PublicFormSubmitResponse } from '@objectstack/spec/api';
7777
import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
7878
import { renderValidationMessage } from '@objectstack/spec/system';
79+
import { thankYouRecordFieldNames } from '@objectstack/spec/ui';
7980
import { validationFailure } from '@objectstack/types';
80-
import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data';
81+
import { FILE_REFERENCE_TYPES, RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data';
8182
import type { ServerScopedDataRequest } from './data-action-routes.js';
8283
import { isExpectedRouteError, mapDataError } from './error-response.js';
8384
import { logError } from './log.js';
@@ -105,6 +106,50 @@ export interface FormRouteHost {
105106
packagedObjectBase: RestServer['packagedObjectBase'];
106107
}
107108

109+
/**
110+
* [#28005, ruling B] The `fields` of the public submit answer: the stored value
111+
* of each name the form's `thank-you` panel interpolates
112+
* (`thankYouRecordFieldNames`), keyed by name, or `undefined` when none
113+
* survives (the answer is then `{ id }` alone).
114+
*
115+
* A name survives only when, at answer time, its field is declared on the
116+
* object with a type in `RUNTIME_OWNED_FIELD_TYPES` (today `autonumber`): a
117+
* value the ENGINE generated on insert. Any other stored value may have been
118+
* set by a `beforeInsert` / `afterInsert` hook, an elevated one included, from
119+
* records the anonymous form grant cannot read. The authoring rule
120+
* `thank-you-record-field` refuses such a name at publish; this re-check covers
121+
* an object retyped after the form was published, and a name that is no field
122+
* at all (a system column, an undeclared name, a prototype key).
123+
*
124+
* The value is read off the row `createData` answered — already stripped of
125+
* `internal: true` fields and masked for the grant — never by a second read. A
126+
* name the row does not carry, or carries as anything but a string or a number,
127+
* is dropped, never answered as `null`.
128+
*/
129+
function thankYouAnswerFields(
130+
names: readonly string[],
131+
objectDef: any,
132+
record: unknown,
133+
): Record<string, string | number> | undefined {
134+
const declared = objectDef?.fields;
135+
if (!declared || typeof declared !== 'object' || !record || typeof record !== 'object') return undefined;
136+
const fields: Record<string, string | number> = {};
137+
let kept = 0;
138+
for (const name of names) {
139+
// A name that is no declared field (a system column, a prototype key
140+
// such as `constructor`) has no string `type` here: dropped.
141+
const type = declared[name]?.type;
142+
if (typeof type !== 'string' || !RUNTIME_OWNED_FIELD_TYPES.has(type)) continue;
143+
// Absent (masked, stripped, never generated), `null`, or any other
144+
// shape: dropped.
145+
const value = (record as Record<string, unknown>)[name];
146+
if (typeof value !== 'string' && typeof value !== 'number') continue;
147+
fields[name] = value;
148+
kept += 1;
149+
}
150+
return kept > 0 ? fields : undefined;
151+
}
152+
108153
/**
109154
* Register the public (anonymous) form endpoints —
110155
* `RestServer#registerFormEndpoints`' body, called by that method with the
@@ -662,9 +707,15 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
662707
const valued = [...allowedFields].filter((name) =>
663708
Object.prototype.hasOwnProperty.call(filteredData, name)
664709
&& !isMissing(filteredData[name]));
710+
// The target object's declaration, read at most once per
711+
// submission: by the judgements below, and by the answer's
712+
// thank-you fields after the write.
713+
let objectDefRead: Promise<any> | undefined;
714+
const readObjectDef = (): Promise<any> => (objectDefRead ??= this
715+
.readFormObjectDefinitions(p, environmentId)
716+
.then((defs) => defs.find((o: any) => o?.name === match.object)));
665717
if (judged.length > 0 || valued.length > 0) {
666-
const objectDef = (await this.readFormObjectDefinitions(p, environmentId))
667-
.find((o: any) => o?.name === match.object);
718+
const objectDef = await readObjectDef();
668719
const labelOf = (def: any, name: string): string => {
669720
const declared = typeof def?.label === 'string' ? def.label.trim() : '';
670721
return declared.length > 0 ? declared : name;
@@ -754,21 +805,33 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
754805
};
755806
const result = await p.createData(formCreateRequest);
756807
// [#22437] The answer is the created record's id, and
757-
// nothing the insert stored. `createData` answers
808+
// nothing else the insert stored. `createData` answers
758809
// `{ object, id, record, droppedFields? }`, and its `record`
759810
// is the row as stored AFTER the insert pipeline: defaults,
760811
// and whatever a `beforeInsert` / `afterInsert` hook stamped
761812
// — including a value an elevated (`runAs: 'system'`) hook
762813
// derived from existing records this anonymous caller's
763814
// grant may never read. The whitelist above filters what the
764-
// caller WRITES; this line is what filters what it is SHOWN.
815+
// caller WRITES; this answer is what filters what it is SHOWN.
765816
// The caller already knows what it submitted, so the one
766817
// fact it lacks is the id. Projecting the row to the form's
767818
// declared fields would not do: a hook may rewrite a
768819
// declared field too. The key stays the top-level `id` of a
769820
// bare body — where the console's public form page reads
770821
// the created id — and no second read builds this answer.
771-
res.status(201).json({ id: result.id });
822+
//
823+
// [#28005, ruling B] The one widening: the values the form's
824+
// `thank-you` panel names as `{{record.field_name}}`, under
825+
// `fields` (`PublicFormSubmitResponseSchema`) — a web-to-case
826+
// visitor's case number. See `thankYouAnswerFields` for which
827+
// names survive to the answer.
828+
const answer: PublicFormSubmitResponse = { id: result.id };
829+
const named = thankYouRecordFieldNames(match.form?.submitBehavior);
830+
if (named.length > 0) {
831+
const fields = thankYouAnswerFields(named, await readObjectDef(), result.record);
832+
if (fields) answer.fields = fields;
833+
}
834+
res.status(201).json(answer);
772835
} catch (error: any) {
773836
const mapped = mapDataError(error);
774837
// Distinct message (this is not the "unhandled" channel),

0 commit comments

Comments
 (0)