Repository navigation
fix(rest): an anonymous public-form submit answers the created id, not the stored row (#22437) - #22462
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
…blic-form-submit-answers-id
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 33f0e814d6f002e4304ed47b262161be4500a69e && git checkout 33f0e814d6f002e4304ed47b262161be4500a69e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 38e9287e336f0cebaffbbbcf339d14c22d8662b2 && git checkout -B drift-repro 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 && git merge --no-ff 38e9287e336f0cebaffbbbcf339d14c22d8662b2
node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389
|
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 <noreply@anthropic.com>
Fixes #22437
Clause-②: no (narrowing)
What changed
POST /api/v1/forms/:slug/submit(the anonymous public-form submit) now answers201with the created record's id and nothing else:{ "id": "..." }. It used to relay the protocol's whole create answer,{ object, id, record, droppedFields? }, whererecordis the row as stored after the insert pipeline. That served the anonymous caller every field it never sent, including defaults and fields abeforeInsert/afterInserthook stamped. A hook running elevated (runAs: 'system') can derive such a field from existing records that the caller's grant may never read.The shape is triage's call (
6076863767): the id only. Projecting to the form's declared fields was rejected, because a hook may rewrite a declared field too. The write path is unchanged: same whitelist, same server-managed anchors, same grant, same hooks. No second read builds the answer. The handler sends{ id: result.id }from thecreateDataresult it already holds.Measured first: what the door answered before and after (real boot, keys and statuses only)
Driven through the real door on
bootStack: realSecurityPlugin,ObjectQL, SQL driver, hook sandbox, REST and auth. The fixture is synthetic. A form-target object has two declared fields (subject,email), two hook-stamped fields and one defaulted field. A second object holds an existing record. Four boots: plain and elevated hook, each on a deployment with no guest set and one that declares the guest set (reading neither object). The elevated hook is abeforeInsertL2 body withrunAs: 'system'. It looks the submitted email up among existing records and stamps the match.da159f74e+ pins only)d7eb45da9)201· top-levelid, object, record·recordkeys:created_at, created_by, email, id, match_kind, match_ref, organization_id, owner_id, owning_business_unit_id, stage, subject, updated_at, updated_by(13, equal to the stored row's keys)201· top-levelidonly · norecord201· top-levelidonlyrecord.match_refequals the existing record's id (the derived value reached the anonymous caller)201· top-levelidonly; the stored row still holds the stamp (the hook ran)201· top-levelidonly; stamp storedEvery answer was
application/jsonwith the id a string at the top level, before and after.H2: the console's success screen (measured at objectui
origin/main47b1f0bb7)apps/console/src/components/FormPage.tsxsubmitPublicposts the door and returnsres.json().thank-you(resolveSubmitBehavior). It reads nothing off the answer beyondres.ok.redirectarm builds its token scope from the submittedpayload, thenunwrapTransportEnvelope(result)?.recordlayered over it, thenreadCreatedRecordId(result). That reads the top-levelidafter stripping a{ success, data }envelope when one is present. This door answers a bare body, so the top-levelidis the key path. It is kept, and pinned on the wire.created-recordis the internal path's default only. The public path never reaches it.FormPage.redirect.test.tsx, "interpolates from the submitted values on the anonymous path"). No objectui change is needed.payload, and{{record.id}}from the answer. A token over a server-filled field resolves empty (urlValuereads absent as empty), which is exactly the disclosure closed here.git grepforsubmitBehavioracrossexamples/and the test trees finds three forms, allthank-you(the instrument's control). Nokind: 'redirect'appears anywhere inexamples/,apps/,packages/qaor the test trees, and no{{record.token appears in an example or a public-form fixture.H3: no spec declaration of this door's answer
packages/specdeclaresCreateDataResponseSchemafor the protocol'screateDatamethod, not for this REST door.POST /api/v1/forms/:slug/submit(rest-route-ledger.ts) isdisposition: 'public', with noclientand noresponseSchema.git grepofpackages/spec/srcfor the door finds only the server-managed field set (security/public-form.ts), slug normalisation, and conversion fixtures.packages/specis untouched.Translation-flip sweep
Repo-wide
git grepfor the door (forms/together with/submit) across test, fixture and docs trees. Every pin that read the stored-row echo is flipped to assert the new meaning:packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts: the security(forms): two public-form doors meet a field masking rule wrongly — the submit read-back serves masked fields stored to an anonymous submitter, and a picker whose first display field is masked answers 403 to every caller it applies to #21062 masking pin keeps its subject (both masked fields: one collected, one defaulted). It now asserts each is absent at any depth of the answer, and that its stored value rides no key. The answer is exactly{ id }. A system read of that id still holds the stored values (the scene is real), and the forged owner still never lands.packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts: the authz-rowpublic-form-managed-anchorsproof and thestatus/sourcehook-stamp pin cited byrecords-forms.json. Both now read the landed row through a system read of the answered id, not off the anonymous answer. Each also asserts the answer is exactly{ id }. The forged-anchor and stamped-default assertions are unchanged in substance.packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts: the door's disposition moves fromprotocol-ingress("201s its result") tono-record-echowith the new reason, because the old reason became false.public-form-routes.test.ts,public-form-routes.stored-row.test.ts,public-form-withdrawal.test.ts,public-form-intake-availability.test.ts, the withdrawal and walled-intake dogfood files (they readcode/ status / raw text of a refusal, or count landed rows),showcase-public-form-redirect.dogfood.test.ts(it counts landed rows), the platform checklist items (they read the landed row as staff), andconsole.public-form-redirect.test.ts.content/docs/ui/forms.mdx: the documented201answer is now{ "id": ... }, with the authenticated-read remedy. The redirect section says what a token resolves from on the public path.New pins
packages/rest/src/public-form-submit-answer.test.tsruns on the registered handler, with acreateDatadouble that answers a stored row, a derived stamp and a drop report. The body is exactly{ id }, and no stored value appears in the serialized answer. Through the real Hono transport:201, a bare object (nosuccess/data), and a non-empty string at the top-levelid.packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.tsis the elevated-beforeInsertpin on a real boot, in both deployment shapes. A system read shows the hook found the existing record and stamped it. The answer names no stored field at any depth, carries none of the derived or defaulted values under any key, and is exactly{ id }. Control:201, and the top-levelidnames the row that landed.Ablation (committed fix, then mutate, then restore)
node scripts/ablation-replace.mjsput the echo back with an identifiable marker (res.status(201).json({ ...result, ablation22437: true })). On disk the anchor count was 0 and the marker count 1.pnpm --filter @objectstack/rest buildran, thenablation-dist-preflight.mjs @objectstack/rest ablation22437found the marker indist/index.jsanddist/index.cjs.git diff HEADempty. Then a rebuild, and--absentprinted "marker absent from all 6 built files" and "working tree clean against HEAD". Rest 2 of 2 passed, dogfood 8 of 8 passed.Re-run on the merged head
74a7828f5, after the absence assertions were widened to any depth of the answer:--absentclean, rest 2 of 2 and dogfood 8 of 8 passed.Tests and gates (head
74a7828f5)All at head
74a7828f5, which mergesorigin/main2b61f2d9d(no conflict):pnpm --filter @objectstack/rest test: exit 0. 266 files passed; 4962 tests passed, 326 skipped.pnpm --filter @objectstack/rest typecheck: exit 0. That istsc --noEmitpluscheck:test-typecheckovertsconfig.test.json, whose program lists both touched rest test files (counted with--listFilesOnly).pnpm --filter @objectstack/dogfood typecheck: exit 0. Its program lists all 3 touched dogfood files.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2): exit 0. 234 files passed, 1 skipped; 1840 tests passed, 9 skipped.pnpm lint, the full run (eslint . --no-inline-config): exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, with no paths, at this head derives 94 commands. All 94 exited 0, and--ranreconciles with "94 run, 0 NOT-MEASURED".dist/absent for packages outside the dogfood build closure). They passed after a fullturbo run build:check:skill-examples("262 prose examples type-check across 3 surface(s)") andcheck:dual-build-cjs-loads("107 published require entry point(s) across 66 package(s) load").content/docs/ui/forms.mdxedit.Patch round 1 (head
38e9287e3).changeset/22437-public-form-submit-answers-id.md:minor,fix(rest)!:,Clause-②: no (narrowing), a BREAKING note, and one ADR-0087 marker,not-required (no-migration-prescription).node scripts/check-adr-0087-registration.mjs --base origin/mainexits 0 and reports "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition".node scripts/check-changeset-no-major.mjs --base origin/main --event(fed this PR's payload) exits 0: "this PR declares clause-②no (narrowing), and no package whosepackages/**/src/**it moves is gradedpatch".packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts: header prose only, with no test logic. The file passes through the verify lock, 1 of 1.38e9287e3: the same 94 commands. All 94 exit 0, and--ranreports "94 run, 0 NOT-MEASURED".pnpm check:changeset-gate-self-testsandpnpm check:doc-authoringexit 0.merge-treeagainstorigin/main3ca71b6e0is clean, somainwas not merged.Acceptance notes
no (narrowing), BREAKING. The answer drops fields a host may have read, so it is a narrowing. Triage6076863767named it that way, and the seat's review answered the dev's open question with B and corrected the claim. The changeset isminor, with afix(rest)!:summary, theClause-②: no (narrowing)line, a BREAKING note, and the ADR-0087 dispositionnot-required (no-migration-prescription), whichcheck-adr-0087-registrationaccepts. The door's answer has no spec declaration, so there is no tombstone and nothing forobjectstack migrate metato rewrite, andpackages/specis untouched. The migration line stays: a host that read the record off this answer reads it through an authenticated read.packages/qa/dogfood/test/zero-set-masking.dogfood.test.tssaid the masker's zero-set reading is still reached on a real boot through the submit's echo. It now says that reading reaches no caller through any door on a real boot. The form grant's read-back is still masked inside the engine, but the submit answers the created id alone. The masked fields' absence from that answer is pinned bypublic-form-read-back-masking. The masker's zero-set output itself is pinned at the security middleware, in plugin-security'spublic-form-grant-masking.test.ts, on a synthetic harness rather than a boot. Prose only; the file passes 1 of 1 at38e9287e3.droppedFields. Measured: the console reads nodroppedFields, and this door never setX-ObjectStack-Dropped-Fields. A public form that declares areadonlyfield already dropped the visitor's value with no other signal. Noted, not filed.content/docs/ui/forms.mdx, the "Current renderer status (2026-08-11)" note says the console does not substitute{{record.field}}tokens. objectuiorigin/mainsubmitRedirect.tssubstitutes them.content/docs/ui/forms.mdxisdomain:devx's and is declared on [PM seat] domain:devx @ objectstack — 🟢 os-bill · session_01LYXc6ckoWuZyVZpWYizdMh #6023 (done by the seat).Generated by Claude Code