1
12import { z } from "zod";
13
14
15
16
17
18export const FORM_QUESTION_TYPES = [
19 "SHORT_TEXT",
20 "LONG_TEXT",
21 "NUMBER",
22 "EMAIL",
23 "DATE",
24 "CHECKBOX",
25 "SINGLE_SELECT",
26 "MULTI_SELECT",
27 "RATING",
28 "FILE"
29] as const;
30export type FormQuestionType = (typeof FORM_QUESTION_TYPES)[number];
31
32export const FORM_STATUSES = ["DRAFT", "PUBLISHED", "CLOSED"] as const;
33export type FormStatus = (typeof FORM_STATUSES)[number];
34
35
36export const DB_PROPERTY_TYPES = [
37 "TITLE", "TEXT", "RICH_TEXT", "NUMBER", "SELECT", "MULTI_SELECT", "STATUS",
38 "DATE", "DATE_RANGE", "PERSON", "EMAIL", "PHONE", "URL", "CHECKBOX", "FILES",
39 "CREATED_TIME", "CREATED_BY", "LAST_EDITED_TIME", "LAST_EDITED_BY", "RELATION",
40 "ROLLUP", "FORMULA", "BUTTON", "UNIQUE_ID"
41] as const;
42export type DbPropertyType = (typeof DB_PROPERTY_TYPES)[number];
43
44
45
46
47
48
49
50
51
52export const QUESTION_PROPERTY_COMPATIBILITY: Record<FormQuestionType, readonly DbPropertyType[]> = {
53 SHORT_TEXT: ["TITLE", "TEXT", "RICH_TEXT", "EMAIL", "PHONE", "URL"],
54 LONG_TEXT: ["RICH_TEXT", "TEXT"],
55 NUMBER: ["NUMBER"],
56 EMAIL: ["EMAIL"],
57 DATE: ["DATE"],
58 CHECKBOX: ["CHECKBOX"],
59 SINGLE_SELECT: ["SELECT", "STATUS"],
60 MULTI_SELECT: ["MULTI_SELECT"],
61 RATING: ["NUMBER"],
62 FILE: ["FILES"]
63};
64
65
66export function compatiblePropertyTypes(type: FormQuestionType): readonly DbPropertyType[] {
67 return QUESTION_PROPERTY_COMPATIBILITY[type] ?? [];
68}
69
70
71export function isCompatibleMapping(type: FormQuestionType, propType: DbPropertyType): boolean {
72 return compatiblePropertyTypes(type).includes(propType);
73}
74
75
76
77
78
79
80
81
82
83
84export const FORM_ERROR_CODES = {
85 PATTERN_TOO_COMPLEX: "PATTERN_TOO_COMPLEX",
86 PROPERTY_TYPE_MISMATCH: "PROPERTY_TYPE_MISMATCH",
87 FORM_CLOSED: "FORM_CLOSED",
88 FORM_CONFIG_INVALID: "FORM_CONFIG_INVALID",
89 FILE_NOT_READY: "FILE_NOT_READY",
90 FILE_NOT_OWNED: "FILE_NOT_OWNED",
91 CONFIRM_REQUIRED: "CONFIRM_REQUIRED",
92 PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
93 NOT_IMPLEMENTED_YET: "NOT_IMPLEMENTED_YET",
94 NO_QUESTIONS: "NO_QUESTIONS",
95 UNKNOWN_PROPERTY: "UNKNOWN_PROPERTY",
96 SLUG_TAKEN: "SLUG_TAKEN"
97} as const;
98export type FormErrorCode = (typeof FORM_ERROR_CODES)[keyof typeof FORM_ERROR_CODES];
99
100const textValidation = z.object({
101 minLength: z.number().int().min(0).max(10_000).optional(),
102 maxLength: z.number().int().min(1).max(100_000).optional(),
103 pattern: z.string().min(1).max(200).optional()
104}).strict();
105
106const numberValidation = z
107 .object({
108 min: z.number().optional(),
109 max: z.number().optional(),
110 integer: z.boolean().optional()
111 })
112 .strict();
113
114const ratingValidation = z
115 .object({
116 max: z.number().int().min(5).max(10),
117 default: z.number().int().min(1).max(10).optional()
118 })
119 .strict();
120
121const optionsArray = z.array(z.string().min(1).max(50)).min(1).max(50);
122
123const selectValidation = z.object({ options: optionsArray }).strict();
124
125const dateValidation = z
126 .object({
127 minDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "ISO date (YYYY-MM-DD)").optional(),
128 maxDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "ISO date (YYYY-MM-DD)").optional()
129 })
130 .strict();
131
132const fileValidation = z
133 .object({
134 maxFiles: z.number().int().min(1).max(5).optional(),
135 maxBytes: z.number().int().min(1).max(500 * 1024 * 1024).optional()
136 })
137 .strict();
138
139
140export const QUESTION_VALIDATION_SCHEMAS: Record<
141 FormQuestionType,
142 z.ZodTypeAny
143> = {
144 SHORT_TEXT: textValidation,
145 LONG_TEXT: textValidation,
146 NUMBER: numberValidation,
147 EMAIL: z.object({}).strict(),
148 DATE: dateValidation,
149 CHECKBOX: z.object({}).strict(),
150 SINGLE_SELECT: selectValidation,
151 MULTI_SELECT: selectValidation,
152 RATING: ratingValidation,
153 FILE: fileValidation
154};
155
156export interface ValidationCheckResult {
157 valid: boolean;
158
159 issue?: string;
160 message?: string;
161}
162
163type ValidationRecord = Record<string, unknown>;
164
165
166function checkCrossField(type: FormQuestionType, v: ValidationRecord): ValidationCheckResult {
167 switch (type) {
168 case "SHORT_TEXT":
169 case "LONG_TEXT": {
170 const min = (v.minLength as number | undefined) ?? 0;
171 const max = (v.maxLength as number | undefined) ?? Number.POSITIVE_INFINITY;
172 if (min > max) return { valid: false, issue: "min_gt_max", message: "minLength must be ≤ maxLength" };
173 return { valid: true };
174 }
175 case "NUMBER": {
176 const min = (v.min as number | undefined) ?? Number.NEGATIVE_INFINITY;
177 const max = (v.max as number | undefined) ?? Number.POSITIVE_INFINITY;
178 if (min > max) return { valid: false, issue: "min_gt_max", message: "min must be ≤ max" };
179 return { valid: true };
180 }
181 case "RATING": {
182 const max = v.max as number;
183 const def = (v.default as number | undefined) ?? 1;
184 if (def > max) return { valid: false, issue: "min_gt_max", message: "default must be ≤ max" };
185 return { valid: true };
186 }
187 case "SINGLE_SELECT":
188 case "MULTI_SELECT": {
189 const options = v.options as string[];
190 if (new Set(options).size !== options.length) {
191 return { valid: false, issue: "duplicate_option", message: "options must be unique" };
192 }
193 return { valid: true };
194 }
195 case "DATE": {
196 const minDate = v.minDate as string | undefined;
197 const maxDate = v.maxDate as string | undefined;
198 if (minDate && maxDate && minDate > maxDate) {
199 return { valid: false, issue: "min_gt_max", message: "minDate must be ≤ maxDate" };
200 }
201 return { valid: true };
202 }
203 default:
204 return { valid: true };
205 }
206}
207
208
214interface ProbeWorker {
215 terminate(): Promise<number | undefined>;
216 once(event: "message", listener: () => void): ProbeWorker;
217 once(event: "error", listener: (err: Error) => void): ProbeWorker;
218}
219
220
221const REDOS_PROBE = "a".repeat(10_000) + "b";
222
223const REDOS_PROBE_BUDGET_MS = 100;
224
225
235async function runPatternProbe(
236 pattern: string,
237 budgetMs: number = REDOS_PROBE_BUDGET_MS,
238 probe: string = REDOS_PROBE
239): Promise<{ ok: true; ms: number } | { ok: false; reason: "timeout" | "error" | "unsupported"; message?: string }> {
240 try {
241
242
243 const mod = (await import("node:worker_threads")) as {
244 Worker: new (code: string, opts: { eval: boolean }) => ProbeWorker;
245 };
246 const WorkerCtor = mod.Worker;
247 const source =
248 `const { parentPort } = require("node:worker_threads");` +
249 `const re = new RegExp(${JSON.stringify(pattern)});` +
250 `re.exec(${JSON.stringify(probe)});` +
251 `parentPort.postMessage("done");`;
252 return await runWorkerProbe(WorkerCtor, source, budgetMs);
253 } catch {
254 return { ok: false, reason: "unsupported", message: "node:worker_threads unavailable" };
255 }
256}
257
258async function runWorkerProbe(
259 WorkerCtor: new (code: string, opts: { eval: boolean }) => ProbeWorker,
260 source: string,
261 budgetMs: number
262): Promise<{ ok: true; ms: number } | { ok: false; reason: "timeout" | "error"; message?: string }> {
263 return await new Promise((resolve) => {
264 let settled = false;
265 let worker: ProbeWorker;
266 try {
267 worker = new WorkerCtor(source, { eval: true });
268 } catch (err) {
269 return resolve({ ok: false, reason: "error", message: (err as Error).message });
270 }
271 const timer = setTimeout(() => {
272 if (settled) return;
273 settled = true;
274 worker.terminate().catch(() => undefined);
275 resolve({ ok: false, reason: "timeout" });
276 }, budgetMs);
277 worker.once("message", () => {
278 if (settled) return;
279 settled = true;
280 clearTimeout(timer);
281 worker.terminate().catch(() => undefined);
282 resolve({ ok: true, ms: 0 });
283 });
284 worker.once("error", (err) => {
285 if (settled) return;
286 settled = true;
287 clearTimeout(timer);
288 worker.terminate().catch(() => undefined);
289 resolve({ ok: false, reason: "error", message: err.message });
290 });
291 });
292}
293
294
305export async function validateQuestionValidation(
306 type: FormQuestionType,
307 validation: unknown
308): Promise<ValidationCheckResult> {
309 if (validation == null) return { valid: true };
310 const schema = QUESTION_VALIDATION_SCHEMAS[type];
311 const parsed = schema.safeParse(validation);
312 if (!parsed.success) {
313 const first = parsed.error.issues[0];
314 if (!first) return { valid: false, issue: "invalid", message: "validation failed" };
315 const code = (first as { code?: string }).code;
316 return { valid: false, issue: typeof code === "string" ? code : "invalid", message: first.message };
317 }
318 const cross = checkCrossField(type, parsed.data as ValidationRecord);
319 if (!cross.valid) return cross;
320 const candidate = parsed.data as ValidationRecord & { pattern?: string };
321 if (typeof candidate.pattern === "string" && candidate.pattern.length > 0) {
322 let re: RegExp;
323 try {
324 re = new RegExp(candidate.pattern);
325 } catch (err) {
326 return { valid: false, issue: "invalid_pattern", message: `invalid regex: ${(err as Error).message}` };
327 }
328 void re;
329 const probe = await runPatternProbe(candidate.pattern);
330 if (!probe.ok && probe.reason === "timeout") {
331 return {
332 valid: false,
333 issue: FORM_ERROR_CODES.PATTERN_TOO_COMPLEX,
334 message: `pattern exceeds the ${REDOS_PROBE_BUDGET_MS} ms ReDoS probe budget; use a linear-time pattern`
335 };
336 }
337 if (!probe.ok && probe.reason === "error") {
338 return { valid: false, issue: "invalid_pattern", message: probe.message ?? "pattern probe failed" };
339 }
340 if (!probe.ok && probe.reason === "unsupported") {
341
342 return { valid: false, issue: "invalid_pattern", message: "pattern probe unavailable; refusing the pattern" };
343 }
344 }
345 return { valid: true };
346}
347
348
349
350
351
352
353export const FORM_ACCENTS = [
354 "#4f46e5",
355 "#059669",
356 "#d97706",
357 "#dc2626",
358 "#0284c7",
359 "#7c3aed"
360] as const;
361export type FormAccent = (typeof FORM_ACCENTS)[number];
362
363export const FORM_CONFIG_SCHEMA = z
364 .object({
365
366 icon: z.string().max(32).nullable().optional(),
367
368 intro: z.string().max(2000).optional(),
369 accent: z.enum(FORM_ACCENTS).optional(),
370 successMessage: z.string().max(500).optional(),
371 closedMessage: z.string().max(500).optional(),
372
373 poweredBy: z.boolean().optional(),
374
375 recaptcha: z
376 .object({
377 enabled: z.boolean().optional(),
378 siteKey: z.string().max(128).optional()
379 })
380 .strict()
381 .optional()
382 })
383 .strict();
384
385export type FormConfig = z.infer<typeof FORM_CONFIG_SCHEMA>;
386
387
388
389
390
391
392
393
394export type PublicSafeValidation = {
395 minLength?: number;
396 maxLength?: number;
397 min?: number;
398 max?: number;
399 options?: string[];
400 maxFiles?: number;
401};
402
403
404export function toPublicSafeValidation(validation: unknown): PublicSafeValidation {
405 const out: PublicSafeValidation = {};
406 if (validation == null || typeof validation !== "object") return out;
407 const v = validation as Record<string, unknown>;
408 for (const key of ["minLength", "maxLength", "min", "max", "maxFiles"] as const) {
409 if (typeof v[key] === "number" && Number.isFinite(v[key])) {
410 (out as Record<string, unknown>)[key] = v[key];
411 }
412 }
413 if (Array.isArray(v.options)) {
414 out.options = v.options.filter((o): o is string => typeof o === "string");
415 }
416 return out;
417}
418