npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ftisindia/form-builder

v2.0.0

Published

Backend-first dynamic form engine for the FTIS foundation starter: versioned JSON definitions, dual-mode validation, phased action pipeline, transactional outbox, and file/reference handling behind framework-free ports.

Readme

@ftisindia/form-builder

Backend-first dynamic form engine for the FTIS foundation starter (@ftisindia/create-app). Form definitions are data — versioned JSON documents you store in your database — while the behavior they reference (field types, actions, data sources, lifecycle hooks) is code your app registers. The engine validates submissions (separate draft and submit modes), runs a phased action pipeline (validation → one transaction → post-commit side effects via a transactional outbox), evaluates bounded json-logic rules, resolves data sources, and verifies file references — all behind framework-free ports.

  • Package: @ftisindia/form-builder - Version: 2.0.0 (the API-key actor model — a major; breaking changes and the 1.3.x upgrade checklist are in CHANGELOG.md)
  • Module format: ESM + CJS dual build (type: module, import/require conditions). Ships .d.ts types. Node ≥ 20.
  • Runtime deps: ajv + ajv-formats (JSON Schema 2020-12) and json-logic-js.
  • License: PolyForm Noncommercial 1.0.0.

This package is the engine core only

It compiles and tests with no NestJS, Prisma, or CASL present (enforced by an eslint no-restricted-imports rule and a CI job). It is run inside a host app through the src/modules/forms glue module that @ftisindia/create-app scaffolds — that glue implements the engine's ports/seams over the app's Prisma, CASL, audit, and request-context services. The engine is not usable standalone: every service needs you (or the generated glue) to supply persistence and context adapters.

Which path should I use?

| You are… | Start here | |----------|------------| | Using a generated @ftisindia/create-app app | You already have forms — use the scaffolded src/modules/forms glue. This README is reference; you rarely call the engine directly. See Using it inside an FTIS app. | | Integrating the engine into your own backend | Implement the ports and seams, then wire the services as in the quick start. | | Maintaining/publishing this package | See Database setup for the schema/version contract and Scripts. |


Table of contents

  1. Install
  2. Core concepts
  3. The form definition format
  4. Example definitions
  5. Quick start (framework-free)
  6. API reference
  7. Ports you must implement
  8. Seams (ecosystem adapters)
  9. Actors and API-key callers
  10. Extending the engine
  11. Using it inside an FTIS app (NestJS glue)
  12. Database setup
  13. Reference tables
  14. Typed errors
  15. Scripts
  16. License

Install

npm install @ftisindia/form-builder
import {
  DefinitionService,
  SubmissionService,
  ValidationEngine,
  FieldTypeRegistry,
  ActionRegistry,
  DataSourceRegistry,
  LifecycleRegistry,
  DataSourceResolver,
  registerCoreFieldTypes,
  registerBuiltinActions,
} from '@ftisindia/form-builder';

The canonical Prisma model snippet is also exported as a package subpath:

// resolvable path string, for tooling that copies the snippet into your schema
import { FORMS_PRISMA_SNIPPET_PATH } from '@ftisindia/form-builder';
// → "@ftisindia/form-builder/prisma/forms.prisma"

Compatibility

This is engine schema version ENGINE_SCHEMA_VERSION = 4. The engine is consumed by the @ftisindia/create-app backend template via the src/modules/forms glue. When using a generated app, the authoritative version pin is the one in your generated app's package.json (copied from the template's _package.json) — match this package to that range rather than upgrading it independently, since the glue module and the engine evolve together. If you bump form-builder yourself, re-run the glue's boot schema check (it compares the live database against EXPECTED_FORMS_SCHEMA / ENGINE_SCHEMA_VERSION and fails fast on a mismatch).


Core concepts

| Idea | What it means | |------|---------------| | Definition-as-data | A FormDefinition is a serializable, versioned JSON document. Published versions are immutable — editing creates a new version. | | Behavior-as-code | Field types, actions, and data sources are looked up by string key from registries you populate at boot. A definition can only reference keys that are registered (enforced at save/publish lint time). | | Four registries | FieldTypeRegistry, ActionRegistry, DataSourceRegistry, LifecycleRegistry (+ OutboxHandlerRegistry for side effects). | | Dual-mode validation | draft relaxes required; submit enforces everything. Both produce the same ValidationErrorItem shape. | | Three-phase pipeline | Actions are partitioned by kind and always run in the order validation → transactional (one DB tx) → post-commit, regardless of how the author listed them. A failed validation leaves no trace; a failed transactional action rolls back the whole transaction; a failed post-commit side effect never rolls back a committed submission. | | Transactional outbox | Post-commit side effects (email, webhooks) are enqueued inside the submission transaction and performed afterwards by a dispatcher with retries/backoff. | | Ports & seams | The engine couples only to interfaces. Your app implements six stores and four seams (plus optional captcha/screening). |


The form definition format

interface FormDefinition {
  key: string;          // stable id, e.g. "abstract-submission" — constant across versions
  version: number;      // bumped on every published change
  status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
  title?: string;
  fields: FieldDef[];   // the field tree
  rules?: Rule[];       // cross-field / conditional logic (bounded json-logic)
  actions?: Record<string, string[]>;  // buttonName -> ordered action names (registry keys)
  settings?: FormSettings;
}

Authoring vs. persisted. Draft/authoring payloads may omit version and status — the engine stamps both (the example JSON below carries a version: 1 placeholder that is ignored). createDraft assigns the next version and writes DRAFT; publish/archive move it to PUBLISHED / ARCHIVED. The version and status you read back on a FormDefinitionRecord are always engine-controlled; you never set them by hand.

FieldDef

interface FieldDef {
  type: string;                 // key into the FieldTypeRegistry ("text", "group", "file", ...)
  name: string;                 // property name in the submission object
  label?: string;
  validators?: Record<string, unknown>;  // interpreted by the field type (required, maxLength, ...)
  dataSource?: { key: string; params?: Record<string, unknown> };
  fields?: FieldDef[];          // present for group types — enables nesting
  repeatable?: boolean;         // a repeatable group becomes an array of objects
  min?: number; max?: number;   // array bounds for repeatable groups
  source?: 'context' | 'computed' | 'default';  // hidden/derived fields
  path?: string;                // for source:'context' — 'user.id' | 'org.id' | 'request.id' | 'actor.type' | 'actor.id'
  accept?: string[];            // file fields: allowed MIME types
  maxSizeMb?: number;           // file fields: max size in MB
  sensitive?: boolean;          // value never reaches action logs / audit / exports
  reportable?: boolean;         // declared for reporting/export pipelines
  indexHint?: boolean;          // suggests a generated column + index (metadata only)
  ui?: Record<string, unknown>; // OPAQUE to the backend — stored & returned, never interpreted
}

Rule (bounded json-logic)

interface Rule {
  id: string;
  enforceOn?: ('draft' | 'submit')[];  // default ['submit']
  if: unknown;                          // json-logic over the sandboxed { data, ctx } scope
  then: { require?: string[] };         // fields that must be present & non-blank when `if` is truthy
  message?: string;                     // custom violation message
}

FormSettings

interface FormSettings {
  access?: 'private' | 'public';
  captcha?: boolean;
  maxSubmissionsPerIpPerDay?: number;   // per-form override of the org cap
  webhooks?: FormWebhookConfig[];       // max 5 per form; delivered via the outbox, SSRF-gated
  [key: string]: unknown;               // extra keys preserved opaquely
}

A FormWebhookConfig carries { url, events?: ['submitted'], secret?, includeData? }. When secret is set, deliveries carry an x-forms-signature: sha256=<hmac> header; includeData (default true) includes the sensitive-redacted submission payload. The webhook section is authoring data: getForRender never returns it (projectDefinitionForRender), and read paths that serve non-editors should pass the document through stripDefinitionSecrets — the FTIS glue does so for every caller without forms.update.


Example definitions

These are the real fixtures shipped in the backend template (packages/template/src/modules/forms/examples/).

Simple — event registration

{
  "key": "registration",
  "version": 1,
  "title": "Event registration",
  "fields": [
    { "type": "text",  "name": "fullName", "label": "Full name",
      "validators": { "required": true, "maxLength": 120 }, "reportable": true },
    { "type": "email", "name": "email", "label": "Email address",
      "validators": { "required": true }, "reportable": true, "indexHint": true },
    { "type": "select", "name": "ticketType", "label": "Ticket type",
      "validators": { "required": true, "options": ["standard", "student", "vip"] }, "reportable": true },
    { "type": "boolean", "name": "newsletter", "label": "Subscribe to updates?" },
    { "type": "hidden", "name": "registeredBy", "source": "context", "path": "user.id" }
  ],
  "actions": {
    "submit": ["validateAll", "persist", "sendConfirmationEmail"],
    "saveDraft": ["persistDraft"]
  }
}

Complex — nested groups, a rule, a file (abstract submission)

{
  "key": "abstract-submission",
  "version": 1,
  "title": "Scientific abstract submission",
  "fields": [
    { "type": "text", "name": "title", "label": "Abstract title",
      "validators": { "required": true, "maxLength": 200 }, "reportable": true },
    { "type": "group", "name": "authors", "label": "Authors",
      "repeatable": true, "min": 1, "max": 10,
      "fields": [
        { "type": "text",    "name": "fullName", "validators": { "required": true } },
        { "type": "email",   "name": "email",    "validators": { "required": true } },
        { "type": "boolean", "name": "isPresenting" }
      ] },
    { "type": "select", "name": "track", "label": "Conference track",
      "dataSource": { "key": "conference-tracks" },
      "validators": { "required": true }, "reportable": true, "indexHint": true },
    { "type": "text", "name": "body", "label": "Abstract body",
      "validators": { "required": true, "maxLength": 3000 }, "ui": { "widget": "richtext" } },
    { "type": "file", "name": "manuscript", "label": "Full manuscript (PDF)",
      "accept": ["application/pdf"], "maxSizeMb": 20 },
    { "type": "boolean", "name": "needsFunding", "label": "Requesting funding?" },
    { "type": "text", "name": "fundingSource", "label": "Funding source",
      "ui": { "showWhen": "needsFunding" } },
    { "type": "hidden", "name": "submittedBy", "source": "context", "path": "user.id" }
  ],
  "rules": [
    { "id": "funding-required", "enforceOn": ["submit"],
      "if": { "==": [{ "var": "data.needsFunding" }, true] },
      "then": { "require": ["fundingSource"] },
      "message": "Funding source is required when requesting funding." }
  ],
  "actions": {
    "submit": ["validateAll", "persist", "lockEditing", "sendConfirmationEmail"],
    "saveDraft": ["persistDraft"]
  }
}

Public form that delegates to a custom action (login)

{
  "key": "login",
  "version": 1,
  "title": "Sign in",
  "settings": { "access": "public" },
  "fields": [
    { "type": "email",    "name": "email",    "label": "Email address",
      "validators": { "required": true } },
    { "type": "password", "name": "password", "label": "Password",
      "validators": { "required": true, "minLength": 8 }, "ui": { "widget": "password" } }
  ],
  "actions": { "submit": ["authenticate"] }
}

authenticate here is an app-provided action marked dangerous: true — it never persists a submission, it delegates to the app's auth service. Wiring a dangerous action requires the forms.wireDangerous permission and the org allowlisting the action name.


Quick start (framework-free)

The engine needs adapters for persistence and request context. The snippet below uses trivial stubs to show the wiring end to end — replace them with your real stores (in an FTIS app the glue module does this over Prisma).

import {
  FieldTypeRegistry, ActionRegistry, DataSourceRegistry, LifecycleRegistry,
  registerCoreFieldTypes, registerBuiltinActions,
  ValidationEngine, DataSourceResolver,
  DefinitionService, SubmissionService,
  type FormsContext, type FormsAuthorization, type FormsAuditSink, type TxRunner,
  type FormDefinitionStore, type SubmissionStore, type FileStore,
  type OutboxStore, type ActionLogStore, type FormDefinition,
} from '@ftisindia/form-builder';

// Supply these — your real implementations (see "Ports you must implement").
// In a generated FTIS app the glue module provides them over Prisma.
declare const definitionStore: FormDefinitionStore;
declare const submissionStore: SubmissionStore;
declare const fileStore: FileStore;
declare const outboxStore: OutboxStore;
declare const actionLogStore: ActionLogStore;
declare const registrationDef: FormDefinition;

// 1. Registries — behavior, by string key.
const fieldTypes = new FieldTypeRegistry();
registerCoreFieldTypes(fieldTypes);   // text, number, boolean, select, date, email, password, file, hidden, group

const dataSources = new DataSourceRegistry();
dataSources.register({
  key: 'conference-tracks',
  async fetch(_params, _ctx) {
    return [{ value: 'ai', label: 'AI' }, { value: 'bio', label: 'Bioinformatics' }];
  },
});

const lifecycles = new LifecycleRegistry();
const resolver = new DataSourceResolver(dataSources, { ttlMs: 30_000 });
const validation = new ValidationEngine(fieldTypes, { cacheSize: 500 });

const actions = new ActionRegistry();
registerBuiltinActions(actions, {     // registers validateAll, persist, persistDraft, lockEditing
  validate: (def, data, mode) => validation.validate(def, data, mode),
  submissions: submissionStore,       // your SubmissionStore (see "Ports you must implement")
});

// 2. Seams + stores (implement these — stubbed here).
const txRunner: TxRunner = { run: (fn) => fn({ __engineTx: true } as never) };
const authz: FormsAuthorization = { can: () => true };
const audit: FormsAuditSink = { async write() {} };
const ctx: FormsContext = {
  requestId: () => 'req-1', source: () => 'http',
  userId: () => 'user-1', orgId: () => 'org-1',
  permissionKeys: () => [], isOwner: () => true,
  assertOrgScope: (orgId) => { if (orgId !== 'org-1') throw new Error('out of scope'); },
  resolve: (path) => (path === 'user.id' ? 'user-1' : path === 'org.id' ? 'org-1' : 'req-1'),
  ipAddress: () => undefined, userAgent: () => undefined,
  actor: () => ({ type: 'user', id: 'user-1' }),   // optional; authoritative when present
};
const policyFor = async () => ({
  allowedDangerousActions: [], enableRuleIteration: false, captchaConfigured: false,
});

// 3. Services.
const definitions = new DefinitionService({
  store: definitionStore, authz, audit, txRunner,
  fieldTypes, actions, dataSources, resolver, policyFor,
});
const submissions = new SubmissionService({
  definitions: definitionStore, submissions: submissionStore, files: fileStore,
  outbox: outboxStore, actionLog: actionLogStore, audit, txRunner, authz,
  fieldTypes, actions, lifecycles, validation, resolver, policyFor,
});

// 4. Use it.
const draft = await definitions.createDraft({ orgId: 'org-1', definition: registrationDef }, ctx);
await definitions.publish({ orgId: 'org-1', key: 'registration', version: draft.version }, ctx);

const result = await submissions.submit({
  orgId: 'org-1', formKey: 'registration',
  data: { fullName: 'Alice', email: '[email protected]', ticketType: 'standard' },
}, ctx);
// → { submissionId, outputs: { validateAll: {...}, persist: { submissionId }, ... } }

API reference

DefinitionService

Definition lifecycle: create/update drafts, publish (with save/publish-time linting and prior-version archiving), archive, gated public-access flips, and render-time data-source resolution. Every method takes a FormsContext and re-checks permissions through the FormsAuthorization seam (defense in depth).

new DefinitionService(deps: DefinitionServiceDeps)

interface DefinitionServiceDeps {
  store: FormDefinitionStore;
  authz: FormsAuthorization;
  audit: FormsAuditSink;
  txRunner: TxRunner;
  fieldTypes: FieldTypeRegistry;
  actions: ActionRegistry;
  dataSources: DataSourceRegistry;
  resolver: DataSourceResolver;
  policyFor(orgId: string): Promise<OrgFormsPolicy>;
  ruleLimits?: RuleLimits;   // defaults to DEFAULT_RULE_LIMITS
}

| Method | Signature → returns | Notes / permission | |--------|---------------------|--------------------| | createDraft | ({ orgId, definition }, ctx) → FormDefinitionRecord | Lints (save stage), assigns the next version, status DRAFT (version/status in the payload are ignored). forms.create. | | updateDraft | ({ orgId, key, version, definition }, ctx) → FormDefinitionRecord | Only DRAFT versions; published are immutable (FormsStateError). forms.update. | | publish | ({ orgId, key, version }, ctx) → FormDefinitionRecord | Runs publish-stage lint, archives any prior PUBLISHED version of the key, freezes this one. A public form additionally needs forms.managePublicAccess. forms.publish. | | archive | ({ orgId, key, version }, ctx) → FormDefinitionRecord | Marks ARCHIVED. forms.archive. | | setPublicAccess | ({ orgId, key, version, access }, ctx) → FormDefinitionRecord | The only op allowed to change access on an (otherwise immutable) published version. Rejects captcha-enabled public forms with no verifier; public file fields rely on the upload-token flow. Always audited (previous → next). forms.managePublicAccess. | | getByKeyVersion | ({ orgId, key, version }, ctx) → FormDefinitionRecord | forms.read. | | getLatest | ({ orgId, key, status? }, ctx) → FormDefinitionRecord | Highest version, optionally by status. forms.read. | | list | ({ orgId, status?, cursor?, limit? }, ctx) → FormDefinitionRecord[] | Default limit 50. forms.read. | | getForRender | ({ orgId, key, version?, public? }, ctx) → RenderResult | Loads the published definition and resolves all data-source options. The returned definition is the renderer's projection — settings.webhooks (destinations and secrets) is removed. The public path requires settings.access === 'public' and otherwise answers "not found". |

interface RenderResult {
  record: FormDefinitionRecord;                   // the stored record, untouched
  definition: FormDefinition;                     // renderer projection: no settings.webhooks
  options: Record<string, DataSourceOption[]>;  // keyed by dotted field path (no indices)
}

SubmissionService

Submission orchestration: authorize → load published definition → coerce + inject context fields → lifecycle hooks → full validation (schema + rules + data-source membership + file references) → phased action pipeline → afterPersist. The engine runs the complete validation pass itself before the pipeline, whether or not the author listed validateAll.

new SubmissionService(deps: SubmissionServiceDeps)

interface SubmissionServiceDeps {
  definitions: FormDefinitionStore;
  submissions: SubmissionStore;
  files: FileStore;
  outbox: OutboxStore;
  actionLog: ActionLogStore;
  audit: FormsAuditSink;
  txRunner: TxRunner;
  authz: FormsAuthorization;
  fieldTypes: FieldTypeRegistry;
  actions: ActionRegistry;
  lifecycles: LifecycleRegistry;
  validation: ValidationEngine;
  resolver: DataSourceResolver;
  captcha?: CaptchaVerifier;                          // optional Tier-2 abuse seam
  policyFor(orgId: string): Promise<OrgFormsPolicy>;
  ruleContext?: (ctx: FormsContext) => Record<string, unknown>;  // allowlisted ctx.* exposed to rules
}

| Method | Signature → returns | Notes | |--------|---------------------|-------| | submit | (args: SubmitArgs, ctx) → PipelineResult | Submit mode (strict). Default pipeline ['validateAll','persist']. Throws FormsValidationError on invalid data. New rows: formSubmissions.create (or the public path). Editing an existing row (submissionId): formSubmissions.update, or formSubmissions.create for a row created by the same actor (user or API key); never on the public path. API-key callers: see Actors. Referenced files are linked (LINKED, submissionId) whenever the row persists — drafts included. | | saveDraft | (args: SubmitArgs, ctx) → PipelineResult | Draft mode (relaxed required). Default pipeline ['validateAll','persistDraft']. Same edit rule as submit. | | validateOnly | ({ orgId, formKey, version?, data, mode }, ctx) → ValidationResult | Live validation, no side effects — for frontend feedback. Runs beforeValidate hooks first, so what it validates is what submit would validate. Applies the same API-key pre-flight as submit (against the mode's default button). formSubmissions.create. | | get | ({ orgId, submissionId }, ctx) → SubmissionRecord | formSubmissions.read. | | list | ({ orgId, formKey, filters }, ctx) → SubmissionRecord[] | formSubmissions.read. |

interface SubmitArgs {
  orgId: string;
  formKey: string;
  version?: number;            // defaults to the latest PUBLISHED version
  data: Record<string, unknown>;
  button?: string;             // key in the definition's `actions` map (default 'submit'/'saveDraft')
  submissionId?: string;       // present when editing an existing draft submission (update, or create + own row)
  input?: unknown;             // action-specific extra input, threaded to the pipeline
  public?: boolean;            // anonymous path - definition must be settings.access 'public'
  uploadTokens?: Record<string, string>;  // public file claim tokens keyed by fileId
  captchaToken?: string;
}

interface PipelineResult {
  submissionId?: string;
  outputs: Record<string, unknown>;  // keyed by action name
}

Registries

FieldTypeRegistry, ActionRegistry, and DataSourceRegistry share the same shape (keyed by type / name / key):

register(def): void   // throws FormsConflictError on a duplicate key
get(key): T | undefined
require(key): T        // throws FormsStateError when missing
has(key): boolean
keys(): string[]

OutboxHandlerRegistry (keyed by type) has the same shape except there is no require() — use register / get / has / keys.

LifecycleRegistry is different — hooks are not keyed and many can apply to one form:

class LifecycleRegistry {
  register(hook: FormLifecycle): void;
  forKey(formKey: string): FormLifecycle[];  // hooks whose formKey is unset, '*', or === formKey
}

Core field types

registerCoreFieldTypes(registry) registers all ten built-ins; CORE_FIELD_TYPES is the array, and each is exported individually (textFieldType, numberFieldType, …, groupFieldType).

| type | Validates to | Notes | |--------|--------------|-------| | text | string | validators: required, minLength, maxLength, pattern. | | number | number (integer when validators.integer === true) | validators: required, min, max, integer. | | boolean | boolean | | | select | enum | options from validators.options or a dataSource binding (re-validated at submit). | | date | string (format: date) | | | email | string (format: email) | | | password | string | alwaysSensitive — always redacted in logs/audit. | | file | file reference { fileId, ... } | accept, maxSizeMb; verified & linked in-tx. | | hidden | server-sourced value | source: 'context' overwrites the client value from path; source: 'computed' discards the client value — a lifecycle hook must supply it; source: 'default' stores what was sent. | | group | object (array if repeatable) | fields[] nests; min/max bound the array. |

A field type implements the FieldTypeDef contract — a JSON Schema fragment builder plus optional coercion/defaults:

interface FieldTypeDef {
  type: string;
  alwaysSensitive?: boolean;
  buildValidator(field: FieldDef, ctx: FieldValidatorContext): JsonSchema;  // ctx.compose(fields) recurses
  coerce?(raw: unknown, field: FieldDef): unknown;
  resolveDefault?(field: FieldDef): unknown;
}

Validation

class ValidationEngine {
  constructor(registry: FieldTypeRegistry, options?: { cacheSize?: number });  // default 500
  validate(def, data, mode: 'draft' | 'submit', cache?: { orgId: string }): ValidationResult;
  validateDraft(def, data, cache?): ValidationResult;
  validateSubmit(def, data, cache?): ValidationResult;
}
  • One Ajv 2020-12 instance per engine — construct once and reuse.
  • When a cache key is passed and the definition is PUBLISHED (immutable), the compiled validator is reused via an LRU keyed by orgId:key:version:mode.
  • Ajv keywords are mapped onto stable codes (required → REQUIRED, maxLength → MAX_LENGTH, additionalProperties → UNEXPECTED_PROPERTY, …).
  • Compilation can throw FormsUnsafePatternError (since 1.2.0). Every validators.pattern in the composed schema passes the ReDoS gate before Ajv sees it; a definition stored with a catastrophic pattern fails closed here rather than blocking the event loop. The linter refuses the same patterns at save and publish, so this only fires for definitions saved before the gate existed.

Pattern safety (ReDoS gate)

Author-supplied patterns are analysed statically — JavaScript regexes cannot be timed out in-process, so one catastrophic pattern would stall every tenant. Refused: patterns over MAX_PATTERN_LENGTH, invalid syntax, backreferences, repetition counts over MAX_BOUNDED_REPEAT, expanding quantifiers over a nullable group body ((a?)*), and nested expanding quantifiers whose inner repetition is ambiguous at the seam ((a+)+, (\w*)*, (a+a)*). Ambiguity is decided by a first/last character-set overlap test, so ordinary delimited-list patterns stay legal — ^\d+(,\d+)*$, ^\w+(\.\w+)*$, ^((a|b)+c)*$.

explainUnsafePattern(pattern): string | null   // reason, or null when safe
isSafePattern(pattern): boolean
MAX_PATTERN_LENGTH, MAX_BOUNDED_REPEAT

This is a hardening gate, not a decision procedure: overlap-ambiguous alternations without nested quantifiers ((a|aa)+) are out of scope.

Lower-level helpers:

composeSchema(def, registry, mode): JsonSchema      // build the JSON Schema for a definition
coerceData(fields, data, registry): Record<string, unknown>  // run field-type coercion

ValidationResult / ValidationErrorItem:

interface ValidationResult { valid: boolean; errors: ValidationErrorItem[]; }
interface ValidationErrorItem {
  path: string;     // dotted, e.g. "authors.0.email"; "" = whole document
  code: string;     // see ValidationErrorCodes
  message: string;
  ruleId?: string;  // present for RULE_VIOLATION
}

Rules (bounded json-logic)

Rules are untrusted data, so the operator set is an allowlist and expressions are size/depth-capped at save time, then evaluated in a fresh sandbox containing only { data, ctx }. ctx is whatever SubmissionServiceDeps.ruleContext projects from the request context — {} when unset, in which case a rule that reads ctx.* can never fire. The FTIS glue exposes userId, orgId, isOwner, source, authenticated and permissions (see the app's docs/FORMS.md). Field and button names that collide with Object.prototype members are refused at lint time (RESERVED_NAME, RESERVED_NAMES).

// operator allowlist
BASE_ALLOWED_OPERATORS: ReadonlySet<string>   // var, missing, ==, <, and, or, if, in, +, -, min, max, ...
ITERATION_OPERATORS: ReadonlySet<string>      // map, reduce, filter, all, some, none (org-gated)
allowedOperators(enableIteration: boolean): ReadonlySet<string>

// save-time linting
lintRules(rules: Rule[], opts: { limits: RuleLimits; enableIteration: boolean }): LintIssue[]
// the expression half of lintRules (shape, operator allowlist, var scope) for one `if` tree
lintRuleExpression(expression: unknown, enableIteration: boolean): LintIssue[]

// runtime evaluation (fail-closed: an un-evaluable rule reports a violation;
// every `if` tree is re-checked with lintRuleExpression before it is applied,
// so a stored rule that bypassed the linter never reaches json-logic)
evaluateRules(rules: Rule[], args: {
  data: Record<string, unknown>;
  ctx: Record<string, unknown>;   // the allowlisted projection from SubmissionServiceDeps.ruleContext
  mode: 'draft' | 'submit';
  enableIteration?: boolean;      // the org's enableRuleIteration flag (default false)
}): ValidationErrorItem[]

Default limits (DEFAULT_RULE_LIMITS): maxDepth: 10, maxNodes: 100, maxRulesPerForm: 50. Iteration operators are off unless the org policy sets enableRuleIteration: true.

Action pipeline & builtins

You usually drive the pipeline through SubmissionService, but ActionPipeline is exported for direct use:

class ActionPipeline {
  constructor(deps: PipelineDeps);
  run(args: PipelineRunArgs): Promise<PipelineResult>;
}

interface PipelineDeps {
  actions: ActionRegistry;
  txRunner: TxRunner;
  outbox: OutboxStore;
  actionLog: ActionLogStore;
  ctx: FormsContext;
  redactForLog: (value: unknown) => unknown;  // e.g. (v) => redactSensitiveData(def, isSensitiveType, v)
}

interface PipelineRunArgs {
  definitionRecord: FormDefinitionRecord;
  pipelineName: string;       // the button name (diagnostic only)
  actionNames: string[];      // exactly as listed on the button; partitioned by kind internally
  data: Record<string, unknown>;
  mode: 'draft' | 'submit';
  input?: unknown;
  submissionId?: string;
  hooks?: { afterTransactional?: (tx, state) => Promise<void> };  // runs inside the tx (file linking)
}

An action implements FormAction:

type ActionKind = 'validation' | 'transactional' | 'post-commit';

interface FormAction<In = unknown, Out = unknown> {
  name: string;
  kind: ActionKind;
  dangerous?: boolean;  // needs forms.wireDangerous + org allowlist to be attached
  allowsApiKeyCallers?: boolean;  // missing ⇒ API-key callers refused (FormsActorNotAllowedError)
  execute(ctx: ActionContext<In>): Promise<Out>;
}

interface ActionContext<In> {
  definition: FormDefinition;
  definitionRecord: FormDefinitionRecord;
  data: Record<string, unknown>;
  mode: 'draft' | 'submit';
  input: In;
  prev: Record<string, unknown>;   // outputs of earlier actions, by name
  ctx: FormsContext;
  tx?: EngineTx;                    // present in the transactional window only
  submissionId?: string;
  setSubmissionId(id: string): void;
  enqueue(job: OutboxJobInput): Promise<void>;  // writes the outbox row through tx
}

Built-in action factories (and the bulk registrar):

createValidateAllAction(validate): FormAction          // kind: 'validation'
createPersistAction(submissions, name?): FormAction    // 'persist' | 'persistDraft', kind: 'transactional'
createLockEditingAction(submissions): FormAction       // 'lockEditing', kind: 'transactional'
registerBuiltinActions(registry, { validate, submissions }): void

Data sources

class DataSourceResolver {
  constructor(registry: DataSourceRegistry, options?: {
    ttlMs?: number;     // default 30_000
    maxEntries?: number;// default 1024 (LRU)
    now?: () => number; // injectable clock
  });
  resolveOptions(binding: { key; params? }, ctx: DataSourceContext): Promise<DataSourceOption[]>;
  validateMembership(def, data, ctx: DataSourceContext): Promise<ValidationErrorItem[]>;  // NOT_IN_DATASOURCE
  clearCache(): void;
}

Options are resolved at render time and re-validated at submit so a stale frontend can never sneak a removed value into a submission. The cache key is org- and actor-scoped (formsActorIdentity), so users and API keys never share entries. An API-key caller reaches only sources that declare allowsApiKeyCallers: true (checked before any cache read or fetch). stableStringify(value) is exported for matching params objects regardless of key order.

interface DataSourceDef<P = Record<string, unknown>> {
  key: string;
  allowsApiKeyCallers?: boolean;  // missing ⇒ API-key callers refused before fetch
  fetch(params: P, ctx: DataSourceContext): Promise<DataSourceOption[]>;
}
interface DataSourceOption { value: string | number; label?: string; [key: string]: unknown; }
interface DataSourceContext {
  orgId: string | undefined;
  userId: string | undefined;     // human-only
  actor?: FormsActor;             // the resolved caller (user or API key)
  source: 'http' | 'worker';
}

Outbox dispatch

The engine owns the per-job state machine; your app owns the polling loop.

dispatchJob(job, handlers, store, opts?): Promise<'done' | 'retried' | 'failed' | 'stale'>
runDispatchCycle(store, handlers, opts?): Promise<DispatchCycleResult>

interface DispatchCycleResult { claimed: number; done: number; retried: number; failed: number; stale: number; }

runDispatchCycle claims a batch (default 10) of due jobs and dispatches them sequentially. opts.wrapJob lets the glue restore worker request context around each dispatch; opts.backoff tunes retry timing. A job with no registered handler is parked (failed) immediately, and so is a job whose handler throws OutboxNonRetryableError — or any error carrying retryable: false (isNonRetryableOutboxError) — because retrying cannot fix a bad destination. Handlers implement OutboxJobHandler:

interface OutboxJobHandler { type: string; handle(job: OutboxJobRecord): Promise<void>; }

Register them on an OutboxHandlerRegistry (register / get / has / keys).

Files & redaction

Submissions carry file ids, never bytes. Upload happens before submit; the engine verifies ownership/status/type/size and links the file in-tx whenever a draft or submission row persists (a file linked to a draft is accepted again when that draft is re-saved or submitted, and is exempt from GC).

FILE_STATUS_TRANSITIONS    // TEMPORARY → [SCANNING, CLEAN, LINKED]; SCANNING → [CLEAN, INFECTED]; CLEAN → [LINKED]; INFECTED/LINKED terminal
canTransition(from, to): boolean
acceptSatisfied(accept: string[] | undefined, mimeType: string): boolean   // exact, type/* wildcard, csv↔plain
verifyFileReference(args: FileReferenceCheckArgs): ValidationErrorItem | undefined  // pre-link check: args.actor must own the file (user → ownerId, API key → ownerApiKeyId); args.submissionId accepts a file LINKED to that same row
collectFileReferences(def, data): CollectedFileReference[]                  // every well-formed file ref
isGcEligible(file, now, ttlMs): boolean                                     // never-linked TEMPORARY/CLEAN uploads past the TTL

Redaction for action logs / audit / webhook payloads:

REDACTED_PLACEHOLDER  // "[REDACTED]"
redactSensitiveData(def, isSensitiveType: (type: string) => boolean, value): unknown
stripDefinitionSecrets(def): FormDefinition       // deep copy without settings.webhooks[].secret
projectDefinitionForRender(def): FormDefinition   // deep copy without settings.webhooks at all

redactSensitiveData returns a deep copy with sensitive fields (and types where isSensitiveType is true, e.g. password) replaced — plus a credential keyword guard (/password|secret|token|api[_-]?key|authorization|credential/i) for keys the field tree doesn't describe. The input is never mutated. A dependency-free magic-byte MIME sniffer ships too — import the MimeSniffer interface and the DefaultMagicByteSniffer implementation from the package root (@ftisindia/form-builder); there is no files/mime-sniff subpath export.

Definition linting

FORM_DEFINITION_META_SCHEMA: JsonSchema                 // the definition's JSON Schema (shape gate)
validateDefinitionShape(input: unknown): LintIssue[]    // stage-1 shape check
lintDefinition(def: FormDefinition, deps: DefinitionLintDeps): LintIssue[]  // full save/publish lint

lintDefinition runs the shape gate first; if the document is well-formed it runs all semantic checks (duplicate names, reserved field/button names (RESERVED_NAME), unknown field/action/data-source keys, group sanity, context paths, validators.pattern safety (PATTERN_UNSAFE), file constraints, rule linting via the injected lintRules, action phase order, dangerous-action wiring, webhook destination/SSRF gating, and — for stage: 'publish' — captcha gates). DefinitionService calls this for you; call it directly only if you lint outside the service.

interface DefinitionLintDeps {
  fieldTypes: FieldTypeRegistry;
  actions: ActionRegistry;
  dataSources: DataSourceRegistry;
  policy: OrgFormsPolicy;
  ruleLimits?: RuleLimits;
  lintRules: (rules, opts: { limits; enableIteration }) => LintIssue[];
  stage: 'save' | 'publish';
  canWireDangerous: boolean;
}

Ports you must implement

The engine reads/writes through these repository interfaces. Every read takes an explicit orgId and implementations must filter by it (a cross-org id behaves like a missing one). Every write that participates in the pipeline accepts the opaque EngineTx so Phase-2 atomicity holds across stores.

| Port | Responsibility | |------|----------------| | FormDefinitionStore | CRUD + versioning for definitions (create, update, findByKeyVersion, findLatest, findAllByKeyStatus, list, maxVersion). | | SubmissionStore | Store/read submissions stamped with formVersion (create, update, findById, list, countByIpSince, listForExport). | | OutboxStore | Transactional outbox lifecycle (enqueue in-tx, claimDue with a lease token, touchProcessing, markDone, markRetry, markFailed). | | FileStore | Uploaded-file records (create, token-aware findById, updateStatus, countPublicUploadsByIpSince, listGcEligible, org-scoped delete(orgId, id)). | | FileStoragePort | Byte storage behind files (put, get, delete) — local disk, S3, … | | ActionLogStore | High-volume action telemetry (write) — input/output already redacted by the engine. |

Actor columns (2.0). SubmissionStore persists and returns createdByApiKeyId and honours SubmissionListFilters.createdByApiKeyId; FileStore persists ownerApiKeyId and returns it on every UploadedFileRecord (null, never undefined); ActionLogStore writes actorApiKeyId; OutboxStore writes actorApiKeyId and returns it from claimDue. See Actors and API-key callers.

Representative signature (the rest follow the same pattern — see src/types/ports.ts):

interface SubmissionStore {
  create(record: NewSubmissionRecord, tx?: EngineTx): Promise<SubmissionRecord>;
  update(orgId: string, id: string, patch: SubmissionPatch, tx?: EngineTx): Promise<SubmissionRecord>;
  findById(orgId: string, id: string, tx?: EngineTx): Promise<SubmissionRecord | null>;
  list(orgId: string, formKey: string, filters: SubmissionListFilters): Promise<SubmissionRecord[]>;
  countByIpSince(orgId: string, formKey: string, ipAddress: string, since: Date): Promise<number>;
  listForExport(orgId: string, formKey: string, filters): Promise<SubmissionRecord[]>;
}

Seams (ecosystem adapters)

The engine couples to four request/identity seams; the glue binds them to the app's real services. They never throw framework exceptions — only the engine's typed errors.

interface FormsContext {
  requestId(): string | undefined;
  source(): 'http' | 'worker';
  userId(): string | undefined;                   // HUMAN-only: undefined for API-key callers
  orgId(): string | undefined;
  permissionKeys(): string[];
  isOwner(): boolean;
  assertOrgScope(orgId: string): void;            // throws when orgId ≠ active scope
  resolve(path: ContextPath): string | undefined; // closed union; never called for actor.type / actor.id
  ipAddress(): string | undefined;
  userAgent(): string | undefined;
  actor?(): FormsActor | undefined;               // optional; authoritative when implemented
}

interface FormsAuthorization { can(required: FormPermissionKey[], ctx: FormsContext): boolean; }

interface FormsAuditSink { write(entry: AuditEntry, tx?: EngineTx): Promise<void>; }

interface TxRunner { run<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T>; }

Optional abuse-control seams: CaptchaVerifier (Tier 2 — verify(token, ctx)), VirusScanner (scan({bytes, mimeType, originalName}) → 'clean' | 'infected') and SubmissionScreener (Tier 3 — declared, not yet consumed by the engine).

Bind VirusScanner whenever an organisation sets forms.virusScanRequired. The glue calls it before storing bytes: clean uploads persist as CLEAN (satisfying the file policy's scan gate) and infected bytes are refused. With the setting on and no scanner bound, uploads are refused outright rather than stranded — see the app's FORMS_VIRUS_SCANNER provider.

The org policy passed via policyFor(orgId):

interface OrgFormsPolicy {
  allowedDangerousActions: string[];   // allowlist for dangerous actions (audited typed setting)
  enableRuleIteration: boolean;        // json-logic map/filter/... (default false)
  captchaConfigured: boolean;
  maxFileSizeMb?: number;
  virusScanRequired?: boolean;         // files must reach CLEAN before linking
  maxSubmissionsPerIpPerDay?: number;  // public-form submission cap
  maxPublicUploadsPerIpPerDay?: number;  // anonymous public upload cap
  webhookAllowedHosts?: string[];      // SSRF gate; empty ⇒ webhooks effectively disabled
}

DEFAULT_RULE_LIMITS and DEFAULT_ORG_FORMS_POLICY are exported as sensible starting points.


Actors and API-key callers

Since 2.0 every operation knows who is calling: a human user, an API key (a machine integration identity that never carries a user id, membership or owner flag), or nobody (the anonymous public path).

type FormsActor =
  | { type: 'user'; id: string }
  | { type: 'apiKey'; id: string; credentialId?: string | null };

resolveFormsActor(ctx): FormsActor | undefined  // ctx.actor() if implemented (authoritative), else userId() → user
formsActorIdentity(actor): string               // 'user:<id>' | 'apiKey:<id>' | 'anonymous'
  • Context adapter. Implement the optional FormsContext.actor() from the request principal. userId() stays human-only and must return undefined for API-key callers. Adapters without actor() keep the 1.x behaviour (a user id becomes a user actor).
  • Opt-in extensions. An API-key caller is refused with FormsActorNotAllowedError (FORMS_ACTOR_NOT_ALLOWED, 403) before any work unless every extension involved declares allowsApiKeyCallers: true: each action of the button's pipeline, each lifecycle hook that applies to the form, and each data source it resolves (render options and submit-time membership). The built-in actions declare true. Declare it only on code that is correct without a human user (no reliance on ctx.userId()).
  • Human-only forms. A form with a required source: 'context', path: 'user.id' field anywhere in its tree cannot be submitted, drafted or validated with an API key.
  • Ownership. A key owns what it creates. Submissions stamp createdByApiKeyId (humans: createdBy; never both) and uploads carry ownerApiKeyId. Editing with formSubmissions.create requires the same actor; file references must belong to the same actor. IP / user-agent are stamped only for anonymous submissions.
  • Attribution. Outbox jobs record actorUserId or actorApiKeyId at enqueue (dispatch never re-checks); action logs split actorId (human) and actorApiKeyId; audit entries carry actor (and actorUserId for humans only). Data-source caches partition by formsActorIdentity.
  • Context paths. actor.type ('user' | 'apiKey' | 'anonymous') and actor.id are resolved by the engine itself, never through ctx.resolve; user.id stays human-only (empty for keys).
actions.register({
  name: 'sendConfirmationEmail',
  kind: 'post-commit',
  allowsApiKeyCallers: true,  // correct for machine callers: no human identity needed
  async execute(ctx) {
    await ctx.enqueue({ type: 'email', payload: { to: ctx.data.email, template: 'confirm' } });
    return { queued: true };
  },
});

Extending the engine

Everything is "implement an interface, register the provider." These examples use the framework-free API (plain register(...) calls).

Custom field type

fieldTypes.register({
  type: 'rating',
  buildValidator(field) {
    const max = (field.validators?.max as number) ?? 5;
    return { type: 'integer', minimum: 1, maximum: max };
  },
  coerce: (raw) => Number(raw),
});

Custom action (pick the kind by when it should run)

// post-commit side effect — only enqueues; the dispatcher delivers later
actions.register({
  name: 'sendConfirmationEmail',
  kind: 'post-commit',
  async execute(ctx) {
    await ctx.enqueue({ type: 'email', payload: { to: ctx.data.email, template: 'confirm' } });
    return { queued: true };
  },
});

// dangerous transactional action — needs forms.wireDangerous + org allowlist
actions.register({
  name: 'authenticate',
  kind: 'transactional',
  dangerous: true,
  async execute(ctx) { return authService.login(ctx.data.email, ctx.data.password); },
});

Custom data source

dataSources.register({
  key: 'conference-tracks',
  async fetch(_params, ctx) {
    const rows = await db.track.findMany({ where: { orgId: ctx.orgId, active: true } });
    return rows.map((r) => ({ value: r.id, label: r.name }));
  },
});

Lifecycle hook (coercion / derived fields — not side effects)

lifecycles.register({
  formKey: 'registration',                 // omit or '*' to apply to all forms
  beforeValidate: (data) => ({ ...data, email: String(data.email).trim().toLowerCase() }),
  afterPersist: async (_data, ctx) => { /* read-only post-commit notification */ },
});

FormLifecycle exposes beforeValidate, afterValidate, beforePersist (all return the possibly-transformed data) and afterPersist (read-only, post-commit).


Using it inside an FTIS app (NestJS glue)

In a scaffolded app you don't wire the registries by hand. The src/modules/forms glue module:

  • Implements every port over Prisma (FormDefinitionStore, SubmissionStore, OutboxStore, FileStore, ActionLogStore) and the four seams over the app's RequestContextService (FormsContext, with actor() mapped from the request principal), CASL ability factory (FormsAuthorization), AuditService (FormsAuditSink), and PrismaService.$transaction (TxRunner).
  • Auto-discovers providers via decorators instead of manual register() calls: @FormFieldType(), @FormActionHandler(), @FormDataSource() and @FormLifecycleHook() (defined in infrastructure/registry/form-extension.decorators.ts). A registry builder collects every decorated provider at boot and populates the engine registries.
  • Maps engine errors onto the app's HTTP envelope (FormsValidationError → 422, FormsNotFoundError → 404, FormsAuthzDeniedError → 403, FormsConflictError → 409, …).
  • Carries the permission keys as route guards. The full taxonomy is exported as FORM_PERMISSION_KEYS; a template test asserts set-equality so the two never drift.
  • Verifies the database against ENGINE_SCHEMA_VERSION / EXPECTED_FORMS_SCHEMA at boot and fails fast on mismatch.

See packages/template/docs/FORMS.md and FORMS_CHECKLIST.md for the full glue wiring, plus the example definitions under packages/template/src/modules/forms/examples/.


Database setup

The app owns schema.prisma and the migration history; the engine ships the canonical model snippet. Copy prisma/forms.prisma into your schema and migrate:

  • Models: FormDefinition, FormSubmission, FormOutboxJob, UploadedFile, FormActionLog (with the FormDefinitionStatus, SubmissionStatus, OutboxStatus, FileStatus enums).
  • The glue's boot check compares the live database against EXPECTED_FORMS_SCHEMA (the load-bearing column subset — additive app columns are fine) and the current ENGINE_SCHEMA_VERSION (currently 4).
  • Version 4 adds the API-key actor columns (FormSubmission.createdByApiKeyId, FormActionLog.actorApiKeyId, FormOutboxJob.actorApiKeyId, UploadedFile.ownerApiKeyId) plus two CHECK constraints Prisma cannot express (at most one creator / owner). Add them to the migration as raw SQL — they are exported as FORMS_ACTOR_CONSTRAINTS_SQL and documented in the snippet header.
import {
  FORMS_PRISMA_SNIPPET_PATH, ENGINE_SCHEMA_VERSION, EXPECTED_FORMS_SCHEMA, FORMS_ACTOR_CONSTRAINTS_SQL,
} from '@ftisindia/form-builder';

Reference tables

Validation error codes (ValidationErrorCodes)

REQUIRED, TYPE, FORMAT, ENUM, MAX_LENGTH, MIN_LENGTH, RANGE, ARRAY_BOUNDS, PATTERN, UNEXPECTED_PROPERTY, RULE_VIOLATION, NOT_IN_DATASOURCE, FILE_REFERENCE, SUBMISSION_CAP, CAPTCHA. RULE_TIMEOUT is declared for a future evaluation budget and is not emitted today.

Permission keys (FORM_PERMISSION_KEYS)

forms.read, forms.create, forms.update, forms.publish, forms.archive, forms.wireDangerous, forms.managePublicAccess, formSubmissions.read, formSubmissions.create, formSubmissions.update, formSubmissions.export, formDataSources.manage.

Action kinds (ActionKind, run in this order)

validation → transactional → post-commit.

File status lifecycle (FileStatus, from FILE_STATUS_TRANSITIONS)

  • TEMPORARY → SCANNING, CLEAN, or LINKED (scanning is optional — a file may go straight to CLEAN/LINKED when no scanner is configured)
  • SCANNING → CLEAN or INFECTED
  • CLEAN → LINKED
  • INFECTED and LINKED are terminal

Typed errors

The engine never throws framework exceptions — only these (the glue maps them to HTTP status):

| Class | code | Typical mapping | |-------|--------|-----------------| | FormsValidationError (carries errors[]) | FORMS_VALIDATION | 422 | | FormsDefinitionLintError (carries issues[]) | FORMS_DEFINITION_INVALID | 422 | | FormsAuthzDeniedError (optional code argument) | FORMS_FORBIDDEN | 403 | | FormsActorNotAllowedError (extends the above) | FORMS_ACTOR_NOT_ALLOWED | 403 — an API-key caller hit an extension without allowsApiKeyCallers: true, or a form that requires a signed-in user | | FormsNotFoundError | FORMS_NOT_FOUND | 404 | | FormsConflictError | FORMS_CONFLICT | 409 | | FormsStateError | FORMS_STATE | 409 / 422 | | FormsUnsafePatternError | FORMS_PATTERN_UNSAFE | 422 | | OutboxNonRetryableError (or any error with retryable: false) | FORMS_OUTBOX_NON_RETRYABLE | n/a — thrown by outbox handlers; the dispatcher parks the job as FAILED at once |

All extend FormsError ({ code, message }). Rule violations arrive as RULE_VIOLATION items inside FormsValidationError (the never-thrown FormsRuleViolationError was removed in 2.0).


Troubleshooting

| Symptom | Cause & fix | |---------|-------------| | Unknown field type / action / data source | The definition references a key that isn't registered. Caught at save/publish as a FormsDefinitionLintError (lint issue per offending key); if it reaches runtime it surfaces as a FormsStateError from require(). Register the provider (registerCoreFieldTypes, registerBuiltinActions, or your own register(...)) so the registry key resolves. | | Schema version / table mismatch at boot | The live database doesn't match EXPECTED_FORMS_SCHEMA / ENGINE_SCHEMA_VERSION. Copy the current prisma/forms.prisma snippet into your schema and run a migration; see Database setup. | | Dangerous action not allowed | Attaching an action with dangerous: true needs the forms.wireDangerous permission and the action name in the org policy's allowedDangerousActions. Add the name to the typed setting and ensure the actor holds the permission, or it fails the wire-time lint / authorization check. | | Captcha enabled without a verifier | A definition sets settings.captcha = true but the org policy reports captchaConfigured: false. Publishing / going public fails the lint (CAPTCHA_NOT_CONFIGURED). Bind a CaptchaVerifier and set captchaConfigured: true, or remove settings.captcha. | | Webhook host not allowed | A webhook URL targets a host outside the org's webhookAllowedHosts (SSRF gate). Add the host to that typed setting (and use https, except loopback). An empty/absent list disables webhooks for the org. | | Public file reference rejected | Public submissions must use files uploaded through the public upload endpoint and include the returned token in uploadTokens[fileId]. Authenticated upload files are owner-bound to the submitting actor (the same user, or the same API key). A store that omits ownerApiKeyId (returns undefined instead of null) makes every public reference fail — return the column. | | FORMS_ACTOR_NOT_ALLOWED for an API key | An action on the button, a lifecycle hook for the form, or a data source it resolves lacks allowsApiKeyCallers: true (the message names it), or the form has a required user.id context field. Declare the flag on extensions that are correct without a human user; human-only forms stay human-only. |


Scripts

npm run build      # bundle with tsup (ESM + CJS + .d.ts)
npm run test       # vitest run --pool=threads, then test:esm
npm run test:esm   # build + import dist/index.js under native Node ESM (guards the ajv deep-import path)
npm run lint       # eslint (enforces the no-Nest/Prisma/CASL boundary)
npm run typecheck  # tsc --noEmit

License

PolyForm Noncommercial 1.0.0 — free for noncommercial use; commercial use requires a license from ftisindia.com.