/** * Submission API for the embeddable form widget (Phase 05 Task #58, layer 4). * * POST /api/embed/forms/:formKey/submit * * Body: `application/x-www-form-urlencoded` with a single `__payload` field * carrying a JSON string `{ values, turnstileToken? }`. The form sends data * this way so the standard `` POST works without CORS preflights. * * Pipeline: * 1. Resolve the form by public key (cuid). 404 when missing / disabled. * 2. Allow-list the Origin header against `form.allowedOrigins` (wildcards * via {@link matchAllowedOrigin}). Reject with 403 on mismatch. * 3. Honeypot — silent 204 when the bot-marker field is filled. * 4. Sliding-window rate-limit (10/min/IP, 100/min/origin) via the shared * Redis store (factory in `app/shared-store.server.ts`). * 5. Optional Cloudflare Turnstile verification, gated behind the * `form-widget-turnstile` feature flag. * 6. Validate payload against the stored form definition. * 7. Persist a {@link FormSubmission} row (status=received) AND publish a * `form.submission.process` outbox event so the worker can route the * payload to CRM-contact / entity-row / webhook targets. * * Response shape: `{ ok: true }` on success, `{ ok: false, error }` for the * client renderer. Honeypot-triggered + origin-blocked requests do NOT echo * the body (silent 204 / generic 403) so bots get minimal signal. */ import { createHash } from "node:crypto"; import { FormSubmissionPayloadSchema, FormDefinitionSchema, isHoneypotTriggered, matchAllowedOrigin, resolveSubmissionTarget, type FormDefinition, } from "@toolctrl/form-widget"; import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox"; import { createSlidingWindowRateLimiter } from "@toolctrl/shared-store"; import { db } from "~/utils/db.server"; import { getTranslations } from "~/locale/i18next.server"; import { getSharedStore } from "~/shared-store.server"; import { createScopedLogger } from "~/utils/logger.server"; import { evaluateFlagForContext } from "~/utils/services/.server/featureFlagEvaluator.server"; import { resolveCaptchaConfig, verifyCaptchaToken } from "@toolctrl/captcha"; const log = createScopedLogger("form-widget.submit"); const RATE_LIMIT_WINDOW_SECONDS = 60; const RATE_LIMIT_PER_IP = 10; const RATE_LIMIT_PER_ORIGIN = 100; // On a rate-limit store (Redis) outage, production fails closed so an attacker // cannot flood this unauthenticated endpoint by knocking the store offline. // Non-production fails open so a local Redis blip doesn't drop submissions. function shouldFailClosedOnRateLimitInfraFailure(): boolean { return process.env.NODE_ENV === "production"; } const PAYLOAD_BYTE_LIMIT = 256 * 1024; // 256 KiB hard cap on the form-encoded body type EmbedSubmitErrorCode = | "METHOD_NOT_ALLOWED" | "FORM_NOT_FOUND" | "PAYLOAD_TOO_LARGE" | "ORIGIN_NOT_ALLOWED" | "INVALID_PAYLOAD" | "INVALID_PAYLOAD_SHAPE" | "RATE_LIMIT_IP" | "RATE_LIMIT_ORIGIN" | "FORM_MISCONFIGURED" | "FIELD_REQUIRED" | "FIELD_TOO_LONG" | "FIELD_TOO_SHORT" | "FIELD_INVALID_EMAIL" | "TURNSTILE_FAILED"; type EmbedSubmitError = { ok: false; error: string; errorCode: EmbedSubmitErrorCode; errorKey: string; errorParams?: Record; }; const ERROR_KEYS: Record = { METHOD_NOT_ALLOWED: "embed.forms.submit.errors.methodNotAllowed", FORM_NOT_FOUND: "embed.forms.submit.errors.formNotFound", PAYLOAD_TOO_LARGE: "embed.forms.submit.errors.payloadTooLarge", ORIGIN_NOT_ALLOWED: "embed.forms.submit.errors.originNotAllowed", INVALID_PAYLOAD: "embed.forms.submit.errors.invalidPayload", INVALID_PAYLOAD_SHAPE: "embed.forms.submit.errors.invalidPayloadShape", RATE_LIMIT_IP: "embed.forms.submit.errors.rateLimitIp", RATE_LIMIT_ORIGIN: "embed.forms.submit.errors.rateLimitOrigin", FORM_MISCONFIGURED: "embed.forms.submit.errors.formMisconfigured", FIELD_REQUIRED: "embed.forms.submit.errors.fieldRequired", FIELD_TOO_LONG: "embed.forms.submit.errors.fieldTooLong", FIELD_TOO_SHORT: "embed.forms.submit.errors.fieldTooShort", FIELD_INVALID_EMAIL: "embed.forms.submit.errors.fieldInvalidEmail", TURNSTILE_FAILED: "embed.forms.submit.errors.turnstileFailed", }; function fallbackErrorMessage(code: EmbedSubmitErrorCode, params: Record = {}): string { const label = typeof params.fieldLabel === "string" ? params.fieldLabel : "This field"; switch (code) { case "METHOD_NOT_ALLOWED": return "Method not allowed"; case "FORM_NOT_FOUND": return "Form not found"; case "PAYLOAD_TOO_LARGE": return "Payload too large"; case "ORIGIN_NOT_ALLOWED": return "Origin not allowed"; case "INVALID_PAYLOAD": return "Invalid payload"; case "INVALID_PAYLOAD_SHAPE": return "Invalid payload shape"; case "RATE_LIMIT_IP": return "Too many submissions. Try again shortly."; case "RATE_LIMIT_ORIGIN": return "Too many submissions for this site."; case "FORM_MISCONFIGURED": return "Form is misconfigured"; case "FIELD_REQUIRED": return `${label} is required.`; case "FIELD_TOO_LONG": return `${label} is too long.`; case "FIELD_TOO_SHORT": return `${label} is too short.`; case "FIELD_INVALID_EMAIL": return `${label} must be a valid email.`; case "TURNSTILE_FAILED": return "Anti-spam check failed. Please retry."; } } function errorResponse( t: Awaited>["t"], code: EmbedSubmitErrorCode, status: number, params?: Record, init?: ResponseInit, ): Response { const errorKey = ERROR_KEYS[code]; const payload: EmbedSubmitError = { ok: false, error: t(errorKey, fallbackErrorMessage(code, params), params), errorCode: code, errorKey, ...(params ? { errorParams: params } : {}), }; return Response.json(payload, { ...init, status }); } function hashIp(ip: string | null): string | null { if (!ip) return null; return createHash("sha256").update(ip).digest("hex").slice(0, 32); } function clientIp(request: Request): string | null { const fwd = request.headers.get("x-forwarded-for"); if (fwd) return fwd.split(",")[0]?.trim() || null; return request.headers.get("x-real-ip"); } function validateAgainstDefinition(values: Record, def: FormDefinition): { code: EmbedSubmitErrorCode; params: Record } | null { for (const field of def.fields) { const raw = values[field.key]; if (field.type === "honeypot") continue; const params = { fieldKey: field.key, fieldLabel: field.label }; const filled = raw !== undefined && raw !== null && raw !== "" && raw !== false; if (field.required && !filled) { return { code: "FIELD_REQUIRED", params }; } if (typeof raw === "string") { if (field.maxLength !== undefined && raw.length > field.maxLength) { return { code: "FIELD_TOO_LONG", params: { ...params, maxLength: field.maxLength } }; } if (field.minLength !== undefined && raw.length < field.minLength && filled) { return { code: "FIELD_TOO_SHORT", params: { ...params, minLength: field.minLength } }; } if (field.type === "email" && filled && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) { return { code: "FIELD_INVALID_EMAIL", params }; } } } return null; } export async function action({ params, request }: { params: { formKey?: string }; request: Request }): Promise { const { t } = await getTranslations(request); if (request.method !== "POST") { return errorResponse(t, "METHOD_NOT_ALLOWED", 405, undefined, { headers: { Allow: "POST" } }); } const formKey = params.formKey; if (!formKey) return errorResponse(t, "FORM_NOT_FOUND", 404); const contentLength = Number(request.headers.get("content-length") ?? "0"); if (Number.isFinite(contentLength) && contentLength > PAYLOAD_BYTE_LIMIT) { return errorResponse(t, "PAYLOAD_TOO_LARGE", 413); } const form = await db.form.findUnique({ where: { key: formKey }, select: { id: true, tenantId: true, isEnabled: true, definition: true, allowedOrigins: true, targetType: true, targetEntityId: true, fieldMapping: true, config: true, webhookUrl: true, notifyEmails: true, }, }); if (!form || !form.isEnabled) { return errorResponse(t, "FORM_NOT_FOUND", 404); } // 2. Origin allowlist const origin = request.headers.get("origin"); if (!matchAllowedOrigin(origin, form.allowedOrigins)) { log.info({ op: "blocked.origin", formId: form.id, origin }, "Submission origin not allowlisted"); return errorResponse(t, "ORIGIN_NOT_ALLOWED", 403); } // 1. Parse body let payloadJson: unknown; try { const formBody = await request.formData(); const raw = formBody.get("__payload"); if (typeof raw !== "string") throw new Error("payload missing"); if (raw.length > PAYLOAD_BYTE_LIMIT) throw new Error("payload too large"); payloadJson = JSON.parse(raw); } catch (err) { return errorResponse(t, "INVALID_PAYLOAD", 400); } const parsed = FormSubmissionPayloadSchema.safeParse(payloadJson); if (!parsed.success) { return errorResponse(t, "INVALID_PAYLOAD_SHAPE", 400); } const { values, turnstileToken } = parsed.data; // 3. Honeypot — silent 204 to starve bot signal if (isHoneypotTriggered(values)) { log.info({ op: "blocked.honeypot", formId: form.id }, "Honeypot triggered — silent drop"); return new Response(null, { status: 204 }); } // 4. Rate-limit (per-IP and per-origin) const ip = clientIp(request); try { const store = getSharedStore(); const limiter = createSlidingWindowRateLimiter({ store, max: RATE_LIMIT_PER_IP, windowSeconds: RATE_LIMIT_WINDOW_SECONDS, prefix: "form-widget:rl", }); if (ip) { const decision = await limiter.consume(`ip:${ip}`); if (!decision.allowed) { return errorResponse(t, "RATE_LIMIT_IP", 429); } } if (origin) { const originLimiter = createSlidingWindowRateLimiter({ store, max: RATE_LIMIT_PER_ORIGIN, windowSeconds: RATE_LIMIT_WINDOW_SECONDS, prefix: "form-widget:rl-origin", }); const decision = await originLimiter.consume(`origin:${origin}`); if (!decision.allowed) { return errorResponse(t, "RATE_LIMIT_ORIGIN", 429); } } } catch (err) { log.warn({ op: "rate-limit.error", err: err instanceof Error ? err.message : String(err) }, "Rate-limit check failed"); if (shouldFailClosedOnRateLimitInfraFailure()) { // Production: refuse rather than allow unbounded submissions on this // unauthenticated endpoint while the rate-limit store is unavailable. return errorResponse(t, "RATE_LIMIT_IP", 503); } // Non-production: fail open so a local Redis blip doesn't drop submissions. } // 5. Validate against form definition const def = FormDefinitionSchema.safeParse(form.definition); if (!def.success) { log.error({ op: "form.misconfigured", formId: form.id }, "Stored definition failed schema"); return errorResponse(t, "FORM_MISCONFIGURED", 500); } const valueError = validateAgainstDefinition(values, def.data); if (valueError) { return errorResponse(t, valueError.code, 400, valueError.params); } // 6. Optional captcha gate (mCaptcha) const flag = await evaluateFlagForContext("form-widget-turnstile", { flagName: "form-widget-turnstile", tenantId: form.tenantId, userId: null, }); const cfg = (form.config ?? {}) as { turnstileEnabled?: boolean }; if (flag.enabled && cfg.turnstileEnabled === true) { const captcha = resolveCaptchaConfig(process.env); const ok = await verifyCaptchaToken({ token: turnstileToken, sitekey: captcha.sitekeys.forms, secret: captcha.accountSecret, instanceUrl: captcha.instanceUrl, failOpen: captcha.failOpen, logger: log, }); if (!ok) { return errorResponse(t, "TURNSTILE_FAILED", 403); } } // 7. Persist submission + publish outbox event atomically. // // The outbox row is committed in the same transaction as the FormSubmission // insert, so the worker (apps/worker/src/jobs/form-submission-route.ts) is // guaranteed to see a routable row when it claims the event. For // `targetType=generic` the worker no-ops; for `crm-contact` / `entity-row` // it materializes the actual entity write via the existing CRM / Rows APIs. const target = resolveSubmissionTarget({ targetType: form.targetType, targetEntityId: form.targetEntityId, fieldMapping: (form.fieldMapping ?? {}) as Record, }); const submission = await db.$transaction(async (tx) => { const created = await tx.formSubmission.create({ data: { formId: form.id, tenantId: form.tenantId, payload: { values } as object, sourceOrigin: origin ?? null, ipHash: hashIp(ip), userAgent: request.headers.get("user-agent")?.slice(0, 500) ?? null, status: "received", result: { target: target.type } as object, }, select: { id: true }, }); const outboxPayload = { submissionId: created.id, formId: form.id, tenantId: form.tenantId, targetType: target.type, targetEntityId: target.entityId, fieldMapping: { ...target.fieldMapping }, webhookUrl: form.webhookUrl ?? null, notifyEmails: form.notifyEmails, values, } as unknown as Parameters[1]["payload"]; await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, { tenantId: form.tenantId, type: "form.submission.process", payload: outboxPayload, dispatchKey: `form-submission:${created.id}`, }); return created; }); log.info({ op: "submission.received", formId: form.id, submissionId: submission.id, target: target.type }, "Submission accepted"); return Response.json({ ok: true }); }