Skip to content

Commit cc92f75

Browse files
committed
feat(rest): public form submit answers the thank-you panel's engine-generated fields
POST /forms/:slug/submit answers PublicFormSubmitResponseSchema: the created id, plus `fields` holding the stored values of exactly the names thankYouRecordFieldNames(form.submitBehavior) returns. Each name is re-checked at answer time against RUNTIME_OWNED_FIELD_TYPES on the object's current declaration and dropped otherwise, and only a string or number the createData row carries is answered. The route ledger names the schema; the docs state the answer. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzzJHmF6J8Rdez8TWu
1 parent 9b1dc99 commit cc92f75

8 files changed

Lines changed: 240 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: 69 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,49 @@ 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+
// An OWN declaration only: a name such as `constructor` must not find
140+
// the map's prototype.
141+
const type = Object.prototype.hasOwnProperty.call(declared, name) ? declared[name]?.type : undefined;
142+
if (typeof type !== 'string' || !RUNTIME_OWNED_FIELD_TYPES.has(type)) continue;
143+
if (!Object.prototype.hasOwnProperty.call(record, name)) continue;
144+
const value = (record as Record<string, unknown>)[name];
145+
if (typeof value !== 'string' && typeof value !== 'number') continue;
146+
fields[name] = value;
147+
kept += 1;
148+
}
149+
return kept > 0 ? fields : undefined;
150+
}
151+
108152
/**
109153
* Register the public (anonymous) form endpoints —
110154
* `RestServer#registerFormEndpoints`' body, called by that method with the
@@ -662,9 +706,15 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
662706
const valued = [...allowedFields].filter((name) =>
663707
Object.prototype.hasOwnProperty.call(filteredData, name)
664708
&& !isMissing(filteredData[name]));
709+
// The target object's declaration, read at most once per
710+
// submission: by the judgements below, and by the answer's
711+
// thank-you fields after the write.
712+
let objectDefRead: Promise<any> | undefined;
713+
const readObjectDef = (): Promise<any> => (objectDefRead ??= this
714+
.readFormObjectDefinitions(p, environmentId)
715+
.then((defs) => defs.find((o: any) => o?.name === match.object)));
665716
if (judged.length > 0 || valued.length > 0) {
666-
const objectDef = (await this.readFormObjectDefinitions(p, environmentId))
667-
.find((o: any) => o?.name === match.object);
717+
const objectDef = await readObjectDef();
668718
const labelOf = (def: any, name: string): string => {
669719
const declared = typeof def?.label === 'string' ? def.label.trim() : '';
670720
return declared.length > 0 ? declared : name;
@@ -754,21 +804,33 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
754804
};
755805
const result = await p.createData(formCreateRequest);
756806
// [#22437] The answer is the created record's id, and
757-
// nothing the insert stored. `createData` answers
807+
// nothing else the insert stored. `createData` answers
758808
// `{ object, id, record, droppedFields? }`, and its `record`
759809
// is the row as stored AFTER the insert pipeline: defaults,
760810
// and whatever a `beforeInsert` / `afterInsert` hook stamped
761811
// — including a value an elevated (`runAs: 'system'`) hook
762812
// derived from existing records this anonymous caller's
763813
// grant may never read. The whitelist above filters what the
764-
// caller WRITES; this line is what filters what it is SHOWN.
814+
// caller WRITES; this answer is what filters what it is SHOWN.
765815
// The caller already knows what it submitted, so the one
766816
// fact it lacks is the id. Projecting the row to the form's
767817
// declared fields would not do: a hook may rewrite a
768818
// declared field too. The key stays the top-level `id` of a
769819
// bare body — where the console's public form page reads
770820
// the created id — and no second read builds this answer.
771-
res.status(201).json({ id: result.id });
821+
//
822+
// [#28005, ruling B] The one widening: the values the form's
823+
// `thank-you` panel names as `{{record.field_name}}`, under
824+
// `fields` (`PublicFormSubmitResponseSchema`) — a web-to-case
825+
// visitor's case number. See `thankYouAnswerFields` for which
826+
// names survive to the answer.
827+
const answer: PublicFormSubmitResponse = { id: result.id };
828+
const named = thankYouRecordFieldNames(match.form?.submitBehavior);
829+
if (named.length > 0) {
830+
const fields = thankYouAnswerFields(named, await readObjectDef(), result.record);
831+
if (fields) answer.fields = fields;
832+
}
833+
res.status(201).json(answer);
772834
} catch (error: any) {
773835
const mapped = mapDataError(error);
774836
// Distinct message (this is not the "unhandled" channel),

0 commit comments

Comments
 (0)