Forms API
Forms let a database collect anonymous, unauthenticated submissions that
are stored as ordinary database records (with an answer→property mapping) plus
a raw FormSubmission audit row. The surface spans three tasks:
- TASK-0106 — builder: form + question CRUD, the compatibility matrix, slug generation, publish/close preconditions.
- TASK-0107 — the public
GET /api/v1/public/forms/:slugwhitelist endpoint (unauthenticated). - TASK-0108 — the
POST /api/v1/public/forms/:slug/submitpipeline (dynamic server-side validation, atomic record+submission,form.submittedevent, owner notification, FILE answer verification). - TASK-0109 — responses: the authenticated submissions list / delete (cascade) / export-stub.
Shared conventions (auth, error model, pagination, rate limits): authentication.md.
Permission note: every builder and responses endpoint is scoped to the
form's database — the caller needs the relevant permission on that database
(VIEW to list forms/submissions, EDIT to create/update/delete, owner or
admin to read full submitter IPs). The two public/forms/* submit endpoints
and the public upload session are unauthenticated (they are the point) and
are explicitly whitelisted in the auth guard.
Single source of truth. The question types, the question→property compatibility matrix, the per-type validation schemas, and the form error codes all live in
packages/shared/src/forms.ts. The API imports them directly; nothing is duplicated inapps/apiorapps/web.
1. Objects
Form
{
"id": "frm_01J9ZKQ8G6B8C0D2E4F6A8B0",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0",
"name": "Feedback",
"slug": "feedback",
"description": "Tell us how we did",
"status": "PUBLISHED",
"config": {
"accent": "#635bff",
"intro": "Thanks for helping us improve.",
"icon": null,
"successMessage": null,
"closedMessage": null,
"poweredBy": true,
"recaptcha": { "enabled": false }
},
"createdById": "usr_01J9ZKP3D8F0A2B4C6D8E0F2A4",
"createdAt": "2026-09-30T10:57:10.449Z",
"updatedAt": "2026-09-30T10:57:12.010Z"
}
config is re-parsed through the shared strict FORM_CONFIG_SCHEMA on read
(corporate Json is never echoed raw — a corrupt value fails closed to the
empty config).
FormQuestion
{
"id": "frq_01J9ZKQ9H7G9D1E3F5A7B9C1",
"formId": "frm_01J9ZKQ8G6B8C0D2E4F6A8B0",
"label": "Rate us",
"description": null,
"type": "RATING",
"required": true,
"validation": { "max": 5 },
"propertyId": "prop_01J9ZKQ6D3E5F7A9B1C3D5E7F9",
"placeholder": null,
"position": "a1"
}
propertyId (the answer→column mapping) is a server/authenticated-only
field — it is never part of the public payload (see §4). A question whose
type has no compatible property type is stored in FormSubmission.answers
only and never mapped onto a record.
FormSubmission
{
"id": "subm_01J9ZKQA8H8E0F2G4B6C8D0E2",
"formId": "frm_01J9ZKQ8G6B8C0D2E4F6A8B0",
"recordId": "rec_01J9ZKQB9I9F1G3H5C7D9E1F3",
"submittedBy": null,
"answers": { "frq_…0": "the search feature", "frq_…1": 4 },
"ipAddress": "127.0.0.1",
"createdAt": "2026-09-30T11:03:13.403Z"
}
answers holds the raw submitted values keyed by question id (the audit
trail — it survives a question being deleted). recordId is the created
database record (or null when no question maps to a property).
2. Builder endpoints (TASK-0106) — authenticated
All paths are relative to /api/v1. Permission = the relevant permission on
databaseId.
2.1 POST /databases/:databaseId/forms — create a form
Body (CreateFormDto, zod-validated):
| field | type | notes |
|---|---|---|
name |
string 1–100 | required |
description |
string ≤ 500 | optional |
config |
object | optional; validated against FORM_CONFIG_SCHEMA (accent ∈ FORM_ACCENTS, intro markdown ≤ 2000 chars, icon fileId, successMessage/closedMessage ≤ 500, poweredBy bool, recaptcha.enabled bool). Unknown keys → 422 FORM_CONFIG_INVALID. |
slug |
string 2–50 | optional; [a-z0-9]+(-[a-z0-9]+)*. Omitted → auto-generated kebab slug from the name. |
questions |
CreateQuestionDto[] ≤ 50 |
optional; created in order. |
Response 201: the Form object. A new form starts as DRAFT.
Slug rules (collision-safe, ≤ 50 chars): the generated slug is unique
platform-wide (slug is a unique column); on collision a -2, -3, …
suffix is appended (truncating the base so the result stays ≤ 50 chars). An
explicit colliding slug → 409 (SLUG_TAKEN).
2.2 GET /databases/:databaseId/forms — list
Query: none. Response 200: the array of Form objects (questions included).
2.3 GET /forms/:formId — read
Response 200: the Form object with questions in position order.
2.4 PATCH /forms/:formId — update
Body (UpdateFormDto, all optional, ≥ 1 required): name, description
(nullable), status (DRAFT|PUBLISHED|CLOSED), slug, config,
questions.
questions, when present, replaces the question set (create/patch/delete
diffed server-side; deleting a question keeps its answers on existing
submissions). Changing status to PUBLISHED enforces the precondition: the
form must have ≥ 1 question, else 422 with details[].reason = NO_QUESTIONS. Response 200: the updated Form.
2.5 DELETE /forms/:formId — delete
Requires EDIT. Response 200: { "deleted": true }. Questions and
submissions cascade (Prisma onDelete: Cascade). The slug is released.
2.6 Questions
GET /forms/:formId/questions→200array (position order).POST /forms/:formId/questions→201the question. Body (CreateQuestionDto):type(required, one of the 10),label(1–100),description(≤ 500),required(bool),placeholder(≤ 200),validation(per-type object — see §3),propertyId(string|null, optional). The compatibility matrix is enforced: apropertyIdwhose property type is not inQUESTION_PROPERTY_COMPATIBILITY[type]→422(PROPERTY_TYPE_MISMATCH) with the allowed types in the detail.positionis assigned server-side (appended after the last).PATCH /forms/:formId/questions/:qid→200. Body (UpdateQuestionDto): any oftype,label,description,required,placeholder,validation,propertyId,position. Changingtype/propertyIdre-validates the matrix.DELETE /forms/:formId/questions/:qid→200. Removing a question does not destroy data: existing submissions keep theiranswers[<qid>](the list renders them under "Deleted question").POST /forms/:formId/questions/reorder→200. Body:{ fromId, beforeId? }(dropfromIdbeforebeforeId;null/omitted = append). Positions are recomputed by the fractional-position engine (Phase-4 reuse) — never array indexes.
3. Question types, compatibility matrix, validation config
10 question types (FORM_QUESTION_TYPES): SHORT_TEXT, LONG_TEXT,
NUMBER, EMAIL, DATE, CHECKBOX, SINGLE_SELECT, MULTI_SELECT,
RATING, FILE.
Compatibility matrix — question type → property types it may map to
(empty = the answer is stored in answers only, never on a record):
| Question | Mappable property types |
|---|---|
SHORT_TEXT |
TITLE, TEXT, RICH_TEXT, EMAIL, PHONE, URL |
LONG_TEXT |
RICH_TEXT, TEXT |
NUMBER |
NUMBER |
EMAIL |
EMAIL |
DATE |
DATE |
CHECKBOX |
CHECKBOX |
SINGLE_SELECT |
SELECT, STATUS |
MULTI_SELECT |
MULTI_SELECT |
RATING |
NUMBER |
FILE |
FILES |
Per-type validation config (each schema is .strict() — unknown keys are
rejected, so a corrupt validation Json can never add a control). Cross-field
checks use stable issue codes surfaced in details[].issue:
| Type | Allowed validation keys |
Cross-field check (issue code) |
|---|---|---|
SHORT_TEXT / LONG_TEXT |
minLength int 0–10 000, maxLength int 1–100 000, pattern string 1–200 |
min_gt_max (minLength ≤ maxLength) |
NUMBER |
min, max (numbers), integer bool |
min_gt_max (min ≤ max) |
EMAIL |
— (empty object) | — |
DATE |
minDate, maxDate (ISO YYYY-MM-DD) |
min_gt_max (minDate ≤ maxDate) |
CHECKBOX |
— (empty object) | — |
SINGLE_SELECT / MULTI_SELECT |
options: string[1–50], each 1–50 chars |
duplicate_option |
RATING |
max int 5–10 (required), default int 1–10 |
min_gt_max (default ≤ max) |
FILE |
maxFiles int 1–5, maxBytes int 1–500 MB |
— |
pattern (text types) is validated for ReDoS at write time: it is matched
against a 10k-char probe in a worker thread with a 100 ms hard-kill budget;
catastrophic patterns (e.g. (a+)+$) are rejected with PATTERN_TOO_COMPLEX.
Answer → record coercion (the value a question writes into its mapped
property, per public-forms.service.ts):
| Mapped property type | Stored RecordProperty.value |
|---|---|
TITLE / TEXT / RICH_TEXT |
{ "text": <string>, "marks": [] } |
NUMBER |
{ "value": <number> } |
SELECT / STATUS |
{ "optionId": <string> } |
MULTI_SELECT |
{ "optionIds": [string] } |
DATE / DATE_RANGE |
{ "start": <ISO date>, "end": null } |
EMAIL / PHONE / URL |
{ "value": <string> } |
CHECKBOX |
{ "value": <boolean> } |
The record title comes from the TITLE-mapped question's string answer
(trimmed, ≤ 300 chars); if there is none, a deterministic fallback
(<form name> — <first 8 chars of the first text answer>).
4. Public endpoints (TASK-0107) — unauthenticated
4.1 GET /public/forms/:slug — the form payload
Whitelist. Returns only the form and its questions — nothing about the workspace, the database, its properties, other forms, or submissions.
- DRAFT or unknown slug →
404(drafts are not discoverable; no status leak). - PUBLISHED →
200withstatus: "published". - CLOSED →
200withstatus: "closed"(the closed state is intentionally visible) andvalidation: {}on every question (no inputs are rendered).
{
"form": {
"slug": "feedback",
"name": "Feedback",
"description": "Tell us how we did",
"status": "published",
"config": {
"iconUrl": "https://<storage>/…?signature=…",
"intro": "Thanks for helping us improve.",
"accent": "#635bff",
"successMessage": null,
"closedMessage": null,
"poweredBy": true,
"recaptcha": { "enabled": false }
}
},
"questions": [
{
"id": "frq_…0",
"type": "SHORT_TEXT",
"label": "What did you use?",
"description": null,
"required": true,
"placeholder": null,
"validation": { "minLength": 1, "maxLength": 280 }
}
]
}
Notes:
config.introis rendered markdown, sanitized (HTML stripped).config.iconUrlis a short-lived signed GET URL (15 min) — only when the icon file exists, isREADY, and is an image.validationis the public-safe subset (toPublicSafeValidation):minLength,maxLength,min,max,maxFiles, andoptions: string[]. Thepatternsource is never sent (patterns run server-side only, to avoid leaking business rules).- Never returned: workspace name/slug,
databaseId, property ids/names,propertyId, other forms, any user or submission data. - Deviation from the §1 whitelist (recorded in the task file):
poweredByandrecaptcha.enabledare added because the page's footer + recaptcha slot need them. Nothing else beyond the whitelist.
4.2 POST /public/forms/:slug/submit — the submission pipeline
Body: { "answers": { "<questionId>": <raw value>, … }, "<honeypot>?" }.
Pipeline order (each step is independent — a failure at any step persists nothing):
- Content-type must be
application/json→ else415. - Payload ≤ 64 KB → else
413. - Honeypot — if either of the two hidden fields (
website,hp) is non-empty →400(generic; the bot is not told why). Logged asform.honeypot_triggered. Bots do not consume rate-limit slots. - Rate limit — 5 submits/min per form + client IP (Redis-backed).
Exceeded →
429with aRetry-Afterheader (seconds until a slot frees). - Session — the client is anonymous; no auth.
- Dynamic validation (
submit-schema.ts) — every answer is validated against the question'stype+validation(see the code table below). Any violation →422with all violations inerror.details[]({ questionId, code, message }). Nothing is persisted. - FILE answers — each submitted
fileIdmust (a) belong to the form's workspace, (b) beREADY, and (c) have been opened for this form (its upload session carriedmetadata.formId = this form). A forged or foreign file →422(FILE_NOT_OWNED). - One transaction: the
DatabaseRecord(+ its backingPagewith the title) +RecordPropertyrows (coerced per §3) + theFormSubmission(rawanswers— the audit trail) +File.pageIdlinks. A failure leaves nothing (atomicity). - After commit (best-effort; a failure never undoes the submission):
the
form.submittedevent (IP redacted to /24 in the payload — the full IP lives only in the DB) + an ownerNotification+ the mandatory audit row.
Response 201:
{ "success": true, "submissionId": "subm_01J9ZKQA8H8E0F2G4B6C8D0E2", "message": null }
Submit-time validation codes (details[].code), by type:
| Type | Codes |
|---|---|
text (SHORT_TEXT/LONG_TEXT) |
required, type, min_length, max_length, pattern |
EMAIL |
required, email (format) |
NUMBER |
required, number, integer, min, max |
RATING |
required, range (1 ≤ rating ≤ max) |
DATE |
required, date (YYYY-MM-DD), min_date, max_date |
CHECKBOX |
required, boolean |
SINGLE_SELECT |
required, option |
MULTI_SELECT |
required, option, duplicate |
FILE |
required, max_files, file_ids |
| (any) | config (the question's stored config is invalid) |
A closed form's submit → 403 (FORM_CLOSED).
4.3 Public file upload sessions (TASK-0108)
POST /public/forms/:slug/files→ open an anonymous upload session attributed to the form (metadata.formIdset at creation). Returns the presignedPUTURL +fileId(Phase-9 primitive, anonymous/form-scoped).PUT /public/forms/:slug/files/:fileId/complete→ finalize; runs the Phase-9 processing pipeline (virus scan → metadata → thumbnail). Only files that both belong to the workspace and were opened for this form are accepted by submit.
5. Responses endpoints (TASK-0109) — authenticated
5.1 GET /forms/:formId/submissions — list responses
Query (SubmissionsQueryDto, .strict()):
| param | type | notes |
|---|---|---|
cursor |
string ≤ 120 | opaque, from nextCursor |
limit |
int 1–100 | default 50 |
q |
string ≤ 200 | free-text search over answer text, submitter name/email, and the IP (as the role sees it) |
from / to |
ISO datetime | received-within window |
anonymousOnly |
"true" |
only anonymous submissions |
Response 200: { "items": [SubmissionView], "nextCursor" } (newest first).
SubmissionView:
{
"id": "subm_…",
"formId": "frm_…",
"recordId": "rec_…",
"answers": { "frq_…0": "the search feature", "frq_…1": 4 },
"questionLabels": { "frq_…0": { "label": "What did you use?", "type": "SHORT_TEXT" } },
"submittedBy": { "id": "usr_…", "name": "Jane", "avatarUrl": null, "email": "jane@x.com", "anonymous": false },
"ip": "1.2.3.4",
"ipMasked": false,
"createdAt": "2026-09-30T11:03:13.403Z"
}
submittedByisnullfor anonymous submissions; a submitter whose user row was deleted still renders as"Former member".- IP masking by role (
isWorkspaceAdmin= OWNER|ADMIN): admins see the full IP (ipMasked: false); everyone else seesmaskIp(e.g.1.2.***.***) withipMasked: true. - A deleted question's label renders as
"Deleted question"(the raw answer is preserved).
5.2 DELETE /forms/:formId/submissions/:submissionId?confirm=true — delete (cascade)
Requires EDIT. confirm must be true, else 422 (CONFIRM_REQUIRED).
One transaction: (1) the linked record + its backing page + blocks,
(2) the linked files' rows (+ their versions), (3) the submission row,
(4) the workspace byte counter adjusted for purged file bytes. After commit:
the object-storage purge (best-effort per key) + audit rows
(record.deleted with via: "form_submission_deleted", and
form.submission_deleted). Response 200:
{ "deleted": true, "recordDeleted": true, "filesPurged": 2 }
5.3 GET /forms/:formId/export — export stub (501)
Requires VIEW. The endpoint exists and is permission-gated but returns
501 NOT_IMPLEMENTED_YET (Phase 13). The body documents the future
contract so Phase 13 implements against it:
{
"error": {
"code": "NOT_IMPLEMENTED",
"message": "CSV export is not implemented yet.",
"details": [
{ "reason": "NOT_IMPLEMENTED_YET", "implementedIn": "phase-13" },
{ "contract": "CSV columns: submission number, received (ISO 8601), submitter (email-or-id-or-anonymous), one column per question (\"label (type)\"), record link. JSON: { items: [SubmissionView] }. See docs/api/forms.md." }
]
}
}
?format=csv is accepted (default) but ignored until Phase 13.
6. Domain event — form.submitted (TASK-0108)
Emitted to the factory:events Redis stream after commit (best-effort).
Payload (IP redacted to /24; the full IP is DB-only):
{
"workspaceId": "wks_…",
"formId": "frm_…",
"slug": "feedback",
"submissionId": "subm_…",
"recordId": "rec_…",
"databaseId": "db_…",
"questionCount": 4,
"anonymous": true,
"ip": "127.0.0.***"
}
This is the Phase-11 automation trigger. See architecture/events.md.
7. Error codes (forms)
From FORM_ERROR_CODES in @factory/shared/forms.ts:
| Code | HTTP | Where |
|---|---|---|
PROPERTY_TYPE_MISMATCH |
422 | question create/update — incompatible propertyId |
FORM_CLOSED |
403 | submit to a closed form |
FORM_CONFIG_INVALID |
422 | form create/update — config fails the strict schema |
FILE_NOT_READY |
422 | submit — the file hasn't finished processing |
FILE_NOT_OWNED |
422 | submit — the file is from another workspace / not opened for this form |
CONFIRM_REQUIRED |
422 | delete submission without confirm=true |
PAYLOAD_TOO_LARGE |
413 | submit — body > 64 KB |
NOT_IMPLEMENTED_YET |
501 | export (Phase 13) |
NO_QUESTIONS |
422 | publish with 0 questions |
UNKNOWN_PROPERTY |
422 | propertyId not in the form's database |
SLUG_TAKEN |
409 | explicit colliding slug |
PATTERN_TOO_COMPLEX |
422 | a pattern fails the ReDoS probe |
The per-question submit-time codes (required, email, range, min, …)
are listed in §4.2.