From bfb5c1dee637edf66aa45aa886b34e22ec751f21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:21:43 +0000 Subject: [PATCH 1/4] test(rest): pin the public form submit answer as the created id only WIP: the pins, the flipped dogfood pins, the docs page and the changeset. The handler change follows the measured before-table. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../22437-public-form-submit-answers-id.md | 21 ++ content/docs/ui/forms.mdx | 16 +- ...lic-form-read-back-masking.dogfood.test.ts | 55 ++--- .../public-form-submit-answer.dogfood.test.ts | 201 ++++++++++++++++++ .../test/showcase-public-form.dogfood.test.ts | 38 +++- .../src/public-form-submit-answer.test.ts | 166 +++++++++++++++ ...-response-internal-fields.tripwire.test.ts | 13 +- 7 files changed, 459 insertions(+), 51 deletions(-) create mode 100644 .changeset/22437-public-form-submit-answers-id.md create mode 100644 packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts create mode 100644 packages/rest/src/public-form-submit-answer.test.ts diff --git a/.changeset/22437-public-form-submit-answers-id.md b/.changeset/22437-public-form-submit-answers-id.md new file mode 100644 index 00000000000..2a783246915 --- /dev/null +++ b/.changeset/22437-public-form-submit-answers-id.md @@ -0,0 +1,21 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): an anonymous public-form submit answers the created record's id, and nothing the insert stored + +Clause-②: no + +`POST /api/v1/forms/:slug/submit` used to answer `201` with the protocol's whole create answer, `{ object, id, record, droppedFields? }`. The `record` was the row as stored after the insert pipeline. So the anonymous caller was shown every field it never sent: a `defaultValue`, a field a `beforeInsert` or `afterInsert` hook stamped, and a value a hook running elevated (`runAs: 'system'`) derived from existing records the caller's grant may never read. The form's field whitelist filtered what the caller could write. Nothing filtered what it was then shown. + +The answer is now the created id alone: + +```json +{ "id": "r7p8cUZoBJbFWudt" } +``` + +- **Still `201`**, as a bare JSON object with no envelope. The created id stays at the top-level `id`, where the console's public form page reads it, so its confirmation and `redirect` behaviors keep working. +- **What the submitter typed is not echoed back either.** The caller already holds it. On the console's `redirect` behavior, a `{{record.field_name}}` token over a submitted field still resolves from the submitted values, and `{{record.id}}` from the answer. A token over a field only the server fills in now resolves empty. That value is exactly what this change stops serving. +- **The write is unchanged**: the same whitelist, the same server-managed anchors stripped, the same grant, the same hooks. Only the answer shrank. + +**If your host read the record off this answer**, read it through an authenticated read instead: `GET /api/v1/data/:object/:id` with the id from the answer, as a principal allowed to see that record. diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 4761e1b6fca..49000a0de48 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -211,18 +211,14 @@ curl -X POST http://localhost:3000/api/v1/forms/contact-us/submit \ }' ``` -Response on success (HTTP `201 Created`): +Response on success (HTTP `201 Created`) — the created record's id, and nothing else: ```json -{ "object": "lead", "id": "r7p8cUZoBJbFWudt", "record": { - "id": "r7p8cUZoBJbFWudt", - "first_name": "Ada", "last_name": "Lovelace", - "status": "new", // ← hook default - "lead_source": "web", // ← hook default - "owner": null // ← whitelist stripped, hook deleted -} } +{ "id": "r7p8cUZoBJbFWudt" } ``` +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: | Status | Code | When | @@ -271,7 +267,7 @@ async function submit(slug: string, payload: Record) { body: JSON.stringify(payload), }); if (!r.ok) throw new Error(await r.text()); - return r.json(); + return r.json(); // { id } — the created record's id only } ``` @@ -368,7 +364,7 @@ formViews: { `url` is **not** a free-form address. It was ruled on 2026-08-11 ([#7496](https://github.com/objectstack-ai/objectstack/issues/7496)) and the schema enforces it, so a URL outside this shape is a parse error at authoring time rather than a surprise in the browser: 1. **Relative paths only.** The value must start with a single `/`. Absolute URLs (`https://example.com/thanks`, and equally `javascript:` / `data:`), protocol-relative `//example.com/thanks`, backslashes, and smuggled whitespace or control characters are all refused. A post-submit redirect is authored metadata that sends a real browser somewhere — leaving it open to any address makes every form an open redirect waiting for one careless copy-paste. To send someone **out** of the app deliberately, that is an app navigation item (`{ type: 'url', url }`), which is declared for external addresses. -2. **Interpolation only from declared record fields**, spelled `{{record.field_name}}` — the same double-brace template dialect the rest of the platform uses, narrowed to the record that was just submitted and to a flat field name. Every interpolated value is **URL-escaped** when the redirect is built, so a token is a *value* in the path or query and can never add path structure. +2. **Interpolation only from declared record fields**, spelled `{{record.field_name}}` — the same double-brace template dialect the rest of the platform uses, narrowed to the record that was just submitted and to a flat field name. Every interpolated value is **URL-escaped** when the redirect is built, so a token is a *value* in the path or query and can never add path structure. On the **public** path the submit answers only the created id, so a token resolves from the values the visitor submitted plus `{{record.id}}`; a field the server fills in (a default, a hook stamp) has no value there. 3. **A verbatim redirect on the resolved relative path is the intended consumption** — what the renderer navigates to is exactly this string with its tokens substituted. ```ts diff --git a/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts b/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts index fac6ce1fe6f..dd1e9523287 100644 --- a/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts @@ -1,15 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#21062] The record an anonymous public-form submit echoes back passes the -// result masker, on a real boot. +// [#21062 → #22437] A masked field never reaches an anonymous public-form +// submitter, on a real boot. // // The form-submit route authorizes the create through the ADR-0056 // declaration-derived grant, which passes before any permission set resolves. // `maskingRule` declares itself for "every non-system caller unless the // field's `requiredPermissions` are ALL held", and the anonymous submitter is a -// non-system caller, so every field whose rule applies is echoed masked: the -// one the form collects, and one filled from its `defaultValue` that the form -// never shows. +// non-system caller. #21062 put the masker's answer on the record the door used +// to echo back, so each masked field arrived masked. #22437 removed the echo: +// the door answers the created id and nothing the insert stored, so a masked +// field is now ABSENT from the answer like every other stored field — a +// stronger property than "masked", and the one pinned here. Both masked fields +// keep their subject: the one the form collects, and one filled from its +// `defaultValue` that the form never shows. // // Two deployment shapes are booted, because the caller the grant stands in for // resolves differently on each: one that registers no guest set (the @@ -17,15 +21,15 @@ // grant context names. // // What is asserted, by class: the scene is real (a system read of the created -// row holds the stored values); each masked field is echoed masked, never -// stored; the field with no rule is echoed as stored (the door really echoed -// the row); and the grant's admission is unchanged — the create succeeds and a -// server-managed field the submitter supplies never lands. +// row holds the stored values, under the id the answer names); neither masked +// field reaches the answer, by key or by stored value, and the answer is +// exactly the created id; and the grant's admission is unchanged — the create +// succeeds and a server-managed field the submitter supplies never lands. // // `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and -// auth layers. `@objectstack/plugin-security` resolves to its BUILT output -// here (no source alias), so build it before reading a verdict. Fixtures are -// synthetic. +// auth layers. `@objectstack/plugin-security` and `@objectstack/rest` resolve +// to their BUILT output here (no source alias), so build them before reading a +// verdict. Fixtures are synthetic. import { describe, it, expect } from 'vitest'; import { bootStack } from '@objectstack/verify'; @@ -106,12 +110,8 @@ const guestSetStack = defineStack({ type Stack = Parameters[0]; -function expectMasked(value: unknown, stored: string): void { - expect(typeof value, 'the masked field is echoed, as a string').toBe('string'); - expect(value).not.toBe(stored); - expect(String(value)).toContain('*'); - expect(String(value)).toHaveLength(stored.length); -} +/** Both masked fields, by key and by the stored value each holds. */ +const MASKED = { pfmask_code: ON_FORM, pfmask_stamp: DEFAULTED } as const; async function submitAndRead(stackDef: unknown): Promise { const stack = await bootStack(stackDef as Stack); @@ -122,25 +122,30 @@ async function submitAndRead(stackDef: unknown): Promise { body: JSON.stringify({ subject: SUBJECT, pfmask_code: ON_FORM, owner_id: FORGED_OWNER }), }); expect(res.status, 'the anonymous create succeeds').toBe(201); - const body = (await res.json()) as { id?: string; record: Record }; - const id = String(body.record?.id ?? body.id ?? ''); - expect(id, 'the echo names the created row').toBeTruthy(); + const wire = await res.text(); + const body = JSON.parse(wire) as Record; + const id = String(body.id ?? ''); + expect(id, 'the answer names the created row').toBeTruthy(); const ql = (await stack.kernel.getServiceAsync('objectql')) as any; const stored = await ql.findOne(TICKET, { where: { id }, ...SYS }); + expect(stored?.subject, 'the row landed under the id the answer names').toBe(SUBJECT); expect(stored?.pfmask_code, 'the stored value is what a system read serves').toBe(ON_FORM); expect(stored?.pfmask_stamp, 'the default was stored').toBe(DEFAULTED); expect(stored?.owner_id ?? null, 'a server-managed field the submitter supplies never lands').not.toBe(FORGED_OWNER); - expect(body.record.subject, 'the door echoed the row').toBe(SUBJECT); - expectMasked(body.record.pfmask_code, ON_FORM); - expectMasked(body.record.pfmask_stamp, DEFAULTED); + // Absent, which is stronger than masked: no key, and no stored value under any key. + for (const [field, value] of Object.entries(MASKED)) { + expect(body, `${field} is absent from the answer`).not.toHaveProperty(field); + expect(wire, `${field}'s stored value reaches no key of the answer`).not.toContain(value); + } + expect(body, 'the answer is the created id, and nothing the insert stored').toEqual({ id }); } finally { await stack.stop(); } } -describe('[#21062] an anonymous public-form submit echoes every masked field masked', () => { +describe('[#21062 → #22437] an anonymous public-form submit answers no masked field at all', () => { it('on a deployment that registers no guest set', () => submitAndRead(noGuestSetStack), 120_000); it('on a deployment whose stack declares the guest set', () => submitAndRead(guestSetStack), 120_000); }); diff --git a/packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts b/packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts new file mode 100644 index 00000000000..84667b570ec --- /dev/null +++ b/packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22437] An anonymous public-form submit answers `201` with the created +// record's id, and nothing the insert stored — on a real boot, with a hook +// that derives a field from an EXISTING record. +// +// The door used to answer with the row as stored after the insert pipeline. +// The form's field whitelist filters what the caller may WRITE; nothing +// filtered what it was then SHOWN. A `beforeInsert` hook that runs elevated +// (`runAs: 'system'`) may read records the anonymous caller's grant can never +// read, and stamp what it found onto the new row — and the echo then handed +// that finding to anyone on the internet: whether a submitted value matches an +// existing record, and which one. +// +// The fixture is that shape, synthetic: a form-target object whose elevated +// `beforeInsert` hook looks the submitted email up among existing contacts and +// stamps the match onto the new row. Two deployment shapes are booted, because +// the caller the form grant stands in for resolves differently on each: one +// that registers no guest set, and one whose stack declares the guest set the +// route's grant context names (reading neither object). +// +// What is asserted, by class: +// - the scene is real: a system read of the created row holds the stamp, so +// the hook ran elevated and found the existing record; +// - the answer is exactly `{ id }`: neither the stamp, nor any other stored +// field, nor the existing record's id under any key; +// - control: the answer still says `201`, and its top-level `id` — the key +// path the console's success screen reads — names the row that landed. +// +// `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, hook +// sandbox, REST and auth layers. `@objectstack/rest` resolves to its BUILT +// output here (no source alias), so build it before reading a verdict. + +import { describe, it, expect } from 'vitest'; +import { bootStack } from '@objectstack/verify'; +import { defineStack, defineView } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { definePermissionSet } from '@objectstack/spec/security'; + +const CONTACT = 'pfans_contact'; +const REQUEST = 'pfans_request'; +const SLUG = 'pfans-intake'; +const SYS = { context: { isSystem: true } } as const; +/** Synthetic values. */ +const KNOWN_EMAIL = 'known-7311@example.test'; +const SUBJECT = 'Synthetic subject 7311'; +const MATCH_KIND = 'SYNTH_EXISTING_MATCH'; +const DEFAULTED = 'SYNTH_DEFAULT_STAGE'; + +/** The existing records the hook reads; no anonymous caller may read them. */ +const PfansContact = ObjectSchema.create({ + name: CONTACT, + label: 'Answer Contact', + pluralLabel: 'Answer Contacts', + sharingModel: 'private', + fields: { + name: Field.text({ label: 'Name' }), + email: Field.text({ label: 'Email' }), + }, +}); + +/** The form's target: two declared fields, two stamped by the hook, one defaulted. */ +const PfansRequest = ObjectSchema.create({ + name: REQUEST, + label: 'Answer Request', + pluralLabel: 'Answer Requests', + sharingModel: 'private', + fields: { + subject: Field.text({ label: 'Subject', required: true }), + email: Field.text({ label: 'Email' }), + match_ref: Field.text({ label: 'Match reference' }), + match_kind: Field.text({ label: 'Match kind' }), + stage: Field.text({ label: 'Stage', defaultValue: DEFAULTED }), + }, +}); + +const data = { provider: 'object' as const, object: REQUEST }; +const PfansRequestViews = defineView({ + list: { label: 'Requests', type: 'grid', data, columns: [{ field: 'subject' }] }, + formViews: { + intake: { + type: 'simple', + data, + sections: [ + { + name: 'intake', + label: 'Intake', + columns: 1, + fields: [{ field: 'subject', required: true }, { field: 'email' }], + }, + ], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, + }, + }, +}); + +/** Look the submitted email up among existing contacts, elevated, and stamp the match. */ +const MATCH_SOURCE = ` + var rows = await ctx.api.object('${CONTACT}').find({ where: { email: ctx.input.email } }); + if (rows && rows.length > 0) { + ctx.input.match_ref = rows[0].id; + ctx.input.match_kind = '${MATCH_KIND}'; + } +`; + +const matchExistingHook = { + name: 'pfans_match_existing', + label: 'Match an existing contact', + object: REQUEST, + events: ['beforeInsert'], + runAs: 'system', + body: { language: 'js', source: MATCH_SOURCE, capabilities: ['api.read'] }, +}; + +/** The guest set the form-submit route's grant context names, declared by the stack. */ +const PfansGuestSet = definePermissionSet({ + name: 'guest_portal', + label: 'Guest (Public Forms)', + objects: { + [REQUEST]: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false }, + [CONTACT]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false }, + }, +}); + +const manifest = (suffix: string, description: string) => ({ + id: `com.dogfood.public-form-answer-${suffix}`, + namespace: 'pfans', + version: '0.0.0', + type: 'app' as const, + name: 'Public-form Answer Fixture', + description, +}); + +const noGuestSetStack = defineStack({ + manifest: manifest('no-guest-set', 'An anonymous form whose elevated hook stamps a match from existing records; no guest set.'), + objects: [PfansContact, PfansRequest], + views: [PfansRequestViews], + hooks: [matchExistingHook], +} as any); + +const guestSetStack = defineStack({ + manifest: manifest('guest-set', 'An anonymous form whose elevated hook stamps a match from existing records, and a guest set.'), + objects: [PfansContact, PfansRequest], + views: [PfansRequestViews], + hooks: [matchExistingHook], + permissions: [PfansGuestSet], +} as any); + +type Stack = Parameters[0]; + +async function submitAndRead(stackDef: unknown): Promise { + const stack = await bootStack(stackDef as Stack); + try { + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + const existing = await ql.insert(CONTACT, { name: 'Existing', email: KNOWN_EMAIL }, SYS); + expect(existing?.id, 'the existing record the hook will find').toBeTruthy(); + + const res = await stack.api(`/forms/${SLUG}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subject: SUBJECT, email: KNOWN_EMAIL }), + }); + const wire = await res.text(); + expect(res.status, wire).toBe(201); + const body = JSON.parse(wire) as Record; + + // Control: the key path the console reads the created id from. No + // `{ success, data }` envelope, so the top-level `id` is what it reads. + expect('success' in body).toBe(false); + expect(typeof body.id, 'the created id is a string at the top level').toBe('string'); + const id = body.id as string; + expect(id).not.toBe(''); + + // The scene is real: the row landed under that id, and the hook ran + // elevated and stamped what it found among the existing records. + const stored = await ql.findOne(REQUEST, { where: { id }, ...SYS }); + expect(stored, 'the answer names the row that landed').toBeTruthy(); + expect(stored.subject).toBe(SUBJECT); + expect(stored.match_ref, 'the elevated hook found the existing record').toBe(existing.id); + expect(stored.match_kind).toBe(MATCH_KIND); + expect(stored.stage, 'the default was stored').toBe(DEFAULTED); + + // The answer is the id, and nothing the insert stored. + expect(body).toEqual({ id }); + for (const key of Object.keys(stored)) { + if (key === 'id') continue; + expect(body, `stored field ${key} must not reach the anonymous caller`).not.toHaveProperty(key); + } + for (const value of [existing.id, MATCH_KIND, DEFAULTED]) { + expect(wire, 'a derived or defaulted value must not reach the anonymous caller under any key') + .not.toContain(String(value)); + } + } finally { + await stack.stop(); + } +} + +describe('[#22437] an anonymous public-form submit answers the created id, and nothing the insert stored', () => { + it('on a deployment that registers no guest set', () => submitAndRead(noGuestSetStack), 120_000); + it('on a deployment whose stack declares the guest set', () => submitAndRead(guestSetStack), 120_000); +}); diff --git a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts index 49a1f176ff4..cdf441d999b 100644 --- a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts @@ -15,14 +15,22 @@ // row's own evidence is the #3022 case: a forged owner_id / organization_id on // the anonymous submit never lands on the row. // authz-row: public-form-managed-anchors +// +// [#22437] The submit answers the created id and nothing the insert stored, so +// what landed is read back here through a SYSTEM read of that id — the row an +// administrator would see — never off the anonymous answer. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +const SYS = { context: { isSystem: true } } as const; + describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => { let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; beforeAll(async () => { stack = await bootStack(showcaseStack, { @@ -30,8 +38,19 @@ describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => { defaultPermissionSets: [...securityDefaultPermissionSets], }), }); + ql = await stack.kernel.getServiceAsync('objectql'); }, 60_000); + /** The anonymous answer must be exactly the created id; returns the row that landed under it. */ + const landedUnder = async (r: Response): Promise> => { + const body = (await r.json()) as Record; + expect(body, 'the anonymous answer is the created id, and nothing the insert stored').toEqual({ id: body.id }); + expect(typeof body.id).toBe('string'); + const row = await ql.findOne('showcase_inquiry', { where: { id: body.id }, ...SYS }); + expect(row, 'the answer names the row that landed').toBeTruthy(); + return row as Record; + }; + afterAll(async () => { await stack?.stop(); }); @@ -61,12 +80,11 @@ describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => { }), }); expect(r.status, 'anonymous submit must succeed under requireAuth=true').toBe(201); - const body = (await r.json()) as { object: string; id: string; record: Record }; - expect(body.object).toBe('showcase_inquiry'); - expect(body.record.name).toBe('Ada Lovelace'); + const row = await landedUnder(r); + expect(row.name).toBe('Ada Lovelace'); // Server-controlled: whitelist stripped the client `status`, the hook stamped defaults. - expect(body.record.status, 'status is server-stamped, not client-set').toBe('new'); - expect(body.record.source).toBe('web'); + expect(row.status, 'status is server-stamped, not client-set').toBe('new'); + expect(row.source).toBe('web'); }); it('a forged owner_id / organization_id never lands on the row (#3022)', async () => { @@ -83,12 +101,12 @@ describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => { }), }); expect(r.status, 'the submit itself still succeeds — the anchors are stripped, not fatal').toBe(201); - const body = (await r.json()) as { record: Record }; - expect(body.record.name).toBe('Mallory'); + const row = await landedUnder(r); + expect(row.name).toBe('Mallory'); // The anchors are server-managed on this surface: never the forged values. - expect(body.record.owner_id ?? null, 'anonymous submission must not forge ownership').not.toBe('usr_victim'); - expect(body.record.organization_id ?? null, 'anonymous submission must not land cross-tenant').not.toBe('org_victim'); - expect(body.record.created_by ?? null).not.toBe('usr_victim'); + expect(row.owner_id ?? null, 'anonymous submission must not forge ownership').not.toBe('usr_victim'); + expect(row.organization_id ?? null, 'anonymous submission must not land cross-tenant').not.toBe('org_victim'); + expect(row.created_by ?? null).not.toBe('usr_victim'); }); it('the public grant is create + read-back ONLY — anonymous cannot list inquiries', async () => { diff --git a/packages/rest/src/public-form-submit-answer.test.ts b/packages/rest/src/public-form-submit-answer.test.ts new file mode 100644 index 00000000000..e1e314860e8 --- /dev/null +++ b/packages/rest/src/public-form-submit-answer.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22437] The anonymous public-form submit answers `201` with the created +// record's id, and nothing the insert stored. +// +// The door used to relay `createData`'s whole answer — `{ object, id, record, +// droppedFields? }` — so the anonymous caller received the row as stored after +// the insert pipeline: every field it never sent, and every field a +// `beforeInsert` / `afterInsert` hook derived. A hook that runs elevated can +// derive a field from EXISTING records, and through the echo that derivation +// reached anyone on the internet. The caller already knows what it submitted, +// so the answer is the one fact it cannot know: the id of the row it created. +// Projecting the row to the form's declared fields was rejected, because a +// hook may rewrite a declared field too. +// +// Two halves, one subject: +// - on the registered handler, with a `createData` answer carrying a stored +// row, a derived field and a drop report, the body is exactly `{ id }`; +// - through the real Hono transport, the wire is that same bare object with +// no envelope, so the top-level `id` — the key path the console's success +// screen reads the created id from — still resolves. +// +// The elevated-hook case on a real boot (a hook that reads an existing record +// and stamps what it found) is pinned in the dogfood suite, +// `public-form-submit-answer.dogfood.test.ts`. Fixtures are synthetic. + +import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import { RestServer } from './rest-server'; + +// [#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. +import '@objectstack/spec/ui'; + +const CREATED_ID = 'rec_answer_1'; +/** Synthetic values the stored row carries. None may reach the anonymous caller. */ +const DERIVED = 'SYNTH-DERIVED-FROM-EXISTING-7311'; +const STAFF = 'usr_synthetic_staff_7311'; +const DEFAULTED = 'SYNTH-DEFAULTED-7311'; + +/** What the protocol's `createData` answers: the stored row, a derived stamp, a drop report. */ +const CREATE_ANSWER = { + object: 'answer_request', + id: CREATED_ID, + record: { + id: CREATED_ID, + subject: 'Help', + email: 'someone@example.test', + stage: DEFAULTED, + match_ref: DERIVED, + owner_id: STAFF, + created_at: '2026-01-01T00:00:00Z', + }, + droppedFields: [{ object: 'answer_request', fields: ['stage'], reason: 'readonly' }], +}; + +function formView() { + return { + name: 'answer_request_form', + object: 'answer_request', + viewKind: 'form', + config: { + data: { object: 'answer_request' }, + sections: [{ fields: ['subject', { field: 'email' }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/answer' }, + }, + }; +} + +const requestObject = { + name: 'answer_request', + label: 'Answer Request', + fields: { + id: { type: 'text' }, + subject: { type: 'text', label: 'Subject' }, + email: { type: 'text', label: 'Email' }, + stage: { type: 'text', label: 'Stage' }, + match_ref: { type: 'text', label: 'Match ref' }, + owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' }, + }, +}; + +function protocolDouble() { + 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 [formView()]; + if (type === 'object') return [requestObject]; + return []; + }), + createData: vi.fn().mockResolvedValue(CREATE_ANSWER), + }; +} + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.end = vi.fn(() => res); + return res; +} + +const SUBMITTED = { subject: 'Help', email: 'someone@example.test' }; + +describe('[#22437] POST /forms/:slug/submit answers the created id, and nothing the insert stored', () => { + it('the registered handler answers 201 with exactly { id }', async () => { + const protocol = protocolDouble(); + 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 really ran, and the double really answered a stored row. + expect(protocol.createData).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(201); + expect(res.body).toEqual({ id: CREATED_ID }); + expect(Object.keys(res.body)).toEqual(['id']); + // Asserted against the whole serialized answer, not one key: the finding is + // about VALUES escaping, under whatever key. + const wire = JSON.stringify(res.body); + for (const leak of [DERIVED, STAFF, DEFAULTED, 'answer_request', 'droppedFields', 'record']) { + expect(wire, `${leak} must not reach the anonymous caller`).not.toContain(leak); + } + }); + + it('through the real transport: 201, a bare JSON object, and the created id at the top-level `id`', async () => { + const server = new HonoHttpServer(0); + const protocol = protocolDouble(); + const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + server.installNotFoundSeam(); + + const res: Response = await server.getRawApp().fetch(new Request('http://local/api/v1/forms/answer/submit', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(SUBMITTED), + })); + + expect(res.status).toBe(201); + expect(protocol.createData).toHaveBeenCalledTimes(1); + expect(protocol.createData.mock.calls[0][0].data).toEqual(SUBMITTED); + const body = (await res.json()) as Record; + expect(body).toEqual({ id: CREATED_ID }); + // The console's success screen reads the created id off the top-level `id`, + // after stripping a `{ success, data }` transport envelope when one is + // present. This door answers no envelope, so the body itself is what it + // reads, and the key is a non-empty string. + expect('success' in body).toBe(false); + expect('data' in body).toBe(false); + expect(typeof body.id).toBe('string'); + expect(body.id).not.toBe(''); + }); +}); 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 06dc9e2befa..d6083c3809f 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 @@ -336,14 +336,15 @@ const DISPOSITIONS: Record = { }, }, - // ── PROTOCOL INGRESS: the write runs through a `*Data` face ─────────────── + // ── 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. 'POST /api/v1/forms/:slug/submit': { - kind: 'protocol-ingress', - why: 'Public form submit calls `p.createData(...)` and 201s its result — the ' - + 'same ingress `POST /data/:object` uses, stripped there.', + kind: 'no-record-echo', + why: 'Public form submit answers the created record\'s id only (`{ id }`), never the stored row.', }, - - // ── 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.' }, From d7eb45da9512176e3d928c972307c55738ffb1a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:38:56 +0000 Subject: [PATCH 2/4] fix(rest): public form submit answers the created id, not the stored row The anonymous POST /forms/:slug/submit relayed createData's whole answer, so the caller was shown the row as stored after the insert pipeline, hook-derived fields included. It now answers 201 with { id } alone, at the top-level key the console reads. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/rest/src/rest-server.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 89fae0b5e5f..02f1be90836 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10801,7 +10801,8 @@ export class RestServer { * as they answer a withdrawn form. Two routes are registered: * * GET {basePath}/forms/:slug → resolved form spec - * POST {basePath}/forms/:slug/submit → INSERT record (no auth required) + * POST {basePath}/forms/:slug/submit → INSERT record (no auth required), + * answering `{ id }` only (#22437) * * Both routes bypass `enforceAuth` even though anonymous-deny is on for the * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the @@ -11221,7 +11222,22 @@ export class RestServer { context, }; const result = await p.createData(formCreateRequest); - res.status(201).json(result); + // [#22437] The answer is the created record's id, and + // nothing 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. + // 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 }); } catch (error: any) { const mapped = mapDataError(error); // Distinct message (this is not the "unhandled" channel), From dff09ef9f01ea596b09f1b21e90cff28085ab9b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:45:17 +0000 Subject: [PATCH 3/4] test(dogfood): the absence pins read the whole answer, at any depth Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../test/public-form-read-back-masking.dogfood.test.ts | 5 +++-- .../dogfood/test/public-form-submit-answer.dogfood.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts b/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts index dd1e9523287..848214ec532 100644 --- a/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts @@ -134,9 +134,10 @@ async function submitAndRead(stackDef: unknown): Promise { expect(stored?.pfmask_stamp, 'the default was stored').toBe(DEFAULTED); expect(stored?.owner_id ?? null, 'a server-managed field the submitter supplies never lands').not.toBe(FORGED_OWNER); - // Absent, which is stronger than masked: no key, and no stored value under any key. + // Absent, which is stronger than masked: the field is named at no depth of + // the answer, and its stored value rides no key of it. for (const [field, value] of Object.entries(MASKED)) { - expect(body, `${field} is absent from the answer`).not.toHaveProperty(field); + expect(wire, `${field} is absent from the answer, at any depth`).not.toContain(JSON.stringify(field)); expect(wire, `${field}'s stored value reaches no key of the answer`).not.toContain(value); } expect(body, 'the answer is the created id, and nothing the insert stored').toEqual({ id }); diff --git a/packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts b/packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts index 84667b570ec..5050d847a3a 100644 --- a/packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts @@ -180,16 +180,17 @@ async function submitAndRead(stackDef: unknown): Promise { expect(stored.match_kind).toBe(MATCH_KIND); expect(stored.stage, 'the default was stored').toBe(DEFAULTED); - // The answer is the id, and nothing the insert stored. - expect(body).toEqual({ id }); + // The answer is the id, and nothing the insert stored: no stored field is + // named at any depth of it. for (const key of Object.keys(stored)) { if (key === 'id') continue; - expect(body, `stored field ${key} must not reach the anonymous caller`).not.toHaveProperty(key); + expect(wire, `stored field ${key} must not reach the anonymous caller`).not.toContain(JSON.stringify(key)); } for (const value of [existing.id, MATCH_KIND, DEFAULTED]) { expect(wire, 'a derived or defaulted value must not reach the anonymous caller under any key') .not.toContain(String(value)); } + expect(body).toEqual({ id }); } finally { await stack.stop(); } From 38e9287e336f0cebaffbbbcf339d14c22d8662b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:55:25 +0000 Subject: [PATCH 4/4] fix(rest)!: declare the public form submit answer narrowing as breaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset moves to minor with a breaking summary, the Clause-② narrowing arm and its ADR-0087 disposition. The zero-set masking dogfood header no longer claims a door the submit answer closed. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .changeset/22437-public-form-submit-answers-id.md | 10 +++++++--- .../qa/dogfood/test/zero-set-masking.dogfood.test.ts | 11 +++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.changeset/22437-public-form-submit-answers-id.md b/.changeset/22437-public-form-submit-answers-id.md index 2a783246915..678145ae229 100644 --- a/.changeset/22437-public-form-submit-answers-id.md +++ b/.changeset/22437-public-form-submit-answers-id.md @@ -1,10 +1,14 @@ --- -'@objectstack/rest': patch +'@objectstack/rest': minor --- -fix(rest): an anonymous public-form submit answers the created record's id, and nothing the insert stored +fix(rest)!: an anonymous public-form submit answers the created record's id, and nothing the insert stored -Clause-②: no +Clause-②: no (narrowing) + + + +**BREAKING** (a response narrowing): this ships as `minor` under the launch-window convention for breaking changes. `POST /api/v1/forms/:slug/submit` used to answer `201` with the protocol's whole create answer, `{ object, id, record, droppedFields? }`. The `record` was the row as stored after the insert pipeline. So the anonymous caller was shown every field it never sent: a `defaultValue`, a field a `beforeInsert` or `afterInsert` hook stamped, and a value a hook running elevated (`runAs: 'system'`) derived from existing records the caller's grant may never read. The form's field whitelist filtered what the caller could write. Nothing filtered what it was then shown. diff --git a/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts b/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts index 70665a41c6b..f2084b03ed9 100644 --- a/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts +++ b/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts @@ -12,8 +12,15 @@ // What is asserted, by class: the scene is real (a system read carries the // stored value), and the door refuses at object admission (403, // `PERMISSION_DENIED`) — the record door for the read and for a query alike. -// The masker's zero-set reading is still reached on a real boot through the -// public form submit's echo, pinned by `public-form-read-back-masking`. +// The masker's zero-set reading no longer reaches a caller through any door on +// a real boot. Its last door was the public form submit's echo: the form grant +// admits ahead of object admission, and its read-back still passes the masker +// inside the engine. [#22437] The submit now answers the created id alone, so +// nothing that read-back holds leaves the door. `public-form-read-back-masking` +// pins that the masked fields are ABSENT from that answer. The masker's +// zero-set output itself is pinned at the security middleware, in +// plugin-security's `public-form-grant-masking.test.ts`, on a synthetic harness +// and not a boot. // // [#21180] There used to be a second door — the public form's anonymous lookup // picker, re-pinned to this 403 by #21079. Ruling E retired the picker and