diff --git a/.changeset/28443-public-form-submit-thank-you-fields.md b/.changeset/28443-public-form-submit-thank-you-fields.md new file mode 100644 index 00000000000..70f1e5e85cc --- /dev/null +++ b/.changeset/28443-public-form-submit-thank-you-fields.md @@ -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`. diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 8b94d914bc9..d5f884cdec4 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -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 @@ -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: @@ -276,7 +285,7 @@ async function submit(slug: string, payload: Record) { 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 } ``` diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index e942f393b31..e152029976c 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -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). diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index ea1da3d785c..bd027a8f4c2 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -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'; @@ -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 | undefined { + const declared = objectDef?.fields; + if (!declared || typeof declared !== 'object' || !record || typeof record !== 'object') return undefined; + const fields: Record = {}; + 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)[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 @@ -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 | undefined; + const readObjectDef = (): Promise => (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; @@ -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), diff --git a/packages/rest/src/public-form-submit-answer.test.ts b/packages/rest/src/public-form-submit-answer.test.ts index e1e314860e8..4d28a52d19a 100644 --- a/packages/rest/src/public-form-submit-answer.test.ts +++ b/packages/rest/src/public-form-submit-answer.test.ts @@ -26,7 +26,10 @@ import { describe, it, expect, vi } from 'vitest'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import * as specApi from '@objectstack/spec/api'; +import { PublicFormSubmitResponseSchema } from '@objectstack/spec/api'; import { RestServer } from './rest-server'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger'; // [#10126] Pay the first transform of this dist-resolved workspace dep at MODULE // LOAD, as `public-form-routes.test.ts` does for the same routes. @@ -164,3 +167,128 @@ describe('[#22437] POST /forms/:slug/submit answers the created id, and nothing expect(body.id).not.toBe(''); }); }); + +// ── [#28005, ruling B] The thank-you panel's named fields ─────────────────── +// +// The one widening of the answer above: when the form's `thank-you` panel names +// fields of the record as `{{record.field_name}}` (`thankYouRecordFieldNames`), +// the answer carries `fields`, holding exactly those fields' stored values. A +// name survives only when, AT ANSWER TIME, it is an own field of the object +// whose type is in `RUNTIME_OWNED_FIELD_TYPES` (today `autonumber`), and the +// row `createData` answered carries it as a string or a number. Every answer is +// parsed with `PublicFormSubmitResponseSchema` (strict), the schema the route +// ledger names for this route. + +const CASE_NUMBER = 'CASE-SYNTH-0042'; + +/** A form whose thank-you panel names `names`, on an object declaring `fields`. */ +function tokenProtocol(opts: { + message: string; + fields?: Record; + record?: Record; +}) { + const view = formView(); + (view.config as any).submitBehavior = { kind: 'thank-you', title: 'Thanks', message: opts.message }; + const object = { + ...requestObject, + fields: { ...requestObject.fields, case_number: { type: 'autonumber', label: 'Case number' }, ...opts.fields }, + }; + const record = opts.record ?? { ...CREATE_ANSWER.record, case_number: CASE_NUMBER }; + return { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'view') return [view]; + if (type === 'object') return [object]; + return []; + }), + createData: vi.fn().mockResolvedValue({ ...CREATE_ANSWER, record }), + }; +} + +async function submitThrough(protocol: ReturnType) { + const rest = new RestServer(mockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const submit = rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/forms/:slug/submit'))!; + const res = mockRes(); + await submit.handler({ params: { slug: 'answer' }, body: { ...SUBMITTED } } as any, res); + // Lit: the write ran, and the answer is the declared shape (strict: no third key). + expect(protocol.createData).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(201); + expect(PublicFormSubmitResponseSchema.safeParse(res.body).success, JSON.stringify(res.body)).toBe(true); + return res.body as Record; +} + +/** No stored value other than the id and `allowed` reaches the wire. */ +function expectNoOtherStoredValue(body: unknown, allowed: readonly string[] = []) { + const wire = JSON.stringify(body); + for (const leak of [DERIVED, STAFF, DEFAULTED, 'droppedFields', 'record', 'someone@example.test', '2026-01-01T00:00:00Z']) { + if (allowed.includes(leak)) continue; + expect(wire, `${leak} must not reach the anonymous caller`).not.toContain(leak); + } +} + +describe('[#28005] POST /forms/:slug/submit answers the engine-generated fields its thank-you panel names', () => { + it('a panel naming an autonumber field answers { id, fields: { that_field } }, and nothing else stored', async () => { + const body = await submitThrough(tokenProtocol({ message: 'Your case number is {{record.case_number}}.' })); + expect(body).toEqual({ id: CREATED_ID, fields: { case_number: CASE_NUMBER } }); + expect(Object.keys(body)).toEqual(['id', 'fields']); + expectNoOtherStoredValue(body); + }); + + it('a name in the title and the message is answered once', async () => { + const protocol = tokenProtocol({ message: 'Again: {{record.case_number}}' }); + const view = (await protocol.getMetaItems({ type: 'view' }))[0] as any; + view.config.submitBehavior.title = 'Case {{record.case_number}}'; + const body = await submitThrough(protocol); + expect(body).toEqual({ id: CREATED_ID, fields: { case_number: CASE_NUMBER } }); + }); + + it('a panel with no token answers { id } only', async () => { + const body = await submitThrough(tokenProtocol({ message: 'We received your message.' })); + expect(body).toEqual({ id: CREATED_ID }); + expect(Object.keys(body)).toEqual(['id']); + expectNoOtherStoredValue(body); + }); + + it('drop rule — type: a name whose field was retyped to text after publish is dropped', async () => { + const body = await submitThrough(tokenProtocol({ + message: 'Your case number is {{record.case_number}}.', + fields: { case_number: { type: 'text', label: 'Case number' } }, + })); + expect(body).toEqual({ id: CREATED_ID }); + }); + + it('drop rule — type: a name that is no own field of the object (a system column) is dropped, the rest kept', async () => { + const body = await submitThrough(tokenProtocol({ + message: 'Filed {{record.created_at}} by {{record.owner_id}} as {{record.case_number}} ({{record.constructor}})', + })); + expect(body).toEqual({ id: CREATED_ID, fields: { case_number: CASE_NUMBER } }); + expectNoOtherStoredValue(body); + }); + + it('drop rule — value: a name the answered row does not carry (masked or stripped) is dropped', async () => { + const { case_number: _omitted, ...withoutCaseNumber } = { ...CREATE_ANSWER.record, case_number: CASE_NUMBER }; + const body = await submitThrough(tokenProtocol({ + message: 'Your case number is {{record.case_number}}.', + record: withoutCaseNumber, + })); + expect(body).toEqual({ id: CREATED_ID }); + }); + + it('drop rule — value: a stored value that is neither a string nor a number is dropped, never answered as null', async () => { + const body = await submitThrough(tokenProtocol({ + message: 'Your case number is {{record.case_number}}.', + record: { ...CREATE_ANSWER.record, case_number: null }, + })); + expect(body).toEqual({ id: CREATED_ID }); + }); + + it('the route ledger names PublicFormSubmitResponseSchema for this route, the schema parsed above', () => { + const row = REST_ROUTE_LEDGER.find((e) => e.route === 'POST /api/v1/forms/:slug/submit'); + expect(row, 'the submit row must exist in REST_ROUTE_LEDGER').toBeDefined(); + expect(row!.responseSchema).toBe('PublicFormSubmitResponseSchema'); + expect((specApi as unknown as Record)[row!.responseSchema!]).toBe(PublicFormSubmitResponseSchema); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 8a7593b00ef..97831f2267d 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -396,7 +396,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/forms/:slug', family: 'forms', source: 'route-manager', disposition: 'public', note: 'anonymous public-form spec resolution — browser form runner, not authenticated SDK surface' }, { route: 'POST /api/v1/forms/:slug/submit', family: 'forms', source: 'route-manager', disposition: 'public', - note: 'anonymous public-form submission' }, + responseSchema: 'PublicFormSubmitResponseSchema', + note: 'anonymous public-form submission. Answers BARE (201, no envelope), so the named schema is the whole body: the created id, plus the stored values of the engine-generated fields the form\'s thank-you panel names. Conformance: public-form-submit-answer.test.ts parses the registered handler\'s answer with this schema and pins this row' }, // ── analytics (semantic layer) ──────────────────────────────────────────── { route: 'POST /api/v1/analytics/dataset/query', family: 'analytics', source: 'route-manager', disposition: 'sdk', client: 'analytics.queryDataset' }, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 979a9341e53..d6e45ec149b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5994,7 +5994,10 @@ export class RestServer { * * GET {basePath}/forms/:slug → resolved form spec * POST {basePath}/forms/:slug/submit → INSERT record (no auth required), - * answering `{ id }` only (#22437) + * answering `{ id }` (#22437), plus + * `fields` when the form's + * thank-you panel names + * engine-generated fields (#28005) * * Both routes bypass `enforceAuth` even though anonymous-deny is on for the * deployment (e.g. ObjectOS multi-tenant). The submit is authorized by the diff --git a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts index f95ab08c12a..3f3c2c5b464 100644 --- a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts +++ b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts @@ -345,15 +345,19 @@ const DISPOSITIONS: Record = { }, }, - // ── NO RECORD ECHO: no user-object record in the body ───────────────────── - // [#22437] This route used to 201 `createData`'s whole answer and was a - // `protocol-ingress` disposition. It now answers the created id alone, which - // `public-form-submit-answer.test.ts` pins exactly (and its dogfood twin on a - // real boot), so no stored row — flagged column or not — rides the body. + // ── PROTOCOL INGRESS: the body's values come off a protocol `*Data` answer ─ + // [#22437] This route used to 201 `createData`'s whole answer. It now answers + // the created id, plus (#28005) the values of the engine-generated fields the + // form's thank-you panel names, read off `createData`'s `record` — the row + // that face already strips of `internal: true` fields (#7823). Both are + // pinned in `public-form-submit-answer.test.ts`; no other stored value rides + // the body. 'POST /api/v1/forms/:slug/submit': { - kind: 'no-record-echo', - why: 'Public form submit answers the created record\'s id only (`{ id }`), never the stored row.', + kind: 'protocol-ingress', + why: 'Public form submit answers the created id plus the panel-named runtime-owned values, read off createData\'s stripped record.', }, + + // ── NO RECORD ECHO: no user-object record in the body ───────────────────── // Metadata plane: bodies carry metadata ITEMS. `internal: true` is a field // flag on a data object's field, and a metadata item is not a data record. 'POST /api/v1/meta/_migrate-stored': { kind: 'no-record-echo', why: 'Metadata plane: migration receipt.' },