ObjectStackObjectStack

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:

ModeRouteAuthSpec sourceSubmit target
Public/_console/f/:sluganonymousGET /api/v1/forms/:slug (resolved by sharing.publicLink)POST /api/v1/forms/:slug/submit (field whitelist + declaration-derived publicFormGrant)
Internal/_console/forms/:nameauthedGET /api/v1/meta/view/:name (+ meta/object/:object)POST /api/v1/data/:object (full RBAC)

Both modes:

  • Use the same FormView Zod schema (@objectstack/spec/ui)
  • Render through the same FormPage renderer 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 publicFormGrant from 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 the requireAuth: false opt-out, #3963) with no guest_portal permission set. The guest_portal permission set + anonymous flag are still attached for back-compat (object hooks that detect a guest via a falsy ctx.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 :slug segment 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). Its GET /forms/:slug publishes 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 === true and sharing.allowAnonymous === true are exposed.
  • On a walled tenancy posture (group or isolated in 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's sharing: 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 a public-form-intake-unavailable warning in advisories. If the object's rows belong to no organization, declare tenancy: { 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.previous and !ctx.user?.id? ctx.previous is 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:

StatusCodeWhen
400 INVALID_REQUESTmissing / blank slug (an empty body is coerced to {} and surfaces as VALIDATION_FAILED below, not here)
400 VALIDATION_ERRORthe form's sections declare no fields, so it collects nothing — wire the fields and resubmit (#6920)
400 VALIDATION_FAILEDobject schema validators fail (required, format, length, …)
403 PERMISSION_DENIEDthe resolved profile does not allow create on the target object
404 FORM_NOT_FOUNDslug 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 select field with static options.
  • 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 no requireAuth knob to configure since v17).
  • The execution context the submit route hands to ObjectQL is { publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true } with no userId. The Security plugin honors publicFormGrant first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a guest_portal profile. 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/lead carrying 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_2026

The 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' },
  },
},
kindRenderer behavior
thank-youReplace the form with a confirmation panel (title, message).
redirectNavigate in-app to url, its {{record.…}} tokens substituted and URL-escaped, after delayMs (defaults to 0) — see below.
continueRe-read prefill values and reset state — user can submit another response without reload.
next-recordStub 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:

  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. 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.
// ✅ 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 field

Widening 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):

ModeDefault when submitBehavior is omittedWhy
Public (/_console/f/:slug){ kind: 'thank-you' } — the confirmation panelThe 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 recordAn 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 (false suppresses 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 publicFormGrant authorizes 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/:slug returns labels and select options for whitelisted fields. If any of those are commercially sensitive, do not include them in the form.

See also

On this page