@ftisindia/form-builder
v0.2.1
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:0.2.1 - Module format: ESM + CJS dual build (
type: module,import/requireconditions). Ships.d.tstypes. Node ≥ 20. - Runtime deps:
ajv+ajv-formats(JSON Schema 2020-12) andjson-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-importsrule and a CI job). It is run inside a host app through thesrc/modules/formsglue module that@ftisindia/create-appscaffolds — 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
- Install
- Core concepts
- The form definition format
- Example definitions
- Quick start (framework-free)
- API reference
- Ports you must implement
- Seams (ecosystem adapters)
- Extending the engine
- Using it inside an FTIS app (NestJS glue)
- Database setup
- Reference tables
- Typed errors
- Scripts
- License
Install
npm install @ftisindia/form-builderimport {
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 = 2. 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 (and the example JSON below) omit
status— the engine stamps it.createDraftwritesDRAFT,publish/archivemove it toPUBLISHED/ARCHIVED. Thestatusyou read back on aFormDefinitionRecordis always engine-controlled; you never set it 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' — must be 'user.id' | 'org.id' | 'request.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.
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"] }
}
authenticatehere is an app-provided action markeddangerous: true— it never persists a submission, it delegates to the app's auth service. Wiring a dangerous action requires theforms.wireDangerouspermission 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,
};
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. 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 public + file fields, and captcha-enabled forms with no verifier. 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 public path requires settings.access === 'public' and otherwise answers "not found". |
interface RenderResult {
record: FormDefinitionRecord;
definition: FormDefinition;
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. formSubmissions.create (or the public path). |
| saveDraft | (args: SubmitArgs, ctx) → PipelineResult | Draft mode (relaxed required). Default pipeline ['validateAll','persistDraft']. |
| validateOnly | ({ orgId, formKey, version?, data, mode }, ctx) → ValidationResult | Live validation, no side effects — for frontend feedback. |
| 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
input?: unknown; // action-specific extra input, threaded to the pipeline
public?: boolean; // anonymous path — definition must be settings.access 'public'
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 | validators: required, minimum, maximum. |
| 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. |
| 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
cachekey is passed and the definition isPUBLISHED(immutable), the compiled validator is reused via an LRU keyed byorgId:key:version:mode. - Ajv keywords are mapped onto stable codes (
required → REQUIRED,maxLength → MAX_LENGTH,additionalProperties → UNEXPECTED_PROPERTY, …).
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 coercionValidationResult / 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 }.
// 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[]
// runtime evaluation (fail-closed: an un-evaluable rule reports a violation)
evaluateRules(rules: Rule[], args: {
data: Record<string, unknown>;
ctx: Record<string, unknown>; // the allowlisted projection from SubmissionServiceDeps.ruleContext
mode: 'draft' | 'submit';
}): 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
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 }): voidData 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 user-scoped. stableStringify(value) is exported for matching params
objects regardless of key order.
interface DataSourceDef<P = Record<string, unknown>> {
key: string;
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; 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. 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.
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
collectFileReferences(def, data): CollectedFileReference[] // every well-formed file ref
isGcEligible(file, now, ttlMs): boolean // abandoned TEMPORARY uploadsRedaction for action logs / audit / webhook payloads:
REDACTED_PLACEHOLDER // "[REDACTED]"
redactSensitiveData(def, isSensitiveType: (type: string) => boolean, value): unknownredactSensitiveData 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 lintlintDefinition runs the shape gate first; if the document is well-formed it
runs all semantic checks (duplicate names, unknown field/action/data-source
keys, group sanity, context paths, file constraints, rule linting via the
injected lintRules, action phase order, dangerous-action wiring, and — for
stage: 'publish' — captcha and public-file 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, findById, updateStatus, listGcEligible, delete). |
| FileStoragePort | Byte storage behind files (put, get, delete) — local disk, S3, … |
| ActionLogStore | High-volume action telemetry (write) — input/output already redacted by the engine. |
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;
orgId(): string | undefined;
permissionKeys(): string[];
isOwner(): boolean;
assertOrgScope(orgId: string): void; // throws when orgId ≠ active scope
resolve(path: 'user.id' | 'org.id' | 'request.id'): string | undefined; // closed union by design
ipAddress(): string | undefined;
userAgent(): string | undefined;
}
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))
and SubmissionScreener (Tier 3 — declared, not yet consumed by the engine).
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 abuse cap
webhookAllowedHosts?: string[]; // SSRF gate; empty ⇒ webhooks effectively disabled
}DEFAULT_RULE_LIMITS and DEFAULT_ORG_FORMS_POLICY are exported as sensible
starting points.
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'sRequestContextService(FormsContext), CASL ability factory (FormsAuthorization),AuditService(FormsAuditSink), andPrismaService.$transaction(TxRunner). - Auto-discovers providers via decorators instead of manual
register()calls:@FormFieldType(),@FormActionHandler(),@FormDataSource()(defined ininfrastructure/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_SCHEMAat 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 theFormDefinitionStatus,SubmissionStatus,OutboxStatus,FileStatusenums). - 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 currentENGINE_SCHEMA_VERSION(currently2).
import { FORMS_PRISMA_SNIPPET_PATH, ENGINE_SCHEMA_VERSION, EXPECTED_FORMS_SCHEMA } 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,
RULE_TIMEOUT, NOT_IN_DATASOURCE, FILE_REFERENCE, SUBMISSION_CAP,
CAPTCHA.
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, orLINKED(scanning is optional — a file may go straight toCLEAN/LINKEDwhen no scanner is configured)SCANNING→CLEANorINFECTEDCLEAN→LINKEDINFECTEDandLINKEDare 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 |
| FormsRuleViolationError (extends the above) | FORMS_VALIDATION | 422 |
| FormsDefinitionLintError (carries issues[]) | FORMS_DEFINITION_INVALID | 422 |
| FormsAuthzDeniedError | FORMS_FORBIDDEN | 403 |
| FormsNotFoundError | FORMS_NOT_FOUND | 404 |
| FormsConflictError | FORMS_CONFLICT | 409 |
| FormsStateError | FORMS_STATE | 409 / 422 |
All extend FormsError ({ code, message }).
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 form rejected at publish | Public forms can't contain file fields (PUBLIC_FILE_FIELD). Remove the file field or keep the form private. Flipping access also requires forms.managePublicAccess. |
Scripts
npm run build # bundle with tsup (ESM + CJS + .d.ts)
npm run test # vitest run --pool=threads
npm run lint # eslint (enforces the no-Nest/Prisma/CASL boundary)
npm run typecheck # tsc --noEmitLicense
PolyForm Noncommercial 1.0.0 — free for noncommercial use; commercial use requires a license from ftisindia.com.
