Forms (Public + Internal)
Render any FormView either publicly (anonymous, /f/:slug) or internally (authed operators, /forms/:name). The same metadata drives both, with URL prefill, configurable post-submit behavior, and declarative open-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.allowAnonymous: true, give it a publicLink, and the framework wires the anonymous REST endpoints automatically.
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: [
{
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,
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.allowAnonymous === trueare exposed.
2. (Optional) Create the guest_portal permission set
Authorization no longer depends on this set — the declaration-derived
publicFormGrant (see the note above) is what permits the insert. 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 it is still attached to the anonymous context, so keep it
INSERT-only on the 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):
{ "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
} }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 sharing.allowAnonymous: true view | |
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.
GET /api/v1/forms/:slug/lookup/:field — the public picker
Lookup, master-detail and user fields are stripped from a public form by
default — surfacing one to anonymous visitors means exposing a record search
to the internet, so it is opt-in per field, like Airtable's "Allow linking to
existing records" toggle. The opt-in is a publicPicker block on the field's
entry in sections[].fields[] (declarable since #7467):
sections: [{
label: 'About you',
fields: [
'company',
{ field: 'owner', publicPicker: { displayFields: ['name'], maxResults: 10 } },
],
}]publicPicker key | Meaning |
|---|---|
displayFields | Fields projected into each result row (plus id); the visitor's q is contains-matched against the first entry. At most 5; omitted → ['name']. |
maxResults | Rows per request, integer 1–50 (default 20). 50 is a hard server ceiling; there is no pagination on this surface (offset is pinned to 0), so a leaked endpoint cannot enumerate the table. |
filter | Static pre-filter rows (same { field, operator, value } dialect as list-view filters), ANDed ahead of the visitor's search. |
object | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object (its reference, or a legacy referenceTo / target / options.objectName on a pre-fold stored row). Declare it only to search something other than what the field points at. |
Those four keys are the whole block. It admits exactly what the route enforces
— an unknown subkey, a 6th display field, or maxResults: 51 is a parse
error at authoring time, not a silently-adjusted request.
Result ordering is fixed: the first displayFields entry, ascending. It is
not configurable — publicPicker.sort is an unknown subkey, and #7485 retired
the route's read of one — so a picker's rows arrive in one predictable order
whatever the target object's own default ordering is.
curl 'http://localhost:3000/api/v1/forms/contact-us/lookup/owner?q=ada'{ "data": [{ "id": "usr_1", "name": "Ada Lovelace" }],
"total": 1, "truncated": false, "displayFields": ["name"] }Errors:
| Status | Code | When |
|---|---|---|
400 INVALID_REQUEST | missing / blank slug or field | |
403 LOOKUP_NOT_PUBLIC | the field has no publicPicker block — the deliberate loud default (#3022); also any server-managed anchor (owner_id, organization_id, …), which never gets a picker even if one is declared | |
404 FORM_NOT_FOUND | slug not registered on any sharing.allowAnonymous: true view | |
500 LOOKUP_TARGET_MISSING | the referenced object could not be resolved from either publicPicker.object or the field definition — the field names no target object at all (or its object metadata is unreachable). Until #7486 this also fired for a perfectly well-formed field, because the fallback read only the legacy spellings and not the canonical reference; declaring object was the workaround and is no longer needed. |
Auth model
- None of the three routes calls
enforceAuth, so they 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. The lookup route's search context is{ permissions: ['guest_portal'], anonymous: true }— nopublicFormGrant(it reads the picker's target object, not the form's), which is why its result set is bounded by the picker declaration instead. - 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();
}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: [
{
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 | window.location.assign(url) after delayMs (defaults to 0). |
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. - 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.
Current renderer status (2026-08-11). The console shipping at the pinned
.objectui-sharedirects verbatim on this value: it does not yet substitute{{record.…}}tokens or escape them. The three rules above are the contract, and the consumer half is tracked in objectui#4190. Until it lands, a token would reach the browser literally — so write a plain path unless you are following that issue.
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.
Current renderer status (2026-08-10). The console shipping at the pinned
.objectui-shastill appliesthank-youas the default in both modes. The table above is the contract; the renderer half is tracked in ObjectUI and linked from the #7245 thread. Until it lands, declaresubmitBehaviorexplicitly on an internal FormView that must land somewhere specific.
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.