This commit is contained in:
2026-07-31 23:21:51 +02:00
parent f6b4b36df8
commit f14f2cfd98
294 changed files with 5880 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
import type { Plugin } from 'payload';
import type { FormsOption } from './types.js';
/**
* Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.
*
* Provides the form/form-submissions collections and field types. Email
* delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel
* sender (submitForm) does that, so the form-builder's built-in email (which
* needs a Payload email adapter) is left unused.
*/
export declare function buildFormsPlugin(forms: FormsOption): Plugin;
+37
View File
@@ -0,0 +1,37 @@
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder';
/**
* Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.
*
* Provides the form/form-submissions collections and field types. Email
* delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel
* sender (submitForm) does that, so the form-builder's built-in email (which
* needs a Payload email adapter) is left unused.
*/ export function buildFormsPlugin(forms) {
return formBuilderPlugin({
fields: {
checkbox: true,
email: true,
message: true,
number: true,
payment: false,
select: true,
text: true,
textarea: true,
...forms.fields
},
...forms.redirectRelationships ? {
redirectRelationships: forms.redirectRelationships
} : {},
// Client-supplied collection overrides (e.g. a per-form notification
// address). The plugin provides the hook, not the opinion about which
// extra fields a form should carry.
...forms.formOverrides ? {
formOverrides: forms.formOverrides
} : {},
...forms.formSubmissionOverrides ? {
formSubmissionOverrides: forms.formSubmissionOverrides
} : {}
});
}
//# sourceMappingURL=formsPluginConfig.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/forms/formsPluginConfig.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { formBuilderPlugin } from '@payloadcms/plugin-form-builder'\n\nimport type { FormsOption } from './types.js'\n\n/**\n * Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.\n *\n * Provides the form/form-submissions collections and field types. Email\n * delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel\n * sender (submitForm) does that, so the form-builder's built-in email (which\n * needs a Payload email adapter) is left unused.\n */\nexport function buildFormsPlugin(forms: FormsOption): Plugin {\n return formBuilderPlugin({\n fields: {\n checkbox: true,\n email: true,\n message: true,\n number: true,\n payment: false,\n select: true,\n text: true,\n textarea: true,\n ...forms.fields,\n },\n ...(forms.redirectRelationships ? { redirectRelationships: forms.redirectRelationships } : {}),\n // Client-supplied collection overrides (e.g. a per-form notification\n // address). The plugin provides the hook, not the opinion about which\n // extra fields a form should carry.\n ...(forms.formOverrides ? { formOverrides: forms.formOverrides } : {}),\n ...(forms.formSubmissionOverrides\n ? { formSubmissionOverrides: forms.formSubmissionOverrides }\n : {}),\n })\n}\n"],"names":["formBuilderPlugin","buildFormsPlugin","forms","fields","checkbox","email","message","number","payment","select","text","textarea","redirectRelationships","formOverrides","formSubmissionOverrides"],"mappings":"AAEA,SAASA,iBAAiB,QAAQ,kCAAiC;AAInE;;;;;;;CAOC,GACD,OAAO,SAASC,iBAAiBC,KAAkB;IACjD,OAAOF,kBAAkB;QACvBG,QAAQ;YACNC,UAAU;YACVC,OAAO;YACPC,SAAS;YACTC,QAAQ;YACRC,SAAS;YACTC,QAAQ;YACRC,MAAM;YACNC,UAAU;YACV,GAAGT,MAAMC,MAAM;QACjB;QACA,GAAID,MAAMU,qBAAqB,GAAG;YAAEA,uBAAuBV,MAAMU,qBAAqB;QAAC,IAAI,CAAC,CAAC;QAC7F,qEAAqE;QACrE,sEAAsE;QACtE,oCAAoC;QACpC,GAAIV,MAAMW,aAAa,GAAG;YAAEA,eAAeX,MAAMW,aAAa;QAAC,IAAI,CAAC,CAAC;QACrE,GAAIX,MAAMY,uBAAuB,GAC7B;YAAEA,yBAAyBZ,MAAMY,uBAAuB;QAAC,IACzD,CAAC,CAAC;IACR;AACF"}
+8
View File
@@ -0,0 +1,8 @@
export { buildFormsPlugin } from './formsPluginConfig.js';
export { checkRateLimit } from './rateLimit.js';
export type { RateLimitArgs } from './rateLimit.js';
export { submitForm } from './submitForm.js';
export type { SubmitFormArgs, SubmitFormResult } from './submitForm.js';
export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js';
export { validateSubmission } from './validateSubmission.js';
export type { FormValidationResult } from './validateSubmission.js';
+8
View File
@@ -0,0 +1,8 @@
// buildFormsPlugin is config-time (safe anywhere). submitForm is server-only
// (Turnstile secret) — never import it from a client component.
export { buildFormsPlugin } from './formsPluginConfig.js';
export { checkRateLimit } from './rateLimit.js';
export { submitForm } from './submitForm.js';
export { validateSubmission } from './validateSubmission.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/forms/index.ts"],"sourcesContent":["// buildFormsPlugin is config-time (safe anywhere). submitForm is server-only\n// (Turnstile secret) — never import it from a client component.\nexport { buildFormsPlugin } from './formsPluginConfig.js'\nexport { checkRateLimit } from './rateLimit.js'\nexport type { RateLimitArgs } from './rateLimit.js'\nexport { submitForm } from './submitForm.js'\nexport type { SubmitFormArgs, SubmitFormResult } from './submitForm.js'\nexport type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js'\nexport { validateSubmission } from './validateSubmission.js'\nexport type { FormValidationResult } from './validateSubmission.js'\n"],"names":["buildFormsPlugin","checkRateLimit","submitForm","validateSubmission"],"mappings":"AAAA,6EAA6E;AAC7E,gEAAgE;AAChE,SAASA,gBAAgB,QAAQ,yBAAwB;AACzD,SAASC,cAAc,QAAQ,iBAAgB;AAE/C,SAASC,UAAU,QAAQ,kBAAiB;AAG5C,SAASC,kBAAkB,QAAQ,0BAAyB"}
+14
View File
@@ -0,0 +1,14 @@
export type RateLimitArgs = {
/** Identifier to limit on — typically the client IP. */
key: string;
/** Max submissions allowed per window. Defaults to 5. */
max?: number;
/** Window length in ms. Defaults to 60_000 (one minute). */
windowMs?: number;
};
/**
* Returns true when the request is within the limit, false when it should be
* rejected. A missing key (no IP) is allowed through — better to accept a
* submission than to block everyone behind a proxy that strips the header.
*/
export declare function checkRateLimit({ key, max, windowMs }: RateLimitArgs): boolean;
+51
View File
@@ -0,0 +1,51 @@
/**
* Per-IP sliding window, in memory.
*
* Deliberately simple: no Redis, no dependency. The trade-off is that the
* counter lives in one process — with several instances behind a load balancer
* each keeps its own, so the effective limit is per-instance, not global. For a
* contact form that's fine (it raises the cost of flooding without pretending
* to be airtight); a high-security form should put a real limiter in front.
*
* State is module-level, so it survives between requests but resets on redeploy
* — acceptable for abuse throttling.
*/ const buckets = new Map();
/** Sweep expired buckets occasionally so the map doesn't grow unbounded. */ let lastSweep = Date.now();
const SWEEP_INTERVAL = 60_000;
function sweep(now) {
if (now - lastSweep < SWEEP_INTERVAL) {
return;
}
lastSweep = now;
for (const [key, bucket] of buckets){
if (bucket.resetAt <= now) {
buckets.delete(key);
}
}
}
/**
* Returns true when the request is within the limit, false when it should be
* rejected. A missing key (no IP) is allowed through — better to accept a
* submission than to block everyone behind a proxy that strips the header.
*/ export function checkRateLimit({ key, max = 5, windowMs = 60_000 }) {
if (!key) {
return true;
}
const now = Date.now();
sweep(now);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, {
count: 1,
resetAt: now + windowMs
});
return true;
}
if (bucket.count >= max) {
return false;
}
bucket.count += 1;
return true;
}
//# sourceMappingURL=rateLimit.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/forms/rateLimit.ts"],"sourcesContent":["type Bucket = { count: number; resetAt: number }\n\n/**\n * Per-IP sliding window, in memory.\n *\n * Deliberately simple: no Redis, no dependency. The trade-off is that the\n * counter lives in one process — with several instances behind a load balancer\n * each keeps its own, so the effective limit is per-instance, not global. For a\n * contact form that's fine (it raises the cost of flooding without pretending\n * to be airtight); a high-security form should put a real limiter in front.\n *\n * State is module-level, so it survives between requests but resets on redeploy\n * — acceptable for abuse throttling.\n */\nconst buckets = new Map<string, Bucket>()\n\n/** Sweep expired buckets occasionally so the map doesn't grow unbounded. */\nlet lastSweep = Date.now()\nconst SWEEP_INTERVAL = 60_000\n\nfunction sweep(now: number) {\n if (now - lastSweep < SWEEP_INTERVAL) {return}\n lastSweep = now\n for (const [key, bucket] of buckets) {\n if (bucket.resetAt <= now) {buckets.delete(key)}\n }\n}\n\nexport type RateLimitArgs = {\n /** Identifier to limit on — typically the client IP. */\n key: string\n /** Max submissions allowed per window. Defaults to 5. */\n max?: number\n /** Window length in ms. Defaults to 60_000 (one minute). */\n windowMs?: number\n}\n\n/**\n * Returns true when the request is within the limit, false when it should be\n * rejected. A missing key (no IP) is allowed through — better to accept a\n * submission than to block everyone behind a proxy that strips the header.\n */\nexport function checkRateLimit({ key, max = 5, windowMs = 60_000 }: RateLimitArgs): boolean {\n if (!key) {return true}\n\n const now = Date.now()\n sweep(now)\n\n const bucket = buckets.get(key)\n\n if (!bucket || bucket.resetAt <= now) {\n buckets.set(key, { count: 1, resetAt: now + windowMs })\n return true\n }\n\n if (bucket.count >= max) {\n return false\n }\n\n bucket.count += 1\n return true\n}\n"],"names":["buckets","Map","lastSweep","Date","now","SWEEP_INTERVAL","sweep","key","bucket","resetAt","delete","checkRateLimit","max","windowMs","get","set","count"],"mappings":"AAEA;;;;;;;;;;;CAWC,GACD,MAAMA,UAAU,IAAIC;AAEpB,0EAA0E,GAC1E,IAAIC,YAAYC,KAAKC,GAAG;AACxB,MAAMC,iBAAiB;AAEvB,SAASC,MAAMF,GAAW;IACxB,IAAIA,MAAMF,YAAYG,gBAAgB;QAAC;IAAM;IAC7CH,YAAYE;IACZ,KAAK,MAAM,CAACG,KAAKC,OAAO,IAAIR,QAAS;QACnC,IAAIQ,OAAOC,OAAO,IAAIL,KAAK;YAACJ,QAAQU,MAAM,CAACH;QAAI;IACjD;AACF;AAWA;;;;CAIC,GACD,OAAO,SAASI,eAAe,EAAEJ,GAAG,EAAEK,MAAM,CAAC,EAAEC,WAAW,MAAM,EAAiB;IAC/E,IAAI,CAACN,KAAK;QAAC,OAAO;IAAI;IAEtB,MAAMH,MAAMD,KAAKC,GAAG;IACpBE,MAAMF;IAEN,MAAMI,SAASR,QAAQc,GAAG,CAACP;IAE3B,IAAI,CAACC,UAAUA,OAAOC,OAAO,IAAIL,KAAK;QACpCJ,QAAQe,GAAG,CAACR,KAAK;YAAES,OAAO;YAAGP,SAASL,MAAMS;QAAS;QACrD,OAAO;IACT;IAEA,IAAIL,OAAOQ,KAAK,IAAIJ,KAAK;QACvB,OAAO;IACT;IAEAJ,OAAOQ,KAAK,IAAI;IAChB,OAAO;AACT"}
+72
View File
@@ -0,0 +1,72 @@
import 'server-only';
import type { BasePayload } from 'payload';
export type SubmitFormArgs = {
/** Submitted field data — shape matches the form's fields. */
data: Record<string, unknown>;
/** Form-builder form ID this submission belongs to. */
formId: string;
/** Client IP — used for Turnstile and rate limiting. */
ip?: string;
/**
* Rate limit: max submissions per IP per minute. Defaults to 5.
* Set to 0 to disable (e.g. when a real limiter sits in front).
*/
maxPerMinute?: number;
payload: BasePayload;
/** Turnstile token; when present it is verified, when absent it is skipped. */
turnstileToken?: string;
};
/**
* Why a submission failed — a code, never a user-facing string.
*
* The plugin knows what went wrong; it deliberately doesn't decide how to say
* it. The frontend maps these to its own copy, in its own language, and renders
* whatever component fits — a field error, a toast, a full message. The plugin
* has no business choosing the wording or the locale.
*
* - `rate_limited` — too many submissions from this IP
* - `turnstile` — bot check failed
* - `validation` — a field is missing/too long, or unknown keys were sent;
* `field` and `kind` narrow it down when a specific field is
* at fault (absent for whole-payload problems like unknown keys)
* - `not_found` — no form with this id
* - `error` — persistence failed unexpectedly
*/
export type SubmitFailure = {
/** The offending field's name, when one field is at fault. */
field?: string;
/** What was wrong with it. */
kind?: 'required' | 'too_long' | 'unknown_fields';
reason: 'validation';
success: false;
} | {
reason: 'error';
success: false;
} | {
reason: 'not_found';
success: false;
} | {
reason: 'rate_limited';
success: false;
} | {
reason: 'turnstile';
success: false;
};
export type SubmitFormResult = {
submissionId: number | string;
success: true;
} | SubmitFailure;
/**
* Handles a form submission end to end: rate limit, verify Turnstile, validate
* against the form's own schema, then store.
*
* The order is cost-ascending on purpose — the cheapest checks reject first, so
* a flood never reaches Turnstile's network call or the database.
*
* Emails aren't sent here. The form-builder sends whatever an editor configured
* under the form's "Emails" tab (form-submissions hook → payload.sendEmail),
* which goes out over panelSmtpAdapter. Storing the submission is enough.
*
* server-only: touches the Turnstile secret.
*/
export declare function submitForm({ data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise<SubmitFormResult>;
+91
View File
@@ -0,0 +1,91 @@
import 'server-only';
import { verifyTurnstile } from '../turnstile/index.js';
import { checkRateLimit } from './rateLimit.js';
import { validateSubmission } from './validateSubmission.js';
/**
* Handles a form submission end to end: rate limit, verify Turnstile, validate
* against the form's own schema, then store.
*
* The order is cost-ascending on purpose — the cheapest checks reject first, so
* a flood never reaches Turnstile's network call or the database.
*
* Emails aren't sent here. The form-builder sends whatever an editor configured
* under the form's "Emails" tab (form-submissions hook → payload.sendEmail),
* which goes out over panelSmtpAdapter. Storing the submission is enough.
*
* server-only: touches the Turnstile secret.
*/ export async function submitForm({ data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) {
// 1. Rate limit — cheapest gate, drops a flood before any real work.
if (maxPerMinute > 0 && ip) {
if (!checkRateLimit({
key: ip,
max: maxPerMinute
})) {
return {
reason: 'rate_limited',
success: false
};
}
}
// 2. Turnstile — verify when a token is supplied; reject on failure.
if (turnstileToken !== undefined) {
const ok = await verifyTurnstile({
ip,
payload,
token: turnstileToken
});
if (!ok) {
return {
reason: 'turnstile',
success: false
};
}
}
// 3. Validate against the form's schema. A public endpoint can't trust the
// shape of `data` — drop unknown keys, enforce required, cap length.
const validation = await validateSubmission(payload, formId, data);
if (!validation.ok) {
if (validation.reason === 'not_found') {
return {
reason: 'not_found',
success: false
};
}
return {
reason: 'validation',
success: false,
...validation.field ? {
field: validation.field
} : {},
...validation.kind ? {
kind: validation.kind
} : {}
};
}
// 4. Persist (form-builder shape: submissionData array). Triggers the email
// hook. Only validated, known fields are stored.
try {
const submission = await payload.create({
collection: 'form-submissions',
data: {
form: formId,
submissionData: Object.entries(validation.cleaned).map(([field, value])=>({
field,
value: value == null ? '' : String(value)
}))
}
});
return {
submissionId: submission.id,
success: true
};
} catch (err) {
payload.logger.error(`[ipal] Form submission failed: ${err.message}`);
return {
reason: 'error',
success: false
};
}
}
//# sourceMappingURL=submitForm.js.map
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
import type { CollectionConfig, Field } from 'payload';
/**
* Receives the collection's default fields and returns the final list — add,
* remove, or reorder. Same shape the form-builder uses.
*/
export type FormsFieldsOverride = (args: {
defaultFields: Field[];
}) => Field[];
/**
* Overrides for a forms-related collection: replace the fields and/or any
* other collection setting (admin, access, hooks…).
*/
export type FormsCollectionOverrides = {
fields?: FormsFieldsOverride;
} & Partial<Omit<CollectionConfig, 'fields'>>;
/**
* Forms configuration — mirrors the fields a client enables in the
* form-builder plugin. Kept minimal; the plugin passes these through.
*/
export type FormsOption = {
/** Field types available in the form builder. Sensible defaults applied. */
fields?: {
checkbox?: boolean;
email?: boolean;
message?: boolean;
number?: boolean;
payment?: boolean;
select?: boolean;
text?: boolean;
textarea?: boolean;
};
/**
* Override the forms collection. The plugin stays opinion-free about what a
* form needs beyond its fields — a client that wants, say, a per-form
* notification address adds it here:
*
* formOverrides: {
* fields: ({ defaultFields }) => [
* ...defaultFields,
* { name: 'notificationEmail', type: 'email' },
* ],
* }
*/
formOverrides?: FormsCollectionOverrides;
/** Override the form-submissions collection (same shape). */
formSubmissionOverrides?: FormsCollectionOverrides;
/** Collections a form can redirect to (e.g. ['pages']). */
redirectRelationships?: string[];
};
+6
View File
@@ -0,0 +1,6 @@
/**
* Forms configuration — mirrors the fields a client enables in the
* form-builder plugin. Kept minimal; the plugin passes these through.
*/ export { };
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WA6BC"}
+48
View File
@@ -0,0 +1,48 @@
import type { BasePayload } from 'payload';
/** A form-builder field, trimmed to what validation needs. */
type FormField = {
blockType?: string;
label?: string;
name?: string;
required?: boolean | null;
};
type FormDoc = {
fields?: FormField[];
id: number | string;
/** Per-form notification address, when the client added the field. */
notificationEmail?: string;
title?: string;
};
/**
* Validation outcome — codes, not user-facing strings. The frontend turns these
* into its own copy (see SubmitFailure in submitForm).
*/
export type FormValidationResult = {
/** Offending field, when a single field is at fault. */
field?: string;
kind: 'required' | 'too_long' | 'unknown_fields';
ok: false;
reason: 'invalid';
} | {
cleaned: Record<string, unknown>;
form: FormDoc;
ok: true;
} | {
ok: false;
reason: 'not_found';
};
/**
* Checks submitted data against the form's own definition, rather than trusting
* whatever arrived.
*
* The server action is a public endpoint: a caller can skip the rendered form
* and post arbitrary keys. Without this, unknown fields would be stored,
* required fields could be missing, and an oversized value could sail through.
* So we load the form, keep only keys that are real fields, reject when a
* required one is blank, and cap length.
*
* Returns the loaded form on success so the caller doesn't fetch it twice, and
* a code + offending field on failure so the frontend can point at it.
*/
export declare function validateSubmission(payload: BasePayload, formId: string, data: Record<string, unknown>): Promise<FormValidationResult>;
export {};
+79
View File
@@ -0,0 +1,79 @@
/** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set([
'message'
]);
/** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000;
/**
* Checks submitted data against the form's own definition, rather than trusting
* whatever arrived.
*
* The server action is a public endpoint: a caller can skip the rendered form
* and post arbitrary keys. Without this, unknown fields would be stored,
* required fields could be missing, and an oversized value could sail through.
* So we load the form, keep only keys that are real fields, reject when a
* required one is blank, and cap length.
*
* Returns the loaded form on success so the caller doesn't fetch it twice, and
* a code + offending field on failure so the frontend can point at it.
*/ export async function validateSubmission(payload, formId, data) {
let form;
try {
form = await payload.findByID({
id: formId,
collection: 'forms',
depth: 0
});
} catch {
return {
ok: false,
reason: 'not_found'
};
}
const fields = (form.fields ?? []).filter((f)=>typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''));
const known = new Map(fields.map((f)=>[
f.name,
f
]));
const cleaned = {};
for (const field of fields){
const value = data[field.name];
const isBlank = value == null || typeof value === 'string' && value.trim() === '' || value === false;
if (field.required && isBlank) {
return {
field: field.name,
kind: 'required',
ok: false,
reason: 'invalid'
};
}
if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {
return {
field: field.name,
kind: 'too_long',
ok: false,
reason: 'invalid'
};
}
// Only carry through keys that belong to the form — unknown keys from a
// hand-crafted request are dropped, not stored.
if (value !== undefined) {
cleaned[field.name] = value;
}
}
// Reject outright if the payload carried keys the form doesn't define — a
// sign the request wasn't produced by the rendered form.
const unknownKeys = Object.keys(data).filter((k)=>!known.has(k));
if (unknownKeys.length > 0) {
return {
kind: 'unknown_fields',
ok: false,
reason: 'invalid'
};
}
return {
cleaned,
form,
ok: true
};
}
//# sourceMappingURL=validateSubmission.js.map
File diff suppressed because one or more lines are too long