Skip to content

Commit 7806a14

Browse files
fix(rest): an anonymous public-form submit answers the created id, not the stored row (#22437) (#22462)
Fixes #22437 Clause-②: no (narrowing) ## What changed `POST /api/v1/forms/:slug/submit` (the anonymous public-form submit) now answers `201` with the created record's id and nothing else: `{ "id": "..." }`. It used to relay the protocol's whole create answer, `{ object, id, record, droppedFields? }`, where `record` is the row as stored after the insert pipeline. That served the anonymous caller every field it never sent, including defaults and fields a `beforeInsert` / `afterInsert` hook 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 the `createData` result 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`: real `SecurityPlugin`, `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 a `beforeInsert` L2 body with `runAs: 'system'`. It looks the submitted email up among existing records and stamps the match. | boot | before (`da159f74e` + pins only) | after (`d7eb45da9`) | |---|---|---| | plain, no guest set | `201` · top-level `id, object, record` · `record` keys: `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-level `id` only · no `record` | | plain, guest set | same as above | `201` · top-level `id` only | | elevated hook, no guest set | same 13 keys; `record.match_ref` **equals the existing record's id** (the derived value reached the anonymous caller) | `201` · top-level `id` only; the stored row still holds the stamp (the hook ran) | | elevated hook, guest set | same as above, the existing record's id served | `201` · top-level `id` only; stamp stored | Every answer was `application/json` with the id a string at the top level, before and after. ## H2: the console's success screen (measured at objectui `origin/main` `47b1f0bb7`) - `apps/console/src/components/FormPage.tsx` `submitPublic` posts the door and returns `res.json()`. - The public path's default behavior is `thank-you` (`resolveSubmitBehavior`). It reads nothing off the answer beyond `res.ok`. - The `redirect` arm builds its token scope from the submitted `payload`, then `unwrapTransportEnvelope(result)?.record` layered over it, then `readCreatedRecordId(result)`. That reads the **top-level `id`** after stripping a `{ success, data }` envelope when one is present. This door answers a bare body, so the top-level `id` is the key path. It is kept, and pinned on the wire. - `created-record` is the internal path's default only. The public path never reaches it. - objectui's own tests already stub the public submit as answering no record (`FormPage.redirect.test.tsx`, "interpolates from the submitted values on the anonymous path"). No objectui change is needed. - After this change, a redirect token over a submitted field still resolves from `payload`, and `{{record.id}}` from the answer. A token over a server-filled field resolves empty (`urlValue` reads absent as empty), which is exactly the disclosure closed here. - **Shipped forms with a redirect over an undeclared field: zero hits.** `git grep` for `submitBehavior` across `examples/` and the test trees finds three forms, all `thank-you` (the instrument's control). No `kind: 'redirect'` appears anywhere in `examples/`, `apps/`, `packages/qa` or 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/spec` declares `CreateDataResponseSchema` for the protocol's `createData` method, not for this REST door. - The route ledger row `POST /api/v1/forms/:slug/submit` (`rest-route-ledger.ts`) is `disposition: 'public'`, with no `client` and no `responseSchema`. - The OpenAPI builtin paths invent no response schemas. - `git grep` of `packages/spec/src` for the door finds only the server-managed field set (`security/public-form.ts`), slug normalisation, and conversion fixtures. - So narrowing this answer changes no published spec contract, and `packages/spec` is untouched. ## Translation-flip sweep Repo-wide `git grep` for 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 #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-row `public-form-managed-anchors` proof and the `status`/`source` hook-stamp pin cited by `records-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 from `protocol-ingress` ("201s its result") to `no-record-echo` with the new reason, because the old reason became false. - Read and left alone, because they assert no echo: `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 read `code` / 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), and `console.public-form-redirect.test.ts`. - `content/docs/ui/forms.mdx`: the documented `201` answer 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.ts` runs on the registered handler, with a `createData` double 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 (no `success` / `data`), and a non-empty string at the top-level `id`. - `packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts` is the elevated-`beforeInsert` pin 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-level `id` names the row that landed. ## Ablation (committed fix, then mutate, then restore) - Mutation: `node scripts/ablation-replace.mjs` put 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 build` ran, then `ablation-dist-preflight.mjs @objectstack/rest ablation22437` found the marker in `dist/index.js` and `dist/index.cjs`. - Mutated: the rest pin failed 2 of 2. The dogfood pins failed 6 of 8 (both new, both masking, and both showcase submit cases). The 2 that passed are the showcase resolve and list-denial cases, which read no answer. - Restore: blob equal to the HEAD blob, `git diff HEAD` empty. Then a rebuild, and `--absent` printed "marker absent from all 6 built files" and "working tree clean against HEAD". Rest 2 of 2 passed, dogfood 8 of 8 passed. - Direction: the pins go red without the fix (the expected direction). Re-run on the merged head `74a7828f5`, after the absence assertions were widened to any depth of the answer: - Mutated: the rest pin failed 2 of 2 and the dogfood pins failed 6 of 8. The first failing assertion is now the per-field one: "pfmask_code is absent from the answer, at any depth" and "stored field created_at must not reach the anonymous caller". - Restored: blob equal to HEAD, `--absent` clean, rest 2 of 2 and dogfood 8 of 8 passed. ## Tests and gates (head `74a7828f5`) All at head `74a7828f5`, which merges `origin/main` `2b61f2d9d` (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 is `tsc --noEmit` plus `check:test-typecheck` over `tsconfig.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. - **The whole dogfood suite**, through the verify lock (`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. - Derived gates: `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 `--ran` reconciles with "94 run, 0 NOT-MEASURED". - Two of them first refused with exit 3 (prerequisite not met: `dist/` absent for packages outside the dogfood build closure). They passed after a full `turbo run build`: `check:skill-examples` ("262 prose examples type-check across 3 surface(s)") and `check:dual-build-cjs-loads` ("107 published require entry point(s) across 66 package(s) load"). - The dispatch's 69 named gates are a subset of the 94. The other 25 are documentation families, derived from the `content/docs/ui/forms.mdx` edit. ## 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/main` exits 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 whose `packages/**/src/**` it moves is graded `patch`". - `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. - The derived gates were re-derived with no paths at `38e9287e3`: the same 94 commands. All 94 exit 0, and `--ran` reports "94 run, 0 NOT-MEASURED". `pnpm check:changeset-gate-self-tests` and `pnpm check:doc-authoring` exit 0. - `merge-tree` against `origin/main` `3ca71b6e0` is clean, so `main` was not merged. ## Acceptance notes - **Clause-② spelling: `no (narrowing)`, BREAKING.** The answer drops fields a host may have read, so it is a narrowing. Triage `6076863767` named it that way, and the seat's review answered the dev's open question with B and corrected the claim. The changeset is `minor`, with a `fix(rest)!:` summary, the `Clause-②: no (narrowing)` line, a BREAKING note, and the ADR-0087 disposition `not-required (no-migration-prescription)`, which `check-adr-0087-registration` accepts. The door's answer has no spec declaration, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite, and `packages/spec` is untouched. The migration line stays: a host that read the record off this answer reads it through an authenticated read. - **The zero-set masking header, corrected in patch round 1.** `packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts` said 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 by `public-form-read-back-masking`. 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 rather than a boot. Prose only; the file passes 1 of 1 at `38e9287e3`. - **The drop report is gone from this door.** The answer no longer carries `droppedFields`. Measured: the console reads no `droppedFields`, and this door never set `X-ObjectStack-Dropped-Fields`. A public form that declares a `readonly` field already dropped the visitor's value with no other signal. Noted, not filed. - **Unrelated drift, not edited.** In `content/docs/ui/forms.mdx`, the "Current renderer status (2026-08-11)" note says the console does not substitute `{{record.field}}` tokens. objectui `origin/main` `submitRedirect.ts` substitutes them. - The docs page `content/docs/ui/forms.mdx` is `domain:devx`'s and is declared on #6023 (done by the seat). --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 166a94f commit 7806a14

9 files changed

Lines changed: 492 additions & 55 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: an anonymous public-form submit answers the created record's id, and nothing the insert stored
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime response narrowing at one REST door, not a metadata change: the anonymous public-form submit's `201` answer drops `object`, `record` and `droppedFields` and keeps `id`. No spec key, export, option, stored shape or authorable metadata is removed, renamed or re-shaped. The door's answer has no spec declaration: its route-ledger row names no response schema, and `CreateDataResponseSchema` types the protocol's `createData`, whose answer this diff leaves unchanged. So there is no tombstone, and nothing for `objectstack migrate meta` to rewrite. What a host that read the echo does instead is a runtime call, an authenticated read of the record by the answered id, which the body states. The other categories are closed on facts: `@objectstack/rest` publishes (not unpublished); no ADR-0087 id covers this door and this diff adds none (not registered / already-registered); and no published TypeScript interface or type changes (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** (a response narrowing): this ships as `minor` under the launch-window convention for breaking changes.
12+
13+
`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.
14+
15+
The answer is now the created id alone:
16+
17+
```json
18+
{ "id": "r7p8cUZoBJbFWudt" }
19+
```
20+
21+
- **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.
22+
- **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.
23+
- **The write is unchanged**: the same whitelist, the same server-managed anchors stripped, the same grant, the same hooks. Only the answer shrank.
24+
25+
**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.

‎content/docs/ui/forms.mdx‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -211,18 +211,14 @@ curl -X POST http://localhost:3000/api/v1/forms/contact-us/submit \
211211
}'
212212
```
213213

214-
Response on success (HTTP `201 Created`):
214+
Response on success (HTTP `201 Created`) — the created record's id, and nothing else:
215215

216216
```json
217-
{ "object": "lead", "id": "r7p8cUZoBJbFWudt", "record": {
218-
"id": "r7p8cUZoBJbFWudt",
219-
"first_name": "Ada", "last_name": "Lovelace",
220-
"status": "new", // ← hook default
221-
"lead_source": "web", // ← hook default
222-
"owner": null // ← whitelist stripped, hook deleted
223-
} }
217+
{ "id": "r7p8cUZoBJbFWudt" }
224218
```
225219

220+
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.
221+
226222
Errors:
227223

228224
| Status | Code | When |
@@ -271,7 +267,7 @@ async function submit(slug: string, payload: Record<string, unknown>) {
271267
body: JSON.stringify(payload),
272268
});
273269
if (!r.ok) throw new Error(await r.text());
274-
return r.json();
270+
return r.json(); // { id } — the created record's id only
275271
}
276272
```
277273

@@ -368,7 +364,7 @@ formViews: {
368364
`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:
369365

370366
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.
371-
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.
367+
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.
372368
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.
373369

374370
```ts

‎packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts‎

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// [#21062] The record an anonymous public-form submit echoes back passes the
4-
// result masker, on a real boot.
3+
// [#21062 → #22437] A masked field never reaches an anonymous public-form
4+
// submitter, on a real boot.
55
//
66
// The form-submit route authorizes the create through the ADR-0056
77
// declaration-derived grant, which passes before any permission set resolves.
88
// `maskingRule` declares itself for "every non-system caller unless the
99
// field's `requiredPermissions` are ALL held", and the anonymous submitter is a
10-
// non-system caller, so every field whose rule applies is echoed masked: the
11-
// one the form collects, and one filled from its `defaultValue` that the form
12-
// never shows.
10+
// non-system caller. #21062 put the masker's answer on the record the door used
11+
// to echo back, so each masked field arrived masked. #22437 removed the echo:
12+
// the door answers the created id and nothing the insert stored, so a masked
13+
// field is now ABSENT from the answer like every other stored field — a
14+
// stronger property than "masked", and the one pinned here. Both masked fields
15+
// keep their subject: the one the form collects, and one filled from its
16+
// `defaultValue` that the form never shows.
1317
//
1418
// Two deployment shapes are booted, because the caller the grant stands in for
1519
// resolves differently on each: one that registers no guest set (the
1620
// showcase's shape), and one whose stack declares the guest set the route's
1721
// grant context names.
1822
//
1923
// What is asserted, by class: the scene is real (a system read of the created
20-
// row holds the stored values); each masked field is echoed masked, never
21-
// stored; the field with no rule is echoed as stored (the door really echoed
22-
// the row); and the grant's admission is unchanged — the create succeeds and a
23-
// server-managed field the submitter supplies never lands.
24+
// row holds the stored values, under the id the answer names); neither masked
25+
// field reaches the answer, by key or by stored value, and the answer is
26+
// exactly the created id; and the grant's admission is unchanged — the create
27+
// succeeds and a server-managed field the submitter supplies never lands.
2428
//
2529
// `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and
26-
// auth layers. `@objectstack/plugin-security` resolves to its BUILT output
27-
// here (no source alias), so build it before reading a verdict. Fixtures are
28-
// synthetic.
30+
// auth layers. `@objectstack/plugin-security` and `@objectstack/rest` resolve
31+
// to their BUILT output here (no source alias), so build them before reading a
32+
// verdict. Fixtures are synthetic.
2933

3034
import { describe, it, expect } from 'vitest';
3135
import { bootStack } from '@objectstack/verify';
@@ -106,12 +110,8 @@ const guestSetStack = defineStack({
106110

107111
type Stack = Parameters<typeof bootStack>[0];
108112

109-
function expectMasked(value: unknown, stored: string): void {
110-
expect(typeof value, 'the masked field is echoed, as a string').toBe('string');
111-
expect(value).not.toBe(stored);
112-
expect(String(value)).toContain('*');
113-
expect(String(value)).toHaveLength(stored.length);
114-
}
113+
/** Both masked fields, by key and by the stored value each holds. */
114+
const MASKED = { pfmask_code: ON_FORM, pfmask_stamp: DEFAULTED } as const;
115115

116116
async function submitAndRead(stackDef: unknown): Promise<void> {
117117
const stack = await bootStack(stackDef as Stack);
@@ -122,25 +122,31 @@ async function submitAndRead(stackDef: unknown): Promise<void> {
122122
body: JSON.stringify({ subject: SUBJECT, pfmask_code: ON_FORM, owner_id: FORGED_OWNER }),
123123
});
124124
expect(res.status, 'the anonymous create succeeds').toBe(201);
125-
const body = (await res.json()) as { id?: string; record: Record<string, unknown> };
126-
const id = String(body.record?.id ?? body.id ?? '');
127-
expect(id, 'the echo names the created row').toBeTruthy();
125+
const wire = await res.text();
126+
const body = JSON.parse(wire) as Record<string, unknown>;
127+
const id = String(body.id ?? '');
128+
expect(id, 'the answer names the created row').toBeTruthy();
128129

129130
const ql = (await stack.kernel.getServiceAsync('objectql')) as any;
130131
const stored = await ql.findOne(TICKET, { where: { id }, ...SYS });
132+
expect(stored?.subject, 'the row landed under the id the answer names').toBe(SUBJECT);
131133
expect(stored?.pfmask_code, 'the stored value is what a system read serves').toBe(ON_FORM);
132134
expect(stored?.pfmask_stamp, 'the default was stored').toBe(DEFAULTED);
133135
expect(stored?.owner_id ?? null, 'a server-managed field the submitter supplies never lands').not.toBe(FORGED_OWNER);
134136

135-
expect(body.record.subject, 'the door echoed the row').toBe(SUBJECT);
136-
expectMasked(body.record.pfmask_code, ON_FORM);
137-
expectMasked(body.record.pfmask_stamp, DEFAULTED);
137+
// Absent, which is stronger than masked: the field is named at no depth of
138+
// the answer, and its stored value rides no key of it.
139+
for (const [field, value] of Object.entries(MASKED)) {
140+
expect(wire, `${field} is absent from the answer, at any depth`).not.toContain(JSON.stringify(field));
141+
expect(wire, `${field}'s stored value reaches no key of the answer`).not.toContain(value);
142+
}
143+
expect(body, 'the answer is the created id, and nothing the insert stored').toEqual({ id });
138144
} finally {
139145
await stack.stop();
140146
}
141147
}
142148

143-
describe('[#21062] an anonymous public-form submit echoes every masked field masked', () => {
149+
describe('[#21062 → #22437] an anonymous public-form submit answers no masked field at all', () => {
144150
it('on a deployment that registers no guest set', () => submitAndRead(noGuestSetStack), 120_000);
145151
it('on a deployment whose stack declares the guest set', () => submitAndRead(guestSetStack), 120_000);
146152
});

0 commit comments

Comments
 (0)