Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/api.embed.forms.$formKey.submit.tsx
T

378 lines
14 KiB
TypeScript

/**
* 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 `<fetcher.Form>` 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<string, string | number | boolean>;
};
const ERROR_KEYS: Record<EmbedSubmitErrorCode, string> = {
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, string | number | boolean> = {}): 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<ReturnType<typeof getTranslations>>["t"],
code: EmbedSubmitErrorCode,
status: number,
params?: Record<string, string | number | boolean>,
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<string, unknown>, def: FormDefinition): { code: EmbedSubmitErrorCode; params: Record<string, string | number | boolean> } | 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<Response> {
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<string, string>,
});
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<typeof publishOutboxEvent>[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 });
}