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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/22437-public-form-submit-answers-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/rest': minor
---

fix(rest)!: an anonymous public-form submit answers the created record's id, and nothing the insert stored

Clause-②: no (narrowing)

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

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

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.
16 changes: 6 additions & 10 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -271,7 +267,7 @@ async function submit(slug: string, payload: Record<string, unknown>) {
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
return r.json(); // { id } — the created record's id only
}
```

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
// 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
// showcase's shape), and one whose stack declares the guest set the route's
// 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';
Expand Down Expand Up @@ -106,12 +110,8 @@ const guestSetStack = defineStack({

type Stack = Parameters<typeof bootStack>[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<void> {
const stack = await bootStack(stackDef as Stack);
Expand All @@ -122,25 +122,31 @@ async function submitAndRead(stackDef: unknown): Promise<void> {
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<string, unknown> };
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<string, unknown>;
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: 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(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 });
} 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);
});
Loading
Loading