Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/28443-public-form-submit-thank-you-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/rest': patch
---

feat(rest): the public form submit answer carries the engine-generated fields its thank-you panel names

Clause-②: yes (widening)
Metadata-protocol: none

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

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.

The route ledger now names `PublicFormSubmitResponseSchema` as this route's `responseSchema`.
13 changes: 11 additions & 2 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ name reaches one thing only:
permissions (`allowCreate`, `allowRead`, …) are not read on this path, so they
neither grant nor limit the insert.
- **The visitor never sees that record.** The submit answers with the new
record's `id` only (section 4). Register a `guest_portal` set only if you want
record's `id`, plus only the engine-generated fields its thank-you panel
names (section 4). Register a `guest_portal` set only if you want
fields masked on the record the route gets back; nothing else needs one.
- **No object hook can read it.** A hook's `ctx.session` and `ctx.user` carry
neither `permissions` nor `anonymous`, and on a public submission both are
Expand Down Expand Up @@ -213,6 +214,14 @@ Response on success (HTTP `201 Created`) — the created record's id, and nothin
{ "id": "r7p8cUZoBJbFWudt" }
```

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:

```json
{ "id": "r7p8cUZoBJbFWudt", "fields": { "case_number": "CASE-0042" } }
```

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`).

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.

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

Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Only the fields listed in `sections[].fields` are accepted on submit; everything

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.

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

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

Expand Down
77 changes: 70 additions & 7 deletions packages/rest/src/form-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,12 @@ import {
anonymousFormObjectName,
} from '@objectstack/metadata-core';
import { isEmptyForRequired, isMissing } from '@objectstack/core';
import type { CreateDataRequest, GetMetaItemsRequest } from '@objectstack/spec/api';
import type { CreateDataRequest, GetMetaItemsRequest, PublicFormSubmitResponse } from '@objectstack/spec/api';
import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
import { renderValidationMessage } from '@objectstack/spec/system';
import { thankYouRecordFieldNames } from '@objectstack/spec/ui';
import { validationFailure } from '@objectstack/types';
import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data';
import { FILE_REFERENCE_TYPES, RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data';
import type { ServerScopedDataRequest } from './data-action-routes.js';
import { isExpectedRouteError, mapDataError } from './error-response.js';
import { logError } from './log.js';
Expand Down Expand Up @@ -105,6 +106,50 @@ export interface FormRouteHost {
packagedObjectBase: RestServer['packagedObjectBase'];
}

/**
* [#28005, ruling B] The `fields` of the public submit answer: the stored value
* of each name the form's `thank-you` panel interpolates
* (`thankYouRecordFieldNames`), keyed by name, or `undefined` when none
* survives (the answer is then `{ id }` alone).
*
* A name survives only when, at answer time, its field is declared on the
* object with a type in `RUNTIME_OWNED_FIELD_TYPES` (today `autonumber`): a
* value the ENGINE generated on insert. Any other stored value may have been
* set by a `beforeInsert` / `afterInsert` hook, an elevated one included, from
* records the anonymous form grant cannot read. The authoring rule
* `thank-you-record-field` refuses such a name at publish; this re-check covers
* an object retyped after the form was published, and a name that is no field
* at all (a system column, an undeclared name, a prototype key).
*
* The value is read off the row `createData` answered — already stripped of
* `internal: true` fields and masked for the grant — never by a second read. A
* name the row does not carry, or carries as anything but a string or a number,
* is dropped, never answered as `null`.
*/
function thankYouAnswerFields(
names: readonly string[],
objectDef: any,
record: unknown,
): Record<string, string | number> | undefined {
const declared = objectDef?.fields;
if (!declared || typeof declared !== 'object' || !record || typeof record !== 'object') return undefined;
const fields: Record<string, string | number> = {};
let kept = 0;
for (const name of names) {
// A name that is no declared field (a system column, a prototype key
// such as `constructor`) has no string `type` here: dropped.
const type = declared[name]?.type;
if (typeof type !== 'string' || !RUNTIME_OWNED_FIELD_TYPES.has(type)) continue;
// Absent (masked, stripped, never generated), `null`, or any other
// shape: dropped.
const value = (record as Record<string, unknown>)[name];
if (typeof value !== 'string' && typeof value !== 'number') continue;
fields[name] = value;
kept += 1;
}
return kept > 0 ? fields : undefined;
}

/**
* Register the public (anonymous) form endpoints —
* `RestServer#registerFormEndpoints`' body, called by that method with the
Expand Down Expand Up @@ -662,9 +707,15 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
const valued = [...allowedFields].filter((name) =>
Object.prototype.hasOwnProperty.call(filteredData, name)
&& !isMissing(filteredData[name]));
// The target object's declaration, read at most once per
// submission: by the judgements below, and by the answer's
// thank-you fields after the write.
let objectDefRead: Promise<any> | undefined;
const readObjectDef = (): Promise<any> => (objectDefRead ??= this
.readFormObjectDefinitions(p, environmentId)
.then((defs) => defs.find((o: any) => o?.name === match.object)));
if (judged.length > 0 || valued.length > 0) {
const objectDef = (await this.readFormObjectDefinitions(p, environmentId))
.find((o: any) => o?.name === match.object);
const objectDef = await readObjectDef();
const labelOf = (def: any, name: string): string => {
const declared = typeof def?.label === 'string' ? def.label.trim() : '';
return declared.length > 0 ? declared : name;
Expand Down Expand Up @@ -754,21 +805,33 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
};
const result = await p.createData(formCreateRequest);
// [#22437] The answer is the created record's id, and
// nothing the insert stored. `createData` answers
// nothing else the insert stored. `createData` answers
// `{ object, id, record, droppedFields? }`, and its `record`
// is the row as stored AFTER the insert pipeline: defaults,
// and whatever a `beforeInsert` / `afterInsert` hook stamped
// — including a value an elevated (`runAs: 'system'`) hook
// derived from existing records this anonymous caller's
// grant may never read. The whitelist above filters what the
// caller WRITES; this line is what filters what it is SHOWN.
// caller WRITES; this answer is what filters what it is SHOWN.
// The caller already knows what it submitted, so the one
// fact it lacks is the id. Projecting the row to the form's
// declared fields would not do: a hook may rewrite a
// declared field too. The key stays the top-level `id` of a
// bare body — where the console's public form page reads
// the created id — and no second read builds this answer.
res.status(201).json({ id: result.id });
//
// [#28005, ruling B] The one widening: the values the form's
// `thank-you` panel names as `{{record.field_name}}`, under
// `fields` (`PublicFormSubmitResponseSchema`) — a web-to-case
// visitor's case number. See `thankYouAnswerFields` for which
// names survive to the answer.
const answer: PublicFormSubmitResponse = { id: result.id };
const named = thankYouRecordFieldNames(match.form?.submitBehavior);
if (named.length > 0) {
const fields = thankYouAnswerFields(named, await readObjectDef(), result.record);
if (fields) answer.fields = fields;
}
res.status(201).json(answer);
} catch (error: any) {
const mapped = mapDataError(error);
// Distinct message (this is not the "unhandled" channel),
Expand Down
Loading
Loading