Forms — public and internal data entry
Render any FormView publicly at /f/:slug or internally at /forms/:name from one metadata source, with URL prefill, post-submit behavior and form actions.
Forms
ObjectStack Forms are Airtable-style metadata-driven forms with two render modes that share one spec and one renderer:
| Mode | Route | Auth | Spec source | Submit target |
|---|---|---|---|---|
| Public | /_console/f/:slug | anonymous | GET /api/v1/forms/:slug (resolved by sharing.publicLink) | POST /api/v1/forms/:slug/submit (field whitelist + declaration-derived publicFormGrant) |
| Internal | /_console/forms/:name | authed | GET /api/v1/meta/view/:name (+ meta/object/:object) | POST /api/v1/data/:object (full RBAC) |
Both modes:
- Use the same
FormViewZod schema (@objectstack/spec/ui) - Render through the same
FormPagerenderer shipped by the ObjectUI console (a separate package/repo) - Honor
?prefill_<field>=<value>URL params - Honor
submitBehavior(thank-you / redirect / continue / next-record) — with mode-aware defaults when it is omitted (see §8)
A public form is the Salesforce Web-to-Lead style embeddable form — declare a FormView with sharing.enabled: true and sharing.allowAnonymous: true, give it a publicLink, and the framework wires the anonymous REST endpoints automatically. Clearing either switch withdraws the form from every anonymous endpoint.
Architecture at a glance
Visitor Framework Driver
─────── ───────── ──────
GET /api/v1/forms/:slug → rest-server scans views →
match sharing.publicLink → (no DB read)
← { form, objectSchema }
POST /api/v1/forms/:slug/ → field whitelist (form)
submit → derive publicFormGrant:
{ object: <form target> } →
protocol.createData() → INSERT
← { object, id, record }Only the spec'd whitelist of form fields is accepted; everything else (status, owner, internal_notes, …) is stripped server-side. Lifecycle hooks then stamp server-controlled defaults.
Self-authorizing forms (ADR-0056 Option A). The submit route does not require a deployment-configured profile. It derives a narrow
publicFormGrantfrom the form's own declaration —{ object: <the form's target object> }— and the SecurityPlugin authorizes only create + the immediate read-back on exactly that object, never anything else and never the anonymous fall-open. So public forms work under the unconditional anonymous-deny posture (v17 retired therequireAuth: falseopt-out, #3963) with noguest_portalpermission set. Theguest_portalpermission set +anonymousflag are still attached for back-compat (object hooks that detect a guest via a falsyctx.user?.id), but they are no longer the authorization mechanism.
1. Declare the form view
// hotcrm/src/views/lead.view.ts (https://github.com/objectstack-ai/hotcrm)
import { defineView } from '@objectstack/spec';
export default defineView({
/* ...default `list` / named `listViews` for `lead` live here too — the
container's target object is derived from a view's `data.object`... */
formViews: {
web_to_lead: {
type: 'simple',
data: { provider: 'object', object: 'lead' },
sections: [
{
name: 'about_you',
label: 'Tell us about yourself',
columns: 2,
fields: [
{ field: 'first_name', required: true },
{ field: 'last_name', required: true },
{ field: 'email', required: true },
{ field: 'phone' },
{ field: 'company', required: true },
{ field: 'title' },
{ field: 'industry' },
{ field: 'website' },
{ field: 'annual_revenue' },
{ field: 'no_of_employees' },
{ field: 'description' },
],
},
],
sharing: {
enabled: true, // ← required (the schema default is false)
allowAnonymous: true, // ← required
publicLink: '/forms/contact-us', // ← the slug ":contact-us" wires this view to the public route
},
},
},
});Rules:
- The slug in
publicLink(contact-us) becomes the:slugsegment in the REST URL.- Anything not in the
sections[].fields[]whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list.- A form whose sections declare no fields collects nothing, so the submit is refused (
400 VALIDATION_ERROR) rather than accepting whatever the caller sent (#6920). ItsGET /forms/:slugpublishes no schema either (#6601) — declare the fields and both planes come alive together.- Multiple form views per object are fine — only the one(s) with
sharing.enabled === trueandsharing.allowAnonymous === trueare exposed.- On a walled tenancy posture (
grouporisolatedin force), a form whose object is walled by an organization column is not offered. An anonymous submission carries no organization, and an insert without one into a walled object is refused, so both anonymous endpoints answer the form exactly as they answer a withdrawn one (404 FORM_NOT_FOUND). The administrator is told why, at the form'ssharing: the view's read (GET /api/v1/meta/view/:name) carries it in_diagnostics.warnings, and a save or publish of the view answers success with apublic-form-intake-unavailablewarning inadvisories. If the object's rows belong to no organization, declaretenancy: { enabled: false }on it and the form is offered again.
2. (Optional) Create the guest_portal permission set
The submit's authorization no longer depends on this set — the
declaration-derived publicFormGrant (see the note above) is what permits the
insert. For the submit you only need a guest_portal permission set if you rely
on the legacy back-compat path (e.g. an older runtime, or object hooks that
branch on the guest_portal permission). When present the set is attached to
the anonymous context, so keep it INSERT-only on the form's target object — the
guest-safe shape (ADR-0090 D9).
// hotcrm/src/security/guest-portal.permission.ts
import { definePermissionSet } from '@objectstack/spec/security';
// Guest-safe capability: INSERT-only on the intake objects. (`isProfile` no
// longer exists — the Profile concept was removed by ADR-0090 D2.)
export default definePermissionSet({
name: 'guest_portal',
label: 'Public Form Submitters',
objects: {
lead: { allowCreate: true }, // no read/edit/delete
case: { allowCreate: true },
},
});3. Stamp server-controlled defaults in a hook
Anonymous submitters cannot be trusted to set lead_source, status, owner, etc. A beforeInsert hook keyed off the absence of ctx.user.id is the canonical place to:
- Apply safe defaults (
status='new',lead_source='web', …) - Strip any internal fields that slipped past the whitelist
// hotcrm/src/objects/lead.hook.ts
import type { Hook, HookContext } from '@objectstack/spec/data';
const leadGuestDefaults: Hook = {
name: 'lead_guest_defaults',
object: 'lead',
events: ['beforeInsert'],
priority: 200,
handler: async (ctx: HookContext) => {
const isGuest = !ctx.previous && !ctx.user?.id;
if (!isGuest) return;
// For inserts the incoming record lives at ctx.input.data, not on ctx.input itself.
const rec = ctx.input.data as Record<string, unknown>;
if (!rec.lead_source) rec.lead_source = 'web';
if (!rec.status) rec.status = 'new';
delete rec.owner;
delete rec.is_converted;
delete rec.converted_account;
delete rec.converted_contact;
delete rec.converted_opportunity;
delete rec.converted_date;
},
};
export default leadGuestDefaults;Why both
!ctx.previousand!ctx.user?.id?ctx.previousis set on update/delete but undefined on insert, so the combined check protects the "new record by an anonymous caller" path specifically. Backend users (or automated jobs) bypass the guest branch.
4. The REST contract
Both routes are mounted under the active environment's API base. For a
standalone environment that is /api/v1; for scoped deployments it is
/api/v1/environments/:environmentId.
GET /api/v1/forms/:slug
Returns the form spec + a restricted object schema for the whitelisted fields:
{
"slug": "contact-us",
"object": "lead",
"label": null,
"form": { "type": "simple", "sections": [/* … */], "sharing": {/* … */} },
"objectSchema": {
"name": "lead",
"label": "Lead",
"fields": { "first_name": {/* … */}, "email": {/* … */}, "company": {/* … */} }
}
}The top-level label is view.label ?? form.label — it is not taken from a section label. The Section-1 example sets neither, so it comes back null; add a label to the form view if you want a value here.
objectSchema.fields contains only the fields referenced by the form, so a public client can render labels, types, and select options without an auth-protected meta lookup.
POST /api/v1/forms/:slug/submit
curl -X POST http://localhost:3000/api/v1/forms/contact-us/submit \
-H 'Content-Type: application/json' \
-d '{
"first_name": "Ada",
"last_name": "Lovelace",
"company": "Analytical Engines Ltd",
"email": "ada@example.com",
"status": "qualified", // ← stripped (not in whitelist)
"owner": "00000000-0000-0000-0000-000000000001" // ← stripped
}'Response on success (HTTP 201 Created) — the created record's id, and nothing else:
{ "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 |
|---|---|---|
400 INVALID_REQUEST | missing / blank slug (an empty body is coerced to {} and surfaces as VALIDATION_FAILED below, not here) | |
400 VALIDATION_ERROR | the form's sections declare no fields, so it collects nothing — wire the fields and resubmit (#6920) | |
400 VALIDATION_FAILED | object schema validators fail (required, format, length, …) | |
403 PERMISSION_DENIED | the resolved profile does not allow create on the target object | |
404 FORM_NOT_FOUND | slug not registered on any view whose form has sharing.enabled: true and sharing.allowAnonymous: true | |
5xx (generic) | driver / hook threw — submit errors are mapped by mapDataError; there is no dedicated FORM_SUBMIT_FAILED code |
The companion GET /api/v1/forms/:slug route returns 500 FORM_RESOLVE_FAILED if form resolution itself throws.
Lookup, master-detail and user fields
An anonymous public form does not offer record search. GET /api/v1/forms/:slug
leaves every lookup, master_detail and user field off the rendered
sections, whatever the form declares — surfacing one would mean letting an
anonymous visitor search existing records on the internet. There is no anonymous
lookup route.
- To let a visitor choose from a fixed list, use a
selectfield with staticoptions. - To let a visitor pick an existing record, put the form behind sign-in — an internal form (section 6) renders lookup fields with the signed-in user's access.
Auth model
- Neither route calls
enforceAuth, so both work under the always-on anonymous-deny default (there is norequireAuthknob to configure since v17). - The execution context the submit route hands to ObjectQL is
{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }with nouserId. The Security plugin honorspublicFormGrantfirst — a create + read-back grant scoped to exactly the declared object — so authorization holds even without aguest_portalprofile.permissions: ['guest_portal']is retained for back-compat. - No CSRF or auth header is needed; embed the form on any domain.
5. Embedding from a front-end
// minimal client — frontend frameworks/SDKs ship their own <PublicForm />
async function loadForm(slug: string) {
const r = await fetch(`/api/v1/forms/${slug}`);
if (!r.ok) throw new Error(`form ${slug} not found`);
return r.json(); // { form, objectSchema }
}
async function submit(slug: string, payload: Record<string, unknown>) {
const r = await fetch(`/api/v1/forms/${slug}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
return r.json(); // { id } — the created record's id only
}The ObjectUI console (a separate package/repo) ships a unified FormPage renderer at /_console/f/:slug (public) and /_console/forms/:name (internal) that does exactly this.
6. Internal forms (/_console/forms/:name)
Sometimes you want the same FormView metadata to power an authed operator flow — "new lead", "new ticket", a queue triage screen. Set the FormView up normally (no sharing.allowAnonymous) and navigate to /_console/forms/<view_name>:
// hotcrm/src/views/lead.view.ts (internal form variant)
export default defineView({
formViews: {
quick_create: {
type: 'simple',
data: { provider: 'object', object: 'lead' },
sections: [
{
name: 'lead',
label: 'Lead',
columns: 2,
fields: [
{ field: 'first_name', required: true },
{ field: 'last_name', required: true },
{ field: 'email', required: true },
{ field: 'company' },
{ field: 'lead_source' },
],
},
],
},
},
});Operators hit /_console/forms/quick_create. The renderer:
- Loads metadata via
GET /api/v1/meta/view/quick_create - Pulls the object schema via
GET /api/v1/meta/object/lead - Submits to
POST /api/v1/data/leadcarrying the auth cookie
Permissions are enforced server-side just like every other authed write — no guest_portal profile required.
7. URL prefill
Both modes accept ?prefill_<field_name>=<value> query params. Use this to seed forms from email links, CRM segments, or campaign pages:
/_console/f/contact-us?prefill_company=Acme&prefill_email=ada@example.com
/_console/forms/quick_create?prefill_lead_source=event_booth_2026The renderer maps each prefill_<name> param to the corresponding form field. Values are still subject to validation and (for public mode) the server-side whitelist — prefill is a UX shortcut, not a permissions bypass.
8. submitBehavior — what happens after submit
Add a submitBehavior discriminated union to a FormView to control the post-submit experience:
formViews: {
web_to_lead: {
type: 'simple',
sections: [/* … */],
sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' },
// Default: show a thank-you panel
submitBehavior: {
kind: 'thank-you',
title: 'Thanks!',
message: 'A specialist will reach out within 24 hours.',
},
// Or: redirect to an in-app path (relative only — see below)
// submitBehavior: { kind: 'redirect', url: '/thanks', delayMs: 500 },
// Or: reset the form for another response (kiosks, batch entry)
// submitBehavior: { kind: 'continue' },
// Or: advance to the next record in a queue (internal mode only)
// submitBehavior: { kind: 'next-record' },
},
},kind | Renderer behavior |
|---|---|
thank-you | Replace the form with a confirmation panel (title, message). |
redirect | Navigate in-app to url, its {{record.…}} tokens substituted and URL-escaped, after delayMs (defaults to 0) — see below. |
continue | Re-read prefill values and reset state — user can submit another response without reload. |
next-record | Stub for queue contexts; falls back to thank-you when no queue is wired. |
What a redirect url may be
url is not a free-form address. It was ruled on 2026-08-11 (#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:
- Relative paths only. The value must start with a single
/. Absolute URLs (https://example.com/thanks, and equallyjavascript:/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. - 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. - 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.
// ✅ accepted
submitBehavior: { kind: 'redirect', url: '/thanks' }
submitBehavior: { kind: 'redirect', url: '/records/{{record.id}}', delayMs: 500 }
submitBehavior: { kind: 'redirect', url: '/thanks?ref={{record.public_ref}}' }
// ❌ refused, with the rule named in the error
submitBehavior: { kind: 'redirect', url: 'https://example.com/thanks' } // absolute
submitBehavior: { kind: 'redirect', url: '//example.com/thanks' } // protocol-relative
submitBehavior: { kind: 'redirect', url: 'thanks' } // document-relative
submitBehavior: { kind: 'redirect', url: '/u/{{os.user.id}}' } // not a record fieldWidening this — an allowlist of absolute origins, say — waits for measured demand; the ruling took the narrowest shape deliberately.
The Console applies the three rules at submit time: it resolves each {{record.…}} token from the record the submit just wrote, URL-escapes the value, and navigates to the resolved path inside the console shell after delayMs; a url the schema refuses is never followed — the submit is still confirmed and the refusal is shown on screen.
The default is mode-aware
submitBehavior is optional, and what you get for omitting it depends on the mode — a public collection form and an authed create have different right answers (ruled 2026-08-10 on #7245):
| Mode | Default when submitBehavior is omitted | Why |
|---|---|---|
Public (/_console/f/:slug) | { kind: 'thank-you' } — the confirmation panel | The anonymous submitter may not read the record back, so a receipt is all there is to show. |
Internal (/_console/forms/:name) | Redirect to the created record | An operator who just created a record belongs on that record, not on a "submission received" receipt. |
An explicit submitBehavior overrides the default in either mode, so nothing here removes an option — it only changes what an author gets for declaring nothing.
9. type: 'form' action — declarative form launchers
App actions can declare type: 'form' to open a FormView without resorting to free-form URLs. The target is the FormView name; the runtime navigates to /_console/forms/:name inside the console shell — the operator keeps the sidebar, navigation and breadcrumb, and a successful submit lands on the created record per the mode-aware default above. The full contract is stated on the Action Protocol page.
// hotcrm/src/actions/new-lead.action.ts
import { Action } from '@objectstack/spec/ui';
export default Action.create({
name: 'new_lead',
label: 'New Lead',
type: 'form',
target: 'quick_create', // FormView name
});Compared to { type: 'url', target: '/_console/forms/quick_create' }, the 'form' variant is portable across runtimes — Studio/console/native shells can route it through their own form renderer instead of a raw navigation.
10. Record detail is driven by semantic roles, not a form binding
There is no object-level key that pins a FormView to the record-detail /
edit screen (the once-documented defaultDetailForm was never implemented and
has been removed from ObjectSchema). Record-detail rendering is derived from
the object's cross-surface semantic roles (ADR-0085):
nameField— which field is the record's display name.highlightFields— the most important fields (detail highlight strip, default list columns, cards).stageField— the linear-lifecycle field behind the detail progress stepper (falsesuppresses detection).fieldGroups+Field.group— semantic grouping, rendered identically on forms, modals, and detail pages.
When a record page needs a bespoke layout beyond what the roles derive, assign
the object a custom Page schema — that is the supported per-page
customization path. FormViews remain the metadata for the two form
experiences this guide covers: public collection (/f/:slug) and internal
entry (/forms/:name).
11. Security checklist (public mode)
- Whitelist enforced server-side — clients cannot widen the field set by hand-crafting JSON.
- Hook strips server-controlled fields —
owner,status,internal_notes,is_*flags, conversion fields are removed even if they survive the whitelist. - Grant is create + read-back only — the declaration-derived
publicFormGrantauthorizes only insert (and the immediate read-back) on the form's target object; anonymous callers cannot read other records, edit, or delete via the form path. - No tenancy leakage — submissions land on the project resolved from hostname/path, not from a client-supplied tenant id.
- Rate limiting / captcha — not built in. Add a reverse-proxy rate limit (e.g. nginx, Cloudflare) or fronting plugin if the form sits on the public internet.
- Schema disclosure —
GET /forms/:slugreturns labels and select options for whitelisted fields. If any of those are commercially sensitive, do not include them in the form.
See also
skills/objectstack-ui/SKILL.md— full FormView schema reference.skills/objectstack-data/SKILL.md— object, hook + permission-set authoring patterns.- Hook & Action Bodies — what runs in the sandbox vs. the host.
- Security — RBAC, profiles, and the
anonymousexecution context.
Translations
Labels and UI text as metadata — one bundle per locale, resolved per request, with CLI tooling to draft and to gate coverage in CI.
Doc Metadata
Ship package documentation as metadata — flat src/docs/*.md files compiled into the manifest and rendered in the console, ordered by a book navigation spine