/** * @factory/shared/forms — Phase 10 (Forms) shared contracts. * * SINGLE SOURCE OF TRUTH (TASK-0106 DoD) for everything both the API * (`apps/api/src/modules/forms`) and the web builder/public renderer * (`apps/web/src/components/forms`) need about question types, property * compatibility, and per-type validation config. Neither side may define * its own copy of the matrix or the config schema. * * Wire enums are UPPERCASE (Prisma `FormQuestionType` / `FormStatus`). */ import { z } from "zod"; // --------------------------------------------------------------------------- // Enums (mirrors of the Prisma enums — shared cannot import @prisma/client) // --------------------------------------------------------------------------- export const FORM_QUESTION_TYPES = [ "SHORT_TEXT", "LONG_TEXT", "NUMBER", "EMAIL", "DATE", "CHECKBOX", "SINGLE_SELECT", "MULTI_SELECT", "RATING", "FILE" ] as const; export type FormQuestionType = (typeof FORM_QUESTION_TYPES)[number]; export const FORM_STATUSES = ["DRAFT", "PUBLISHED", "CLOSED"] as const; export type FormStatus = (typeof FORM_STATUSES)[number]; /** All 22 database property types (mirror of the Prisma PropertyType). */ export const DB_PROPERTY_TYPES = [ "TITLE", "TEXT", "RICH_TEXT", "NUMBER", "SELECT", "MULTI_SELECT", "STATUS", "DATE", "DATE_RANGE", "PERSON", "EMAIL", "PHONE", "URL", "CHECKBOX", "FILES", "CREATED_TIME", "CREATED_BY", "LAST_EDITED_TIME", "LAST_EDITED_BY", "RELATION", "ROLLUP", "FORMULA", "BUTTON", "UNIQUE_ID" ] as const; export type DbPropertyType = (typeof DB_PROPERTY_TYPES)[number]; // --------------------------------------------------------------------------- // Compatibility matrix (spec TASK-0106 §1) — single source of truth. // // question type → database property types a question of that type may map to. // A question whose type has no compatible property is stored in // `FormSubmission.answers` only (never mapped onto a record). // --------------------------------------------------------------------------- export const QUESTION_PROPERTY_COMPATIBILITY: Record = { 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"] }; /** The property types a `type` question may map to (empty = never mapped). */ export function compatiblePropertyTypes(type: FormQuestionType): readonly DbPropertyType[] { return QUESTION_PROPERTY_COMPATIBILITY[type] ?? []; } /** True when a question of `type` may map onto a property of `propType`. */ export function isCompatibleMapping(type: FormQuestionType, propType: DbPropertyType): boolean { return compatiblePropertyTypes(type).includes(propType); } // --------------------------------------------------------------------------- // Validation config per question type (spec TASK-0106 §2). // // Each per-type schema is STRICT (`.strict()`): unknown keys are rejected so // a corrupt `validation` Json can never sneak a new control through. The // per-type error codes are stable strings the builder UI + API surface as // `details[].issue` (e.g. `min_gt_max`, `empty_option`, `duplicate_option`). // --------------------------------------------------------------------------- export const FORM_ERROR_CODES = { PATTERN_TOO_COMPLEX: "PATTERN_TOO_COMPLEX", PROPERTY_TYPE_MISMATCH: "PROPERTY_TYPE_MISMATCH", FORM_CLOSED: "FORM_CLOSED", FORM_CONFIG_INVALID: "FORM_CONFIG_INVALID", FILE_NOT_READY: "FILE_NOT_READY", FILE_NOT_OWNED: "FILE_NOT_OWNED", CONFIRM_REQUIRED: "CONFIRM_REQUIRED", PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE", NOT_IMPLEMENTED_YET: "NOT_IMPLEMENTED_YET", NO_QUESTIONS: "NO_QUESTIONS", UNKNOWN_PROPERTY: "UNKNOWN_PROPERTY", SLUG_TAKEN: "SLUG_TAKEN" } as const; export type FormErrorCode = (typeof FORM_ERROR_CODES)[keyof typeof FORM_ERROR_CODES]; const textValidation = z.object({ minLength: z.number().int().min(0).max(10_000).optional(), maxLength: z.number().int().min(1).max(100_000).optional(), pattern: z.string().min(1).max(200).optional() }).strict(); const numberValidation = z .object({ min: z.number().optional(), max: z.number().optional(), integer: z.boolean().optional() }) .strict(); const ratingValidation = z .object({ max: z.number().int().min(5).max(10), default: z.number().int().min(1).max(10).optional() }) .strict(); const optionsArray = z.array(z.string().min(1).max(50)).min(1).max(50); const selectValidation = z.object({ options: optionsArray }).strict(); const dateValidation = z .object({ minDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "ISO date (YYYY-MM-DD)").optional(), maxDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "ISO date (YYYY-MM-DD)").optional() }) .strict(); const fileValidation = z .object({ maxFiles: z.number().int().min(1).max(5).optional(), maxBytes: z.number().int().min(1).max(500 * 1024 * 1024).optional() }) .strict(); /** Per-type validation config schemas. CHECKBOX takes none. */ export const QUESTION_VALIDATION_SCHEMAS: Record< FormQuestionType, z.ZodTypeAny > = { SHORT_TEXT: textValidation, LONG_TEXT: textValidation, NUMBER: numberValidation, EMAIL: z.object({}).strict(), DATE: dateValidation, CHECKBOX: z.object({}).strict(), SINGLE_SELECT: selectValidation, MULTI_SELECT: selectValidation, RATING: ratingValidation, FILE: fileValidation }; export interface ValidationCheckResult { valid: boolean; /** Stable issue code (safe for `details[].issue`), e.g. `min_gt_max`. */ issue?: string; message?: string; } type ValidationRecord = Record; /** Cross-field checks per type (stable codes — zod refine codes are unstable). */ function checkCrossField(type: FormQuestionType, v: ValidationRecord): ValidationCheckResult { switch (type) { case "SHORT_TEXT": case "LONG_TEXT": { const min = (v.minLength as number | undefined) ?? 0; const max = (v.maxLength as number | undefined) ?? Number.POSITIVE_INFINITY; if (min > max) return { valid: false, issue: "min_gt_max", message: "minLength must be ≤ maxLength" }; return { valid: true }; } case "NUMBER": { const min = (v.min as number | undefined) ?? Number.NEGATIVE_INFINITY; const max = (v.max as number | undefined) ?? Number.POSITIVE_INFINITY; if (min > max) return { valid: false, issue: "min_gt_max", message: "min must be ≤ max" }; return { valid: true }; } case "RATING": { const max = v.max as number; const def = (v.default as number | undefined) ?? 1; if (def > max) return { valid: false, issue: "min_gt_max", message: "default must be ≤ max" }; return { valid: true }; } case "SINGLE_SELECT": case "MULTI_SELECT": { const options = v.options as string[]; if (new Set(options).size !== options.length) { return { valid: false, issue: "duplicate_option", message: "options must be unique" }; } return { valid: true }; } case "DATE": { const minDate = v.minDate as string | undefined; const maxDate = v.maxDate as string | undefined; if (minDate && maxDate && minDate > maxDate) { return { valid: false, issue: "min_gt_max", message: "minDate must be ≤ maxDate" }; } return { valid: true }; } default: return { valid: true }; } } /** * Minimal structural type of the `node:worker_threads.Worker` surface the * probe uses. The shared package is node-types-free (`lib: ES2022` only — * it is also bundled for the browser), so the class cannot be referenced by * name; the dynamic import is cast to this shape instead. */ interface ProbeWorker { terminate(): Promise; once(event: "message", listener: () => void): ProbeWorker; once(event: "error", listener: (err: Error) => void): ProbeWorker; } /** The ReDoS probe input (10k `a` + a non-matching tail). */ const REDOS_PROBE = "a".repeat(10_000) + "b"; /** Probe budget in ms (TASK-0106 §Security: reject pathological > 100 ms). */ const REDOS_PROBE_BUDGET_MS = 100; /** * Run `pattern` against the 10k probe in a WORKER THREAD with a hard-kill * timeout. A catastrophic pattern (e.g. `(a+)+$`) does not merely exceed a * time budget — it never returns, so a wall-clock check in the calling * thread would hang the API process. The worker is `terminate()`d after the * budget; the pattern is then rejected with `PATTERN_TOO_COMPLEX`. * * Works under both the web bundle and the Node API (tsx/CJS) — the worker * source is an inline CommonJS string with no imports. */ async function runPatternProbe( pattern: string, budgetMs: number = REDOS_PROBE_BUDGET_MS, probe: string = REDOS_PROBE ): Promise<{ ok: true; ms: number } | { ok: false; reason: "timeout" | "error" | "unsupported"; message?: string }> { try { // Node-only path — the browser never reaches this (patterns are // server-side only; the client pre-validation uses the numeric subset). const mod = (await import("node:worker_threads")) as { Worker: new (code: string, opts: { eval: boolean }) => ProbeWorker; }; const WorkerCtor = mod.Worker; const source = `const { parentPort } = require("node:worker_threads");` + `const re = new RegExp(${JSON.stringify(pattern)});` + `re.exec(${JSON.stringify(probe)});` + `parentPort.postMessage("done");`; return await runWorkerProbe(WorkerCtor, source, budgetMs); } catch { return { ok: false, reason: "unsupported", message: "node:worker_threads unavailable" }; } } async function runWorkerProbe( WorkerCtor: new (code: string, opts: { eval: boolean }) => ProbeWorker, source: string, budgetMs: number ): Promise<{ ok: true; ms: number } | { ok: false; reason: "timeout" | "error"; message?: string }> { return await new Promise((resolve) => { let settled = false; let worker: ProbeWorker; try { worker = new WorkerCtor(source, { eval: true }); } catch (err) { return resolve({ ok: false, reason: "error", message: (err as Error).message }); } const timer = setTimeout(() => { if (settled) return; settled = true; worker.terminate().catch(() => undefined); resolve({ ok: false, reason: "timeout" }); }, budgetMs); worker.once("message", () => { if (settled) return; settled = true; clearTimeout(timer); worker.terminate().catch(() => undefined); resolve({ ok: true, ms: 0 }); }); worker.once("error", (err) => { if (settled) return; settled = true; clearTimeout(timer); worker.terminate().catch(() => undefined); resolve({ ok: false, reason: "error", message: err.message }); }); }); } /** * Validate a stored/sent `validation` config for a question `type`. * Returns `{ valid: true }` or `{ valid: false, issue, message }` (the * `issue` is a stable code — e.g. `min_gt_max`, `duplicate_option`, * `PATTERN_TOO_COMPLEX` — safe for the API's `details[].issue`). * * `pattern` additionally gets the ReDoS probe (TASK-0106 §Security): the * compiled regex is executed against a 10k-char probe in a worker thread * with a 100 ms hard budget; patterns that exceed it (catastrophic * backtracking) are rejected with `PATTERN_TOO_COMPLEX`. */ export async function validateQuestionValidation( type: FormQuestionType, validation: unknown ): Promise { if (validation == null) return { valid: true }; const schema = QUESTION_VALIDATION_SCHEMAS[type]; const parsed = schema.safeParse(validation); if (!parsed.success) { const first = parsed.error.issues[0]; if (!first) return { valid: false, issue: "invalid", message: "validation failed" }; const code = (first as { code?: string }).code; return { valid: false, issue: typeof code === "string" ? code : "invalid", message: first.message }; } const cross = checkCrossField(type, parsed.data as ValidationRecord); if (!cross.valid) return cross; const candidate = parsed.data as ValidationRecord & { pattern?: string }; if (typeof candidate.pattern === "string" && candidate.pattern.length > 0) { let re: RegExp; try { re = new RegExp(candidate.pattern); } catch (err) { return { valid: false, issue: "invalid_pattern", message: `invalid regex: ${(err as Error).message}` }; } void re; // compile check only — the worker re-compiles against the probe const probe = await runPatternProbe(candidate.pattern); if (!probe.ok && probe.reason === "timeout") { return { valid: false, issue: FORM_ERROR_CODES.PATTERN_TOO_COMPLEX, message: `pattern exceeds the ${REDOS_PROBE_BUDGET_MS} ms ReDoS probe budget; use a linear-time pattern` }; } if (!probe.ok && probe.reason === "error") { return { valid: false, issue: "invalid_pattern", message: probe.message ?? "pattern probe failed" }; } if (!probe.ok && probe.reason === "unsupported") { // Non-Node host (should not happen server-side) — fail closed. return { valid: false, issue: "invalid_pattern", message: "pattern probe unavailable; refusing the pattern" }; } } return { valid: true }; } // --------------------------------------------------------------------------- // Form.config (branding) — TASK-0106 §7, TASK-0107 §4. // --------------------------------------------------------------------------- /** The 6 preset accent colors (the builder + public page CSS variables). */ export const FORM_ACCENTS = [ "#4f46e5", // indigo (default) "#059669", // emerald "#d97706", // amber "#dc2626", // red "#0284c7", // sky "#7c3aed" // violet ] as const; export type FormAccent = (typeof FORM_ACCENTS)[number]; export const FORM_CONFIG_SCHEMA = z .object({ /** Emoji (single codepoint cluster) or a Phase-9 file id (image icon). */ icon: z.string().max(32).nullable().optional(), /** Markdown intro, ≤ 2000 chars (sanitized server-side, TASK-0107). */ intro: z.string().max(2000).optional(), accent: z.enum(FORM_ACCENTS).optional(), successMessage: z.string().max(500).optional(), closedMessage: z.string().max(500).optional(), /** "Powered by AI Software Factory" footer (default true). */ poweredBy: z.boolean().optional(), /** reCAPTCHA stub (real integration is Phase 13+). */ recaptcha: z .object({ enabled: z.boolean().optional(), siteKey: z.string().max(128).optional() }) .strict() .optional() }) .strict(); export type FormConfig = z.infer; // --------------------------------------------------------------------------- // Public-safe validation subset (TASK-0107 §1) — the ONLY validation the // public endpoint may expose. `pattern` is NEVER included (business rules // run server-side only); minLength/maxLength/min/max/options are exposed for // client pre-validation. // --------------------------------------------------------------------------- export type PublicSafeValidation = { minLength?: number; maxLength?: number; min?: number; max?: number; options?: string[]; maxFiles?: number; }; /** Strip a stored validation config to the public-safe subset. */ export function toPublicSafeValidation(validation: unknown): PublicSafeValidation { const out: PublicSafeValidation = {}; if (validation == null || typeof validation !== "object") return out; const v = validation as Record; for (const key of ["minLength", "maxLength", "min", "max", "maxFiles"] as const) { if (typeof v[key] === "number" && Number.isFinite(v[key])) { (out as Record)[key] = v[key]; } } if (Array.isArray(v.options)) { out.options = v.options.filter((o): o is string => typeof o === "string"); } return out; }