@nextlyhq/plugin-form-builder 0.0.2-alpha.74 → 0.0.2-alpha.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/admin/index.d.ts +81 -65
- package/dist/admin/index.js +3459 -1457
- package/dist/admin/index.js.map +1 -1
- package/dist/{field-references-CtzGg51W.js → field-references-oCuFEwkr.js} +1256 -524
- package/dist/field-references-oCuFEwkr.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +1893 -404
- package/dist/index.js.map +1 -1
- package/dist/{new-submissions-CzlhVGhH.d.ts → new-submissions-DGiIhdBF.d.ts} +96 -5
- package/package.json +10 -10
- package/dist/field-references-CtzGg51W.js.map +0 -1
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["text","select","textarea","checkbox","date","select","text","textarea","date","NextlyError","NextlyError","fieldWithOptions"],"sources":["../src/utils/redirect-target.ts","../src/collections/access-defaults.ts","../src/collections/forms.ts","../src/collections/submissions.ts","../src/dashboard/widget.ts","../src/document-shapes.ts","../src/utils/generate-schema.ts","../src/handlers/prepare-submission.ts","../src/handlers/spam-detection.ts","../src/handlers/submit-form.ts","../src/routes/public-forms.ts","../src/plugin.ts","../src/config/validate-form.ts","../src/config/field-helpers.ts","../src/index.ts"],"sourcesContent":["/**\n * Where a form sends the visitor after a successful submission, when the\n * author picked a PAGE rather than typing a URL.\n *\n * The plugin cannot derive this. A site's URL structure belongs to the site:\n * the playground serves the `pages` collection at `/{slug}` from its own\n * catch-all route, and another app may serve the same collection under\n * `/blog/{slug}` or behind a locale segment. Nothing inside a CMS package can\n * read that decision off the collection, so the mapping is configuration.\n *\n * A pattern covers the common case and a function covers the rest — a path\n * that depends on a parent document, a locale, or anything else the site\n * knows and this package does not.\n *\n * The pattern syntax is not invented here. `previewUrlFromTemplate` already\n * turns a `{field}` path into a URL for preview links, for the same reason and\n * with the same constraint, so this uses it rather than substituting\n * alongside it: two rules that agree today would drift, and a wrong URL still\n * looks like a URL. Encoding and the treatment of `0` and `false` come with\n * it.\n */\nimport { previewUrlFromTemplate } from \"nextly/config\";\n\nimport type { RedirectTargetDocument } from \"./redirect-reference\";\n\nexport type { RedirectTargetDocument } from \"./redirect-reference\";\nexport {\n documentReachability,\n type Reachability,\n hasPublishLifecycle,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n type RedirectReference,\n} from \"./redirect-reference\";\n\n/**\n * How documents in one collection become URLs.\n *\n * A string is a path with `{field}` placeholders filled from the document.\n * A function receives the document and returns the path itself.\n */\nexport type RedirectUrlPattern =\n | string\n | ((document: RedirectTargetDocument) => string | undefined);\n\n/** The collection-to-pattern map, after the array shorthand is normalised. */\nexport type RedirectRelationships = Record<string, RedirectUrlPattern>;\n\n/**\n * The pattern assumed for the `string[]` shorthand.\n *\n * `redirectRelationships: [\"pages\"]` means \"pages are redirect targets\" and\n * says nothing about their URLs, so it needs a default. This one matches the\n * routing the page builder ships and the scaffold templates generate.\n */\nexport const DEFAULT_REDIRECT_PATTERN = \"/{slug}\";\n\n/**\n * Both accepted spellings of the option reduced to the map.\n *\n * Normalised at the edge so everything downstream reads ONE shape — including\n * the list of collections, which is `Object.keys` of this rather than a second\n * field that agrees with it today.\n */\nexport function normalizeRedirectRelationships(\n option: string[] | RedirectRelationships | undefined\n): RedirectRelationships {\n if (!option) return {};\n const entries = Array.isArray(option)\n ? option.map(collection => [collection, DEFAULT_REDIRECT_PATTERN] as const)\n : Object.entries(option);\n\n // An empty pattern is dropped rather than kept, so that being OFFERED as a\n // redirect target and being able to PRODUCE a URL are one decision. Kept,\n // the collection appears in the picker, validation accepts a reference into\n // it, and every submission then resolves to nothing — a form that passes\n // every check and sends nobody anywhere.\n return Object.fromEntries(\n entries.filter(\n ([, pattern]) => typeof pattern !== \"string\" || pattern.trim()\n )\n );\n}\n\n/**\n * The pattern applied to a document, or undefined when it cannot be.\n *\n * Undefined rather than a partial string: a placeholder whose field is empty\n * would otherwise produce `/undefined` or `/`, and a form that sends every\n * visitor to a broken URL is worse than one that sends them nowhere. The\n * caller reports the difference.\n */\nexport function applyRedirectPattern(\n pattern: RedirectUrlPattern,\n document: RedirectTargetDocument\n): string | undefined {\n if (typeof pattern === \"function\") {\n const produced = pattern(document);\n return produced ? produced : undefined;\n }\n return previewUrlFromTemplate(pattern)(document) ?? undefined;\n}\n","/**\n * The access rules both collections this plugin contributes fall back to.\n *\n * Written out per collection they were byte-identical apart from `read`, and\n * two copies of an access policy is the copy that matters most: they agree\n * today, one gets tightened later, and the other silently keeps the old rule\n * while looking deliberate.\n *\n * `read` and `create` are the axes they genuinely differ on, so both are\n * parameters: a form is publicly READABLE because a site renders it, and a\n * submission is publicly CREATABLE because a visitor makes it. Update and\n * delete are the same policy in both collections.\n *\n * @module collections/access-defaults\n */\n\nimport type { AccessControlFunction, CollectionAccessControl } from \"nextly\";\n\n/**\n * A single rule, in either form every operation accepts. Not the read rule's\n * type: a read rule may also answer a filter, which create, update and delete\n * may not.\n */\ntype AccessRule = AccessControlFunction | boolean;\n\n/** The subset of a collection's access declaration these defaults supply. */\nexport type AccessOverrides = Pick<\n CollectionAccessControl,\n \"read\" | \"create\" | \"update\" | \"delete\"\n>;\n\n/**\n * The caller holds the permission for this operation on this collection, as\n * the role screen grants it: `read-form-submissions` to read submissions,\n * `update-forms` to edit a form.\n *\n * The grant is checked before a collection's rule, and the rule only narrows\n * it, so this check repeats the gate's and keeps the default plain; a role\n * name here would refuse a custom role that holds the permission. The rule is given\n * the caller's permissions as the role screen spells them, `<action>-<slug>`,\n * for a session and an API key alike, and a visitor holds none. `collection` is the slug the host installed\n * the collection under, so a renamed collection is judged by its own\n * permissions. A super-admin is admitted before any rule runs.\n */\nconst permitted: AccessRule = ({ permissions, collection, operation }) =>\n permissions.includes(`${operation}-${collection}`);\n\n/**\n * Host overrides applied over this plugin's defaults, per operation.\n *\n * Each override is taken with `??`, so a host may pass `false` to close an\n * operation it would otherwise be given — which `||` would have silently\n * replaced with the default.\n */\nexport function accessWithDefaults(\n overrides: AccessOverrides | undefined,\n defaults: { read?: AccessRule; create?: AccessRule } = {}\n): AccessOverrides {\n return {\n read: overrides?.read ?? defaults.read ?? permitted,\n create: overrides?.create ?? defaults.create ?? permitted,\n update: overrides?.update ?? permitted,\n delete: overrides?.delete ?? permitted,\n };\n}\n","/**\n * Forms Collection\n *\n * Stores form definitions created via Code-First or Schema Builder.\n * Each form contains field definitions, settings, notifications, and access control.\n *\n * @module collections/forms\n * @since 0.1.0\n */\n\nimport { NextlyError } from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport {\n text,\n textarea,\n select,\n checkbox,\n date,\n group,\n json,\n relationship,\n} from \"nextly\";\n\nimport type { ResolvedFormBuilderConfig } from \"../types\";\nimport {\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n} from \"../utils/redirect-reference\";\nimport {\n applyRedirectPattern,\n type RedirectRelationships,\n type RedirectTargetDocument,\n} from \"../utils/redirect-target\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n// ============================================================\n// Type Augmentation for Custom Admin Components\n// ============================================================\n// These types extend CollectionAdminOptions to support custom admin views.\n// This augmentation will be removed once nextly exports these types.\n\n/**\n * Component path string format.\n * Format: `\"package-name/path#ExportName\"`\n */\ntype ComponentPath = string;\n\n/**\n * Custom view configuration for replacing default admin views.\n */\ninterface CollectionAdminViewConfig {\n /** Component path to the custom view component */\n Component: ComponentPath;\n}\n\n/**\n * Custom components configuration for collection admin UI.\n */\ninterface CollectionAdminComponents {\n views?: {\n Edit?: CollectionAdminViewConfig;\n List?: CollectionAdminViewConfig;\n };\n BeforeListTable?: ComponentPath;\n AfterListTable?: ComponentPath;\n BeforeEdit?: ComponentPath;\n AfterEdit?: ComponentPath;\n}\n\n/**\n * Extended CollectionConfig with custom admin components support.\n * This extends the base CollectionConfig to include the components property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n components?: CollectionAdminComponents;\n };\n}\n\n/**\n * Generate the Forms collection configuration.\n *\n * Creates a collection for storing form definitions with fields,\n * settings, notifications, and access control.\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns ExtendedCollectionConfig for the Forms collection (with custom admin components)\n *\n * @example\n * ```typescript\n * const formsCol = formsCollection(resolvedConfig);\n * // Returns a collection with slug \"forms\" (or custom slug from config)\n * ```\n */\n/**\n * The plugin's hooks, with a host's own appended per phase.\n *\n * A host may pass `formOverrides.hooks`, and the trailing `...overrides` spread\n * would otherwise replace this object outright — taking with it the slug\n * generation, the at-least-one-field rule and the redirect check, none of\n * which are the host's to remove. Those are invariants of the collection this\n * plugin ships; an override extends it rather than disarming it.\n *\n * Handlers in `pluginHooks` run FIRST — they are transformations a host may\n * want to build on, like the generated slug. Handlers in `trailing` run LAST,\n * after every host handler in that phase, which is where an INVARIANT has to\n * sit: one placed first is checked against a payload the host can still\n * rewrite afterwards.\n */\nfunction withHostHooks<T>(\n pluginHooks: T,\n hostHooks: T | undefined,\n trailing: Partial<Record<string, unknown[]>> = {}\n): T {\n // Annotated rather than asserted. `hostHooks ?? {}` widens to `T | {}`\n // under the generic and cannot be indexed by phase name; a cast says the\n // same thing but reads as removable, and `--fix` removes it.\n const host: Record<string, unknown> = { ...hostHooks };\n\n const asList = (value: unknown) =>\n Array.isArray(value) ? value : value === undefined ? [] : [value];\n\n const merged: Record<string, unknown> = { ...host };\n for (const [phase, handlers] of Object.entries(\n pluginHooks as Record<string, unknown>\n )) {\n merged[phase] = [...asList(handlers), ...asList(host[phase])];\n }\n for (const [phase, handlers] of Object.entries(trailing)) {\n merged[phase] = [...asList(merged[phase]), ...asList(handlers)];\n }\n return merged as T;\n}\n\n/**\n * Refuses a write whose redirect target the resolver could not use.\n *\n * One function, called from two phases. `beforeValidate` so an author gets the\n * error where the rest of the form's errors appear, and LAST in `beforeChange`\n * so the guarantee survives a host hook: a check placed before host handlers\n * judges a payload they can still rewrite, and one that can be rewritten past\n * is not an invariant.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would reject every partial update — renaming a form — for a setting it never\n * touched.\n */\n/**\n * The settings a write is SETTING to a page redirect, or null for every other\n * write.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would refuse a rename for a setting it never touched.\n */\nfunction documentRedirectSettings(\n settings: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof settings !== \"object\" || settings === null) return null;\n const record = settings as Record<string, unknown>;\n const field = pickedDocumentField(record);\n return field ? { settings: record, field } : null;\n}\n\nfunction pageRedirectSettings(\n data: Record<string, unknown> | undefined,\n operation: string\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (!data) return null;\n if (operation !== \"create\" && data.settings === undefined) return null;\n return documentRedirectSettings(data.settings);\n}\n\n/**\n * Whether the form this write leaves behind can receive a submission.\n *\n * A form's `status` is its own lifecycle — draft, published, closed — and only\n * a published form accepts submissions, so only a published form can send a\n * visitor anywhere at all. That is the whole reason this rule is conditional:\n * a draft form pointing at a draft page is fine, because the two go live\n * together.\n *\n * The stored status is consulted when the write does not carry one. Publishing\n * a form and editing its settings are separate saves, and an update that never\n * mentions `status` leaves the stored one standing — so reading an absent\n * field as \"not published\" would wave through exactly the case this rule\n * exists to catch.\n */\nexport function formAcceptsSubmissions(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n return (data?.status ?? originalData?.status) === \"published\";\n}\n\n/**\n * The stored `settings`, which is not the shape the payload uses.\n *\n * Measured on 2026-08-25: the incoming write carries `settings` as an OBJECT,\n * and the same field on `originalData` comes back as JSON TEXT. Reading the\n * stored row with the payload's reader answers \"no redirect configured\" for\n * every form that has one — silently, and indistinguishably from a form that\n * genuinely has none.\n */\nfunction storedSettings(\n value: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof value === \"string\") {\n try {\n return documentRedirectSettings(JSON.parse(value));\n } catch {\n return null;\n }\n }\n return documentRedirectSettings(value);\n}\n\n/**\n * The redirect target already stored on the form, for a write that does not\n * mention it.\n *\n * Publishing a form is such a write: it carries `status` and nothing else, and\n * the target it inherits is the one a visitor will be sent to. Without this,\n * the rule would guard the save that PICKS a draft page and ignore the save\n * that makes that same pairing reachable — protecting one path while appearing\n * to protect both.\n *\n * Nothing here is written back. `originalData` is the stored row, not the\n * payload; normalising it would edit a document this write never touched.\n */\nfunction storedRedirectReference(\n originalData: Record<string, unknown> | undefined,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = storedSettings(originalData?.settings);\n if (!picked) return null;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(\n picked.settings[picked.field],\n collections\n );\n if (!reference) return null;\n\n // A collection dropped from `redirectRelationships` since the form was saved\n // is REPORTED, not discarded. Discarding it made the rule skip a form whose\n // destination can no longer be built at all, so publishing succeeded and\n // every submission afterwards resolved to no redirect — the silent version\n // of the failure the rule exists to prevent.\n return {\n reference,\n field: picked.field,\n configured: collections.includes(reference.collection),\n };\n}\n\n/**\n * Writes the parser's normalised names back into the row being saved.\n *\n * Not merely read: trimming only the parsed copy leaves the padded name in\n * `data`, so this rule accepts the write and the framework's own relationship\n * validator downstream still sees `\" pages \"`. The plugin and the framework\n * then disagree about a value this parser has already decided.\n */\nfunction normalizeStoredReference(\n settings: Record<string, unknown>,\n field: PickedDocumentField,\n reference: { collection: string; id: string }\n) {\n const stored = settings[field];\n\n if (typeof stored === \"string\") {\n if (stored !== reference.id) settings[field] = reference.id;\n return;\n }\n if (typeof stored !== \"object\" || stored === null || Array.isArray(stored)) {\n return;\n }\n\n const record = stored as Record<string, unknown>;\n if (record.relationTo !== reference.collection) {\n settings[field] = { ...record, relationTo: reference.collection };\n }\n}\n\nfunction namedRedirectReference(\n data: Record<string, unknown> | undefined,\n operation: string,\n patterns: RedirectRelationships\n): {\n settings: Record<string, unknown>;\n field: PickedDocumentField;\n /** Null when the settings name a redirect this cannot read. */\n reference: { collection: string; id: string } | null;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = pageRedirectSettings(data, operation);\n if (!picked) return null;\n const { settings, field } = picked;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(settings[field], collections);\n if (reference) normalizeStoredReference(settings, field, reference);\n\n return {\n settings,\n field,\n reference,\n configured:\n reference !== null && collections.includes(reference.collection),\n };\n}\n\n/**\n * The same rule, plus the one question that needs a read: can this document\n * actually fill its collection's pattern?\n *\n * Split from the shape check so the cheap half stays synchronous — the early\n * `beforeValidate` call wants an error, not a database round trip, and making\n * that hook async changes what a caller has to do to observe its rejection.\n *\n * Best effort by design: `req.nextly` is optional, and a slug can be emptied\n * AFTER this form is saved. The submit path keeps its own check and its log\n * line. What this buys is telling the author NOW, while the page they picked\n * is in front of them, rather than leaving a form that saves cleanly and\n * redirects nobody.\n */\n/**\n * Whether this write leaves a form that would send visitors to a page they\n * cannot reach.\n *\n * One question, so it has one implementation and one name. It is the whole\n * rule: a form that accepts submissions is the only kind that redirects\n * anyone, and an unreachable target is the only thing that 404s them. A draft\n * form pointing at a draft page satisfies neither half and is allowed — the\n * picker offers unpublished pages on purpose, because a form is usually\n * configured beside the page it points at and the two go live together.\n */\nexport function wouldStrandVisitors(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined,\n target: RedirectTargetDocument,\n targetCollectionIsLocalized: boolean | undefined\n): boolean {\n return (\n formAcceptsSubmissions(data, originalData) &&\n documentReachability(target, targetCollectionIsLocalized) === \"unreachable\"\n );\n}\n\n/**\n * Whether this write is the one that makes the form able to receive\n * submissions.\n *\n * Distinct from {@link formAcceptsSubmissions}, which answers what the form\n * WILL be: this asks what the write CHANGES, which is what decides whether an\n * inherited target is any of its business. A form that was already published\n * is not made reachable again by an edit that merely restates its status.\n */\nfunction publishesForm(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n if (data?.status !== \"published\") return false;\n // Already published before this write, so this write is not what makes the\n // form reachable. The admin sends `status` on every save exactly as it sends\n // `settings`, so reading the value alone treats every edit to a live form as\n // the moment it went live.\n return originalData?.status !== \"published\";\n}\n\n/**\n * The page this write leaves the form pointing at, and whether the write\n * CHOSE it.\n *\n * The distinction decides which refusals apply. A write that names a page\n * answers for that page; a write that only publishes the form, or renames it,\n * inherits whatever is stored and must not be refused for it.\n */\n/**\n * Whether two readings name the same document in the same field.\n *\n * `null` on either side is not a match: a reference this cannot read has not\n * been shown to be the stored one.\n */\nfunction sameTarget(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): boolean {\n if (!named.reference || !stored) return false;\n return (\n stored.field === named.field &&\n stored.reference.collection === named.reference.collection &&\n stored.reference.id === named.reference.id\n );\n}\n\n/**\n * What a write that NAMES a target is answering for.\n *\n * NAMING is not CHOOSING, and the refusal has to come after that distinction\n * rather than before it. The admin's save sends the whole `settings` object on\n * every write, so a rename carries the redirect it never touched — judging the\n * mention meant a form whose collection had since been unconfigured could not\n * be edited at all.\n */\nfunction judgeNamedReference(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n configured: boolean;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n configured: boolean;\n} {\n if (sameTarget(named, stored) && named.reference) {\n return {\n reference: named.reference,\n field: named.field,\n chosen: false,\n configured: named.configured,\n };\n }\n\n // This write moved the target, so it answers for what it moved it to.\n if (!named.reference || !named.configured) {\n throw redirectRefusal(\n \"Choose a page to redirect to, or pick a different confirmation.\",\n named.field\n );\n }\n return {\n reference: named.reference,\n field: named.field,\n chosen: true,\n configured: true,\n };\n}\n\nfunction redirectReferenceForWrite(\n context: HookContext,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n /** Whether the target's collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const stored = storedRedirectReference(\n context.originalData as Record<string, unknown> | undefined,\n patterns\n );\n const named = namedRedirectReference(\n context.data,\n context.operation,\n patterns\n );\n\n if (named) return judgeNamedReference(named, stored);\n\n // A write that REPLACES `settings` has answered the question: this form no\n // longer redirects to a page. Inheriting the old target there would refuse\n // the very edit that removes an invalid redirect, which is the one edit an\n // author in that state needs to make. Only a write that omits `settings`\n // inherits.\n const data = context.data as Record<string, unknown> | undefined;\n if (data?.settings !== undefined) return null;\n\n return stored ? { ...stored, chosen: false } : null;\n}\n\n/**\n * Whether the collection's configured pattern can build a URL for this\n * document.\n *\n * A pattern may be a FUNCTION, which is host code and can throw. Authoring\n * must not be blocked by that: the submission path contains the same throw and\n * degrades to no redirect with a log line, so a broken pattern costs a\n * destination rather than the ability to edit forms.\n */\nfunction assertPatternBuildsUrl(\n patterns: RedirectRelationships,\n reference: { collection: string; id: string },\n field: PickedDocumentField,\n target: RedirectTargetDocument\n) {\n let url: string | undefined;\n try {\n url = applyRedirectPattern(patterns[reference.collection], target);\n } catch {\n return;\n }\n if (url) return;\n\n // Pattern-agnostic. The configured pattern may depend on any field, or be a\n // function that declines for its own reasons, so naming a slug states a\n // remedy that often cannot fix the actual failure — and the save is\n // correctly blocked either way, leaving the author following wrong advice.\n throw redirectRefusal(\n `No URL could be built for that page from the pattern configured for ` +\n `\"${reference.collection}\". Choose another page, or ask a developer ` +\n `to check that collection's redirect pattern.`,\n field\n );\n}\n\nexport async function assertRedirectTargetUsable(\n context: HookContext,\n patterns: RedirectRelationships,\n localization: Record<string, boolean>\n): Promise<void> {\n const write = redirectReferenceForWrite(context, patterns);\n if (!write) return;\n const { reference, field, chosen, configured } = write;\n\n // Nothing can be built for a collection that is no longer a redirect target,\n // so there is nothing to read either. Only the write that makes the form\n // reachable answers for it; an unrelated edit inherits the state as it does\n // any other.\n if (!configured) {\n if (\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n throw redirectRefusal(\n `This form redirects to \"${reference.collection}\", which is no longer ` +\n `configured as a redirect target, so it has nowhere to send anyone. ` +\n `Choose another page, or pick a different confirmation.`,\n field\n );\n }\n return;\n }\n\n const nextly = context.req?.nextly;\n if (!nextly) return;\n\n // Not inside a caller-owned transaction. `context.executor` is present only\n // there, and the contract on it is explicit: a hook that reads the database\n // must use that executor, because a second pooled connection can stall\n // against a small pool while the caller's transaction holds the only one —\n // and a pooled read cannot see the transaction's own uncommitted rows, so a\n // page created in the same transaction would read as missing and this would\n // refuse a correct write.\n //\n // `findByID` takes no executor, and reaching for Drizzle directly here would\n // be raw database access inside a plugin. So the transactional path keeps\n // the shape check and skips the read; the submit path is the backstop it\n // already was.\n if (context.executor) return;\n\n const found = await readRedirectTarget(nextly, reference);\n if (found.status === \"unreadable\") return;\n if (found.status === \"missing\") {\n // A write that neither NAMES the page nor makes the form able to send\n // anyone there does not answer for it: a rename inherits whatever is\n // stored, and refusing it would block an edit for a setting it never\n // touched.\n //\n // Publishing is not that write. It is the moment the pairing starts\n // reaching visitors, and a deleted target is a stricter case than the\n // unpublished one refused below — every submission resolves it to no\n // redirect at all. Returning here on `!chosen` alone let the worse case\n // through while the milder one was refused.\n if (\n !chosen &&\n !publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n return;\n }\n throw redirectRefusal(\n `That page no longer exists in \"${reference.collection}\". Pick another.`,\n field\n );\n }\n\n // An inherited target is only judged by the write that PUBLISHES the form.\n // A published form can acquire a draft target without being touched — the\n // page is unpublished later, or the pairing predates this rule — and\n // refusing every subsequent rename would hold the form hostage to a state\n // the write neither created nor mentions. A write that CHOOSES a target\n // answers for it whatever else it does.\n const judged =\n chosen ||\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n );\n\n if (\n judged &&\n wouldStrandVisitors(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined,\n found.document,\n localization[reference.collection]\n )\n ) {\n throw redirectRefusal(\n `That page is not published yet, and this form is. A visitor sent ` +\n `there would reach a \"page not found\". Publish the page, or save ` +\n `this form as a draft until you do.`,\n field\n );\n }\n\n // The pattern belongs to the write that CHOOSES a page. A form being\n // published does not get refused for a pattern its author never edited.\n if (chosen)\n assertPatternBuildsUrl(patterns, reference, field, found.document);\n}\n\n/**\n * The target document, as one of three outcomes.\n *\n * Three rather than a nullable document, because \"the page is not there\" and\n * \"I could not ask\" are different answers and only the first is the author's\n * to fix. Collapsing them has been wrong in both directions on this rule:\n * refusing on any failure blocks a save a retry would complete, and skipping\n * on any failure loses the deleted-page refusal entirely.\n */\nasync function readRedirectTarget(\n nextly: NonNullable<NonNullable<HookContext[\"req\"]>[\"nextly\"]>,\n reference: { collection: string; id: string }\n): Promise<\n | { status: \"ok\"; document: RedirectTargetDocument }\n | { status: \"missing\" }\n | { status: \"unreadable\" }\n> {\n try {\n const row = (await nextly.findByID({\n collection: reference.collection,\n id: reference.id,\n })) as RedirectTargetDocument | null;\n return row ? { status: \"ok\", document: row } : { status: \"missing\" };\n } catch (error) {\n return isMissingTarget(error)\n ? { status: \"missing\" }\n : { status: \"unreadable\" };\n }\n}\n\n/**\n * Whether a failed read means the document is GONE, or that it could not be\n * asked for.\n *\n * Both arrive as exceptions — `findByID` throws `NOT_FOUND` for a missing\n * entry rather than returning null, despite a signature that says `| null` —\n * so the two are separated by the error's own code. Not by its message, which\n * is prose, and not by catching everything, which has now been wrong in both\n * directions: refusing on any failure blocks a save a retry would complete,\n * and skipping on any failure loses the deleted-page refusal.\n */\nexport function isMissingTarget(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { code?: unknown }).code === \"NOT_FOUND\"\n );\n}\n\n/**\n * Records the moment a form first goes live.\n *\n * `status` on this collection is an ordinary select, not the framework's\n * publish lifecycle, so nothing else writes down that a form was ever public.\n * Without that, `closed` stands for two different histories — a form an author\n * released and later stopped, and one created closed that nobody ever saw —\n * and the public by-slug endpoint served both.\n *\n * Written once. A form republished after being closed keeps the original date,\n * because the question this answers is \"has this ever been public\", not \"when\n * was it last\".\n */\nfunction stampFirstPublish(context: HookContext): void {\n const data = context.data as Record<string, unknown> | undefined;\n if (!data) return;\n const stored = context.originalData as Record<string, unknown> | undefined;\n\n // Written here or not at all. Duplicating an entry copies every non-system\n // field, so a copy of a closed form arrives carrying the original's stamp\n // and would qualify as previously public at a slug nobody has ever seen. The\n // same goes for any caller that simply sends the field. Removing it from the\n // patch leaves a stamp that IS stored untouched, because a column absent\n // from an update is a column not written.\n delete data.wentLiveAt;\n\n if (recordedStamp(stored) !== undefined) return;\n\n // Either side of the transition is proof. A form live before this field\n // existed carries no stamp, so its first edit is the only chance to record\n // one — and if that edit is the one CLOSING it, reading the incoming status\n // alone would miss it and take a form that had been public offline.\n if (data.status === \"published\" || stored?.status === \"published\") {\n data.wentLiveAt = new Date();\n }\n}\n\n/** The stamp a form already carries, treating blank as absent. */\nfunction recordedStamp(stored: Record<string, unknown> | undefined): unknown {\n const value = stored?.wentLiveAt;\n return value === null || value === \"\" ? undefined : value;\n}\n\n/** One shape for every refusal this rule makes, so callers see one field. */\nfunction redirectRefusal(message: string, field: PickedDocumentField) {\n // The path names the field the author actually filled in. Reporting every\n // refusal against `redirectPage` points a form configured through the URL\n // option at a control it does not show.\n return NextlyError.validation({\n errors: [{ path: `settings.${field}`, code: \"REQUIRED\", message }],\n });\n}\n\nexport function formsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n hooks: hookOverrides,\n admin: adminOverrides,\n ...overrides\n } = pluginConfig.formOverrides;\n\n // Derived from the map rather than carried beside it: the collections a form\n // may redirect to are exactly the ones with a URL pattern configured, so\n // there is no second list to fall out of step with the first.\n const redirectCollections = Object.keys(pluginConfig.redirectRelationships);\n\n // Build settings group fields based on plugin configuration\n const settingsFields: FieldConfig[] = [\n text({\n name: \"submitButtonText\",\n label: \"Submit Button Text\",\n defaultValue: \"Submit\",\n admin: {\n description: \"Text displayed on the form submit button\",\n },\n }),\n\n select({\n name: \"confirmationType\",\n label: \"After Submission\",\n defaultValue: \"message\",\n options: [\n { label: \"Show Message\", value: \"message\" },\n { label: \"Redirect to URL\", value: \"redirect\" },\n ...(redirectCollections.length > 0\n ? [{ label: \"Redirect to Page\", value: \"relationship\" }]\n : []),\n ],\n admin: {\n description: \"What happens after a successful form submission\",\n },\n }),\n\n textarea({\n name: \"successMessage\",\n label: \"Success Message\",\n defaultValue: \"Thank you for your submission!\",\n admin: {\n description: \"Message shown after successful submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"message\",\n },\n },\n }),\n\n text({\n name: \"redirectUrl\",\n label: \"Redirect URL\",\n admin: {\n description: \"URL to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"redirect\",\n },\n },\n }),\n ];\n\n // Add redirect relationship field if configured\n if (redirectCollections.length > 0) {\n settingsFields.push(\n relationship({\n name: \"redirectPage\",\n label: \"Redirect Page\",\n relationTo: redirectCollections,\n admin: {\n description: \"Select a page to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"relationship\",\n },\n },\n })\n );\n }\n\n // Add remaining settings fields\n settingsFields.push(\n checkbox({\n name: \"allowMultipleSubmissions\",\n label: \"Allow Multiple Submissions\",\n defaultValue: true,\n admin: {\n description:\n \"Allow the same user/IP to submit this form multiple times\",\n },\n }),\n\n checkbox({\n name: \"honeypotEnabled\",\n label: \"Honeypot\",\n admin: {\n description:\n \"Per-form honeypot override; unset inherits the plugin default\",\n },\n }),\n\n checkbox({\n name: \"captchaEnabled\",\n label: \"Enable reCAPTCHA\",\n admin: {\n description:\n \"Per-form reCAPTCHA override; unset inherits the plugin default\",\n },\n }),\n\n text({\n name: \"captchaSiteKey\",\n label: \"reCAPTCHA Site Key\",\n admin: {\n description: \"Override the default reCAPTCHA site key for this form\",\n condition: {\n field: \"settings.captchaEnabled\",\n equals: true,\n },\n },\n })\n );\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Basic Information\n // ============================================================\n text({\n name: \"name\",\n label: \"Form Name\",\n required: true,\n admin: {\n description: \"Internal name for this form (not shown to users)\",\n },\n }),\n\n text({\n name: \"slug\",\n label: \"Slug\",\n required: true,\n unique: true,\n admin: {\n description: \"URL-friendly identifier. Used in API: /api/forms/{slug}\",\n },\n }),\n\n textarea({\n name: \"description\",\n label: \"Description\",\n admin: {\n description: \"Internal notes about this form's purpose\",\n },\n }),\n\n // ============================================================\n // Form Fields (JSON schema managed by Form Builder UI)\n // ============================================================\n json({\n name: \"fields\",\n label: \"Form Fields\",\n required: true,\n defaultValue: [],\n admin: {\n description:\n \"Form field configuration. Managed by the Form Builder UI.\",\n },\n }),\n\n // ============================================================\n // Form Settings\n // ============================================================\n group({\n name: \"settings\",\n label: \"Form Settings\",\n fields: settingsFields,\n }),\n\n // ============================================================\n // Email Notifications\n // ============================================================\n json({\n name: \"notifications\",\n label: \"Notifications\",\n defaultValue: [],\n admin: {\n description:\n \"Email notification rules. Managed by the Form Builder UI.\",\n },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"draft\",\n options: [\n { label: \"Draft\", value: \"draft\" },\n { label: \"Published\", value: \"published\" },\n { label: \"Closed\", value: \"closed\" },\n ],\n admin: {\n description:\n \"Only published forms can receive submissions. Closed forms display a message instead.\",\n },\n }),\n\n // NOT `firstPublishedAt`, which is the obvious name and the one to reach\n // for next. The framework owns that name on collections it manages the\n // publish lifecycle for, and a write naming it from a plugin collection is\n // stripped on the way to the insert — silently, leaving the column null and\n // every closed form reading as one that was never public.\n date({\n name: \"wentLiveAt\",\n label: \"First Published\",\n admin: {\n readOnly: true,\n description:\n \"When this form first went live. Written once and never changed.\",\n },\n }),\n\n textarea({\n name: \"closedMessage\",\n label: \"Closed Form Message\",\n defaultValue: \"This form is no longer accepting submissions.\",\n admin: {\n description: \"Message shown when form status is 'closed'\",\n condition: {\n field: \"status\",\n equals: \"closed\",\n },\n },\n }),\n ];\n\n // Process additional fields from formOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name for database compatibility\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 50,\n useAsTitle: \"name\",\n description:\n \"Create and manage your form templates using the visual form builder. Create and manage forms for collecting user submissions\",\n\n // Custom components for visual form builder\n components: {\n views: {\n // Replace default edit view with visual Form Builder\n Edit: {\n Component: \"@nextlyhq/plugin-form-builder/admin#FormBuilderView\",\n },\n },\n },\n\n ...adminOverrides,\n },\n\n // Access control with sensible defaults\n // `read: true` — a form is public because a site renders it. The rest are\n // the shared defaults.\n access: accessWithDefaults(accessOverrides, { read: true }),\n\n hooks: withHostHooks(\n {\n // Auto-generate slug from name if not provided\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n\n // Auto-generate slug from name if not provided (only on create)\n if (operation === \"create\" && data && !data.slug && data.name) {\n data.slug = String(data.name)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n }\n\n // A form must have at least one field, checked against what the\n // write actually sets. An update carries the patch rather than the\n // merged document, so treating an absent `fields` as empty rejects\n // every partial update -- renaming a form, or changing a setting --\n // even though its fields are untouched and still there.\n const setsFields =\n operation === \"create\" || data?.fields !== undefined;\n if (\n data &&\n setsFields &&\n (data.fields === undefined ||\n (Array.isArray(data.fields) && data.fields.length === 0))\n ) {\n // Typed, so the rejection survives as a validation failure with\n // its field issue rather than being read as a crash and replaced\n // with a generic server-fault message.\n throw NextlyError.validation({\n errors: [\n {\n path: \"fields\",\n code: \"REQUIRED\",\n message: \"Form must have at least one field.\",\n },\n ],\n });\n }\n\n // Reported here so an author sees it beside the form's other\n // errors. The GUARANTEE is the trailing `beforeChange` call below,\n // which a host hook cannot run after.\n // Shape only at this phase: the same reading, without the\n // stored document a refusal for an unconfigured collection would\n // need. `assertRedirectTargetUsable` below makes that call.\n namedRedirectReference(\n data,\n operation,\n pluginConfig.redirectRelationships\n );\n\n return data;\n },\n ],\n },\n hookOverrides,\n {\n // The last COLLECTION-level phase, after every host handler in it. A\n // host `beforeValidate` may rewrite the payload once the earlier call\n // has passed, so the check has to run again here.\n //\n // It is not the last mutating point in the write, and saying so would\n // be false: `collection-mutation-service` runs FIELD-level\n // `beforeChange` hooks after this one, and a host may contribute a\n // field through `formOverrides.fields`. Nothing collection-level can\n // sit after that — the next steps are hashing and the insert. What\n // bounds the consequence is the submit path, which resolves an\n // unusable target to NO redirect and logs it, never to a wrong one.\n beforeChange: [\n async (context: HookContext) => {\n await assertRedirectTargetUsable(\n context,\n pluginConfig.redirectRelationships,\n pluginConfig.redirectTargetLocalization\n );\n stampFirstPublish(context);\n return context.data;\n },\n ],\n }\n ),\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n };\n}\n","/**\n * Form Submissions Collection\n *\n * Stores form submission data from user-submitted forms.\n * Each submission contains the submitted data, metadata, and status.\n *\n * @module collections/submissions\n * @since 0.1.0\n */\n\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport { text, textarea, select, json, date, relationship } from \"nextly\";\n\nimport type { ResolvedFormBuilderConfig } from \"../types\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n/**\n * Extended CollectionConfig with isPlugin support.\n * This extends the base CollectionConfig to include the isPlugin property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n };\n}\n\n/**\n * Whether a create keeps the `submittedAt` it names: only one made on the\n * server with no request behind it (`req.http` absent), such as an import\n * restoring old submissions with their dates, and only where it names one.\n *\n * A create a request produced, a visitor's through the public grant above\n * all, is given the server's time whatever it says: the dashboard lists new\n * submissions newest first by this field, and a date a visitor chose (one far\n * in the future) would keep theirs ahead of every real submission.\n */\nfunction trustedSubmittedAt(context: HookContext): boolean {\n return context.req?.http === undefined && Boolean(context.data?.submittedAt);\n}\n\n/**\n * Generate the Form Submissions collection configuration.\n *\n * Creates a collection for storing form submissions with:\n * - Reference to the parent form\n * - Submitted data (JSON)\n * - Status tracking (new, read, archived)\n * - Metadata (IP, user agent, timestamp)\n * - Internal notes for admin use\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns CollectionConfig for the Submissions collection\n *\n * @example\n * ```typescript\n * const submissionsCol = submissionsCollection(resolvedConfig);\n * // Returns a collection with slug \"form-submissions\" (or custom slug from config)\n * ```\n */\nexport function submissionsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n ...overrides\n } = pluginConfig.formSubmissionOverrides;\n\n // Get the forms collection slug for the relationship\n const formSlug = pluginConfig.formOverrides.slug;\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Form Relationship\n // ============================================================\n relationship({\n name: \"form\",\n label: \"Form\",\n relationTo: formSlug,\n required: true,\n admin: {\n readOnly: true,\n description: \"The form this submission belongs to\",\n },\n }),\n\n // ============================================================\n // Submission Data (JSON)\n // ============================================================\n json({\n name: \"data\",\n label: \"Submission Data\",\n required: true,\n admin: {\n readOnly: true,\n description: \"The submitted form data as JSON\",\n },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"new\",\n options: [\n { label: \"New\", value: \"new\" },\n { label: \"Read\", value: \"read\" },\n { label: \"Archived\", value: \"archived\" },\n // Spam is stored and flagged, never silently dropped: a false\n // positive stays reviewable and recoverable (\"Not spam\" = back to new).\n { label: \"Spam\", value: \"spam\" },\n ],\n // Indexed with `submittedAt`: the dashboard's New form submissions card\n // and its header count filter every submission by status and list them\n // newest first, on every dashboard load.\n index: true,\n admin: {\n description: \"Track whether this submission has been reviewed\",\n },\n }),\n\n text({\n name: \"spamReason\",\n label: \"Spam Reason\",\n admin: {\n readOnly: true,\n description:\n \"Which spam check flagged this submission (empty for clean submissions)\",\n },\n }),\n\n // ============================================================\n // Internal Notes\n // ============================================================\n textarea({\n name: \"notes\",\n label: \"Internal Notes\",\n admin: {\n description: \"Notes visible only to admins (not shown to submitter)\",\n },\n }),\n\n // ============================================================\n // Metadata\n // ============================================================\n text({\n name: \"ipAddress\",\n label: \"IP Address\",\n admin: {\n readOnly: true,\n description: \"IP address of the submitter\",\n },\n }),\n\n text({\n name: \"userAgent\",\n label: \"User Agent\",\n admin: {\n readOnly: true,\n description: \"Browser/device information of the submitter\",\n },\n }),\n\n date({\n name: \"submittedAt\",\n label: \"Submitted At\",\n required: true,\n // The order the dashboard lists new submissions in (see `status`).\n index: true,\n admin: {\n readOnly: true,\n description: \"When the form was submitted\",\n },\n }),\n\n // Honesty stamps for admin edits of submitted data: a corrected\n // submission must never be indistinguishable from what the visitor sent.\n date({\n name: \"editedAt\",\n label: \"Edited At\",\n admin: {\n readOnly: true,\n description: \"When an admin last edited the submitted data\",\n },\n }),\n\n text({\n name: \"editedBy\",\n label: \"Edited By\",\n admin: {\n readOnly: true,\n description: \"ID of the admin who last edited the submitted data\",\n },\n }),\n ];\n\n // Process additional fields from formSubmissionOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name (e.g., \"form-submissions\" -> \"form_submissions\")\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n // Submissions carry visitor-entered answers plus ipAddress/userAgent, so the\n // default `entry.*` events stay suppressed — otherwise every submission would\n // be delivered to any endpoint subscribed to `entry.created` or `*`. Instead\n // emit a curated `form.submission.created` carrying ONLY safe metadata (which\n // form, when, and the status) — never the answers, IP, or user agent.\n webhooks: {\n record: false,\n emit: {\n event: \"form.submission.created\",\n fields: [\"form\", \"submittedAt\", \"status\"],\n },\n },\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 51,\n useAsTitle: \"id\",\n description: \"View and manage form submissions\",\n // Submissions are created by visitors submitting forms — a hand-typed\n // \"New Submission\" would be fiction, so the admin never offers one.\n disableCreate: true,\n\n components: {\n // The full submissions experience (per-form columns, status tabs,\n // drawer detail, export) replaces the generic list.\n views: {\n List: {\n Component: \"@nextlyhq/plugin-form-builder/admin#SubmissionsView\",\n },\n },\n },\n\n ...overrides.admin,\n },\n\n // Access control with sensible defaults\n // Public can create (submit forms); reading, updating and deleting need the\n // matching permission on the caller's role (`read-form-submissions`, ...).\n // `create: true` — a visitor submits without an account. Reading, updating\n // and deleting are the shared defaults.\n access: accessWithDefaults(accessOverrides, { create: true }),\n\n hooks: {\n // The submission time on creation. (The edit stamp for updates is\n // registered in plugin.ts init — direct registry hooks are the path\n // that runs for every API surface.)\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n if (operation === \"create\" && data && !trustedSubmittedAt(context)) {\n data.submittedAt = new Date();\n }\n return data;\n },\n ],\n\n afterChange: [\n (context: HookContext) => {\n const { data } = context;\n return data;\n },\n ],\n },\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n };\n}\n","/**\n * New form submissions: the form-builder plugin's dashboard widget, declared\n * once with `defineWidget` and contributed under `contributes.dashboard`.\n *\n * Offered to a reader of both the forms and the submissions, and shown while\n * at least one form is theirs to read; its card lists the new submissions of\n * those forms, newest first, and stays on the dashboard with \"No new\n * submissions\" while there are none. Its summary puts the card's own count in\n * the dashboard's header. Core's default dashboard names it by its id\n * (`NEW_SUBMISSIONS_WIDGET_ID`), so a site without this plugin has no such\n * card and no such part.\n *\n * @module dashboard/widget\n */\n\nimport {\n defineWidget,\n rowsThatFit,\n type AnyWidget,\n} from \"@nextlyhq/plugin-sdk\";\n\nimport {\n NEW_SUBMISSIONS_WIDGET_ID,\n readNewSubmissions,\n type NewSubmissionsData,\n type SubmissionsCollections,\n} from \"./new-submissions\";\n\n/** The component the card is drawn by, as the admin's component map imports it. */\nconst CARD_COMPONENT = \"@nextlyhq/plugin-form-builder/admin#NewSubmissionsCard\";\n\n/** A case for the preview page and the add panel's tile. */\nconst PREVIEW: NewSubmissionsData = {\n items: [\n {\n id: \"preview-1\",\n title: \"Ana Ruiz\",\n form: \"Contact\",\n excerpt: \"Could you send me your price list?\",\n at: \"2026-01-05T09:30:00.000Z\",\n href: \"/admin/collections/form-submissions/preview-1\",\n },\n {\n id: \"preview-2\",\n title: \"sam@example.com\",\n form: \"Newsletter\",\n at: \"2026-01-05T08:10:00.000Z\",\n href: \"/admin/collections/form-submissions/preview-2\",\n },\n ],\n total: 2,\n allHref: \"/admin/collections/form-submissions\",\n collection: \"form-submissions\",\n};\n\n/**\n * The widget, over the plugin's collections as it declares them and the\n * submissions slug the admin knows them by once installed.\n *\n * `submissionsPath` is read when a card loads rather than when the widget is\n * declared: the plugin learns the slug a site renamed its collection to only\n * at `init`, after this declaration was made. Every link the card draws, its\n * empty state's among them, is made from it, so none names a slug the site\n * renamed away.\n */\nexport function newSubmissionsWidget(\n declared: SubmissionsCollections,\n submissionsPath: () => string\n): AnyWidget {\n return defineWidget({\n id: NEW_SUBMISSIONS_WIDGET_ID,\n title: \"New form submissions\",\n description: \"Form submissions nobody has opened yet, newest first.\",\n icon: \"Inbox\",\n category: \"Forms\",\n size: { default: { w: 6, h: 3 }, max: { w: 12, h: 8 } },\n // Each must be readable before the card is answered at all.\n access: { collections: [declared.forms, declared.submissions] },\n // Shown while a form is the reader's to read: without one there are no\n // submissions they could ever be shown.\n shows: async ({ services, signal }) =>\n (await services.count(declared.forms, { status: \"all\", signal })) > 0,\n skeleton: \"list\",\n load: async ({ services, size, signal, scope }) =>\n readNewSubmissions(services, declared, {\n limit: rowsThatFit(size, { density: \"default\" }),\n signal,\n scope,\n submissionsPath: submissionsPath(),\n }),\n view: { component: CARD_COMPONENT },\n // The card draws its own empty state: its link is to the submissions as\n // the site installed them, which a declaration made before `init` cannot\n // name, and which the card's data carries.\n isEmpty: () => false,\n preview: [{ name: \"Two new\", data: PREVIEW }],\n // The card's own read, asked for no rows, so the header counts exactly\n // what the card lists, a floor (\"1,000+\") where the card's is one.\n summary: {\n label: \"new submissions\",\n count: async ({ services, signal, scope }) => {\n const { total, atLeast } = await readNewSubmissions(\n services,\n declared,\n { limit: 0, signal, scope, submissionsPath: submissionsPath() }\n );\n return { count: total, atLeast: atLeast === true };\n },\n },\n });\n}\n","/**\n * Reading a stored row as one of this plugin's documents.\n *\n * The services layer answers with a loose row -- `id`, `createdAt`, `updatedAt`\n * and an `unknown` index signature -- while this plugin declares concrete\n * documents. There is no overlap for TypeScript to check between the two, so\n * the conversion goes through `unknown` and **nothing here is verified at\n * compile time**. A single assertion does not compile:\n *\n * ```\n * error TS2352: Conversion of type 'CollectionEntry' to type\n * 'SubmissionDocument' may be a mistake because neither type sufficiently\n * overlaps with the other.\n * ```\n *\n * The parameters are therefore `unknown`, which is what the rows honestly are\n * at this boundary. One assertion from `unknown` is legal where two from a\n * partially-known shape were needed, so the widening removes a cast rather\n * than adding one -- but it removes the notation, not the risk.\n *\n * The point of this module is therefore not safety it cannot provide. It is\n * that the unchecked step happens in ONE place a reviewer can find, rather than\n * at six call sites where it reads as ordinary code.\n *\n * @module document-shapes\n */\n\nimport type { FormDocument, SubmissionDocument } from \"./types\";\n\n/**\n * A row as the services layer hands it over.\n *\n * `unknown` rather than `Record<string, unknown>`: the read paths type their\n * results loosely enough that a narrower parameter only moves the assertion to\n * the call site, which is the thing this module exists to stop.\n */\ntype StoredRow = unknown;\n\n/**\n * Read a stored row as a submission.\n *\n * Does not validate, and deliberately cannot throw: every caller is on a path\n * where the row has already committed, so rejecting here would turn a saved\n * submission into an error the visitor is invited to retry.\n */\nexport function asSubmissionDocument(row: StoredRow): SubmissionDocument {\n return row as SubmissionDocument;\n}\n\n/** Read a list of stored rows as submissions. */\nexport function asSubmissionDocuments(\n rows: readonly StoredRow[]\n): SubmissionDocument[] {\n return rows as SubmissionDocument[];\n}\n\n/** Read a stored row as a form. Same contract as {@link asSubmissionDocument}. */\nexport function asFormDocument(row: StoredRow): FormDocument {\n return row as FormDocument;\n}\n","/**\n * Dynamic Zod Schema Generator\n *\n * Converts FormField[] configuration to Zod validation schemas.\n * Supports all form field types with proper validation rules.\n *\n * @module utils/generate-schema\n * @since 0.1.0\n */\n\nimport type { FieldValidationRule } from \"nextly/field-catalog\";\nimport { z } from \"zod\";\n\nimport { isKnownFormField } from \"../types\";\nimport type {\n AnyFieldValidation,\n AnyFormField,\n FormField,\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"../types\";\n\nimport { ENFORCED_VALIDATION_RULES } from \"./enforced-validation\";\n\n// ============================================================\n// Main Schema Generator\n// ============================================================\n\n/**\n * Generate a Zod validation schema from form field configuration.\n *\n * Creates a dynamic Zod schema based on the form fields array,\n * applying appropriate validation rules for each field type.\n *\n * @param fields - Array of form field configurations\n * @returns Zod object schema for validating submission data\n *\n * @example\n * ```typescript\n * const fields: FormField[] = [\n * { type: 'text', name: 'firstName', label: 'First Name', required: true },\n * { type: 'email', name: 'email', label: 'Email', required: true },\n * { type: 'number', name: 'age', label: 'Age', validation: { min: 18, max: 120 } },\n * ];\n *\n * const schema = generateZodSchema(fields);\n * const result = schema.safeParse({ firstName: 'John', email: 'john@example.com', age: 25 });\n * ```\n */\nexport function generateZodSchema(\n fields: AnyFormField[]\n): z.ZodObject<Record<string, z.ZodTypeAny>> {\n const schemaShape: Record<string, z.ZodTypeAny> = {};\n\n for (const field of fields) {\n // A plugin-contributed field's value shape is owned by the plugin: accept\n // any value, but keep the base required check so a required plugin field\n // cannot be omitted (z.unknown() alone treats a missing key as valid).\n if (!isKnownFormField(field)) {\n schemaShape[field.name] = field.required\n ? z\n .unknown()\n .refine(\n value => value !== undefined && value !== null && value !== \"\",\n { message: `${field.label || field.name} is required` }\n )\n : z.unknown();\n continue;\n }\n\n // Skip hidden fields without required flag\n if (field.type === \"hidden\" && !field.required) {\n // Still add to schema but make optional\n schemaShape[field.name] = z.string().optional();\n continue;\n }\n\n const fieldSchema = generateFieldSchema(field);\n if (fieldSchema) {\n schemaShape[field.name] = fieldSchema;\n }\n }\n\n return z.object(schemaShape);\n}\n\n// ============================================================\n// Field Schema Generators\n// ============================================================\n\n/**\n * Generate a Zod schema for a single field.\n */\nfunction generateFieldSchema(field: FormField): z.ZodTypeAny | null {\n switch (field.type) {\n case \"text\":\n return generateTextSchema(field);\n case \"email\":\n return generateEmailSchema(field);\n case \"number\":\n return generateNumberSchema(field);\n case \"phone\":\n return generatePhoneSchema(field);\n case \"url\":\n return generateUrlSchema(field);\n case \"textarea\":\n return generateTextareaSchema(field);\n case \"select\":\n return generateSelectSchema(field);\n case \"checkbox\":\n return generateCheckboxSchema(field);\n case \"radio\":\n return generateRadioSchema(field);\n case \"file\":\n return generateFileSchema(field);\n case \"date\":\n return generateDateSchema(field);\n case \"time\":\n return generateTimeSchema(field);\n case \"hidden\":\n return generateHiddenSchema(field);\n default:\n // Unreachable for a known FormField; plugin types are handled in\n // generateZodSchema before this switch is reached.\n return z.unknown();\n }\n}\n\n/**\n * Generate schema for text field.\n */\n/**\n * The author's own wording for a failure, or `undefined` when this field's type\n * does not enforce `message`.\n *\n * Asked of the row rather than read off `validation` directly, for the reason\n * the table exists at all: the editor decides whether to OFFER the control from\n * that row, so a generator honouring a stored message regardless would enforce\n * a value the author was never shown a box for — and, worse, the table would\n * describe a rule it did not govern.\n *\n * `message` was the one rule that stayed outside this. It agreed with the table\n * only because every row happens to list it, which is agreement by coincidence:\n * drop `message` from a row and the control disappears while the wording went\n * on being applied.\n */\nfunction customMessage(field: FormField): string | undefined {\n const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];\n if (!enforced.includes(\"message\")) return undefined;\n return field.validation?.errorMessage;\n}\n\n/**\n * A field's stored bounds, with the custom message already resolved by the row.\n *\n * Handed to the appliers so they cannot read an ungated message: they take a\n * validation object, and giving them one whose `errorMessage` has been through\n * {@link customMessage} makes the rule hold for every applier without each of\n * them having to remember it.\n */\nfunction enforcedValidation(field: FormField): AnyFieldValidation {\n const stored = (field.validation ?? {}) as AnyFieldValidation;\n return { ...stored, errorMessage: customMessage(field) };\n}\n\n/**\n * Which rules a type's schema applies, and how each one constrains a string.\n *\n * The WHICH comes from {@link ENFORCED_VALIDATION_RULES}, which the field\n * editor also reads to decide what to offer. One definition answers both\n * questions, so a rule cannot be offered by the editor and ignored here, or\n * honoured here and hidden there — the two used to state the same thing\n * separately and could only agree by being edited together.\n *\n * The HOW stays here, because it is about zod and the table is deliberately\n * free of it: the editor imports the table, and pulling the schema builder in\n * behind it would ship this file and its validation library to every admin\n * client before anyone opened a field.\n */\nconst STRING_RULE_APPLIERS: Partial<\n Record<\n FieldValidationRule,\n (\n schema: z.ZodString,\n validation: AnyFieldValidation,\n fallback: string\n ) => z.ZodString\n >\n> = {\n minLength: (schema, validation) =>\n validation.minLength === undefined\n ? schema\n : schema.min(\n validation.minLength,\n validation.errorMessage ||\n `Minimum ${validation.minLength} characters required`\n ),\n maxLength: (schema, validation) =>\n validation.maxLength === undefined\n ? schema\n : schema.max(\n validation.maxLength,\n validation.errorMessage ||\n `Maximum ${validation.maxLength} characters allowed`\n ),\n pattern: (schema, validation, fallback) =>\n validation.pattern\n ? schema.regex(\n new RegExp(validation.pattern),\n validation.errorMessage || fallback\n )\n : schema,\n};\n\n/** The same, for the bounds a number carries. */\nconst NUMBER_RULE_APPLIERS: Partial<\n Record<\n FieldValidationRule,\n (schema: z.ZodNumber, validation: AnyFieldValidation) => z.ZodNumber\n >\n> = {\n min: (schema, validation) =>\n validation.min === undefined\n ? schema\n : schema.min(\n validation.min,\n validation.errorMessage || `Minimum value is ${validation.min}`\n ),\n max: (schema, validation) =>\n validation.max === undefined\n ? schema\n : schema.max(\n validation.max,\n validation.errorMessage || `Maximum value is ${validation.max}`\n ),\n};\n\n/**\n * Whether an author-supplied pattern actually replaces a type's intrinsic one.\n *\n * Two conditions, and both are load-bearing: a pattern must be stored, AND the\n * type's row must enforce `pattern`. A type that keeps its own shape check —\n * a phone, a URL — stands it down only for an override that will really be\n * applied, because standing down for one that will not leaves nothing behind.\n */\nfunction overridesPattern(field: FormField): boolean {\n const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];\n if (!enforced.includes(\"pattern\")) return false;\n const validation = (field.validation ?? {}) as AnyFieldValidation;\n return Boolean(validation.pattern);\n}\n\n/**\n * Constrain a string schema by the rules this field's TYPE enforces.\n *\n * A rule the type does not list is not applied even when the field carries a\n * value for it, which is the point: a `pattern` stored on a textarea is a bound\n * the editor never offered, and honouring it here would enforce a restriction\n * its author could not see.\n *\n * `patternMessage` is the type's own wording for a failed pattern — \"Invalid\n * URL format\" reads differently from \"Invalid format\" — so the rule set decides\n * WHICH constraints apply while each type keeps its own voice.\n */\nfunction applyStringRules(\n schema: z.ZodString,\n field: FormField,\n patternMessage: string\n): z.ZodString {\n const validation = enforcedValidation(field);\n let constrained = schema;\n for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {\n const apply = STRING_RULE_APPLIERS[rule];\n if (apply) constrained = apply(constrained, validation, patternMessage);\n }\n return constrained;\n}\n\nfunction generateTextSchema(field: TextFormField): z.ZodTypeAny {\n const schema = applyStringRules(z.string(), field, \"Invalid format\");\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for email field.\n */\nfunction generateEmailSchema(field: EmailFormField): z.ZodTypeAny {\n // The format check is the type's own, not a validation rule: an email field\n // is an email field whether or not its author bounded it further.\n const schema = applyStringRules(\n z.string().email(customMessage(field) || \"Invalid email address\"),\n field,\n \"Invalid email format\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for number field.\n */\nfunction generateNumberSchema(field: NumberFormField): z.ZodTypeAny {\n // Zod 4 unified message customization under a single `error` callback;\n // an undefined input is the \"missing required\" case, anything else is a\n // type mismatch.\n let schema = z.number({\n error: issue =>\n issue.input === undefined\n ? customMessage(field) || \"This field is required\"\n : \"Must be a number\",\n });\n\n // The same rule set the editor reads decides which bounds apply here.\n const validation = enforcedValidation(field);\n for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {\n const apply = NUMBER_RULE_APPLIERS[rule];\n if (apply) schema = apply(schema, validation);\n }\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional().nullable();\n}\n\n/**\n * Generate schema for phone field.\n */\nfunction generatePhoneSchema(field: PhoneFormField): z.ZodTypeAny {\n // The default pattern is part of what a phone field IS, so it belongs to the\n // base rather than to the rule set: it applies when the author supplied none,\n // and the author's own pattern replaces it through the rule below.\n //\n // Whether the author CAN replace it is the row's decision, not the stored\n // value's. Standing the base down because a pattern happens to be stored,\n // while `applyStringRules` then declines to apply it because the row does not\n // list `pattern`, would leave the field with no check at all — every string\n // accepted, on the one type whose whole purpose is a shape.\n const base = overridesPattern(field)\n ? z.string()\n : z\n .string()\n .regex(\n /^[\\d\\s\\-+()]+$/,\n customMessage(field) || \"Invalid phone number\"\n );\n\n const schema = applyStringRules(base, field, \"Invalid phone number format\");\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for URL field.\n */\nfunction generateUrlSchema(field: UrlFormField): z.ZodTypeAny {\n const schema = applyStringRules(\n z.string().url(customMessage(field) || \"Invalid URL\"),\n field,\n \"Invalid URL format\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for textarea field.\n */\nfunction generateTextareaSchema(field: TextareaFormField): z.ZodTypeAny {\n const schema = applyStringRules(z.string(), field, \"Invalid format\");\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for select field.\n */\nfunction generateSelectSchema(field: SelectFormField): z.ZodTypeAny {\n const validValues = field.options.map(opt => opt.value);\n\n if (field.allowMultiple) {\n // Multi-select: array of valid values\n const schema = z.array(z.enum(validValues as [string, ...string[]]));\n\n if (field.required) {\n return schema.min(\n 1,\n customMessage(field) || \"Please select at least one option\"\n );\n }\n\n return schema.optional();\n }\n\n // Single select\n const schema = z.enum(validValues as [string, ...string[]], {\n error: () => customMessage(field) || \"Please select a valid option\",\n });\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for checkbox field.\n */\nfunction generateCheckboxSchema(field: CheckboxFormField): z.ZodTypeAny {\n const schema = z.boolean();\n\n if (field.required) {\n // For required checkbox, value must be true\n return schema.refine(val => val === true, {\n message: customMessage(field) || \"This field is required\",\n });\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for radio field.\n */\nfunction generateRadioSchema(field: RadioFormField): z.ZodTypeAny {\n const validValues = field.options.map(opt => opt.value);\n\n const schema = z.enum(validValues as [string, ...string[]], {\n error: () => customMessage(field) || \"Please select an option\",\n });\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for file field.\n *\n * Note: File validation is typically handled server-side.\n * This schema validates file references (IDs or URLs).\n */\nfunction generateFileSchema(field: FileFormField): z.ZodTypeAny {\n if (field.multiple) {\n const schema = z.array(z.string());\n\n if (field.required) {\n return schema.min(\n 1,\n customMessage(field) || \"Please upload at least one file\"\n );\n }\n\n return schema.optional();\n }\n\n // Single file\n const schema = z.string();\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for date field.\n */\nfunction generateDateSchema(field: DateFormField): z.ZodTypeAny {\n // Start with base string schema\n let schema: z.ZodTypeAny = z.string();\n\n // Add date format validation\n schema = (schema as z.ZodString).refine(\n val => {\n if (!val) return true; // Allow empty for optional fields\n const date = new Date(val);\n return !isNaN(date.getTime());\n },\n { message: customMessage(field) || \"Invalid date\" }\n );\n\n // Min/max date validation\n if (field.min || field.max) {\n schema = schema.refine(\n (val: unknown) => {\n if (!val) return true;\n const date = new Date(val as string);\n if (field.min && date < new Date(field.min)) return false;\n if (field.max && date > new Date(field.max)) return false;\n return true;\n },\n {\n message:\n customMessage(field) ||\n `Date must be between ${field.min || \"any\"} and ${field.max || \"any\"}`,\n }\n );\n }\n\n // Handle required vs optional\n if (field.required) {\n // Add non-empty check for required fields\n return schema.refine(\n (val: unknown) => val !== undefined && val !== null && val !== \"\",\n { message: customMessage(field) || \"This field is required\" }\n );\n }\n\n return schema.optional().or(z.literal(\"\"));\n}\n\n/**\n * Generate schema for time field.\n */\nfunction generateTimeSchema(field: TimeFormField): z.ZodTypeAny {\n const schema = z\n .string()\n .regex(\n /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,\n customMessage(field) || \"Invalid time format (HH:mm)\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for hidden field.\n */\nfunction generateHiddenSchema(field: HiddenFormField): z.ZodTypeAny {\n const schema = z.string();\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Apply required/optional to a schema.\n */\nfunction applyRequired(\n schema: z.ZodString,\n required?: boolean,\n errorMessage?: string\n): z.ZodTypeAny {\n if (required) {\n return schema.min(1, errorMessage || \"This field is required\");\n }\n\n return schema.optional().or(z.literal(\"\"));\n}\n\n// ============================================================\n// Data Transformation\n// ============================================================\n\n/**\n * Transform form data before validation.\n *\n * Handles type coercion and normalization:\n * - Converts string numbers to actual numbers\n * - Converts string booleans to actual booleans\n * - Trims string values\n * - Handles empty strings\n *\n * @param data - Raw form data\n * @param fields - Form field configurations\n * @returns Transformed data ready for validation\n */\nexport function transformFormData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): Record<string, unknown> {\n const transformed: Record<string, unknown> = {};\n\n for (const field of fields) {\n const value = data[field.name];\n\n // Skip undefined values\n if (value === undefined) {\n continue;\n }\n\n // A plugin field's value passes through untransformed — the plugin owns its\n // value shape; only built-in types get type coercion.\n transformed[field.name] = isKnownFormField(field)\n ? transformFieldValue(value, field)\n : value;\n }\n\n return transformed;\n}\n\n/**\n * Transform a single field value based on its type.\n */\nfunction transformFieldValue(value: unknown, field: FormField): unknown {\n // Handle null/undefined\n if (value === null || value === undefined) {\n return value;\n }\n\n switch (field.type) {\n case \"number\":\n // Coerce to number\n if (typeof value === \"string\") {\n const num = parseFloat(value);\n return isNaN(num) ? value : num;\n }\n return value;\n\n case \"checkbox\":\n // Coerce to boolean\n if (typeof value === \"string\") {\n return value === \"true\" || value === \"1\" || value === \"on\";\n }\n return Boolean(value);\n\n case \"text\":\n case \"email\":\n case \"phone\":\n case \"url\":\n case \"textarea\":\n case \"hidden\":\n // Trim strings\n if (typeof value === \"string\") {\n return value.trim();\n }\n return value;\n\n case \"select\":\n // Ensure array for multi-select\n if (field.allowMultiple) {\n if (!Array.isArray(value)) {\n return value ? [value] : [];\n }\n }\n return value;\n\n default:\n return value;\n }\n}\n\n// ============================================================\n// Validation Helper\n// ============================================================\n\n/**\n * Validate form data against a form's field configuration.\n *\n * Combines transformation and validation in one step.\n *\n * @param data - Raw form data\n * @param fields - Form field configurations\n * @returns Zod safe parse result\n *\n * @example\n * ```typescript\n * const result = validateFormData({ name: 'John', email: 'john@example.com' }, fields);\n *\n * if (result.success) {\n * console.log('Valid data:', result.data);\n * } else {\n * console.log('Validation errors:', result.error.flatten());\n * }\n * ```\n */\nexport function validateFormData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): z.ZodSafeParseResult<Record<string, unknown>> {\n const transformed = transformFormData(data, fields);\n const schema = generateZodSchema(fields);\n return schema.safeParse(transformed);\n}\n\n/**\n * Get validation errors as a flat object.\n *\n * @param result - Zod safe parse result\n * @returns Object mapping field names to error messages\n */\n/** A field that failed, with the machine code a wire error reports. */\nexport interface ValidationIssue {\n path: string;\n /** `REQUIRED` for an answer that is missing or empty, else `INVALID`. */\n code: \"REQUIRED\" | \"INVALID\";\n message: string;\n}\n\n/**\n * Whether the visitor supplied nothing at this field.\n *\n * The same four shapes the endpoint has always counted as blank. A `false`\n * checkbox and a `0` are answers, so neither is absent.\n */\nfunction isBlank(value: unknown): boolean {\n return (\n value === undefined ||\n value === null ||\n value === \"\" ||\n (Array.isArray(value) && value.length === 0)\n );\n}\n\n/**\n * The failures with their codes, first one per field.\n *\n * A code as well as a sentence, because the public submission error has always\n * carried one and a client uses it to tell \"you left this blank\" from \"this is\n * not an email\" without parsing English. Flattening every failure to `INVALID`\n * silently stopped required-field detection working for those clients.\n *\n * The code is decided by looking at the SUBMITTED VALUE, not at the Zod issue.\n * Zod's codes do not divide the same way: `too_small` covers a blank answer and\n * a four-character password under a minimum of eight, and `invalid_type` covers\n * an absent key and a string sent to a number field. Reading the code alone\n * told a visitor to fill in a field they had already filled in.\n */\nexport function getValidationIssues(\n result: z.ZodSafeParseResult<unknown>,\n submitted: Record<string, unknown> = {}\n): ValidationIssue[] {\n if (result.success) return [];\n\n const seen = new Set<string>();\n const issues: ValidationIssue[] = [];\n\n // Zod 4 renamed ZodError.errors to ZodError.issues.\n for (const issue of result.error.issues) {\n const path = issue.path.join(\".\");\n if (seen.has(path)) continue;\n seen.add(path);\n issues.push({\n path,\n // Read at the top level, which is the only depth a form field has: a\n // declared field is one key, so a dotted path can only come from inside a\n // value the visitor did supply.\n code: isBlank(submitted[path]) ? \"REQUIRED\" : \"INVALID\",\n message: issue.message,\n });\n }\n\n return issues;\n}\n\n/**\n * The same failures as a field-to-message map.\n *\n * Derived from {@link getValidationIssues} rather than walking the issues a\n * second time, so the two cannot disagree about which message belongs to a\n * field or which failure won when one field had several.\n */\nexport function getValidationErrors(\n result: z.ZodSafeParseResult<unknown>\n): Record<string, string> {\n return Object.fromEntries(\n getValidationIssues(result).map(issue => [issue.path, issue.message])\n );\n}\n","/**\n * What a submission has to be before it is stored, wherever it came from.\n *\n * Two things accept a submission. `submitForm` answers `POST\n * /api/forms/:slug/submit`, and `nextly.forms.submit()` is the Direct API a\n * server-side caller reaches through `getNextly()`. The first transformed,\n * validated and sanitized; the second did none of it, so a submission arriving\n * that way was stored with undeclared keys, values that did not match the\n * form's own schema, and markup intact.\n *\n * The rule lives here rather than in either caller because a rule stated in one\n * of two places is a rule the other can disagree with, and the two had already\n * drifted that far apart. `beforeCreate` on the submissions collection now asks\n * this on every write, so a third caller inherits it without knowing it exists.\n *\n * What is NOT here is the spam machinery: the honeypot, the rate limiter and\n * reCAPTCHA are facts about a REQUEST, not about a row. A trusted server\n * importing a thousand submissions would be rate-limited by its own import, and\n * a honeypot field means nothing without a browser to have skipped it. Those\n * stay on the HTTP path.\n */\n\nimport { stripHtmlTags } from \"nextly\";\n\nimport type { AnyFormField } from \"../types\";\nimport {\n generateZodSchema,\n getValidationErrors,\n getValidationIssues,\n type ValidationIssue,\n transformFormData,\n} from \"../utils/generate-schema\";\n\n/**\n * Form field types that accept free-text string input from end users.\n * These fields can contain HTML injection vectors and must be sanitized.\n *\n * Fields NOT in this set (select, radio, checkbox, number, date, time, file)\n * are constrained by Zod enum/type validation and don't need sanitization.\n */\nconst TEXT_FORM_FIELDS = new Set([\n \"text\",\n \"email\",\n \"textarea\",\n \"phone\",\n \"url\",\n \"hidden\",\n]);\n\n/**\n * Sanitize form submission data by stripping HTML tags from free-text fields.\n *\n * Iterates over the form's field definitions and applies `stripHtmlTags()`\n * to values whose field type is in `TEXT_FORM_FIELDS`. Non-string values\n * and constrained fields (select, radio, checkbox, etc.) are left unchanged.\n *\n * Mutates the data object in place for efficiency.\n *\n * @param data - Validated submission data (mutated in place)\n * @param fields - Form field definitions (used for type-aware dispatch)\n */\nexport function sanitizeSubmissionData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): void {\n for (const field of fields) {\n // Plugin field types are not in TEXT_FORM_FIELDS, so they skip naturally.\n if (!TEXT_FORM_FIELDS.has(field.type)) continue;\n\n const value = data[field.name];\n if (typeof value !== \"string\") continue;\n\n data[field.name] = stripHtmlTags(value);\n }\n}\n\n/**\n * How the plugin's own submit handler says a row is its own.\n *\n * The write-seam check has to be lenient in exactly one case: a honeypot or\n * reCAPTCHA hit is stored flagged rather than dropped, so a false positive stays\n * reviewable, and requiring it to be valid would throw away the thing being\n * reviewed. Deciding that from the row's own `status` made it caller-controlled:\n * the submissions collection grants public create, nothing restricts `status`,\n * so anyone could post `status: \"spam\"` and switch validation off.\n *\n * A SYMBOL key on the row is the channel, and it is the only one that both\n * reaches the hook and cannot be reached from a request. `JSON.parse` never\n * produces a symbol key, so nothing a caller posts can carry it, and\n * `JSON.stringify` ignores it, so it cannot be stored by accident. It travels\n * with the row it describes, which is what the alternatives could not do.\n *\n * The alternatives were measured, not assumed. `createEntry`'s `params.context`\n * is unreachable: `wrapCollectionsForPlugin` rebuilds the trailing argument as\n * `{ user, overrideAccess }` and drops everything else. `AsyncLocalStorage`\n * reaches the hook but describes the CALL rather than the row, so a submission\n * written by another hook inside the same call took the exception, and\n * identifying the intended row by its content broke as soon as a host hook\n * normalised the payload, since core rebuilds the payload object on the way.\n * A symbol on the row has neither problem: booting a real Nextly and reading\n * the hook's context back, the row's symbol arrives and the payload's does not.\n */\nconst PLUGIN_SUBMISSION = Symbol.for(\"nextly.plugin-form-builder.submission\");\n\n/** What the handler says about the row it is writing. */\nexport interface SubmissionOriginMarks {\n /**\n * The handler decided this row is spam and is keeping it as evidence, so the\n * write seam sanitizes it but does not require it to be valid.\n */\n keepAsEvidence: boolean;\n /**\n * The parent form the handler has already read, so the write seam can check\n * the payload against it without reading it again. Used only when its id is\n * the one the row names.\n */\n form?: Record<string, unknown>;\n}\n\n/** Mark `row` as this plugin's own write, and hand it back. */\nexport function asPluginSubmission(\n row: Record<string, unknown>,\n marks: SubmissionOriginMarks\n): Record<string, unknown> {\n return Object.defineProperty(row, PLUGIN_SUBMISSION, {\n value: marks,\n enumerable: true,\n configurable: true,\n });\n}\n\n/** A row as it looks once this plugin has marked it. */\ninterface MarkedSubmission {\n [PLUGIN_SUBMISSION]?: SubmissionOriginMarks;\n}\n\n/** What this row was marked as, or nothing when it is somebody else's. */\nexport function submissionMarks(\n row: Record<string, unknown>\n): SubmissionOriginMarks | undefined {\n return (row as MarkedSubmission)[PLUGIN_SUBMISSION];\n}\n\n/** A form value, compared the way a form value can be. */\nfunction sameValue(left: unknown, right: unknown): boolean {\n if (Object.is(left, right)) return true;\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() === right.getTime();\n }\n if (Array.isArray(left) && Array.isArray(right)) {\n return (\n left.length === right.length &&\n left.every((value, index) => sameValue(value, right[index]))\n );\n }\n return false;\n}\n\n/** Whether two submitted payloads say the same thing. */\nexport function sameSubmittedPayload(\n granted: Record<string, unknown>,\n incoming: Record<string, unknown>\n): boolean {\n const answers = Object.entries(granted);\n if (answers.length !== Object.keys(incoming).length) return false;\n return answers.every(([field, answer]) => sameValue(answer, incoming[field]));\n}\n\n/** A submission ready to store, or the reasons it is not. */\nexport interface PreparedSubmission {\n /** Transformed and sanitized. Present whether or not validation passed. */\n data: Record<string, unknown>;\n /**\n * Field name to its first message, which is the shape `getValidationErrors`\n * already returns and the HTTP handler already answers with. Absent when the\n * submission is storable.\n */\n validationErrors?: Record<string, string>;\n /**\n * The same failures carrying their machine codes.\n *\n * Beside the map rather than replacing it: the map is this plugin's public\n * result shape, while an HTTP route needs the code to report `REQUIRED` for a\n * blank answer the way the endpoint always has. Both come from one walk of\n * the issues, so they cannot disagree.\n */\n validationIssues?: ValidationIssue[];\n}\n\n/**\n * Bring a submission to the shape the form declares.\n *\n * Transform, then sanitize, then validate, in that order and for reasons.\n * Transforming projects the payload onto the declared fields, so an undeclared\n * key never reaches the schema or the row. Sanitizing before validating is what\n * makes every rule judge the value that will actually be stored: the other\n * order let `<b></b>` satisfy a required field and then reduced it to an empty\n * string, and made a length rule count markup the visitor never sees.\n *\n * `validate: false` is for content the plugin has already decided to keep as\n * evidence. A honeypot hit is stored flagged rather than dropped so a false\n * positive stays recoverable, and requiring it to be valid would throw the\n * evidence away. It is still transformed and sanitized, because a row nobody\n * validated is exactly the one that should not carry markup.\n *\n * Idempotent, which is what lets the write-seam hook run over data the HTTP\n * handler has already prepared: transforming a projected payload projects it\n * again, and stripping tags from text that has none returns it unchanged.\n */\nexport function prepareSubmission({\n data,\n fields,\n validate,\n}: {\n data: Record<string, unknown>;\n fields: AnyFormField[];\n validate: boolean;\n}): PreparedSubmission {\n const transformed = transformFormData(data, fields);\n sanitizeSubmissionData(transformed, fields);\n\n if (!validate) {\n return { data: transformed };\n }\n\n const result = generateZodSchema(fields).safeParse(transformed);\n if (!result.success) {\n // The transformed payload comes back with the errors rather than nothing,\n // so a caller that wants to store it anyway has the same value the valid\n // path would have produced, minus the schema's blessing.\n return {\n data: transformed,\n validationErrors: getValidationErrors(result),\n // The RAW submission, not the transformed payload. Transformation\n // normalises a value before the schema sees it, so a required\n // multi-select sent a scalar `0` arrives here as `[]` and would read as\n // blank: the visitor answered, and would be told they had not.\n validationIssues: getValidationIssues(result, data),\n };\n }\n\n return { data: result.data };\n}\n","/**\n * Spam Detection\n *\n * Honeypot and rate limiting for form submissions.\n *\n * The honeypot is a pure read of the submitted payload. The rate limit is not:\n * it counts, and where the count lives decides whether the limit means\n * anything. It lives in the deployment's own {@link RateLimitStore}, the one\n * the REST and auth limiters already share, so `rateLimit.store` is configured\n * once and all three agree about what they are counting. A store private to\n * this module would count per process, and on a deployment that spans several\n * the effective limit becomes `configured x instances` -- a number the operator\n * never chose, that fails open, and only under load.\n *\n * @module handlers/spam-detection\n * @since 0.1.0\n */\n\nimport { RateLimiter, resolveRateLimitStore } from \"nextly\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Configuration for spam detection.\n */\nexport interface SpamCheckConfig {\n /**\n * Enable honeypot field detection.\n * When enabled, submissions with filled honeypot fields are marked as spam.\n */\n honeypot?: boolean;\n\n /**\n * Rate limiting configuration.\n */\n rateLimit?: {\n /** Maximum submissions allowed per window */\n maxSubmissions: number;\n /** Time window in milliseconds */\n windowMs: number;\n };\n\n /**\n * reCAPTCHA configuration (for future implementation).\n */\n recaptcha?: {\n enabled: boolean;\n secretKey?: string;\n scoreThreshold?: number;\n };\n}\n\n/**\n * Options for spam check.\n */\nexport interface SpamCheckOptions {\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Submitter's IP address */\n ipAddress?: string;\n\n /** Form slug for rate limiting key */\n formSlug: string;\n\n /** Spam protection configuration */\n config: SpamCheckConfig;\n}\n\n/**\n * Result of spam check.\n */\nexport interface SpamCheckResult {\n /** Whether the submission is detected as spam */\n isSpam: boolean;\n\n /** Reason for spam detection (for logging, not exposed to user) */\n reason?: \"honeypot\" | \"rate_limit\" | \"recaptcha\";\n\n /** Additional details (for logging) */\n details?: string;\n}\n\n// ============================================================\n// Main Spam Check Function\n// ============================================================\n\n/**\n * Check a form submission for spam indicators.\n *\n * Performs multiple spam checks based on configuration:\n * 1. Honeypot field detection\n * 2. Rate limiting by IP/form\n * 3. reCAPTCHA verification (future)\n *\n * @param options - Spam check options\n * @returns Spam check result\n *\n * @example\n * ```typescript\n * const result = await checkSpam({\n * data: formData,\n * ipAddress: req.ip,\n * formSlug: 'contact-form',\n * config: {\n * honeypot: true,\n * rateLimit: { maxSubmissions: 5, windowMs: 60000 },\n * },\n * });\n *\n * if (result.isSpam) {\n * // Silently reject (return fake success to bot)\n * return { success: true };\n * }\n * ```\n */\nexport async function checkSpam(\n options: SpamCheckOptions\n): Promise<SpamCheckResult> {\n const { data, ipAddress, formSlug, config } = options;\n\n // 1. Honeypot check\n if (config.honeypot) {\n const honeypotResult = checkHoneypot(data);\n if (honeypotResult.isSpam) {\n return honeypotResult;\n }\n }\n\n // 2. Rate limiting check\n if (config.rateLimit && ipAddress) {\n const rateLimitResult = await checkRateLimit(\n ipAddress,\n formSlug,\n config.rateLimit\n );\n if (rateLimitResult.isSpam) {\n return rateLimitResult;\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Honeypot Detection\n// ============================================================\n\n/**\n * Standard honeypot field names to check.\n * These fields should be hidden from users but filled by bots.\n */\nconst HONEYPOT_FIELDS = [\n \"__honeypot\",\n \"_honeypot\",\n \"honeypot\",\n \"__hp\",\n \"_hp\",\n \"website\", // Common honeypot field name\n \"url_field\", // Another common honeypot\n \"fax_number\", // Rarely used by humans\n];\n\n/**\n * Check for honeypot field presence.\n *\n * Honeypot fields are hidden form fields that legitimate users\n * won't see or fill, but bots often fill automatically.\n *\n * @param data - Form submission data\n * @returns Spam check result\n */\nfunction checkHoneypot(data: Record<string, unknown>): SpamCheckResult {\n for (const fieldName of HONEYPOT_FIELDS) {\n const value = data[fieldName];\n\n // Check if honeypot field has a non-empty value\n if (value !== undefined && value !== null && value !== \"\") {\n let raw: string;\n if (typeof value === \"object\") {\n raw = JSON.stringify(value);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- value narrowed to primitive above; rule doesn't follow control flow on unknown\n raw = String(value);\n }\n const stringValue = raw.trim();\n if (stringValue.length > 0) {\n return {\n isSpam: true,\n reason: \"honeypot\",\n details: `Honeypot field \"${fieldName}\" was filled`,\n };\n }\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Rate Limiting\n// ============================================================\n\n/**\n * Whether this address has submitted this form too often, recording the attempt.\n *\n * Keyed by form and address together, so a burst against one form does not\n * lock a visitor out of another. The window lives in the deployment's shared\n * store, so two processes serving the same visitor count once.\n *\n * An address of `unknown` is not a bucket to key on: every client whose address\n * could not be resolved would share one window, and the first bot to fill it\n * would lock out every visitor behind an untrusted proxy. The caller decides\n * what to do about an unidentifiable client; this counts only identified ones.\n */\nasync function checkRateLimit(\n ipAddress: string,\n formSlug: string,\n config: { maxSubmissions: number; windowMs: number }\n): Promise<SpamCheckResult> {\n const limiter = new RateLimiter(resolveRateLimitStore());\n const result = await limiter.check(\n `form-submit:${formSlug}:${ipAddress}`,\n config.maxSubmissions,\n config.windowMs\n );\n if (result.allowed) return { isSpam: false };\n return {\n isSpam: true,\n reason: \"rate_limit\",\n details: `Rate limit exceeded: ${config.maxSubmissions} per ${config.windowMs}ms`,\n };\n}\n\n// ============================================================\n// reCAPTCHA Support (Future Implementation)\n// ============================================================\n\n/**\n * Verify reCAPTCHA v3 token.\n *\n * Note: This is a placeholder for future implementation.\n * reCAPTCHA verification requires server-side API calls to Google.\n *\n * @param data - Form data containing reCAPTCHA token\n * @param config - reCAPTCHA configuration\n * @returns Spam check result\n */\n// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await -- declared return type is `Promise<SpamCheckResult>`; placeholder for unimplemented Google siteverify fetch\nasync function verifyRecaptcha(\n _data: Record<string, unknown>,\n _config: { secretKey?: string; scoreThreshold?: number }\n): Promise<SpamCheckResult> {\n // Placeholder - always pass for now\n return { isSpam: false };\n}\n","/**\n * Form Submission Handler\n *\n * Processes form submissions including validation, spam detection,\n * storage, and notification triggering.\n *\n * @module handlers/submit-form\n * @since 0.1.0\n */\n\nimport { isLocaleSelector } from \"@nextlyhq/plugin-sdk\";\nimport { formAvailability, NextlyError, NO_SUCH_FORM } from \"nextly\";\nimport type { PluginContext } from \"nextly\";\n\nimport { asFormDocument, asSubmissionDocument } from \"../document-shapes\";\nimport type {\n FormDocument,\n SubmissionDocument,\n ResolvedFormBuilderConfig,\n} from \"../types\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\nimport type { ValidationIssue } from \"../utils/generate-schema\";\nimport {\n applyRedirectPattern,\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type RedirectTargetDocument,\n type RedirectUrlPattern,\n} from \"../utils/redirect-target\";\n\nimport { asPluginSubmission, prepareSubmission } from \"./prepare-submission\";\nimport { checkSpam } from \"./spam-detection\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Options for submitting a form.\n */\nexport interface SubmitFormOptions {\n /** Form slug (URL-friendly identifier) */\n formSlug: string;\n\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Optional metadata about the submission */\n metadata?: {\n /** Submitter's IP address */\n ipAddress?: string;\n /** Submitter's user agent string */\n userAgent?: string;\n };\n\n /**\n * The HTTP request this submission arrived on.\n *\n * A route calling this helper is its own route, outside everything Nextly\n * pins a request around, so nothing else can supply it. Without it the write\n * reaches the submissions hooks looking like a seed or a job, and every rule\n * scoped to a visitor stands down: the rate limit above all.\n *\n * The request rather than an address, because the core resolves the client\n * from it under this deployment's proxy-trust settings. `metadata.ipAddress`\n * is a string the caller chose, which is the right shape for the audit column\n * it fills and the wrong shape for deciding who to throttle.\n */\n request?: Request;\n\n /**\n * Whose permissions the reads and the write are judged against.\n *\n * Defaults to `system`, so every existing caller behaves as before.\n */\n access?: SubmissionAccess;\n\n /**\n * The language the visitor submitted in.\n *\n * Decides which translation a picked redirect page is read in, and so which\n * URL the visitor is sent to: a page whose slug is `thanks` in English and\n * `merci` in French answered `/thanks` to everyone, because the target was\n * read with no locale and the default answered. The built-in route takes it\n * from `?locale=` on the submit URL, the same way core's own routes take a\n * locale on the wire; a host route passes whatever it resolved for the page\n * the form was on.\n *\n * A code that is not a configured locale reads as the default rather than\n * failing the submission, which the collection services decide. Absent means\n * the default, as it always has. The read wildcards (`all`, `*`) are not a\n * language a visitor can have submitted in and are treated as absent: a read\n * in \"every language\" answers a localized URL field with one value per\n * language, which no URL pattern can be filled from.\n */\n locale?: string;\n}\n\n/**\n * How this submission reaches the collections it touches.\n *\n * `system` is the default and is right for host code: the caller already\n * decided, and there is no visitor whose permissions could be consulted.\n *\n * `public` is for a route serving an anonymous visitor. It keeps the\n * collection's configured `access` rules in force, so a host that closed\n * submissions or restricted reading forms is obeyed rather than bypassed. The\n * former core endpoint ran this way, and elevating a public route to `system`\n * silently overrode that configuration.\n */\nexport type SubmissionAccess = { as: \"system\" } | { as: \"public\" };\n\n/**\n * Why a submission ended the way it did.\n *\n * `success` alone cannot carry this. An HTTP route has to answer 404 for a slug\n * nobody used, 409 for a form its author closed and 400 for data that did not\n * validate, and a boolean plus a human sentence cannot tell those apart without\n * matching on the sentence.\n *\n * `accepted` covers every ending a visitor is told succeeded, INCLUDING the two\n * that are deliberately indistinguishable from success: a submission the\n * honeypot flagged, and one the rate limiter refused. A caller must not try to\n * separate them, which is why they do not appear here as their own names.\n */\nexport type SubmitFormOutcome =\n | \"accepted\"\n | \"no-such-form\"\n | \"closed\"\n | \"invalid\"\n | \"duplicate\"\n | \"failed\";\n\n/**\n * Result of form submission.\n */\nexport interface SubmitFormResult {\n /** Whether the submission was successful */\n success: boolean;\n\n /**\n * Which ending this was, for a caller that has to map it to a status code.\n *\n * Required rather than optional, so a return path added later cannot omit it\n * and leave the HTTP route guessing from the message text.\n */\n outcome: SubmitFormOutcome;\n\n /** The created submission document (on success) */\n submission?: SubmissionDocument;\n\n /** Error message (on failure) */\n error?: string;\n\n /** Field-level validation errors */\n validationErrors?: Record<string, string>;\n\n /**\n * The same failures with their machine codes, for a caller building an HTTP\n * error. `validationErrors` above cannot carry one, so a route reading only\n * that has to invent a code and reports every blank answer as `INVALID`.\n */\n validationIssues?: ValidationIssue[];\n\n /** Redirect URL (if form configured for redirect on success) */\n redirect?: string;\n\n /**\n * The error that ended a `failed` submission, kept rather than flattened.\n *\n * A collection can refuse a write for reasons its host chose: a\n * `beforeValidate` hook throwing `NextlyError.validation`, a uniqueness\n * conflict. Those carry their own status and field detail, and reporting them\n * all as one internal error tells a visitor the server broke when in fact\n * their submission was answered deliberately.\n */\n cause?: unknown;\n\n /**\n * The toast the form's author wrote for a successful submission.\n *\n * Carried on the result rather than read from the form again by whoever\n * presents it: the HTTP route would otherwise have to fetch the form a second\n * time purely to learn a string this function already had in hand, and a\n * second read is a second chance to disagree about which form was submitted.\n *\n * Present on every `accepted` ending, including the ones a bot caused, so the\n * message cannot be used to tell an accepted submission from a flagged one.\n */\n successMessage?: string;\n}\n\n/**\n * Context for the submission handler.\n */\nexport interface SubmitFormContext {\n /** Plugin context with access to services */\n pluginContext: PluginContext;\n\n /** Resolved plugin configuration */\n pluginConfig: ResolvedFormBuilderConfig;\n}\n\n// ============================================================\n// Main Submission Handler\n// ============================================================\n\n/**\n * Process a form submission.\n *\n * This is the main entry point for handling form submissions.\n * It performs the following steps:\n *\n * 1. Fetch form configuration by slug\n * 2. Verify form status (must be \"published\")\n * 3. Transform and validate submission data\n * 4. Check for spam (honeypot, rate limiting)\n * 5. Create submission record in database\n * 6. Return result with redirect info if configured\n *\n * Note: Email notifications are triggered separately via collection\n * hooks in the submissions collection (see Phase 4.2).\n *\n * @param options - Submission options\n * @param context - Handler context with services\n * @returns Submission result\n *\n * @example\n * ```typescript\n * const result = await submitForm(\n * {\n * formSlug: 'contact-form',\n * data: { name: 'John', email: 'john@example.com', message: 'Hello!' },\n * metadata: { ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0...' },\n * },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (result.success) {\n * console.log('Submission created:', result.submission?.id);\n * if (result.redirect) {\n * // Redirect user\n * }\n * } else {\n * console.error('Submission failed:', result.error);\n * }\n * ```\n */\nexport async function submitForm(\n options: SubmitFormOptions,\n context: SubmitFormContext\n): Promise<SubmitFormResult> {\n const { formSlug, data, metadata, request } = options;\n const access: SubmissionAccess = options.access ?? { as: \"system\" };\n const locale = visitorLocale(options.locale);\n const targetRead: TargetRead = { access, locale, request };\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n const { logger } = pluginContext;\n\n try {\n // 1. Fetch form configuration\n const form = await fetchFormBySlug(\n formSlug,\n pluginConfig,\n pluginContext,\n access\n );\n\n if (!form) {\n logger.warn?.(\"Form submission attempted for non-existent form\", {\n formSlug,\n });\n return { success: false, outcome: \"no-such-form\", error: NO_SUCH_FORM };\n }\n\n // The destination is resolved from one place, because two endings ask for\n // it — the accepted submission and the throttled one disguised as accepted\n // — and two call sites naming the inputs separately is how one of them\n // comes to be answered in the wrong language while the other is right.\n const redirectFor = () =>\n resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);\n\n // 2. Check form status. The same reading the HTTP and Direct API paths do,\n // so what a visitor is told does not depend on which entry point their\n // client used.\n const availability = formAvailability(form);\n if (availability.kind !== \"open\") {\n logger.info?.(\"Form submission rejected - form not accepting\", {\n formSlug,\n status: form.status,\n availability: availability.kind,\n });\n // `absent` answers exactly as a slug nobody used does. Saying \"not\n // currently accepting submissions\" here would confirm the form exists,\n // which is the enumeration this whole reading exists to prevent — and it\n // would disagree with the REST and Direct API paths, which is how the\n // four came to give four different answers in the first place.\n return {\n success: false,\n outcome: availability.kind === \"closed\" ? \"closed\" : \"no-such-form\",\n error:\n availability.kind === \"closed\" ? availability.message : NO_SUCH_FORM,\n };\n }\n\n // 2b. Settings come through the one canonical reader — stored settings\n // may carry legacy keys from earlier builder versions.\n const settings = normalizeFormSettings(form.settings);\n\n // 2c. Single-submission forms: the same visitor (by IP) submits once.\n // Checked before spam so a repeat visitor gets the honest message\n // instead of an inexplicable success that stored nothing new. This is a\n // best-effort deterrent, not a guarantee: IPs are shared (NAT/proxies),\n // and the check-then-create pair is not atomic, so two truly concurrent\n // submissions can both pass — an airtight version needs a partial\n // unique index the portable field config cannot express yet.\n if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {\n // Without an identifier there is nothing to deduplicate on. Failing\n // closed would block every server-to-server integration, so the gate\n // stays open — loudly.\n logger.warn?.(\n \"Single-submission form received a submission without an IP — the restriction cannot apply\",\n { formSlug }\n );\n }\n if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {\n const existing = await collections.count(\n pluginConfig.formSubmissionOverrides.slug,\n {\n where: {\n form: { equals: form.id },\n ipAddress: { equals: metadata.ipAddress },\n status: { not_equals: \"spam\" },\n },\n },\n // Deliberately elevated, unlike the write below. This counts rows the\n // visitor is not entitled to see in order to refuse their second\n // submission; judged as the public it would count zero on any install\n // that hides submissions and the restriction would never apply.\n { as: \"system\" }\n );\n if (existing > 0) {\n logger.info?.(\"Repeat submission rejected (single-submission form)\", {\n formSlug,\n ipAddress: metadata.ipAddress,\n });\n return {\n success: false,\n outcome: \"duplicate\",\n error: \"You have already submitted this form.\",\n };\n }\n }\n\n // 3. Spam detection — BEFORE validation, so a bot that trips the trap\n // while also failing validation still receives the same fake success as\n // any other bot instead of a distinguishable validation error.\n // Honeypot fields are by definition NOT declared form fields, so the\n // probe is the raw payload minus the declared schema — a declared field\n // (e.g. a real \"website\" input) must never trip the trap.\n const declaredFieldNames = new Set(form.fields.map(field => field.name));\n const undeclaredData = Object.fromEntries(\n Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))\n );\n\n // The honeypot only, and only to decide whether to validate. It is a pure\n // read of the payload, so running it here and again at the write seam\n // costs nothing and cannot disagree. The RATE LIMIT is not asked here: it\n // counts, and a check with a side effect run twice counts one submission\n // twice. The seam owns that one, and owns the verdict written to the row.\n const spamResult = await checkSpam({\n data: undeclaredData,\n formSlug,\n config: {\n // Per-form overrides win where set; blank inherits the plugin config.\n honeypot:\n settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,\n },\n });\n\n const isContentSpam = spamResult.isSpam;\n\n // 4. Transform + validate. Content spam skips validation entirely — it\n // is stored for review exactly as the bot shaped it (declared fields\n // only, sanitized), and requiring validity would drop the evidence.\n if (isContentSpam) {\n logger.info?.(\"Spam submission detected — storing flagged\", {\n formSlug,\n reason: spamResult.reason,\n ipAddress: metadata?.ipAddress,\n });\n }\n\n const prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: !isContentSpam,\n });\n\n if (prepared.validationErrors) {\n logger.debug?.(\"Form validation failed\", {\n formSlug,\n errors: prepared.validationErrors,\n });\n return {\n success: false,\n outcome: \"invalid\",\n error: \"Validation failed\",\n validationErrors: prepared.validationErrors,\n validationIssues: prepared.validationIssues,\n };\n }\n\n const storedData = prepared.data;\n\n // 5. Create submission record. Content spam (honeypot/recaptcha) is\n // stored FLAGGED, never silently dropped: a false positive stays\n // reviewable in the Spam view and recoverable via \"Not spam\".\n const submissionData = {\n form: form.id,\n data: storedData,\n status: isContentSpam ? (\"spam\" as const) : (\"new\" as const),\n spamReason: isContentSpam ? (spamResult.reason ?? null) : null,\n ipAddress: metadata?.ipAddress || null,\n userAgent: metadata?.userAgent || null,\n submittedAt: new Date(),\n };\n\n // Marked as the plugin's own write for the length of the call, so the\n // write-seam hook knows a flagged row is evidence this handler chose to\n // keep rather than a caller asking for validation to be skipped.\n // Marked as this plugin's own write, so the write seam knows a flagged row\n // is evidence this handler chose to keep rather than a caller asking for\n // validation to be skipped. The form goes with it so the seam does not read\n // it a second time.\n let submission: Awaited<ReturnType<typeof collections.createEntry>>;\n try {\n submission = await collections.createEntry(\n pluginConfig.formSubmissionOverrides.slug,\n asPluginSubmission(submissionData, {\n keepAsEvidence: isContentSpam,\n form: { id: form.id, fields: form.fields },\n }),\n // Public form submission — create as system. No ambient user; an\n // empty context already resolves to system, but be explicit.\n //\n // The request travels with it so the seam judges this submission on the\n // same facts as one arriving through a Nextly route. Omitted by a caller\n // that has none, and then the seam correctly reads the write as\n // server-side work.\n //\n // `access` rather than a fixed `system`, so a route serving an anonymous\n // visitor is still judged against the collection's own create rule. A\n // host that closed public submissions configured that on the\n // collection, and a write elevated past it reports a 201 for a\n // submission they had refused.\n { ...access, ...(request ? { request } : {}) }\n );\n } catch (error) {\n // The seam refuses a submission over the limit, and refuses it the same\n // way at every door. This answers its own caller with a success anyway: a\n // browser is on the other end of it, and a bot told it was limited learns\n // the rate to sit under.\n //\n // `POST /api/forms/:slug/submit` reaches this. The route is contributed\n // by this plugin and calls this function, so the refusal a visitor sees\n // is decided here rather than being reconstructed as a 429 by an endpoint\n // that never consulted the seam.\n //\n // Nothing is stored. A limiter that wrote a row per refusal would hand an\n // attacker a way to fill the database with the very volume it exists to\n // refuse.\n if (NextlyError.isRateLimited(error)) {\n logger.info?.(\"Form submission refused (rate limit)\", { formSlug });\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The same destination an accepted submission gets. Returning early\n // without it left a throttled visitor on the form instead of the\n // page its author sends people to, and made the one response this\n // whole branch exists to disguise the only accepted one carrying no\n // redirect.\n redirect: await redirectFor(),\n };\n }\n throw error;\n }\n\n logger.info?.(\"Form submission created successfully\", {\n formSlug,\n submissionId: submission.item.id,\n flaggedAsSpam: isContentSpam,\n });\n\n // A post-commit hook cannot un-save the submission, so a failure there is\n // logged against the row rather than turned into a failed submission: the\n // notification email is the one that fails this way, and telling the\n // visitor their submission did not go through would have them send it\n // again.\n if (submission.warnings) {\n logger.warn?.(\"Form submission side effects failed\", {\n formSlug,\n submissionId: submission.item.id,\n warnings: submission.warnings,\n });\n }\n\n // Email notifications are sent via the afterCreate hook registered in\n // plugin.ts init(), so they fire for all submission paths (HTTP + direct).\n // The hook itself skips spam-flagged rows.\n\n // 6. Determine redirect URL. Spam gets the same success shape as a real\n // submission (minus the stored row reference) so bots can't diff the two.\n const redirect = await redirectFor();\n\n if (isContentSpam) {\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n redirect,\n };\n }\n\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The created ROW, not the envelope around it. Through the shared\n // conversion, which is where the unchecked step lives and says so; the\n // integration test reads `submission.id` off the result because nothing\n // at compile time will.\n submission: asSubmissionDocument(submission.item),\n redirect,\n };\n } catch (error) {\n logger.error?.(\"Form submission error\", {\n formSlug,\n error: error instanceof Error ? error.message : String(error),\n });\n\n return {\n success: false,\n outcome: \"failed\",\n error: \"An error occurred processing your submission. Please try again.\",\n cause: error,\n };\n }\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Fetch a form by its slug via a service-level `where` query.\n *\n * @param slug - Form slug\n * @param pluginConfig - Plugin configuration\n * @param pluginContext - Plugin context\n * @returns Form document or null if not found\n */\nexport async function fetchFormBySlug(\n slug: string,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n access: SubmissionAccess = { as: \"system\" }\n): Promise<FormDocument | null> {\n const { collections } = pluginContext.services;\n\n // D56: resolve the form by slug via a service-level `where` query instead of\n // fetching every form and filtering client-side.\n //\n // Nothing is caught here. A read that THREW did not establish that the form\n // is absent, and answering `null` made a database outage or a broken schema\n // indistinguishable from a slug nobody has ever used: the caller was told\n // \"no such form\", which is a 404 a client may cache and a person may act on\n // by deleting the link. A failure now propagates and is answered as the\n // server error it is.\n const result = await collections.listEntries(\n pluginConfig.formOverrides.slug,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n access\n );\n\n const form = result.data?.[0];\n return form ? asFormDocument(form) : null;\n}\n\n/**\n * Where a successful submission sends the visitor, or undefined for nowhere.\n *\n * A destination is either TYPED as a URL or PICKED as a document, and the two\n * are answered differently: a typed URL is returned verbatim, since it may\n * legitimately point off-site, while a picked document has to be read and put\n * through its collection's configured URL pattern.\n *\n * Two settings hold a picked document — `redirectPage` under the\n * \"Redirect to Page\" option, and `redirectRelation` under the URL option —\n * and both resolve through the same path, because they ask the same question.\n *\n * Every way of failing to produce a URL is logged. A form whose destination\n * cannot be built behaves exactly like one configured to stay put, so without\n * a line in the log the two are indistinguishable from the outside.\n */\nasync function resolveRedirectUrl(\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const settings = form.settings;\n if (!settings) return undefined;\n\n // A typed URL is returned verbatim: it is the whole point of that option,\n // and it may legitimately point off-site.\n if (settings.confirmationType === \"redirect\" && settings.redirectUrl) {\n return settings.redirectUrl;\n }\n\n // Otherwise whichever key names a document, read through the SAME function\n // the save-time rule uses. Two readers here would let this path redirect to\n // a document the rule never inspected — which it did: the rule matched only\n // `relationship`, so a `redirect`-with-relation form was resolved here and\n // guarded nowhere.\n const field = pickedDocumentField(settings as Record<string, unknown>);\n if (!field) return undefined;\n\n return urlForPickedDocument(\n (settings as Record<string, unknown>)[field],\n form,\n pluginConfig,\n pluginContext,\n read\n );\n}\n\n/**\n * The URL for a picked document, or undefined with a reason in the log.\n *\n * Every branch reports. A form whose destination cannot be built behaves\n * exactly like one configured to stay put, so without a line here the two are\n * indistinguishable from outside — which is how a redirect that never fired\n * went unnoticed.\n */\nasync function urlForPickedDocument(\n stored: unknown,\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const { logger } = pluginContext;\n const patterns = pluginConfig.redirectRelationships;\n const reference = parseRedirectReference(stored, Object.keys(patterns));\n\n if (!reference) {\n logger.warn?.(\"Form redirects to a page, but names no readable document\", {\n form: form.slug,\n });\n return undefined;\n }\n\n const pattern = patterns[reference.collection];\n if (!pattern) {\n logger.warn?.(\n \"Form redirects to a collection that is not in redirectRelationships\",\n { form: form.slug, collection: reference.collection }\n );\n return undefined;\n }\n\n const target = await readTarget(reference, form, pluginContext, read);\n if (!target) return undefined;\n\n // Reachability is re-decided HERE, not inherited from the save. Nothing runs\n // a forms hook when the target itself changes, so a page that was published\n // when the form was saved can be unpublished afterwards and the save-time\n // rule never sees it. Sending a visitor there produces exactly the \"page not\n // found\" that rule exists to prevent, so this degrades to no redirect — the\n // submission is still stored and still succeeds.\n // The target collection's own `localized` setting, resolved at plugin init.\n // Absent means it could not be read, which reports as undecided rather than\n // as \"not localized\" — dropping a redirect on that guess would strand a\n // destination a translation is still serving.\n if (\n documentReachability(\n target,\n pluginConfig.redirectTargetLocalization[reference.collection]\n ) === \"unreachable\"\n ) {\n logger.warn?.(\"Form redirects to a page that has never been published\", {\n form: form.slug,\n collection: reference.collection,\n target: reference.id,\n });\n return undefined;\n }\n\n return buildUrl(pattern, target, reference.collection, form, pluginContext);\n}\n\n/**\n * The pattern applied, or undefined with the reason logged.\n *\n * Separate because a configured pattern may be a FUNCTION — host code, which\n * can throw. Letting that escape would reach `submitForm`'s outer catch AFTER\n * the submission row exists, reporting a failure the caller may retry into a\n * duplicate of a submission that was already saved.\n */\nfunction buildUrl(\n pattern: RedirectUrlPattern,\n target: RedirectTargetDocument,\n collection: string,\n form: FormDocument,\n pluginContext: PluginContext\n): string | undefined {\n const { logger } = pluginContext;\n try {\n const url = applyRedirectPattern(pattern, target);\n if (!url) {\n logger.warn?.(\n \"Form redirect pattern could not be filled from the target document\",\n { form: form.slug, collection }\n );\n }\n return url;\n } catch (error) {\n logger.warn?.(\"Form redirect pattern threw while building a URL\", {\n form: form.slug,\n collection,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n}\n\n/** The target row, or undefined — a deleted target and a failed read differ. */\n/**\n * How the redirect target is read: as whom, in which language, and on which\n * request.\n *\n * As whom is the submission's own `access`, because the destination is a page\n * the VISITOR is about to be sent to, and the visitor's view of it is the one\n * that decides whether it is there. A public submission reads the target as a\n * public caller, which is scoped to published rows and — the part the locale\n * makes matter — to published TRANSLATIONS: a companion row still in draft is\n * withheld and the field falls back to the published default. Read as the\n * system instead, the trusted read admits every draft, and a French slug that\n * nobody has published yet becomes the URL a French visitor is sent to.\n *\n * A host that elevated the submission to `system` keeps the trusted read it\n * asked for, as before this carried a language.\n *\n * The request travels for the same reason the access does: a read that is\n * the visitor's should look like the visitor's to every hook on the target\n * collection. One that selects content by a tenant header, or applies only\n * to a browser, reads `ctx.req.http` — and a read carrying no request runs\n * those hooks as background work and builds a URL the visitor was never\n * meant to get.\n */\ninterface TargetRead {\n access: SubmissionAccess;\n locale: string | undefined;\n request: Request | undefined;\n}\n\n/**\n * The language a visitor submitted in, or nothing.\n *\n * A selector is not one: it asks the collection services for every\n * translation at once, which answers a localized URL field with one value per\n * language — a shape no URL pattern can be filled from — and the plugin\n * boundary refuses it outright, which after the submission is stored degrades\n * to no redirect. Neither is a language a form was filled in, so a selector\n * reads as no language named. Which spellings are selectors is the core's\n * question, asked of the core, so this cannot forward one the boundary has\n * since learned to refuse.\n */\nfunction visitorLocale(locale: string | undefined): string | undefined {\n if (locale === undefined || isLocaleSelector(locale)) return undefined;\n return locale;\n}\n\nasync function readTarget(\n reference: { collection: string; id: string },\n form: FormDocument,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<RedirectTargetDocument | undefined> {\n const { logger } = pluginContext;\n const context = {\n form: form.slug,\n collection: reference.collection,\n id: reference.id,\n };\n\n let row: unknown;\n try {\n // In the visitor's language, so a localized URL field answers with the\n // slug that page has THERE, and as the visitor, so it answers only with a\n // translation they can reach (see {@link TargetRead}). The fallback chain\n // stays in force: a page with no published translation for this locale\n // still has a URL, in the language the site falls back to, and sending\n // the visitor there beats sending them nowhere.\n row = await pluginContext.services.collections.findEntryById(\n reference.collection,\n reference.id,\n { ...read.access, locale: read.locale, request: read.request }\n );\n } catch (error) {\n // The submission already succeeded, so this degrades to \"no redirect\"\n // rather than failing the request — reported, because a broken read and a\n // deleted target are different problems with the same symptom.\n logger.warn?.(\"Form redirect target could not be read\", {\n ...context,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n\n if (!row || typeof row !== \"object\") {\n logger.warn?.(\"Form redirect target no longer exists\", context);\n return undefined;\n }\n return row as RedirectTargetDocument;\n}\n\n// ============================================================\n// Validation-Only Function\n// ============================================================\n\n/**\n * Validate form data without creating a submission.\n *\n * Useful for real-time validation in the frontend.\n *\n * @param formSlug - Form slug\n * @param data - Form data to validate\n * @param context - Handler context\n * @returns Validation result\n *\n * @example\n * ```typescript\n * const result = await validateSubmission(\n * 'contact-form',\n * { name: 'John', email: 'invalid-email' },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (!result.valid) {\n * console.log('Validation errors:', result.errors);\n * }\n * ```\n */\nexport async function validateSubmission(\n formSlug: string,\n data: Record<string, unknown>,\n context: SubmitFormContext\n): Promise<{ valid: boolean; errors?: Record<string, string> }> {\n const { pluginContext, pluginConfig } = context;\n\n // Fetch form\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return { valid: false, errors: { _form: \"Form not found\" } };\n }\n\n // The same rule storage applies, asked rather than restated. Restating it\n // meant this answered on the transformed but unsanitized value while the\n // write seam answers on the sanitized one, so a preflight check could call a\n // submission valid that storage then refused.\n const prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: true,\n });\n\n if (!prepared.validationErrors) {\n return { valid: true };\n }\n\n return {\n valid: false,\n errors: prepared.validationErrors,\n };\n}\n\n// ============================================================\n// Form Status Check\n// ============================================================\n\n/**\n * Check if a form is accepting submissions.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Whether the form accepts submissions\n */\nexport async function isFormAcceptingSubmissions(\n formSlug: string,\n context: SubmitFormContext\n): Promise<boolean> {\n const { pluginContext, pluginConfig } = context;\n\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return false;\n }\n\n return form.status === \"published\";\n}\n\n// ============================================================\n// Bulk Operations (Admin)\n// ============================================================\n\n/**\n * Get submission statistics for a form.\n *\n * Note: This function uses client-side filtering which works for\n * small-medium submission counts. For large-scale deployments,\n * consider using direct database queries with COUNT aggregation.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Submission statistics\n */\nexport async function getFormSubmissionStats(\n formSlug: string,\n context: SubmitFormContext\n): Promise<{\n total: number;\n new: number;\n read: number;\n archived: number;\n} | null> {\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n\n // Get form ID\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n if (!form) {\n return null;\n }\n\n try {\n const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;\n const sys = { as: \"system\" } as const;\n const base = { form: { equals: form.id } };\n\n // D56: count per status server-side instead of listing every\n // submission and counting client-side. Stats run as system (plugin-owned\n // aggregate over submissions for this form).\n const [total, newCount, read, archived] = await Promise.all([\n collections.count(submissionsSlug, { where: base }, sys),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"new\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"read\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"archived\" } } },\n sys\n ),\n ]);\n\n return { total, new: newCount, read, archived };\n } catch {\n return null;\n }\n}\n","/**\n * The public form endpoints, served by the plugin that owns the collections.\n *\n * `GET /api/forms`, `GET /api/forms/:slug` and `POST /api/forms/:slug/submit`\n * were served by the core dispatcher, which read collections named `forms` and\n * `form-submissions` in four hardcoded places. Core defines neither, so those\n * endpoints only ever worked when this plugin was installed, and they broke\n * outright on any install that renamed a collection through the plugin's own\n * overrides. They also validated a submission BEFORE the write seam judged it,\n * so a bot that tripped the honeypot while omitting a required field received a\n * distinguishable validation error and left no evidence behind.\n *\n * Contributed at the root mount, so the addresses are unchanged: a client\n * already calling `/api/forms/contact/submit` keeps calling it. Root routes are\n * consulted only where the built-in router declines, which is why core's\n * parser had to stop claiming `/forms` in the same change rather than a later\n * one. Left in place, it would answer first and these would never be reached.\n *\n * The bodies are the canonical envelopes, built with core's own helpers rather\n * than assembled here. A route that rebuilt the shape by hand would preserve\n * the URL while changing the contract underneath every client already calling\n * it, and would silently drop the post-commit warnings `respondAction` carries.\n *\n * @module routes/public-forms\n */\n\nimport {\n buildPaginatedResponse,\n clampLimit,\n formAvailability,\n NextlyError,\n NO_SUCH_FORM,\n respondAction,\n respondDoc,\n respondList,\n trustedClientIp,\n type FormAvailabilityInput,\n type PaginationMeta,\n type PluginRoute,\n type PluginRouteContext,\n} from \"nextly\";\n\nimport { submitForm } from \"../handlers/submit-form\";\nimport type { ResolvedFormBuilderConfig } from \"../types\";\n\n/**\n * These routes read and write as the anonymous visitor on the other end.\n *\n * `public: true` waives the ROUTE's authentication and says nothing about the\n * collections behind it. Elevating to `system` here would hand a form to any\n * caller on an install whose host set `formOverrides.access.read` to something\n * narrower, and accept a submission on one that closed creation, which is the\n * host's configuration being silently overridden rather than obeyed.\n */\nconst PUBLIC = { as: \"public\" } as const;\n\n/** How many forms a page of the public listing holds when none is asked for. */\nconst DEFAULT_LIST_LIMIT = 100;\n\n/** What a visitor is told when the form's author wrote no message of their own. */\nconst DEFAULT_SUCCESS_MESSAGE = \"Thank you for your submission!\";\n\n/**\n * The collection names this install actually uses.\n *\n * A host may rename either collection through the plugin's overrides, and\n * `ctx.self.collections` maps what the plugin DECLARED to what was registered.\n * Reading the declared name directly is what made the core endpoints fail on a\n * renamed install while the plugin's own export route kept working.\n */\nfunction installedSlugs(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): { forms: string; submissions: string } {\n const declaredForms = config.formOverrides.slug;\n const declaredSubmissions = config.formSubmissionOverrides.slug;\n return {\n forms: self.collections[declaredForms] ?? declaredForms,\n submissions: self.collections[declaredSubmissions] ?? declaredSubmissions,\n };\n}\n\n/**\n * The config `submitForm` should read on THIS install.\n *\n * That helper takes the declared slugs from configuration, which is right for a\n * host calling it directly and wrong for a route, where the registered names\n * are knowable. Handing it a config whose slugs are already resolved keeps one\n * submission path rather than a second one that reads different tables.\n */\nfunction configForInstall(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): ResolvedFormBuilderConfig {\n const slugs = installedSlugs(config, self);\n return {\n ...config,\n formOverrides: { ...config.formOverrides, slug: slugs.forms },\n formSubmissionOverrides: {\n ...config.formSubmissionOverrides,\n slug: slugs.submissions,\n },\n };\n}\n\n/** A positive integer from a query string, or `undefined` when it is not one. */\nfunction positiveInt(raw: string | null): number | undefined {\n if (raw === null) return undefined;\n const value = Number(raw);\n return Number.isInteger(value) && value > 0 ? value : undefined;\n}\n\n/**\n * The service's offset-based pagination, as the wire's page-based meta.\n *\n * The page arithmetic is `buildPaginatedResponse`'s, not a second copy of it.\n * Its edge cases are the ones this endpoint has always answered with and are\n * easy to get subtly wrong alone: an empty collection reports ONE page rather\n * than zero, and a page past the end reports the clamped page rather than the\n * one that was asked for, so `?page=100` on a single-page list cannot answer\n * \"page 100 of 1\".\n */\nfunction toPageMeta(pagination: {\n total: number;\n limit: number;\n offset: number;\n}): PaginationMeta {\n const limit = pagination.limit > 0 ? pagination.limit : DEFAULT_LIST_LIMIT;\n const built = buildPaginatedResponse([], {\n total: pagination.total,\n page: Math.floor(pagination.offset / limit) + 1,\n limit,\n });\n return {\n total: built.totalDocs,\n page: built.page,\n limit: built.limit,\n totalPages: built.totalPages,\n hasNext: built.hasNextPage,\n hasPrev: built.hasPrevPage,\n };\n}\n\nasync function listPublishedForms(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const url = new URL(req.url);\n const slugs = installedSlugs(config, ctx.self);\n\n // Published only. The listing is the one surface anyone can enumerate, so a\n // draft or closed form must not appear in it; the by-slug read below is what\n // explains a closed form, and only to a caller who already holds its slug.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n {\n where: { status: { equals: \"published\" } },\n pagination: {\n // Clamped HERE, with the canonical bound, so the limit that computes\n // the offset is the limit that comes back. The query service clamps to\n // 500 internally and reports the clamped value, while the offset is\n // built from what was asked for, so `?limit=1000&page=2` returned the\n // second 500-row slice and dividing the two reported page 3.\n limit: clampLimit(\n positiveInt(url.searchParams.get(\"limit\")) ?? DEFAULT_LIST_LIMIT\n ),\n page: positiveInt(url.searchParams.get(\"page\")) ?? 1,\n },\n },\n PUBLIC\n );\n\n return respondList(result.data ?? [], toPageMeta(result.pagination));\n}\n\nasync function readFormBySlug(\n slug: string,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const slugs = installedSlugs(config, ctx.self);\n\n // Fetched by slug alone, then judged. A status filter in the query would\n // answer 404 for a CLOSED form as readily as for one that never existed, and\n // those are different answers: a visitor following a link to a closed form is\n // owed the reason its author wrote.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n PUBLIC\n );\n\n const doc = result.data?.[0] as FormAvailabilityInput | undefined;\n const availability = formAvailability(doc);\n\n if (availability.kind === \"absent\") {\n // The same sentence every other door gives. The canonical \"Not found.\"\n // would make what a visitor is told depend on which client they used.\n throw NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: { entity: \"form\", slug, reason: availability.reason },\n });\n }\n\n if (availability.kind === \"closed\") {\n // Only what a visitor at this address needs. The author wrote the message\n // FOR them; nothing else on the row is theirs, and a closed form renders a\n // sentence rather than fields.\n return respondDoc({\n slug,\n status: \"closed\",\n closedMessage: availability.message,\n });\n }\n\n return respondDoc(doc as unknown as Record<string, unknown>);\n}\n\n/**\n * The submitted answers, or the refusal for a body that carries none.\n *\n * A malformed body and an absent one are the same mistake to a caller, so both\n * answer the way core did: a validation error naming `data`, rather than the\n * parser's own exception surfacing as a 500.\n */\nasync function readSubmittedData(\n req: Request,\n slug: string\n): Promise<Record<string, unknown>> {\n let parsed: unknown;\n try {\n parsed = await req.json();\n } catch {\n parsed = undefined;\n }\n const body = parsed as { data?: Record<string, unknown> } | undefined;\n\n if (!body?.data || typeof body.data !== \"object\") {\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"MISSING_FIELD\",\n message: \"Request body must contain a 'data' object.\",\n },\n ],\n logContext: { slug },\n });\n }\n return body.data;\n}\n\n/**\n * The refusal an unaccepted outcome owes the caller.\n *\n * One place, so the status a given ending produces is read off a single list\n * rather than inferred from a message. `accepted` never reaches here: it is the\n * one outcome with a body rather than an error, and the caller returns it.\n */\nfunction refusalFor(\n result: Awaited<ReturnType<typeof submitForm>>,\n slug: string\n): NextlyError {\n const entity = { entity: \"form\", slug };\n switch (result.outcome) {\n case \"no-such-form\":\n return NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: entity,\n });\n\n // A state conflict, not a validation failure. `validation` fixes the\n // canonical `error.message` to \"Validation failed.\" and nests the real one\n // under `error.data.errors[0]`, so a client reading the documented envelope\n // would be handed the author's explanation and never show it.\n case \"closed\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? NO_SUCH_FORM,\n logContext: { ...entity, reason: \"closed\" },\n });\n\n case \"duplicate\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? \"You have already submitted this form.\",\n logContext: { ...entity, reason: \"duplicate\" },\n });\n\n // The codes come from the issue list, not from the message map beside it.\n // A client tells \"you left this blank\" from \"this is not an email\" by the\n // code, and reporting every failure as `INVALID` stops that working while\n // looking correct in a status-only test.\n case \"invalid\":\n return NextlyError.validation({\n errors: (result.validationIssues ?? []).map(issue => ({\n path: issue.path,\n code: issue.code,\n message: issue.message,\n })),\n logContext: { slug },\n });\n\n // A typed refusal is re-thrown as itself. The collection may refuse a write\n // for a reason its host chose, and those carry their own status and field\n // detail; answering all of them with one internal error tells a visitor the\n // server broke when their submission was in fact answered deliberately.\n // Anything untyped stays a 500, which is what an unexpected failure is.\n default:\n if (result.cause instanceof NextlyError) return result.cause;\n return NextlyError.internal({\n logContext: { ...entity, reason: \"submission-failed\" },\n });\n }\n}\n\nasync function acceptSubmission(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig,\n slug: string\n): Promise<Response> {\n const data = await readSubmittedData(req, slug);\n\n const result = await submitForm(\n {\n formSlug: slug,\n data,\n // The visitor's language, from `?locale=` on the submit URL — the same\n // place core's own routes read a locale on the wire — so a picked\n // redirect page answers with its URL in that language. A form on a\n // French page posts to `/api/forms/contact/submit?locale=fr` and is\n // sent to `/merci`; one that names no locale is answered in the\n // default, as before. An unconfigured code reads as the default rather\n // than refusing the submission: the visitor filled the form in, and a\n // wrong query string is not a reason to lose what they wrote.\n locale: new URL(req.url).searchParams.get(\"locale\") || undefined,\n // The request itself, so the write seam resolves the client under this\n // deployment's proxy-trust settings and judges this submission on the\n // same facts as one arriving through any other door.\n request: req,\n // The address for the audit column and the single-submission check,\n // resolved by the core rather than read off a header here, which would\n // record whatever the sender chose to claim and let one visitor pass as\n // many.\n metadata: {\n ipAddress: trustedClientIp(req) ?? undefined,\n userAgent: req.headers.get(\"user-agent\") ?? undefined,\n },\n access: PUBLIC,\n },\n {\n pluginContext: ctx,\n pluginConfig: configForInstall(config, ctx.self),\n }\n );\n\n if (result.outcome !== \"accepted\") throw refusalFor(result, slug);\n\n // One envelope for every accepted ending, including the two that are\n // deliberately indistinguishable from a refusal a bot could learn from: a\n // submission the honeypot flagged, and one the rate limiter refused.\n //\n // Both omit `submissionId`, because neither has a row a caller could then ask\n // about. That is a residual difference in the BODY, not in the status, and it\n // is the honest one: the alternatives are to invent an id that addresses\n // nothing, or to stop returning it to the real clients this endpoint has\n // always returned it to.\n return respondAction(\n result.successMessage ?? DEFAULT_SUCCESS_MESSAGE,\n {\n ...(result.submission ? { submissionId: result.submission.id } : {}),\n // Forwarded because this route is the only thing that can act on it. A\n // form configured to redirect on success resolves one here, and core's\n // endpoint dropped it, so the setting did nothing for any client posting\n // over HTTP. Additive, and present on a flagged submission too, so it\n // cannot be diffed against an accepted one.\n ...(result.redirect ? { redirect: result.redirect } : {}),\n },\n { status: 201 }\n );\n}\n\n/**\n * The three public form endpoints, at the addresses core used to serve.\n *\n * `public: true` on all three: a form is filled in by a visitor with no session\n * at all, which is the whole point of the feature.\n */\nexport function publicFormRoutes(\n config: ResolvedFormBuilderConfig\n): PluginRoute[] {\n return [\n {\n method: \"GET\",\n path: \"/forms\",\n mount: \"root\",\n public: true,\n // Collection documents, so their stored timestamps are presented in the\n // installation's timezone exactly as the built-in read did.\n formatTimestamps: true,\n handler: (req, ctx) => listPublishedForms(req, ctx, config),\n },\n {\n method: \"GET\",\n path: \"/forms/:slug\",\n mount: \"root\",\n public: true,\n formatTimestamps: true,\n handler: (_req, ctx) => readFormBySlug(ctx.params.slug, ctx, config),\n },\n {\n method: \"POST\",\n path: \"/forms/:slug/submit\",\n mount: \"root\",\n public: true,\n handler: (req, ctx) =>\n acceptSubmission(req, ctx, config, ctx.params.slug),\n },\n ];\n}\n","/**\n * Form Builder Plugin\n *\n * Visual form builder with drag-and-drop UI, submission management,\n * email notifications, and spam protection.\n *\n * @module plugin\n * @since 0.1.0\n */\n\nimport { createRequire } from \"node:module\";\n\nimport {\n definePlugin,\n type HookContext,\n NextlyError,\n type PluginDefinition,\n type PluginEmailService,\n} from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig } from \"nextly\";\n// Author against the SDK — the stable, experimental plugin boundary.\n\nimport { formsCollection } from \"./collections/forms\";\nimport { submissionsCollection } from \"./collections/submissions\";\nimport { newSubmissionsWidget } from \"./dashboard/widget\";\nimport {\n asFormDocument,\n asSubmissionDocument,\n asSubmissionDocuments,\n} from \"./document-shapes\";\nimport {\n sameSubmittedPayload,\n submissionMarks,\n type SubmissionOriginMarks,\n prepareSubmission,\n} from \"./handlers/prepare-submission\";\nimport { checkSpam, type SpamCheckResult } from \"./handlers/spam-detection\";\nimport { publicFormRoutes } from \"./routes/public-forms\";\nimport type {\n AnyFormField,\n BeforeEmailFilterContext,\n FormNotification,\n FormBuilderPluginOptions,\n FormEmailNotification,\n ResolvedFormBuilderConfig,\n} from \"./types\";\nimport { evaluateSingleCondition } from \"./utils/evaluate-conditions\";\nimport { exportToCSV, generateExportFilename } from \"./utils/export-formats\";\nimport { parseFieldRef } from \"./utils/field-references\";\nimport { normalizeRedirectRelationships } from \"./utils/redirect-target\";\nimport {\n SCHEMA_ONLY_CONTEXT,\n SCHEMA_ONLY_READ,\n} from \"./utils/schema-only-read\";\n\nexport type NextlyPlugin = PluginDefinition;\n\n// Read the version from package.json so it can never drift from the published\n// package. Node/config-side only (this module is not part of the admin bundle).\nconst { version: PLUGIN_VERSION } = createRequire(import.meta.url)(\n \"../package.json\"\n) as { version: string };\n\n/** The runtime instance passed to plugin hooks (type extracted from `init`'s parameter). */\ntype NextlyInstance = Parameters<NonNullable<NextlyPlugin[\"init\"]>>[0];\n\n/** Internal augmentation: we stash the resolved config on the nextly instance for later retrieval. */\ntype NextlyWithFormBuilderConfig = NextlyInstance & {\n __formBuilderConfig?: ResolvedFormBuilderConfig;\n};\n\n// ---------------------------------------------------------------------------\n// Configuration resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Merge user options with sensible defaults.\n */\nfunction resolveConfig(\n options: FormBuilderPluginOptions\n): ResolvedFormBuilderConfig {\n const formOverrides = options.formOverrides || {};\n const submissionOverrides = options.formSubmissionOverrides || {};\n\n return {\n formOverrides: {\n ...formOverrides,\n slug: formOverrides.slug || \"forms\",\n labels: {\n singular: formOverrides.labels?.singular || \"Form\",\n plural: formOverrides.labels?.plural || \"Forms\",\n },\n },\n\n formSubmissionOverrides: {\n ...submissionOverrides,\n slug: submissionOverrides.slug || \"form-submissions\",\n labels: {\n singular: submissionOverrides.labels?.singular || \"Submission\",\n plural: submissionOverrides.labels?.plural || \"Submissions\",\n },\n },\n\n fields: {\n text: options.fields?.text ?? true,\n email: options.fields?.email ?? true,\n number: options.fields?.number ?? true,\n phone: options.fields?.phone ?? true,\n url: options.fields?.url ?? true,\n textarea: options.fields?.textarea ?? true,\n select: options.fields?.select ?? true,\n checkbox: options.fields?.checkbox ?? true,\n radio: options.fields?.radio ?? true,\n file: options.fields?.file ?? true,\n date: options.fields?.date ?? true,\n time: options.fields?.time ?? true,\n hidden: options.fields?.hidden ?? true,\n },\n\n redirectRelationships: normalizeRedirectRelationships(\n options.redirectRelationships\n ),\n // Filled at `init` from each collection's own metadata. Empty until then,\n // and a slug that stays absent reads as unknown rather than as not\n // localized — see `ResolvedFormBuilderConfig`.\n redirectTargetLocalization: {},\n beforeEmail: options.beforeEmail,\n\n notifications: {\n defaultFrom: options.notifications?.defaultFrom,\n defaultToEmail: options.notifications?.defaultToEmail,\n enabled: options.notifications?.enabled ?? true,\n },\n\n spamProtection: {\n honeypot: options.spamProtection?.honeypot ?? true,\n recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },\n rateLimit: {\n maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,\n windowMs: options.spamProtection?.rateLimit?.windowMs ?? 60_000,\n },\n },\n\n uploads: {\n maxFileSize: options.uploads?.maxFileSize ?? 10_485_760, // 10 MB\n allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [\n \"image/*\",\n \"application/pdf\",\n \"text/*\",\n ],\n uploadCollection: options.uploads?.uploadCollection ?? \"media\",\n },\n\n features: {\n builder: options.features?.builder ?? true,\n conditionalLogic: options.features?.conditionalLogic ?? true,\n fileUploads: options.features?.fileUploads ?? true,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport interface FormBuilderPluginResult {\n /** Plugin definition — pass to `defineConfig({ plugins: [...] })`. */\n plugin: NextlyPlugin;\n /** Forms & Submissions collections (also auto-added by the plugin). */\n collections: CollectionConfig[];\n /** Resolved configuration with all defaults applied. */\n config: ResolvedFormBuilderConfig;\n}\n\n/**\n * Create a Form Builder plugin instance.\n *\n * @example Basic usage\n * ```ts\n * import { defineConfig } from \"nextly/config\";\n * import { formBuilder } from \"@nextlyhq/plugin-form-builder\";\n *\n * const fb = formBuilder();\n *\n * export default defineConfig({\n * plugins: [fb.plugin],\n * });\n * ```\n *\n * @example With options\n * ```ts\n * const fb = formBuilder({\n * notifications: { defaultFrom: \"noreply@example.com\" },\n * spamProtection: { honeypot: true },\n * formOverrides: {\n * slug: \"contact-forms\",\n * labels: { singular: \"Contact Form\", plural: \"Contact Forms\" },\n * },\n * });\n * ```\n */\nexport function formBuilder(\n options: FormBuilderPluginOptions = {}\n): FormBuilderPluginResult {\n const resolvedConfig = resolveConfig(options);\n\n const formsCol = formsCollection(resolvedConfig);\n const submissionsCol = submissionsCollection(resolvedConfig);\n\n // The submissions slug the admin links a card's rows to: the declared one\n // until `init` reads the slug the site installed it under.\n let installedSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;\n const newSubmissions = newSubmissionsWidget(\n {\n forms: resolvedConfig.formOverrides.slug,\n submissions: resolvedConfig.formSubmissionOverrides.slug,\n },\n () => installedSubmissionsSlug\n );\n\n const plugin = definePlugin({\n name: \"@nextlyhq/plugin-form-builder\",\n version: PLUGIN_VERSION,\n nextly: \">=0.0.2-alpha.21\",\n // Identity metadata for the admin plugins page, mirroring package.json.\n author: \"Nextly\",\n homepage: \"https://nextlyhq.com\",\n repository: \"https://github.com/nextlyhq/nextly\",\n license: \"MIT\",\n category: \"forms\",\n tags: [\"forms\", \"submissions\", \"email-notifications\"],\n\n // Declarative schema: the merged pipeline folds these into the\n // app schema — no manual `setup()` append needed. Just register the plugin.\n contributes: {\n collections: [formsCol, submissionsCol],\n // New form submissions, the one plugin widget core's default dashboard\n // names. Contributed, never suggested: where it goes is core's default's\n // to say, and a site's own default may leave it out.\n dashboard: { widgets: [newSubmissions] },\n // A visitor submits a form without an account, so the Public role, the\n // role visitors are judged by, may create submissions. Granted once;\n // an owner who takes it away closes public submissions for good.\n publicGrants: [\n {\n collection: resolvedConfig.formSubmissionOverrides.slug,\n action: \"create\",\n },\n ],\n // Custom permission — gates submission export beyond CRUD. The\n // canonical example third-party plugin authors copy.\n permissions: [\n {\n action: \"export\",\n resource: \"submissions\",\n label: \"Export Submissions\",\n description: \"Export form submissions as CSV/JSON\",\n // No `group`: the admin already files this under the plugin that\n // declared it, so naming the plugin again would nest it inside\n // itself. `group` is for a plugin with enough permissions to sort\n // its own into headings, which one is not.\n //\n // `danger` because the point of the permission is to take\n // submissions — names, emails, whatever a form asked for — out of\n // the site in a file.\n danger: true,\n },\n ],\n // HTTP route — exported at\n // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by\n // default: gated by the custom `export-submissions` permission. Reads\n // via the secure-by-default service path as the authed user and\n // resolves its OWN slug through `ctx.self`. The canonical\n // contributes.routes example for third-party authors.\n routes: [\n // The public form endpoints, at the addresses core used to serve\n // before this plugin took them over. Root-mounted, so no caller's URL\n // changes; see `routes/public-forms`.\n ...publicFormRoutes(resolvedConfig),\n {\n method: \"GET\",\n path: \"/submissions/export\",\n requiredPermission: \"export-submissions\",\n handler: async (req, ctx) => {\n const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;\n const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;\n const url = new URL(req.url);\n const format = url.searchParams.get(\"format\") ?? \"json\";\n const formId = url.searchParams.get(\"form\");\n const status = url.searchParams.get(\"status\");\n\n // Spam stays out of exports unless it is explicitly requested —\n // an export is \"what people submitted\", not \"what bots sent\".\n const where: Record<string, unknown> = {};\n if (formId) where.form = { equals: formId };\n if (status) where.status = { equals: status };\n else where.status = { not_equals: \"spam\" };\n\n const opts = { as: \"user\", user: ctx.user ?? undefined } as const;\n\n // CSV needs its form BEFORE any submissions are read: the\n // columns come from one form's fields, and a malformed request\n // must fail fast instead of paginating the whole table first.\n let form: Record<string, unknown> | undefined;\n if (format === \"csv\") {\n if (!formId) {\n return Response.json(\n {\n error: {\n code: \"VALIDATION_ERROR\",\n message: \"CSV export requires a form parameter.\",\n },\n },\n { status: 400 }\n );\n }\n const formsSlugDeclared = resolvedConfig.formOverrides.slug;\n const formsSlug =\n ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;\n const formResult = await ctx.services.collections.listEntries(\n formsSlug,\n { where: { id: { equals: formId } } },\n opts\n );\n form = formResult.data[0];\n if (!form) {\n return Response.json(\n { error: { code: \"NOT_FOUND\", message: \"Form not found.\" } },\n { status: 404 }\n );\n }\n }\n\n // Page through the export with a hard ceiling: an unbounded loop\n // over a high-traffic form could hold the whole table in memory.\n // Hitting the ceiling is reported in a header, never silent.\n const MAX_EXPORT_ROWS = 50_000;\n const items: unknown[] = [];\n const pageSize = 200;\n let truncated = false;\n for (let page = 1; ; page += 1) {\n const result = await ctx.services.collections.listEntries(\n slug,\n { where, pagination: { limit: pageSize, page } },\n opts\n );\n items.push(...result.data);\n const pageWasFull = result.data.length === pageSize;\n if (items.length >= MAX_EXPORT_ROWS) {\n truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;\n items.length = MAX_EXPORT_ROWS;\n break;\n }\n if (!pageWasFull) break;\n }\n const truncationHeaders: Record<string, string> = truncated\n ? { \"X-Export-Truncated\": \"true\" }\n : {};\n\n if (format !== \"csv\" || !form) {\n return Response.json({ items }, { headers: truncationHeaders });\n }\n\n // The service returns parsed entries; the export helpers declare\n // the document shapes — the boundary cast is through unknown.\n const csv = exportToCSV(\n asSubmissionDocuments(items),\n asFormDocument(form)\n );\n const filename = generateExportFilename(\n typeof form.slug === \"string\" ? form.slug : \"form\",\n \"csv\"\n );\n return new Response(csv, {\n headers: {\n \"Content-Type\": \"text/csv; charset=utf-8\",\n \"Content-Disposition\": `attachment; filename=\"${filename}\"`,\n ...truncationHeaders,\n },\n });\n },\n },\n {\n // The builder UI reads the host's resolved field enable/disable map\n // from here, so the plugin option actually gates the type picker.\n // Options resolve server-side only; this is the one channel the\n // admin client has to them.\n method: \"GET\",\n path: \"/builder-config\",\n // Derived from the forms slug like the menu entry: with an\n // overridden slug, users hold read-<slug>, not read-forms.\n requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,\n handler: (_req, ctx) =>\n Promise.resolve(\n Response.json({\n fields: resolvedConfig.fields,\n // Notification defaults the builder surfaces: defaultToEmail\n // seeds a new form's first rule, defaultFrom renders as the\n // inherited sender placeholder.\n notifications: {\n defaultFrom: resolvedConfig.notifications.defaultFrom,\n defaultToEmail: resolvedConfig.notifications.defaultToEmail,\n },\n // Spam defaults the Settings tab surfaces, so per-form\n // override selects can show what \"inherit\" resolves to.\n spamProtection: {\n honeypot: resolvedConfig.spamProtection.honeypot,\n recaptchaEnabled:\n resolvedConfig.spamProtection.recaptcha?.enabled ?? false,\n },\n // Runtime-resolved collection slugs (through ctx.self, so a\n // framework .rename() is honored too) — admin components\n // never hardcode \"forms\"/\"form-submissions\".\n // Names only. A pattern may be a function, which does not\n // survive JSON and which the browser has no use for — the\n // admin picks a document, the server builds the URL.\n redirectCollections: Object.keys(\n resolvedConfig.redirectRelationships\n ),\n slugs: {\n forms:\n ctx.self.collections[resolvedConfig.formOverrides.slug] ??\n resolvedConfig.formOverrides.slug,\n submissions:\n ctx.self.collections[\n resolvedConfig.formSubmissionOverrides.slug\n ] ?? resolvedConfig.formSubmissionOverrides.slug,\n },\n })\n ),\n },\n ],\n },\n\n // Forms is a first-class destination, not a plugin detail: standalone\n // placement gives it its own main-rail icon after Media, and its\n // sub-sidebar lists the plugin's collections (Forms, Submissions) —\n // no separate menu contribution, so \"Forms\" exists exactly once.\n // Hosts preferring the Plugins section override placement in config.\n // (The old admin.settings.component that rendered a second full builder\n // at /admin/plugins/<slug> is gone; the collection Edit-view override\n // is the single FormBuilderView mount.)\n admin: {\n placement: \"standalone\",\n after: \"media\",\n order: 50,\n appearance: { icon: \"FileText\", label: \"Forms\" },\n description: \"Create and manage forms with submission tracking\",\n },\n\n // -- Init ----------------------------------------------------------------\n // Registers an afterCreate hook on submissions to send email notifications.\n async init(nextly: NextlyInstance) {\n // Resolve our OWN submissions slug through ctx.self, so the hook\n // follows a framework `.rename()` as well as our formSubmissionOverrides\n // option. The declared slug is the key; ctx.self maps it to the resolved\n // (possibly renamed) slug. Identity when not renamed.\n const declaredSubmissionsSlug =\n resolvedConfig.formSubmissionOverrides.slug;\n const submissionSlug =\n nextly.self.collections[declaredSubmissionsSlug] ??\n declaredSubmissionsSlug;\n installedSubmissionsSlug = submissionSlug;\n\n // Hook/event subscriptions are idempotent across HMR — the platform\n // clears a plugin's prior subscriptions before re-init (B2), so the old\n // globalThis dedup guard is no longer needed.\n nextly.logger.info(\"Form Builder plugin initialized\", {\n formsCollection: resolvedConfig.formOverrides.slug,\n submissionsCollection: submissionSlug,\n enabledFields: Object.entries(resolvedConfig.fields)\n .filter(([, on]) => on)\n .map(([name]) => name),\n });\n\n // Ask each redirect target collection whether it is localized. The save\n // rule and the submission path both need it and neither can reach the\n // registry themselves: a collection hook is handed `req.nextly`, which\n // is the Direct API and carries no metadata accessor. Resolved once here\n // and written onto the config both of them already close over.\n //\n // Per collection rather than in one call: a slug that cannot be read\n // must stay ABSENT from the map, and a single failing lookup would\n // otherwise take its readable siblings with it.\n // Emptied first. `init` runs again on HMR and re-registration against the\n // SAME config object, so a lookup that succeeded on the previous boot and\n // fails on this one would leave its old answer standing while the catch\n // below believes it left the slug absent. A stale `false` then reads a\n // localized target as unreachable and drops its redirect; a stale `true`\n // lets a plain draft target through.\n for (const slug of Object.keys(\n resolvedConfig.redirectTargetLocalization\n )) {\n delete resolvedConfig.redirectTargetLocalization[slug];\n }\n\n // AWAITED, not fired and forgotten: a write reaching the save rule before\n // this resolves would find the map empty and read every target as\n // undecided, which is the refusal silently not happening.\n await Promise.all(\n Object.keys(resolvedConfig.redirectRelationships).map(\n async declared => {\n const slug = nextly.self.collections[declared] ?? declared;\n try {\n // `getCollection` reads registered metadata and does not consult\n // the context — its parameter is declared for a future ACL hook\n // and is unused today — so an empty context is the honest\n // argument rather than a fabricated user.\n const collection =\n await nextly.services.collections.getCollection(slug, {});\n const localized = (collection as { localized?: unknown })\n ?.localized;\n if (typeof localized === \"boolean\") {\n resolvedConfig.redirectTargetLocalization[declared] = localized;\n }\n } catch {\n // Left absent on purpose: unknown, not \"not localized\".\n nextly.logger.debug?.(\n \"Redirect target localization could not be read\",\n { collection: slug }\n );\n }\n }\n )\n );\n\n (nextly as NextlyWithFormBuilderConfig).__formBuilderConfig =\n resolvedConfig;\n\n // D63: run the user's beforeEmail config as a filter on the form-builder seam.\n if (resolvedConfig.beforeEmail) {\n nextly.filters.add(\n \"form-builder.beforeEmail\",\n (emails: FormEmailNotification[], ctx: BeforeEmailFilterContext) =>\n resolvedConfig.beforeEmail!({\n emails,\n // Boundary: loose runtime documents → the user's typed contract.\n form: asFormDocument(ctx.form),\n submission: asSubmissionDocument(ctx.submission),\n })\n );\n }\n\n // Every submission is brought to the form's own shape here, whatever\n // created it. Registered directly on the registry, like the two below, so\n // it runs for every API surface that writes one: the plugin's HTTP\n // handler, `nextly.forms.submit()`, and an admin creating a row by hand.\n //\n // The Direct API did none of this. A submission arriving that way was\n // stored with whatever keys the caller sent, values that never met the\n // form's schema, and markup intact, while the same submission over HTTP\n // was transformed, validated and sanitized. Putting the rule at the write\n // seam rather than in the second caller is what stops a third one\n // inheriting the gap.\n // The forms slug, resolved the way the submissions one above is. A host\n // that renames a contributed collection takes the declared slug out of\n // the registry, so reading the parent form by `formOverrides.slug` finds\n // nothing, and this hook would then refuse every submission on a renamed\n // install. Resolved once here and used by both readers.\n const declaredFormsSlug = resolvedConfig.formOverrides.slug;\n const formsSlug =\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;\n\n // `beforeChange` rather than `beforeCreate`: it is the last\n // COLLECTION-level mutating phase before the insert. Core runs stored\n // `beforeCreate` hooks after the code-registered ones, so a host hook\n // could put undeclared keys or markup back into a payload a\n // `beforeCreate` check had just passed.\n //\n // Field-level `beforeChange` hooks run after this one and are handed the\n // whole record, so a host that registers one on this collection can still\n // reshape `data` afterwards. No collection phase follows them to register\n // on, and core's own sanitizer sits earlier still, on `beforeCreate`.\n // This is the latest point a plugin can check from, which is not the same\n // as a point nothing can follow.\n nextly.hooks.on(\n \"beforeChange\",\n submissionSlug,\n async (context: unknown) =>\n prepareSubmissionForWrite(context, formsSlug, nextly)\n );\n\n // Register afterCreate hook for email notifications\n nextly.hooks.on(\n \"afterCreate\",\n submissionSlug,\n async (context: unknown) => {\n await handleSubmissionCreated(context, resolvedConfig, nextly);\n }\n );\n\n // Stamp admin edits of submitted data: changing what the visitor\n // submitted must leave a visible trace. Registered directly on the\n // registry (like the notification hook) so it runs for every API\n // surface that updates a submission.\n // Reads the patch as it arrived. Core runs this phase before\n // `beforeChange`, so the payload the write seam adds to a status-only\n // patch is not here yet and cannot be mistaken for an admin's edit.\n nextly.hooks.on(\"beforeUpdate\", submissionSlug, (context: unknown) => {\n const ctx = context as {\n data?: Record<string, unknown>;\n user?: { id?: string };\n };\n if (ctx.data && ctx.data.data !== undefined) {\n ctx.data.editedAt = new Date();\n ctx.data.editedBy = ctx.user?.id ?? null;\n }\n return ctx.data;\n });\n\n // Inject a real submissionCount into form reads (spam excluded — the\n // number answers \"how many people submitted\", not \"how many bots\").\n nextly.hooks.on(\"afterRead\", formsSlug, (context: unknown) =>\n injectSubmissionCount(context, submissionSlug, nextly)\n );\n },\n });\n\n return {\n plugin,\n collections: [formsCol, submissionsCol],\n config: resolvedConfig,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Utilities\n// ---------------------------------------------------------------------------\n\n/**\n * Retrieve the resolved Form Builder config from a Nextly plugin context.\n */\nexport function getFormBuilderConfig(\n nextly: unknown\n): ResolvedFormBuilderConfig | undefined {\n if (!nextly || typeof nextly !== \"object\") return undefined;\n return (nextly as Partial<NextlyWithFormBuilderConfig>).__formBuilderConfig;\n}\n\n/**\n * Resolve a `{{fieldName}}` template reference against submitted values.\n * Plain strings (e.g. email addresses) are returned as-is.\n */\nfunction resolveFieldRef(ref: string, data: Record<string, unknown>): string {\n // The pattern is shared with the admin, which decides whether a value is a\n // reference at all. Written twice, a value the editor accepted and this did\n // not would be delivered with its braces intact.\n const name = parseFieldRef(ref);\n if (name === null) return ref;\n const value = data[name];\n return typeof value === \"string\" ? value : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Attachment collection for file fields\n// ---------------------------------------------------------------------------\n\n/**\n * Scan form fields for file-upload fields marked `attachToEmail` and\n * collect the corresponding mediaIds from the submitted data. Returns\n * an array of `{ mediaId }` objects ready to pass as `attachments` to\n * `emailService.sendWithTemplate()`.\n *\n * Skips empty/missing values silently (optional file fields). Handles\n * both single-value and `multiple: true` file fields.\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function collectAttachmentInputs(\n fields: Array<Record<string, unknown>>,\n submittedData: Record<string, unknown>\n): Array<{ mediaId: string }> {\n const attachments: Array<{ mediaId: string }> = [];\n\n for (const field of fields) {\n if (field.type !== \"file\" || !field.attachToEmail) continue;\n\n const name = field.name as string;\n const value = submittedData[name];\n if (value == null) continue;\n\n if (Array.isArray(value)) {\n for (const item of value) {\n if (typeof item === \"string\" && item) {\n attachments.push({ mediaId: item });\n }\n }\n } else if (typeof value === \"string\" && value) {\n attachments.push({ mediaId: value });\n }\n }\n\n return attachments;\n}\n\n// ---------------------------------------------------------------------------\n// Submission notification handler\n// ---------------------------------------------------------------------------\n\n/**\n * Normalize a JSON column value from a raw DB row into an object. Hook\n * contexts carry the row as stored, so the value may be an already-parsed\n * object (jsonb dialects) or a serialized string (text-storage dialects).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function parseJsonColumn(value: unknown): Record<string, unknown> {\n if (typeof value === \"string\") {\n try {\n const parsed: unknown = JSON.parse(value);\n return parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n }\n return value && typeof value === \"object\" && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {};\n}\n\n/** Trim an address list and drop blanks; undefined when nothing remains. */\nfunction normalizeAddressList(\n addresses: readonly string[] | undefined\n): string[] | undefined {\n if (!Array.isArray(addresses)) return undefined;\n const cleaned = addresses\n .map(address => (typeof address === \"string\" ? address.trim() : \"\"))\n .filter(Boolean);\n return cleaned.length > 0 ? cleaned : undefined;\n}\n\n/** Why a notification rule produced no email for a given submission. */\nexport interface SkippedNotification {\n notificationId: string;\n reason: \"empty-recipient\" | \"condition-unmet\" | \"no-template\";\n}\n\n/**\n * Resolve a form's notification rules against one submission into outgoing\n * email descriptors, honoring everything a rule can configure: the send\n * condition gates the rule, the recipient/reply-to resolve `{{fieldName}}`\n * references, and the sender falls back from the rule's own address to the\n * plugin's `notifications.defaultFrom` (undefined lets the template/provider\n * default apply downstream).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function buildNotificationEmails(input: {\n notifications: readonly FormNotification[];\n submittedData: Record<string, unknown>;\n formName: unknown;\n submissionId: unknown;\n defaultFrom?: string;\n}): { emails: FormEmailNotification[]; skipped: SkippedNotification[] } {\n const { notifications, submittedData, formName, submissionId, defaultFrom } =\n input;\n\n const seen = new Set<string>();\n const emails: FormEmailNotification[] = [];\n const skipped: SkippedNotification[] = [];\n\n for (const notification of notifications) {\n if (!notification.enabled) continue;\n\n if (!notification.templateSlug) {\n skipped.push({ notificationId: notification.id, reason: \"no-template\" });\n continue;\n }\n\n // Deduplicate (UI can append duplicates on repeated saves). For id-less\n // rules (API-authored), only an exact structural duplicate counts — a\n // to+template key would silently drop distinct rules that share a\n // recipient and template but differ elsewhere (e.g. their condition).\n const key = notification.id || JSON.stringify(notification);\n if (seen.has(key)) continue;\n seen.add(key);\n\n // An unmet send condition skips the rule for this submission — an\n // expected state, never an error.\n if (\n notification.condition &&\n !evaluateSingleCondition(notification.condition, submittedData)\n ) {\n skipped.push({\n notificationId: notification.id,\n reason: \"condition-unmet\",\n });\n continue;\n }\n\n // Trim every address on the way out: whitespace-only values must count\n // as empty, and stray spaces must not reach the provider as headers.\n const to = (\n notification.recipientType === \"field\"\n ? resolveFieldRef(notification.to, submittedData)\n : notification.to\n ).trim();\n\n if (!to) {\n skipped.push({\n notificationId: notification.id,\n reason: \"empty-recipient\",\n });\n continue;\n }\n\n const cc = normalizeAddressList(notification.cc);\n const bcc = normalizeAddressList(notification.bcc);\n\n // Sender resolution: the rule's own address wins, then the plugin's\n // configured default; undefined defers to the template/provider chain.\n const from =\n notification.senderEmail?.trim() || defaultFrom?.trim() || undefined;\n\n // Reply-To resolves {{fieldName}} like recipients do; a reference to a\n // field the visitor left empty degrades to \"no Reply-To header\" rather\n // than an invalid address.\n const replyTo = notification.replyTo\n ? resolveFieldRef(notification.replyTo, submittedData).trim() || undefined\n : undefined;\n\n emails.push({\n to,\n templateSlug: notification.templateSlug,\n variables: {\n ...submittedData,\n formName,\n submissionId,\n },\n providerId: notification.providerId,\n from,\n replyTo,\n cc,\n bcc,\n notificationId: notification.id,\n });\n }\n\n return { emails, skipped };\n}\n\n/**\n * Send email notifications after a form submission is created.\n */\n/**\n * Bring an incoming submission to the shape its form declares.\n *\n * Runs on `beforeCreate` for the submissions collection, so it sees every write\n * rather than every caller. The cost is one read of the parent form per\n * submission: the HTTP handler has already loaded it and cannot hand it over,\n * because a hook receives the row and not the request. Submissions are written\n * one visitor at a time and the read is by primary key, which is the cheaper\n * side of the trade against a rule two callers have to remember.\n *\n * Refuses rather than storing when it cannot judge. A form it cannot read, or\n * one whose `fields` is not a list, leaves nothing to transform against, and\n * transforming against an empty list would project the submission down to `{}`\n * and store a row that says the visitor sent nothing. Silently emptying someone's\n * submission is worse than declining it.\n *\n * A row that names no form is passed through untouched. `form` is `required` on\n * the collection, so it is already refused a step later, and rejecting it here\n * would answer a question this hook was not asked.\n */\n/**\n * The parent form's id, however the row spells the relationship.\n *\n * A relationship arrives as an id string from a write and as a populated object\n * from a read, and both hooks on this collection have to cope with either.\n * Stated once so they cannot come to disagree about what counts as a reference.\n */\nfunction parentFormId(submission: Record<string, unknown>): string | null {\n const raw = submission.form;\n if (typeof raw === \"string\") return raw;\n if (raw && typeof raw === \"object\") {\n const maybeId = (raw as { id?: unknown }).id;\n if (typeof maybeId === \"string\") return maybeId;\n }\n return null;\n}\n\n/**\n * The submitted payload, as an object, or a refusal.\n *\n * `data` arrives as an object from every caller in this repository. A string is\n * read as the JSON a dialect stores rather than assumed to be one field's\n * value, and anything else is refused: that column is `required`, so an omitted\n * payload is meant to be refused, and substituting `{}` satisfied the check on\n * the way past. A form whose fields are all optional then accepted the empty\n * object and the row was stored.\n */\nfunction submittedPayload(raw: unknown): Record<string, unknown> {\n if (typeof raw === \"string\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // Falls through to the refusal below.\n }\n } else if (raw && typeof raw === \"object\" && !Array.isArray(raw)) {\n return raw as Record<string, unknown>;\n }\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"INVALID\",\n message: \"Submission data must be an object.\",\n },\n ],\n });\n}\n\n/**\n * Whether an update has to be brought back to the form's shape.\n *\n * Two kinds of update do, and one does not.\n *\n * A patch that replaces `data` is replacing what the visitor sent, so it is\n * checked exactly as the create was. Exempting every update meant a caller who\n * may edit a submission could put undeclared keys, values the form's schema\n * rejects and markup into the row a moment after the create had refused to\n * accept them.\n *\n * A patch that touches `form` moves the row under a different schema, and the\n * payload it already holds is what has to satisfy that one. Such a patch\n * carries no `data` of its own, so a rule keyed on `data` alone let a caller\n * move a submission onto any form and leave behind a payload the form it now\n * belongs to rejects.\n *\n * A patch that takes the row out of spam is the third. An evidence row was\n * stored WITHOUT being validated, deliberately, so that a false positive stays\n * reviewable. The admin's \"Not spam\" action sends only a status, and letting it\n * through unchecked turned a payload the form rejects into an ordinary counted\n * submission. Leaving spam is the moment that payload has to satisfy the form,\n * and a row that cannot has to be corrected in the same update.\n *\n * An update that does none of these leaves nothing to check: an admin changing\n * a status between two non-spam values sends only that, and preparing an absent\n * payload would store an empty submission over a real one. The last two are\n * left alone for the same reason when the stored row did not reach this hook,\n * since there is then no payload to judge.\n */\nfunction updateNeedsChecking(\n submission: Record<string, unknown>,\n stored: Record<string, unknown> | undefined\n): boolean {\n if (submission.data !== undefined) return true;\n // Everything below judges the STORED payload, so there has to be one.\n if (stored?.data === undefined) return false;\n if (submission.form !== undefined) return true;\n return (\n stored.status === \"spam\" &&\n submission.status !== undefined &&\n submission.status !== \"spam\"\n );\n}\n\nexport async function prepareSubmissionForWrite(\n context: unknown,\n formsSlug: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | undefined> {\n const ctx = context as {\n data?: Record<string, unknown>;\n operation?: string;\n originalData?: Record<string, unknown>;\n user?: { id?: string };\n req?: { http?: { ip: string | null; method: string } };\n };\n const submission = ctx.data;\n if (!submission || typeof submission !== \"object\") return ctx.data;\n const stored = ctx.originalData;\n\n // A patch need not repeat the relationship, so the stored row is what says\n // which form this payload has to match.\n const formId =\n parentFormId(submission) ?? (stored ? parentFormId(stored) : null);\n if (!formId) return ctx.data;\n\n if (ctx.operation !== \"create\" && !updateNeedsChecking(submission, stored)) {\n return ctx.data;\n }\n\n const incoming = submittedPayload(\n submission.data !== undefined ? submission.data : stored?.data\n );\n\n // Read off the row itself, so it describes this write and no other.\n const marks = submissionMarks(submission);\n\n const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);\n const fields = form.fields as AnyFormField[];\n\n // Spam is judged HERE, at the seam every door passes through, rather than in\n // the route: the submissions collection grants public create, so the generic\n // collection create is a second public door and the Direct API a third. A\n // rule that lives in one of them guards one of them.\n //\n // Only when a request produced this write. `ctx.req.http` is absent for a\n // seed, an import or a job, and a rule aimed at a visitor must not judge a\n // server that is importing ten thousand rows as one.\n const spam =\n ctx.operation === \"create\" && ctx.req?.http\n ? await judgeSubmission(incoming, form, ctx.req.http, nextly)\n : undefined;\n\n if (spam?.reason === \"rate_limit\") {\n // Refused, not stored: a limiter that wrote a row per refusal would turn\n // volume into a database it fills for the attacker. The plugin's own route\n // answers its visitor with a success anyway, so a bot learns nothing from\n // it; the other doors are machine-facing and get the honest 429.\n throw NextlyError.rateLimited();\n }\n\n // Content spam keeps its evidence. The handler stores a honeypot or reCAPTCHA\n // hit flagged rather than dropping it, so a false positive stays recoverable,\n // and requiring it to be valid would throw away the thing being reviewed. It\n // is still transformed and sanitized.\n // Leniency is a fact about the CALL, not about the row. Reading it off\n // `status` made it caller-controlled: the collection grants public create and\n // nothing restricts that field, so anyone could post `status: \"spam\"` and\n // switch validation off for their own row.\n const prepared = prepareSubmission({\n data: incoming,\n fields,\n validate: marks?.keepAsEvidence !== true && spam === undefined,\n });\n\n if (prepared.validationErrors) {\n throw NextlyError.validation({\n errors: Object.entries(prepared.validationErrors).map(\n ([path, message]) => ({ path, code: \"INVALID\", message })\n ),\n });\n }\n\n ctx.data = {\n ...submission,\n data: prepared.data,\n ...startingStatus(ctx, marks),\n };\n if (spam) {\n // Flagged, never dropped: a false positive stays reviewable in the Spam\n // view and recoverable through \"Not spam\". Written here rather than trusted\n // from the payload, because the collection grants public create and nothing\n // restricts these fields, so a caller could otherwise mark its own row.\n ctx.data.status = \"spam\";\n ctx.data.spamReason = spam.reason ?? null;\n }\n if (ctx.operation !== \"create\" && submission.data === undefined) {\n stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);\n }\n return ctx.data;\n}\n\n/**\n * The status a submission a request created starts with: `new`, whatever the\n * request named, before the spam verdict may make it `spam`. The collection\n * grants public create and nothing restricts `status`, so a visitor posting\n * `read` or `archived` would otherwise file their submission where nobody\n * looks for one that was never opened.\n *\n * A create no request produced (an import, a seed, a job) keeps the status it\n * names, and so does this plugin's own handler storing content spam as\n * evidence (`keepAsEvidence`), which chose that status itself. An update is\n * left as it is.\n */\nfunction startingStatus(\n ctx: { operation?: string; req?: { http?: unknown } },\n marks: SubmissionOriginMarks | undefined\n): { status?: \"new\" } {\n const requestCreate = ctx.operation === \"create\" && ctx.req?.http;\n return requestCreate && marks?.keepAsEvidence !== true\n ? { status: \"new\" }\n : {};\n}\n\n/**\n * The fields a submission has to satisfy.\n *\n * The handler that already read this form hands it over rather than have the\n * write read it a second time. That read is not free: `findEntryById` runs the\n * forms collection's `afterRead` hooks, and this plugin registers one that\n * COUNTs the form's submissions, so a write was paying for a count of every\n * write before it. Only ever the form this row names, because the id has to\n * match.\n */\nasync function formToCheckAgainst(\n marks: SubmissionOriginMarks | undefined,\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown>> {\n const handedOver = marks?.form?.id === formId ? marks.form : null;\n const form = handedOver ?? (await fetchParentForm(formsSlug, formId, nextly));\n if (!form || !Array.isArray(form.fields)) {\n throw NextlyError.validation({\n errors: [\n {\n path: \"form\",\n code: \"INVALID\",\n message:\n \"The form this submission belongs to could not be read, so the submission could not be checked against it.\",\n },\n ],\n });\n }\n return form;\n}\n\n/**\n * The part of a rate-limit key that names the form.\n *\n * The ID, never the slug. A door that hands the seam the form it already read\n * carries `{ id, fields }`, and a door that makes the seam fetch it carries the\n * whole row: keyed on whatever each happens to have, one form gets one window\n * per door and a caller alternating between two doors spends two budgets.\n *\n * Only a string identifies a form. A row whose id is unusable is limited as its\n * own bucket rather than silently joining a neighbour's.\n */\nfunction rateLimitKeyFor(form: Record<string, unknown>): string {\n const { id } = form;\n return typeof id === \"string\" && id.length > 0 ? id : \"unidentified-form\";\n}\n\n/**\n * Whether this submission looks like a bot's, and why.\n *\n * `undefined` means nothing was detected, which is not the same as \"no rule\n * ran\": a form that disables the honeypot and a deployment that configures no\n * rate limit both answer that way, and both mean the submission is stored as an\n * ordinary one.\n *\n * The address comes from the core, resolved under the deployment's proxy-trust\n * settings, so it is the closest untrusted hop rather than whatever the sender\n * put in `x-forwarded-for`. `null` means no address could be trusted, and the\n * rate limit does not run: keying it on a placeholder would put every\n * unidentifiable client in one window, where the first bot to fill it locks out\n * every visitor behind an untrusted proxy. The honeypot still applies, because\n * it reads the payload rather than the caller.\n */\nasync function judgeSubmission(\n payload: Record<string, unknown>,\n form: Record<string, unknown>,\n http: { ip: string | null; method: string },\n nextly: NextlyInstance\n): Promise<SpamCheckResult | undefined> {\n const config = getFormBuilderConfig(nextly);\n if (!config) return undefined;\n\n // The trap is set among the keys the form does NOT declare. Several honeypot\n // names are ones a real form might use -- `website`, `url_field` -- so\n // probing the whole payload would flag every submission to a form that\n // declares one, and flag it as bot traffic.\n const declared = new Set(\n (Array.isArray(form.fields) ? form.fields : [])\n .map(field => (field as { name?: unknown }).name)\n .filter((name): name is string => typeof name === \"string\")\n );\n const undeclared = Object.fromEntries(\n Object.entries(payload).filter(([key]) => !declared.has(key))\n );\n\n const settings = (form.settings ?? {}) as { honeypotEnabled?: boolean };\n const verdict = await checkSpam({\n data: undeclared,\n ipAddress: http.ip ?? undefined,\n formSlug: rateLimitKeyFor(form),\n config: {\n honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,\n rateLimit: config.spamProtection.rateLimit,\n },\n });\n return verdict.isSpam ? verdict : undefined;\n}\n\n/**\n * Record a payload change this hook derived, since nothing else will.\n *\n * A patch that did not carry a payload but changed one is still an edit of what\n * the visitor sent: moving a submission to another form re-projects its answers\n * onto that form's fields and can drop one it does not declare. The stamp on\n * `beforeUpdate` has already run by then and only marks a patch that arrived\n * with `data`, so without this the stored answers change with nothing in the\n * row saying who changed them.\n */\nfunction stampDerivedEdit(\n row: Record<string, unknown>,\n user: { id?: string } | undefined,\n before: Record<string, unknown>,\n after: Record<string, unknown>\n): void {\n if (sameSubmittedPayload(before, after)) return;\n row.editedAt = new Date();\n row.editedBy = user?.id ?? null;\n}\n\nasync function handleSubmissionCreated(\n context: unknown,\n config: ResolvedFormBuilderConfig,\n nextly: NextlyInstance\n): Promise<void> {\n // The plugin-level kill switch: `notifications.enabled: false` turns off\n // all form emails regardless of per-rule state.\n if (!config.notifications.enabled) return;\n\n const submission = (context as { data?: Record<string, unknown> }).data;\n if (!submission) return;\n\n // Spam is stored for review, but nobody gets emailed about it — otherwise\n // every bot hit would trigger the form's notification rules.\n if (submission.status === \"spam\") return;\n\n const formId = parentFormId(submission);\n if (!formId) return;\n\n // Fetch the parent form\n const declaredFormsSlug = config.formOverrides.slug;\n const form = await fetchParentForm(\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,\n formId,\n nextly\n );\n if (!form) return;\n\n const notifications = Array.isArray(form.notifications)\n ? (form.notifications as FormNotification[])\n : [];\n if (notifications.length === 0) return;\n\n const emailService = nextly.services.email;\n if (!emailService) return;\n\n // The afterCreate hook receives the raw DB row, so on dialects that store\n // JSON columns as text (e.g. SQLite) `data` arrives serialized. Without\n // parsing it, {{field}} recipients, reply-to references, and send\n // conditions all silently see an empty submission.\n const submittedData = parseJsonColumn(submission.data);\n\n // Collect mediaIds from file fields marked for email attachment\n const formFields = Array.isArray(form.fields)\n ? (form.fields as Array<Record<string, unknown>>)\n : [];\n const fileAttachments = collectAttachmentInputs(formFields, submittedData);\n\n // -- Build phase: resolve each enabled notification into an outgoing\n // descriptor (the value the D63 seam transforms).\n const { emails, skipped } = buildNotificationEmails({\n notifications,\n submittedData,\n formName: form.name,\n submissionId: submission.id,\n defaultFrom: config.notifications.defaultFrom,\n });\n\n for (const skip of skipped) {\n if (skip.reason === \"empty-recipient\") {\n nextly.logger.warn?.(\n \"Form Builder: empty recipient, skipping notification\",\n { notificationId: skip.notificationId, formSlug: form.slug }\n );\n } else {\n // Unmet conditions and missing templates are expected states, not\n // faults — surface them only at debug level.\n nextly.logger.debug?.(\"Form Builder: notification skipped\", {\n notificationId: skip.notificationId,\n formSlug: form.slug,\n reason: skip.reason,\n });\n }\n }\n\n if (emails.length === 0) return;\n\n // -- Seam: thread the outgoing notifications through the D63 filter so user\n // config (and any other registered handler) can modify/filter them.\n const finalEmails = await nextly.filters.apply<\n FormEmailNotification[],\n BeforeEmailFilterContext\n >(\"form-builder.beforeEmail\", emails, { form, submission });\n\n // -- Send phase: send the (possibly transformed) outgoing notifications,\n // once the submission is durable. An email cannot be taken back, and a\n // submission saved inside a caller's transaction runs this hook before that\n // transaction commits: sent here, it would go out, and its delivery be\n // recorded, for a submission a rollback then removes. The hook context's\n // `afterCommit` holds the send until the outermost transaction commits and\n // drops it on rollback; outside a transaction it sends at once, as before.\n const send = () =>\n sendNotificationEmails({\n emails: finalEmails,\n emailService,\n logger: nextly.logger,\n formSlug: form.slug,\n fileAttachments,\n });\n const { afterCommit } = context as Pick<HookContext, \"afterCommit\">;\n await (afterCommit ? afterCommit(send) : send());\n}\n\n/**\n * Send each notification, and log what happened to it.\n *\n * The email service reports a provider's failure in its result rather than by\n * throwing, so the result is read: a notification the provider did not take is\n * logged as failed, with whether a retry can help, and never as sent. No log\n * line names a recipient: an address in a log outlives the submission it came\n * from, and the notification's id is enough to find it.\n */\nexport async function sendNotificationEmails(input: {\n emails: FormEmailNotification[];\n emailService: Pick<PluginEmailService, \"sendWithTemplate\">;\n logger: Pick<NextlyInstance[\"logger\"], \"info\" | \"error\">;\n /** The form's slug as stored, for the log lines only. */\n formSlug: unknown;\n fileAttachments: Array<{ mediaId: string; filename?: string }>;\n}): Promise<void> {\n const { emails, emailService, logger, formSlug, fileAttachments } = input;\n for (const email of emails) {\n const identity = {\n formSlug,\n templateSlug: email.templateSlug,\n notificationId: email.notificationId,\n };\n try {\n const result = await emailService.sendWithTemplate(\n email.templateSlug,\n email.to,\n email.variables,\n {\n providerId: email.providerId,\n from: email.from,\n replyTo: email.replyTo,\n cc: email.cc,\n bcc: email.bcc,\n attachments: fileAttachments.length > 0 ? fileAttachments : undefined,\n }\n );\n\n if (result.success) {\n logger.info?.(\"Form Builder: notification sent\", {\n ...identity,\n attachmentCount: fileAttachments.length,\n });\n } else {\n logger.error?.(\"Form Builder: notification failed\", {\n ...identity,\n retryable: result.failure?.retryable ?? true,\n ...(result.failure?.status !== undefined\n ? { status: result.failure.status }\n : {}),\n reason: result.failure?.reason ?? \"The email was not sent.\",\n });\n }\n } catch (err) {\n // Thrown rather than returned: nothing was sent. A refusal of the send\n // itself (an invalid address) would be refused again, so it is not\n // retryable; anything else, a storage read or a lookup that failed, may\n // pass, and counts as retryable, as an unclassified failure does.\n logger.error?.(\"Form Builder: notification failed\", {\n ...identity,\n retryable: !NextlyError.isCode(err, \"VALIDATION_ERROR\"),\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n}\n\n/**\n * Fetch the parent form document for a submission.\n */\n/**\n * Put a real `submissionCount` on every form a read returned.\n *\n * Spam is excluded, because the number answers \"how many people submitted\", not\n * \"how many bots\". `afterRead` fires for single reads and for list reads, so\n * both shapes are handled; the counts run concurrently, and form lists are\n * paginated, so this is a bounded fan-out of small indexed queries rather than\n * a serial walk.\n *\n * A read that asked for the schema alone is left alone. A submission write\n * reads its parent form only to check the payload against that form's fields,\n * and counting there is presentation work nobody on that path reads. It also\n * grows with the form's history, so every submission was paying for a count of\n * every submission before it.\n *\n * The flag decides how much work to do and nothing else. Forged, the worst it\n * can produce is a form read whose `submissionCount` is absent, and it cannot\n * come from a request body in any case: the hook context is set by the\n * server-side caller of the service.\n */\nexport async function injectSubmissionCount(\n context: unknown,\n submissionSlug: string,\n nextly: NextlyInstance\n): Promise<void> {\n const hook = context as {\n data?: unknown;\n context?: Record<string, unknown>;\n };\n if (hook.context?.[SCHEMA_ONLY_READ] === true) return;\n\n const records = Array.isArray(hook.data)\n ? hook.data\n : hook.data\n ? [hook.data]\n : [];\n await Promise.all(\n records.map(async record => {\n const form = record as Record<string, unknown>;\n if (typeof form.id !== \"string\") return;\n try {\n // Count as system: whoever may read the form may see its submission\n // volume without holding submission read rights.\n form.submissionCount = await nextly.services.collections.count(\n submissionSlug,\n {\n where: {\n form: { equals: form.id },\n status: { not_equals: \"spam\" },\n },\n },\n { as: \"system\" }\n );\n } catch {\n // A failed count must never break reading the form itself.\n form.submissionCount = 0;\n }\n })\n );\n}\n\nexport async function fetchParentForm(\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | null> {\n try {\n // D35/D56: read the parent form through the secure managed service as\n // system — the afterCreate hook runs without an ambient user. Replaces the\n // legacy `getCollectionsHandler()` + `overrideAccess` runtime path.\n //\n // The slug arrives RESOLVED. Passing `formOverrides.slug` read the declared\n // name, which a host that renames the collection has taken out of the\n // registry, so the lookup found nothing on exactly the installs the rename\n // API supports.\n //\n // NOT on the caller's transaction, and it cannot be here. The plugin facade\n // rebuilds its context from `user` and `overrideAccess` alone, and\n // `CollectionService.findEntryById` forwards only those two to the entry\n // service, so an executor has nowhere to go on this path: passing one\n // looked like transaction awareness and was discarded before the query.\n //\n // The cost is written down rather than hidden. A submission created inside\n // a transaction alongside a form created in that same transaction cannot\n // see it and is refused, and on a pool whose only connection the\n // transaction holds, this read waits for it. Both fail safely. The pattern\n // for fixing it exists in collection-hook-service and\n // collection-access-service, which forward a context's executor for exactly\n // this reason; bringing it here means changing the entry service and the\n // plugin facade with it.\n const form = await nextly.services.collections.findEntryById(\n formsSlug,\n formId,\n { as: \"system\", context: { ...SCHEMA_ONLY_CONTEXT } }\n );\n return form;\n } catch (err) {\n // A form that is not there is an answer, and the caller decides what a\n // submission naming no form means. Anything else is the read itself\n // failing, and it is rethrown: swallowing a pool timeout or a throwing\n // `afterRead` hook turned a server fault into \"this form could not be\n // read\", so the writer was told their submission was invalid, with a status\n // that says not to retry.\n if (!NextlyError.isNotFound(err)) throw err;\n nextly.logger.error?.(\"Form Builder: form not found\", {\n formId,\n error: err instanceof Error ? err.message : String(err),\n });\n return null;\n }\n}\n","/**\n * Form Configuration Validator\n *\n * Provides comprehensive validation for form configurations including:\n * - Slug validation (format, reserved names)\n * - Field name validation (format, duplicates)\n * - Field-specific validation (select/radio options, min/max constraints)\n * - Conditional logic validation\n * - Nested field validation\n *\n * @module config/validate-form\n * @since 1.0.0\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const result = validateFormConfig(config);\n * if (!result.valid) {\n * console.error('Validation errors:', result.errors);\n * }\n * ```\n */\n\nimport { isPluginFieldTypeOnSurface } from \"nextly\";\n\nimport { BUILT_IN_FORM_FIELD_TYPES } from \"../types\";\nimport type { FormConfig, FormField, FormFieldType } from \"../types\";\nimport { parseRedirectReference } from \"../utils/redirect-reference\";\n\nimport { warnIfWebhooksConfigured } from \"./removed-options\";\n\n// ============================================================\n// Validation Error Types\n// ============================================================\n\n/**\n * Error codes for form validation failures.\n * Used for programmatic error handling.\n */\nexport type FormValidationErrorCode =\n // Slug errors\n | \"SLUG_REQUIRED\"\n | \"SLUG_INVALID_TYPE\"\n | \"SLUG_TOO_SHORT\"\n | \"SLUG_TOO_LONG\"\n | \"SLUG_INVALID_FORMAT\"\n | \"SLUG_RESERVED\"\n // Field errors\n | \"FIELDS_REQUIRED\"\n | \"FIELDS_INVALID_TYPE\"\n | \"FIELDS_EMPTY\"\n | \"FIELD_NAME_REQUIRED\"\n | \"FIELD_NAME_INVALID_FORMAT\"\n | \"FIELD_NAME_DUPLICATE\"\n | \"FIELD_TYPE_REQUIRED\"\n | \"FIELD_TYPE_INVALID\"\n | \"FIELD_LABEL_REQUIRED\"\n // Field-specific errors\n | \"OPTIONS_REQUIRED\"\n | \"OPTIONS_EMPTY\"\n | \"MIN_GREATER_THAN_MAX\"\n | \"MIN_LENGTH_GREATER_THAN_MAX\"\n | \"FILE_MAX_SIZE_INVALID\"\n // Conditional logic errors\n | \"CONDITIONS_REQUIRED\"\n | \"CONDITION_FIELD_REQUIRED\"\n | \"CONDITION_COMPARISON_REQUIRED\"\n // Settings errors\n | \"SETTINGS_INVALID_TYPE\"\n | \"REDIRECT_URL_REQUIRED\"\n | \"REDIRECT_PAGE_REQUIRED\"\n // Access errors\n | \"ACCESS_INVALID_TYPE\"\n | \"ACCESS_FUNCTION_INVALID\";\n\n/**\n * A single validation error with path and context.\n *\n * @example\n * ```typescript\n * const error: FormValidationError = {\n * path: 'fields.email.validation',\n * message: \"Min length cannot be greater than max length\",\n * code: 'MIN_LENGTH_GREATER_THAN_MAX',\n * };\n * ```\n */\nexport interface FormValidationError {\n /**\n * Dot-notation path to the invalid property.\n * @example 'slug', 'fields.0.name', 'fields.email.options'\n */\n path: string;\n\n /**\n * Human-readable error message.\n */\n message: string;\n\n /**\n * Machine-readable error code for programmatic handling.\n */\n code: FormValidationErrorCode;\n}\n\n/**\n * Result of form config validation.\n */\nexport interface FormValidationResult {\n /**\n * Whether the configuration is valid.\n */\n valid: boolean;\n\n /**\n * Array of validation errors (empty if valid).\n */\n errors: FormValidationError[];\n}\n\n// ============================================================\n// Reserved Names\n// ============================================================\n\n/**\n * Reserved form slugs that cannot be used.\n * These are used by the system or have special meaning.\n */\nexport const RESERVED_FORM_SLUGS = [\n // API routes\n \"api\",\n \"submit\",\n \"submissions\",\n \"export\",\n // System slugs\n \"admin\",\n \"new\",\n \"create\",\n \"edit\",\n \"delete\",\n] as const;\n\n/**\n * Set for O(1) lookup of reserved slugs.\n */\nconst RESERVED_SLUGS_SET = new Set<string>(RESERVED_FORM_SLUGS);\n\n// ============================================================\n// Validation Patterns\n// ============================================================\n\n/**\n * Regex pattern for valid form slugs.\n * Must start with a lowercase letter, contain only lowercase letters,\n * numbers, hyphens, and underscores. Length: 1-50 chars.\n * Allows hyphens for URL-friendly slugs (e.g., 'contact-form').\n */\nconst SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * Regex pattern for valid field names.\n * Must start with a letter (upper or lower), contain only letters,\n * numbers, and underscores. Supports camelCase (e.g., 'firstName').\n */\nconst FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\n/**\n * All valid built-in form field types (plugin types validate via the registry).\n */\nconst VALID_FIELD_TYPES: readonly FormFieldType[] = BUILT_IN_FORM_FIELD_TYPES;\n\nconst VALID_FIELD_TYPES_SET = new Set<string>(VALID_FIELD_TYPES);\n\n// ============================================================\n// Validation Helper Functions\n// ============================================================\n\n/**\n * Checks if a string is a reserved form slug.\n */\nfunction isReservedSlug(slug: string): boolean {\n return RESERVED_SLUGS_SET.has(slug.toLowerCase());\n}\n\n/**\n * Validates a form slug.\n */\nfunction validateSlug(slug: unknown, errors: FormValidationError[]): void {\n const path = \"slug\";\n\n if (!slug) {\n errors.push({\n path,\n message: \"Form slug is required\",\n code: \"SLUG_REQUIRED\",\n });\n return;\n }\n\n if (typeof slug !== \"string\") {\n errors.push({\n path,\n message: \"Form slug must be a string\",\n code: \"SLUG_INVALID_TYPE\",\n });\n return;\n }\n\n if (slug.length < 1) {\n errors.push({\n path,\n message: \"Form slug must be at least 1 character\",\n code: \"SLUG_TOO_SHORT\",\n });\n }\n\n if (slug.length > 50) {\n errors.push({\n path,\n message: \"Form slug must be at most 50 characters\",\n code: \"SLUG_TOO_LONG\",\n });\n }\n\n if (!SLUG_PATTERN.test(slug)) {\n errors.push({\n path,\n message:\n \"Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores\",\n code: \"SLUG_INVALID_FORMAT\",\n });\n }\n\n if (isReservedSlug(slug)) {\n errors.push({\n path,\n message: `Form slug '${slug}' is reserved and cannot be used`,\n code: \"SLUG_RESERVED\",\n });\n }\n}\n\n/**\n * Validates a single field name.\n */\nfunction validateFieldName(\n name: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!name) {\n errors.push({\n path: `${path}.name`,\n message: \"Field name is required\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (typeof name !== \"string\") {\n errors.push({\n path: `${path}.name`,\n message: \"Field name must be a string\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (!FIELD_NAME_PATTERN.test(name)) {\n errors.push({\n path: `${path}.name`,\n message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,\n code: \"FIELD_NAME_INVALID_FORMAT\",\n });\n }\n\n // Check for duplicates at this level\n const nameLower = name.toLowerCase();\n if (seenNames.has(nameLower)) {\n errors.push({\n path: `${path}.name`,\n message: `Duplicate field name '${name}'`,\n code: \"FIELD_NAME_DUPLICATE\",\n });\n } else {\n seenNames.add(nameLower);\n }\n}\n\n/**\n * Validates a single field label.\n */\nfunction validateFieldLabel(\n label: unknown,\n path: string,\n errors: FormValidationError[]\n): void {\n if (!label || (typeof label === \"string\" && label.trim() === \"\")) {\n errors.push({\n path: `${path}.label`,\n message: \"Field label is required\",\n code: \"FIELD_LABEL_REQUIRED\",\n });\n }\n}\n\n/**\n * Validates select/radio/checkbox-group field options.\n */\nfunction validateOptions(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n // Type assertion for fields with options\n const fieldWithOptions = field as {\n options?: Array<{ label: string; value: string }>;\n };\n const options = fieldWithOptions.options;\n\n if (!options) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have an 'options' array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(options)) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field 'options' must be an array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (options.length === 0) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have at least one option`,\n code: \"OPTIONS_EMPTY\",\n });\n }\n}\n\n/**\n * Validates conditional logic configuration.\n */\nfunction validateConditionalLogic(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n const { conditionalLogic } = field;\n\n if (!conditionalLogic || !conditionalLogic.enabled) {\n return;\n }\n\n if (\n !conditionalLogic.conditions ||\n conditionalLogic.conditions.length === 0\n ) {\n errors.push({\n path: `${path}.conditionalLogic.conditions`,\n message: \"Conditional logic must have at least one condition\",\n code: \"CONDITIONS_REQUIRED\",\n });\n return;\n }\n\n conditionalLogic.conditions.forEach((condition, index) => {\n const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;\n\n if (!condition.field) {\n errors.push({\n path: `${conditionPath}.field`,\n message: \"Condition must specify a field name\",\n code: \"CONDITION_FIELD_REQUIRED\",\n });\n }\n\n if (!condition.comparison) {\n errors.push({\n path: `${conditionPath}.comparison`,\n message: \"Condition must specify a comparison operator\",\n code: \"CONDITION_COMPARISON_REQUIRED\",\n });\n }\n });\n}\n\n/**\n * Validates field based on its type.\n */\nfunction validateFieldByType(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n switch (field.type) {\n case \"select\":\n case \"radio\":\n validateOptions(field, path, errors);\n break;\n\n case \"number\": {\n const numberField = field;\n if (numberField.validation) {\n const { min, max } = numberField.validation;\n if (min !== undefined && max !== undefined && min > max) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min value cannot be greater than max value\",\n code: \"MIN_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"text\":\n case \"textarea\": {\n const textField = field;\n if (textField.validation) {\n const { minLength, maxLength } = textField.validation;\n if (\n minLength !== undefined &&\n maxLength !== undefined &&\n minLength > maxLength\n ) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min length cannot be greater than max length\",\n code: \"MIN_LENGTH_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"file\": {\n const fileField = field;\n if (fileField.maxFileSize !== undefined && fileField.maxFileSize <= 0) {\n errors.push({\n path: `${path}.maxFileSize`,\n message: \"File maxFileSize must be a positive number\",\n code: \"FILE_MAX_SIZE_INVALID\",\n });\n }\n break;\n }\n }\n}\n\n/**\n * Validates a single field configuration.\n */\nfunction validateField(\n field: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!field || typeof field !== \"object\") {\n return;\n }\n\n const f = field as Record<string, unknown>;\n\n // Validate field type\n const fieldType = f.type;\n if (!fieldType) {\n errors.push({\n path: `${path}.type`,\n message: \"Field type is required\",\n code: \"FIELD_TYPE_REQUIRED\",\n });\n return;\n }\n\n // A plugin may contribute a field type that opted into the forms surface; it\n // is as valid here as a built-in. Built-ins keep the fast allowlist; other\n // strings fall through to the registry, surface-gated.\n const isValidType =\n typeof fieldType === \"string\" &&\n (VALID_FIELD_TYPES_SET.has(fieldType) ||\n isPluginFieldTypeOnSurface(fieldType, \"forms\"));\n\n if (!isValidType) {\n let fieldTypeRepr: string;\n if (typeof fieldType === \"object\" && fieldType !== null) {\n fieldTypeRepr = JSON.stringify(fieldType);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- fieldType narrowed to primitive above; rule doesn't follow control flow on unknown\n fieldTypeRepr = String(fieldType);\n }\n errors.push({\n path: `${path}.type`,\n message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(\", \")}`,\n code: \"FIELD_TYPE_INVALID\",\n });\n return;\n }\n\n validateFieldName(f.name, path, errors, seenNames);\n\n // All fields except hidden require a label\n if (fieldType !== \"hidden\") {\n validateFieldLabel(f.label, path, errors);\n }\n\n // Type-specific validation\n validateFieldByType(field as FormField, path, errors);\n\n // Validate conditional logic if present\n if (f.conditionalLogic) {\n validateConditionalLogic(field as FormField, path, errors);\n }\n}\n\n/**\n * Validates an array of field configurations.\n */\nfunction validateFieldsArray(\n fields: unknown[],\n basePath: string,\n errors: FormValidationError[]\n): void {\n // Track field names for duplicate detection\n const seenNames = new Set<string>();\n\n fields.forEach((field, index) => {\n const fieldPath = `${basePath}[${index}]`;\n validateField(field, fieldPath, errors, seenNames);\n });\n}\n\n/**\n * Validates the top-level fields array.\n */\nfunction validateFields(fields: unknown, errors: FormValidationError[]): void {\n const path = \"fields\";\n\n if (!fields) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(fields)) {\n errors.push({\n path,\n message: \"Form fields must be an array\",\n code: \"FIELDS_INVALID_TYPE\",\n });\n return;\n }\n\n if (fields.length === 0) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_EMPTY\",\n });\n return;\n }\n\n validateFieldsArray(fields, path, errors);\n}\n\n/**\n * Validates form settings.\n */\nfunction validateSettings(\n settings: unknown,\n errors: FormValidationError[]\n): void {\n if (!settings) {\n return; // Settings are optional\n }\n\n if (typeof settings !== \"object\" || Array.isArray(settings)) {\n errors.push({\n path: \"settings\",\n message: \"Form settings must be an object\",\n code: \"SETTINGS_INVALID_TYPE\",\n });\n return;\n }\n\n const s = settings as Record<string, unknown>;\n\n // If confirmationType is \"redirect\", redirectUrl or redirectRelation is required\n if (s.confirmationType === \"redirect\") {\n if (!s.redirectUrl && !s.redirectRelation) {\n errors.push({\n path: \"settings.redirectUrl\",\n message:\n \"Redirect URL or redirect relation is required when confirmation type is 'redirect'\",\n code: \"REDIRECT_URL_REQUIRED\",\n });\n }\n }\n\n // A form set to redirect to a page needs the page. Without this the form\n // saves, the admin shows it as redirecting, and every submission finishes\n // with nowhere to go — the destination is missing at submit time, when\n // there is no one to tell.\n //\n // The SHAPE, not truthiness. `{}` and `{ relationTo: \"pages\" }` are truthy\n // and name no document, so a presence test admits precisely the values that\n // resolve to nothing. `null` for the collection list because this entry\n // point does not have the plugin config: a reference carrying its own\n // `relationTo` is checkable without it, and a bare id is not — which is the\n // honest limit of validating a form in isolation.\n if (\n s.confirmationType === \"relationship\" &&\n !namesADocument(s.redirectPage)\n ) {\n errors.push({\n path: \"settings.redirectPage\",\n message: \"A page is required when confirmation type is 'relationship'\",\n code: \"REDIRECT_PAGE_REQUIRED\",\n });\n }\n}\n\n/**\n * Whether a stored redirect value names a document at all.\n *\n * Derived from the shared reader rather than restating its rules, with an\n * empty collection list: that accepts every shape carrying its own\n * `relationTo` and rejects the ones naming nothing. A bare id is the case this\n * cannot judge here, since which collection it belongs to is plugin\n * configuration this entry point never sees — so it is accepted rather than\n * reported, because refusing a valid form is the worse error.\n */\nfunction namesADocument(value: unknown): boolean {\n if (typeof value === \"string\") return value.length > 0;\n return parseRedirectReference(value, []) !== null;\n}\n\n/**\n * Validates form access control.\n */\nfunction validateAccess(access: unknown, errors: FormValidationError[]): void {\n if (!access) {\n return; // Access is optional\n }\n\n if (typeof access !== \"object\" || Array.isArray(access)) {\n errors.push({\n path: \"access\",\n message: \"Access control must be an object\",\n code: \"ACCESS_INVALID_TYPE\",\n });\n return;\n }\n\n const accessObj = access as Record<string, unknown>;\n const validAccessKeys = [\"submit\", \"read\"];\n\n for (const key of validAccessKeys) {\n const fn = accessObj[key];\n if (fn !== undefined && typeof fn !== \"function\") {\n errors.push({\n path: `access.${key}`,\n message: `Access control '${key}' must be a function`,\n code: \"ACCESS_FUNCTION_INVALID\",\n });\n }\n }\n}\n\n// ============================================================\n// Main Validation Function\n// ============================================================\n\n/**\n * Validates a complete form configuration.\n *\n * This is the main entry point for form validation. It performs\n * comprehensive validation including:\n * - Slug format and reserved name checking\n * - Field name validation\n * - Field-specific validation (options, min/max, etc.)\n * - Duplicate field name detection\n * - Conditional logic validation\n * - Settings validation\n * - Access function type validation\n *\n * @param config - The form configuration to validate\n * @returns Validation result with any errors found\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const config = {\n * slug: 'contact-form',\n * fields: [\n * { type: 'text', name: 'firstName', label: 'First Name' },\n * { type: 'email', name: 'email', label: 'Email', required: true },\n * ],\n * };\n *\n * const result = validateFormConfig(config);\n *\n * if (!result.valid) {\n * result.errors.forEach(err => {\n * console.error(`[${err.code}] ${err.path}: ${err.message}`);\n * });\n * }\n * ```\n */\nexport function validateFormConfig(config: FormConfig): FormValidationResult {\n const errors: FormValidationError[] = [];\n\n // A removed option is not an error, since the rest of the config still\n // works, but it is said here too, so a config checked this way is told.\n warnIfWebhooksConfigured(config);\n\n // Validate slug\n validateSlug(config.slug, errors);\n\n // Validate fields\n validateFields(config.fields, errors);\n\n // Validate settings\n validateSettings(config.settings, errors);\n\n // Validate access control\n validateAccess(config.access, errors);\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Throws an error if the form configuration is invalid.\n *\n * This is a convenience wrapper around `validateFormConfig` that\n * throws a descriptive error instead of returning a result object.\n *\n * @param config - The form configuration to validate\n * @throws Error if validation fails with all error messages\n *\n * @example\n * ```typescript\n * import { assertValidFormConfig } from '@nextly/plugin-form-builder';\n *\n * // Throws if invalid\n * assertValidFormConfig(config);\n * ```\n */\nexport function assertValidFormConfig(config: FormConfig): void {\n const result = validateFormConfig(config);\n\n if (!result.valid) {\n const errorMessages = result.errors\n .map(err => ` - [${err.code}] ${err.path}: ${err.message}`)\n .join(\"\\n\");\n\n throw new Error(\n `Invalid form config for '${config.slug || \"unknown\"}':\\n${errorMessages}`\n );\n }\n}\n","/**\n * Form Field Helpers\n *\n * Convenient factory functions for creating form field configurations.\n * These helpers eliminate the need to manually specify the `type` property,\n * providing a cleaner, more ergonomic API for form definitions.\n *\n * @module config/field-helpers\n *\n * @example\n * ```typescript\n * import {\n * text,\n * email,\n * select,\n * checkbox,\n * option,\n * } from '@nextly/plugin-form-builder';\n *\n * const contactFormFields = [\n * text({ name: 'firstName', label: 'First Name', required: true }),\n * text({ name: 'lastName', label: 'Last Name', required: true }),\n * email({ name: 'email', label: 'Email Address', required: true }),\n * select({\n * name: 'subject',\n * label: 'Subject',\n * options: [\n * option('General Inquiry'),\n * option('Support Request'),\n * option('Feedback'),\n * ],\n * }),\n * checkbox({ name: 'subscribe', label: 'Subscribe to newsletter' }),\n * ];\n * ```\n */\n\nimport type {\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"../types\";\n\n// ============================================================\n// Utility Types\n// ============================================================\n\n/**\n * Option for select and radio fields.\n */\nexport interface FormFieldOption {\n label: string;\n value: string;\n}\n\n// ============================================================\n// Utility Helpers\n// ============================================================\n\n/**\n * Creates an option for select or radio fields.\n *\n * If no value is provided, the label is converted to lowercase with\n * spaces replaced by underscores.\n *\n * @param label - Display label for the option\n * @param value - Optional value (defaults to lowercase label with underscores)\n * @returns Option object with label and value\n *\n * @example\n * ```typescript\n * // Value derived from label\n * option('United States') // { label: 'United States', value: 'united_states' }\n *\n * // Explicit value\n * option('United States', 'us') // { label: 'United States', value: 'us' }\n *\n * // Usage in select field\n * select({\n * name: 'country',\n * label: 'Country',\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n * ```\n */\nexport const option = (label: string, value?: string): FormFieldOption => ({\n label,\n value: value ?? label.toLowerCase().replace(/\\s+/g, \"_\"),\n});\n\n// ============================================================\n// Text Input Field Helpers\n// ============================================================\n\n/**\n * Creates a text input field configuration.\n *\n * Text fields store simple string values with optional constraints\n * like min/max length and pattern validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete text field configuration\n *\n * @example\n * ```typescript\n * // Basic text field\n * text({ name: 'firstName', label: 'First Name', required: true })\n *\n * // Text with validation\n * text({\n * name: 'username',\n * label: 'Username',\n * required: true,\n * placeholder: 'Enter username',\n * validation: {\n * minLength: 3,\n * maxLength: 20,\n * pattern: '^[a-zA-Z0-9_]+$',\n * },\n * })\n * ```\n */\nexport const text = (config: Omit<TextFormField, \"type\">): TextFormField => ({\n ...config,\n type: \"text\",\n});\n\n/**\n * Creates an email input field configuration.\n *\n * Email fields provide built-in email format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete email field configuration\n *\n * @example\n * ```typescript\n * // Basic email field\n * email({ name: 'email', label: 'Email Address', required: true })\n *\n * // Email with placeholder\n * email({\n * name: 'workEmail',\n * label: 'Work Email',\n * required: true,\n * placeholder: 'you@nextly.local',\n * })\n * ```\n */\nexport const email = (\n config: Omit<EmailFormField, \"type\">\n): EmailFormField => ({\n ...config,\n type: \"email\",\n});\n\n/**\n * Creates a phone input field configuration.\n *\n * Phone fields are optimized for phone number entry with\n * appropriate keyboard on mobile devices.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete phone field configuration\n *\n * @example\n * ```typescript\n * // Basic phone field\n * phone({ name: 'phone', label: 'Phone Number', required: true })\n *\n * // Phone with placeholder\n * phone({\n * name: 'mobile',\n * label: 'Mobile Number',\n * placeholder: '+1 (555) 123-4567',\n * })\n * ```\n */\nexport const phone = (\n config: Omit<PhoneFormField, \"type\">\n): PhoneFormField => ({\n ...config,\n type: \"phone\",\n});\n\n/**\n * Creates a URL input field configuration.\n *\n * URL fields provide built-in URL format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete URL field configuration\n *\n * @example\n * ```typescript\n * // Basic URL field\n * url({ name: 'website', label: 'Website URL' })\n *\n * // URL with placeholder\n * url({\n * name: 'portfolio',\n * label: 'Portfolio URL',\n * placeholder: 'https://example.com',\n * })\n * ```\n */\nexport const url = (config: Omit<UrlFormField, \"type\">): UrlFormField => ({\n ...config,\n type: \"url\",\n});\n\n/**\n * Creates a textarea field configuration.\n *\n * Textarea fields provide multi-line text input with configurable\n * row count and length validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete textarea field configuration\n *\n * @example\n * ```typescript\n * // Basic textarea\n * textarea({ name: 'message', label: 'Message', required: true })\n *\n * // Textarea with rows and validation\n * textarea({\n * name: 'description',\n * label: 'Description',\n * rows: 5,\n * validation: {\n * minLength: 10,\n * maxLength: 500,\n * },\n * })\n * ```\n */\nexport const textarea = (\n config: Omit<TextareaFormField, \"type\">\n): TextareaFormField => ({\n ...config,\n type: \"textarea\",\n});\n\n// ============================================================\n// Numeric Field Helpers\n// ============================================================\n\n/**\n * Creates a number input field configuration.\n *\n * Number fields store numeric values with optional min/max/step\n * constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete number field configuration\n *\n * @example\n * ```typescript\n * // Basic number field\n * number({ name: 'age', label: 'Age', required: true })\n *\n * // Number with range validation\n * number({\n * name: 'quantity',\n * label: 'Quantity',\n * required: true,\n * validation: {\n * min: 1,\n * max: 100,\n * step: 1,\n * },\n * })\n * ```\n */\nexport const number = (\n config: Omit<NumberFormField, \"type\">\n): NumberFormField => ({\n ...config,\n type: \"number\",\n});\n\n// ============================================================\n// Selection Field Helpers\n// ============================================================\n\n/**\n * Creates a select dropdown field configuration.\n *\n * Select fields display a dropdown list of options. Use the `option()`\n * helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete select field configuration\n *\n * @example\n * ```typescript\n * // Basic select\n * select({\n * name: 'country',\n * label: 'Country',\n * required: true,\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n *\n * // Multi-select\n * select({\n * name: 'interests',\n * label: 'Interests',\n * allowMultiple: true,\n * options: [\n * option('Technology'),\n * option('Design'),\n * option('Business'),\n * ],\n * })\n * ```\n */\nexport const select = (\n config: Omit<SelectFormField, \"type\">\n): SelectFormField => ({\n ...config,\n type: \"select\",\n});\n\n/**\n * Creates a single checkbox field configuration.\n *\n * Checkbox fields store boolean values (true/false).\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete checkbox field configuration\n *\n * @example\n * ```typescript\n * // Terms agreement checkbox\n * checkbox({\n * name: 'agreeToTerms',\n * label: 'I agree to the terms and conditions',\n * required: true,\n * })\n *\n * // Newsletter subscription\n * checkbox({\n * name: 'subscribe',\n * label: 'Subscribe to our newsletter',\n * defaultValue: true,\n * })\n * ```\n */\nexport const checkbox = (\n config: Omit<CheckboxFormField, \"type\">\n): CheckboxFormField => ({\n ...config,\n type: \"checkbox\",\n});\n\n/**\n * Creates a radio group field configuration.\n *\n * Radio group fields allow selecting exactly one option from a list.\n * Use the `option()` helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete radio field configuration\n *\n * @example\n * ```typescript\n * radio({\n * name: 'contactMethod',\n * label: 'Preferred Contact Method',\n * required: true,\n * options: [\n * option('Email', 'email'),\n * option('Phone', 'phone'),\n * option('SMS', 'sms'),\n * ],\n * })\n * ```\n */\nexport const radio = (\n config: Omit<RadioFormField, \"type\">\n): RadioFormField => ({\n ...config,\n type: \"radio\",\n});\n\n// ============================================================\n// File Field Helpers\n// ============================================================\n\n/**\n * Creates a file upload field configuration.\n *\n * File fields allow users to upload files with configurable\n * accepted types, size limits, and multiple file support.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete file field configuration\n *\n * @example\n * ```typescript\n * // Single file upload\n * file({\n * name: 'resume',\n * label: 'Upload Resume',\n * required: true,\n * accept: 'application/pdf,.doc,.docx',\n * maxFileSize: 5242880, // 5MB\n * })\n *\n * // Multiple images\n * file({\n * name: 'photos',\n * label: 'Upload Photos',\n * accept: 'image/*',\n * multiple: true,\n * maxFileSize: 10485760, // 10MB\n * })\n * ```\n */\nexport const file = (config: Omit<FileFormField, \"type\">): FileFormField => ({\n ...config,\n type: \"file\",\n});\n\n// ============================================================\n// Date/Time Field Helpers\n// ============================================================\n\n/**\n * Creates a date picker field configuration.\n *\n * Date fields provide a date picker with optional min/max constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete date field configuration\n *\n * @example\n * ```typescript\n * // Basic date field\n * date({ name: 'birthdate', label: 'Date of Birth', required: true })\n *\n * // Date with range constraints\n * date({\n * name: 'eventDate',\n * label: 'Event Date',\n * required: true,\n * min: '2024-01-01',\n * max: '2024-12-31',\n * })\n * ```\n */\nexport const date = (config: Omit<DateFormField, \"type\">): DateFormField => ({\n ...config,\n type: \"date\",\n});\n\n/**\n * Creates a time picker field configuration.\n *\n * Time fields provide a time picker for selecting time values.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete time field configuration\n *\n * @example\n * ```typescript\n * // Basic time field\n * time({ name: 'appointmentTime', label: 'Preferred Time', required: true })\n *\n * // Time with default\n * time({\n * name: 'meetingTime',\n * label: 'Meeting Time',\n * defaultValue: '09:00',\n * })\n * ```\n */\nexport const time = (config: Omit<TimeFormField, \"type\">): TimeFormField => ({\n ...config,\n type: \"time\",\n});\n\n// ============================================================\n// Special Field Helpers\n// ============================================================\n\n/**\n * Creates a hidden field configuration.\n *\n * Hidden fields store values that are not visible to users but\n * are included in the submission data. Useful for tracking\n * metadata like source, campaign, or referrer.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete hidden field configuration\n *\n * @example\n * ```typescript\n * // Track form source\n * hidden({ name: 'source', label: 'Source', defaultValue: 'website' })\n *\n * // Campaign tracking\n * hidden({ name: 'campaign', label: 'Campaign', defaultValue: 'summer-sale' })\n * ```\n */\nexport const hidden = (\n config: Omit<HiddenFormField, \"type\">\n): HiddenFormField => ({\n ...config,\n type: \"hidden\",\n});\n","/**\n * @nextly/plugin-form-builder\n *\n * Visual form builder plugin for Nextly CMS.\n * Provides drag-and-drop form building, submission management,\n * email notifications, and spam protection.\n *\n * @packageDocumentation\n */\n\n// ============================================================\n// Main Plugin Export\n// ============================================================\n\n// ============================================================\n// Default Plugin Instance\n// ============================================================\n\n/**\n * Pre-configured form builder plugin for simple usage.\n *\n * This is a default instance with all defaults applied.\n * For customization, use `formBuilder()` instead.\n *\n * @example Simple usage - just add the plugin!\n * ```typescript\n * // nextly.config.ts\n * import { defineConfig } from 'nextly';\n * import { formBuilderPlugin } from '@nextly/plugin-form-builder';\n *\n * export default defineConfig({\n * plugins: [formBuilderPlugin],\n * collections: [Posts, Users, Media], // Your collections only\n * });\n * ```\n *\n * @example With customization\n * ```typescript\n * import { formBuilder } from '@nextly/plugin-form-builder';\n *\n * const myFormPlugin = formBuilder({\n * notifications: { defaultFrom: 'noreply@example.com' },\n * });\n *\n * export default defineConfig({\n * plugins: [myFormPlugin.plugin],\n * });\n * ```\n */\nimport { formBuilder } from \"./plugin\";\n\nexport {\n formBuilder,\n getFormBuilderConfig,\n type NextlyPlugin,\n type FormBuilderPluginResult,\n} from \"./plugin\";\nexport const formBuilderPlugin: ReturnType<typeof formBuilder>[\"plugin\"] =\n formBuilder().plugin;\n\n// The New form submissions widget's id, for a site's own `defaultLayout` to\n// name, and the data its card draws.\nexport {\n NEW_SUBMISSIONS_WIDGET_ID,\n type NewSubmissionRow,\n type NewSubmissionsData,\n} from \"./dashboard/new-submissions\";\n\n// ============================================================\n// Type Exports\n// ============================================================\n\n// Plugin configuration types\nexport type {\n FormBuilderPluginOptions,\n ResolvedFormBuilderConfig,\n FieldBlockConfig,\n} from \"./types\";\n\n// Form field types (discriminated union)\nexport type {\n FormField,\n FormFieldType,\n FormFieldTypeId,\n AnyFormField,\n CustomFormField,\n BaseFormField,\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"./types\";\nexport { isKnownFormField, BUILT_IN_FORM_FIELD_TYPES } from \"./types\";\n\n// Form configuration types\nexport type {\n FormConfig,\n FormSettings,\n FormNotifications,\n FormAccess,\n FormHooks,\n FormHookContext,\n} from \"./types\";\n\n// Document types\nexport type { FormDocument, SubmissionDocument, FormSubmission } from \"./types\";\n\n// Notification rule type (stored on the form; edited by the builder UI)\nexport type { FormNotification, FormNotificationItem } from \"./types\";\n\n// Validation and conditional logic types\nexport type {\n ValidationRules,\n ConditionalLogic,\n ConditionalLogicCondition,\n} from \"./types\";\n\n// Email types\nexport type { EmailConfig } from \"./types\";\n\n// ============================================================\n// Utility Exports\n// ============================================================\n\n// Type guards\nexport {\n isFormField,\n isFormFieldType,\n isTextFormField,\n isEmailFormField,\n isSelectFormField,\n isCheckboxFormField,\n isFileFormField,\n} from \"./types\";\n\n// ============================================================\n// Config Utilities (validation & defaults)\n// ============================================================\n\n// Validation utilities\nexport {\n validateFormConfig,\n assertValidFormConfig,\n RESERVED_FORM_SLUGS,\n type FormValidationError,\n type FormValidationErrorCode,\n type FormValidationResult,\n} from \"./config\";\n\n// Default application utilities\nexport {\n applyFormDefaults,\n applyFieldDefaults,\n createFormConfig,\n toTitleCase,\n pluralize,\n DEFAULT_FORM_SETTINGS,\n DEFAULT_NOTIFICATION_SETTINGS,\n} from \"./config\";\n\n// ============================================================\n// Field Helper Functions\n// ============================================================\n\n// Utility helper\nexport { option, type FormFieldOption } from \"./config\";\n\n// Text input field helpers\nexport { text, email, phone, url, textarea } from \"./config\";\n\n// Numeric field helpers\nexport { number } from \"./config\";\n\n// Selection field helpers\nexport { select, checkbox, radio } from \"./config\";\n\n// File field helpers\nexport { file } from \"./config\";\n\n// Date/time field helpers\nexport { date, time } from \"./config\";\n\n// Special field helpers\nexport { hidden } from \"./config\";\n\n// ============================================================\n// Conditional Logic Utilities\n// ============================================================\n\n// Conditional logic evaluation\nexport {\n evaluateConditions,\n isValidComparisonOperator,\n getSupportedComparisonOperators,\n type ComparisonOperator,\n} from \"./utils\";\n\n// ============================================================\n// Schema Generation & Validation Utilities\n// ============================================================\n\n// Zod schema generation for form validation\nexport {\n generateZodSchema,\n transformFormData,\n validateFormData,\n getValidationErrors,\n} from \"./utils\";\n\n// ============================================================\n// Export Utilities (CSV, JSON)\n// ============================================================\n\n// Export format utilities\nexport {\n exportToCSV,\n exportToJSON,\n formatExportValue,\n downloadFile,\n generateExportFilename,\n exportAndDownload,\n type CSVExportOptions,\n type JSONExportOptions,\n type ExportedJSON,\n} from \"./utils\";\n\n// ============================================================\n// Form Submission Handlers\n// ============================================================\n\n// Main submission handler\nexport {\n submitForm,\n validateSubmission,\n isFormAcceptingSubmissions,\n getFormSubmissionStats,\n type SubmitFormOptions,\n type SubmitFormResult,\n type SubmitFormContext,\n} from \"./handlers\";\n\n// Spam detection utilities\nexport {\n checkSpam,\n type SpamCheckConfig,\n type SpamCheckOptions,\n type SpamCheckResult,\n} from \"./handlers\";\n\n// ============================================================\n// Collection Exports (for advanced usage)\n// ============================================================\n\nexport { formsCollection, submissionsCollection } from \"./collections\";\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["text","select","textarea","checkbox","number","date","select","text","textarea","date","sameValue","NextlyError","NextlyError","fieldWithOptions"],"sources":["../src/utils/redirect-target.ts","../src/collections/access-defaults.ts","../src/collections/forms.ts","../src/collections/submissions.ts","../src/dashboard/widget.ts","../src/document-shapes.ts","../src/emails/answers.ts","../src/handlers/bot-check-send.ts","../src/handlers/bot-check.ts","../src/utils/form-closing.ts","../src/handlers/one-response.ts","../src/handlers/prepare-submission.ts","../src/handlers/form-closing-hold.ts","../src/handlers/one-response-field-check.ts","../src/handlers/one-response-rebuild.ts","../src/handlers/spam-detection.ts","../src/handlers/submit-form.ts","../src/routes/public-forms.ts","../src/plugin.ts","../src/config/validate-form.ts","../src/config/field-helpers.ts","../src/index.ts"],"sourcesContent":["/**\n * Where a form sends the visitor after a successful submission, when the\n * author picked a PAGE rather than typing a URL.\n *\n * The plugin cannot derive this. A site's URL structure belongs to the site:\n * the playground serves the `pages` collection at `/{slug}` from its own\n * catch-all route, and another app may serve the same collection under\n * `/blog/{slug}` or behind a locale segment. Nothing inside a CMS package can\n * read that decision off the collection, so the mapping is configuration.\n *\n * A pattern covers the common case and a function covers the rest — a path\n * that depends on a parent document, a locale, or anything else the site\n * knows and this package does not.\n *\n * The pattern syntax is not invented here. `previewUrlFromTemplate` already\n * turns a `{field}` path into a URL for preview links, for the same reason and\n * with the same constraint, so this uses it rather than substituting\n * alongside it: two rules that agree today would drift, and a wrong URL still\n * looks like a URL. Encoding and the treatment of `0` and `false` come with\n * it.\n */\nimport { previewUrlFromTemplate } from \"nextly/config\";\n\nimport type { RedirectTargetDocument } from \"./redirect-reference\";\n\nexport type { RedirectTargetDocument } from \"./redirect-reference\";\nexport {\n documentReachability,\n type Reachability,\n hasPublishLifecycle,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n type RedirectReference,\n} from \"./redirect-reference\";\n\n/**\n * How documents in one collection become URLs.\n *\n * A string is a path with `{field}` placeholders filled from the document.\n * A function receives the document and returns the path itself.\n */\nexport type RedirectUrlPattern =\n | string\n | ((document: RedirectTargetDocument) => string | undefined);\n\n/** The collection-to-pattern map, after the array shorthand is normalised. */\nexport type RedirectRelationships = Record<string, RedirectUrlPattern>;\n\n/**\n * The pattern assumed for the `string[]` shorthand.\n *\n * `redirectRelationships: [\"pages\"]` means \"pages are redirect targets\" and\n * says nothing about their URLs, so it needs a default. This one matches the\n * routing the page builder ships and the scaffold templates generate.\n */\nexport const DEFAULT_REDIRECT_PATTERN = \"/{slug}\";\n\n/**\n * Both accepted spellings of the option reduced to the map.\n *\n * Normalised at the edge so everything downstream reads ONE shape — including\n * the list of collections, which is `Object.keys` of this rather than a second\n * field that agrees with it today.\n */\nexport function normalizeRedirectRelationships(\n option: string[] | RedirectRelationships | undefined\n): RedirectRelationships {\n if (!option) return {};\n const entries = Array.isArray(option)\n ? option.map(collection => [collection, DEFAULT_REDIRECT_PATTERN] as const)\n : Object.entries(option);\n\n // An empty pattern is dropped rather than kept, so that being OFFERED as a\n // redirect target and being able to PRODUCE a URL are one decision. Kept,\n // the collection appears in the picker, validation accepts a reference into\n // it, and every submission then resolves to nothing — a form that passes\n // every check and sends nobody anywhere.\n return Object.fromEntries(\n entries.filter(\n ([, pattern]) => typeof pattern !== \"string\" || pattern.trim()\n )\n );\n}\n\n/**\n * The pattern applied to a document, or undefined when it cannot be.\n *\n * Undefined rather than a partial string: a placeholder whose field is empty\n * would otherwise produce `/undefined` or `/`, and a form that sends every\n * visitor to a broken URL is worse than one that sends them nowhere. The\n * caller reports the difference.\n */\nexport function applyRedirectPattern(\n pattern: RedirectUrlPattern,\n document: RedirectTargetDocument\n): string | undefined {\n if (typeof pattern === \"function\") {\n const produced = pattern(document);\n return produced ? produced : undefined;\n }\n return previewUrlFromTemplate(pattern)(document) ?? undefined;\n}\n","/**\n * The access rules both collections this plugin contributes fall back to.\n *\n * Written out per collection they were byte-identical apart from `read`, and\n * two copies of an access policy is the copy that matters most: they agree\n * today, one gets tightened later, and the other silently keeps the old rule\n * while looking deliberate.\n *\n * `read` and `create` are the axes they genuinely differ on, so both are\n * parameters: a form is publicly READABLE because a site renders it, and a\n * submission is publicly CREATABLE because a visitor makes it. Update and\n * delete are the same policy in both collections.\n *\n * @module collections/access-defaults\n */\n\nimport type { AccessControlFunction, CollectionAccessControl } from \"nextly\";\n\n/**\n * A single rule, in either form every operation accepts. Not the read rule's\n * type: a read rule may also answer a filter, which create, update and delete\n * may not.\n */\ntype AccessRule = AccessControlFunction | boolean;\n\n/** The subset of a collection's access declaration these defaults supply. */\nexport type AccessOverrides = Pick<\n CollectionAccessControl,\n \"read\" | \"create\" | \"update\" | \"delete\"\n>;\n\n/**\n * The caller holds the permission for this operation on this collection, as\n * the role screen grants it: `read-form-submissions` to read submissions,\n * `update-forms` to edit a form.\n *\n * The grant is checked before a collection's rule, and the rule only narrows\n * it, so this check repeats the gate's and keeps the default plain; a role\n * name here would refuse a custom role that holds the permission. The rule is given\n * the caller's permissions as the role screen spells them, `<action>-<slug>`,\n * for a session and an API key alike, and a visitor holds none. `collection` is the slug the host installed\n * the collection under, so a renamed collection is judged by its own\n * permissions. A super-admin is admitted before any rule runs.\n */\nconst permitted: AccessRule = ({ permissions, collection, operation }) =>\n permissions.includes(`${operation}-${collection}`);\n\n/**\n * Host overrides applied over this plugin's defaults, per operation.\n *\n * Each override is taken with `??`, so a host may pass `false` to close an\n * operation it would otherwise be given — which `||` would have silently\n * replaced with the default.\n */\nexport function accessWithDefaults(\n overrides: AccessOverrides | undefined,\n defaults: { read?: AccessRule; create?: AccessRule } = {}\n): AccessOverrides {\n return {\n read: overrides?.read ?? defaults.read ?? permitted,\n create: overrides?.create ?? defaults.create ?? permitted,\n update: overrides?.update ?? permitted,\n delete: overrides?.delete ?? permitted,\n };\n}\n","/**\n * Forms Collection\n *\n * Stores form definitions created via Code-First or Schema Builder.\n * Each form contains field definitions, settings, notifications, and access control.\n *\n * @module collections/forms\n * @since 0.1.0\n */\n\nimport { NextlyError } from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport {\n text,\n textarea,\n select,\n checkbox,\n date,\n group,\n number,\n json,\n relationship,\n} from \"nextly\";\n\nimport { FORM_EDITOR_VIEW } from \"../form-pages\";\nimport type { ResolvedFormBuilderConfig } from \"../types\";\nimport {\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n} from \"../utils/redirect-reference\";\nimport {\n applyRedirectPattern,\n type RedirectRelationships,\n type RedirectTargetDocument,\n} from \"../utils/redirect-target\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n// ============================================================\n// Type Augmentation for Custom Admin Components\n// ============================================================\n// These types extend CollectionAdminOptions to support custom admin views.\n// This augmentation will be removed once nextly exports these types.\n\n/**\n * Component path string format.\n * Format: `\"package-name/path#ExportName\"`\n */\ntype ComponentPath = string;\n\n/**\n * Custom view configuration for replacing default admin views.\n */\ninterface CollectionAdminViewConfig {\n /** Component path to the custom view component */\n Component: ComponentPath;\n}\n\n/**\n * Custom components configuration for collection admin UI.\n */\ninterface CollectionAdminComponents {\n views?: {\n Edit?: CollectionAdminViewConfig;\n List?: CollectionAdminViewConfig;\n };\n BeforeListTable?: ComponentPath;\n AfterListTable?: ComponentPath;\n BeforeEdit?: ComponentPath;\n AfterEdit?: ComponentPath;\n}\n\n/**\n * Extended CollectionConfig with custom admin components support.\n * This extends the base CollectionConfig to include the components property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n components?: CollectionAdminComponents;\n };\n}\n\n/**\n * Generate the Forms collection configuration.\n *\n * Creates a collection for storing form definitions with fields,\n * settings, notifications, and access control.\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns ExtendedCollectionConfig for the Forms collection (with custom admin components)\n *\n * @example\n * ```typescript\n * const formsCol = formsCollection(resolvedConfig);\n * // Returns a collection with slug \"forms\" (or custom slug from config)\n * ```\n */\n/**\n * The plugin's hooks, with a host's own appended per phase.\n *\n * A host may pass `formOverrides.hooks`, and the trailing `...overrides` spread\n * would otherwise replace this object outright — taking with it the slug\n * generation, the at-least-one-field rule and the redirect check, none of\n * which are the host's to remove. Those are invariants of the collection this\n * plugin ships; an override extends it rather than disarming it.\n *\n * Handlers in `pluginHooks` run FIRST — they are transformations a host may\n * want to build on, like the generated slug. Handlers in `trailing` run LAST,\n * after every host handler in that phase, which is where an INVARIANT has to\n * sit: one placed first is checked against a payload the host can still\n * rewrite afterwards.\n */\nfunction withHostHooks<T>(\n pluginHooks: T,\n hostHooks: T | undefined,\n trailing: Partial<Record<string, unknown[]>> = {}\n): T {\n // Annotated rather than asserted. `hostHooks ?? {}` widens to `T | {}`\n // under the generic and cannot be indexed by phase name; a cast says the\n // same thing but reads as removable, and `--fix` removes it.\n const host: Record<string, unknown> = { ...hostHooks };\n\n const asList = (value: unknown) =>\n Array.isArray(value) ? value : value === undefined ? [] : [value];\n\n const merged: Record<string, unknown> = { ...host };\n for (const [phase, handlers] of Object.entries(\n pluginHooks as Record<string, unknown>\n )) {\n merged[phase] = [...asList(handlers), ...asList(host[phase])];\n }\n for (const [phase, handlers] of Object.entries(trailing)) {\n merged[phase] = [...asList(merged[phase]), ...asList(handlers)];\n }\n return merged as T;\n}\n\n/**\n * Refuses a write whose redirect target the resolver could not use.\n *\n * One function, called from two phases. `beforeValidate` so an author gets the\n * error where the rest of the form's errors appear, and LAST in `beforeChange`\n * so the guarantee survives a host hook: a check placed before host handlers\n * judges a payload they can still rewrite, and one that can be rewritten past\n * is not an invariant.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would reject every partial update — renaming a form — for a setting it never\n * touched.\n */\n/**\n * The settings a write is SETTING to a page redirect, or null for every other\n * write.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would refuse a rename for a setting it never touched.\n */\nfunction documentRedirectSettings(\n settings: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof settings !== \"object\" || settings === null) return null;\n const record = settings as Record<string, unknown>;\n const field = pickedDocumentField(record);\n return field ? { settings: record, field } : null;\n}\n\nfunction pageRedirectSettings(\n data: Record<string, unknown> | undefined,\n operation: string\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (!data) return null;\n if (operation !== \"create\" && data.settings === undefined) return null;\n return documentRedirectSettings(data.settings);\n}\n\n/**\n * Whether the form this write leaves behind can receive a submission.\n *\n * A form's `status` is its own lifecycle — draft, published, closed — and only\n * a published form accepts submissions, so only a published form can send a\n * visitor anywhere at all. That is the whole reason this rule is conditional:\n * a draft form pointing at a draft page is fine, because the two go live\n * together.\n *\n * The stored status is consulted when the write does not carry one. Publishing\n * a form and editing its settings are separate saves, and an update that never\n * mentions `status` leaves the stored one standing — so reading an absent\n * field as \"not published\" would wave through exactly the case this rule\n * exists to catch.\n */\nexport function formAcceptsSubmissions(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n return (data?.status ?? originalData?.status) === \"published\";\n}\n\n/**\n * The stored `settings`, which is not the shape the payload uses.\n *\n * Measured on 2026-08-25: the incoming write carries `settings` as an OBJECT,\n * and the same field on `originalData` comes back as JSON TEXT. Reading the\n * stored row with the payload's reader answers \"no redirect configured\" for\n * every form that has one — silently, and indistinguishably from a form that\n * genuinely has none.\n */\nfunction storedSettings(\n value: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof value === \"string\") {\n try {\n return documentRedirectSettings(JSON.parse(value));\n } catch {\n return null;\n }\n }\n return documentRedirectSettings(value);\n}\n\n/**\n * The redirect target already stored on the form, for a write that does not\n * mention it.\n *\n * Publishing a form is such a write: it carries `status` and nothing else, and\n * the target it inherits is the one a visitor will be sent to. Without this,\n * the rule would guard the save that PICKS a draft page and ignore the save\n * that makes that same pairing reachable — protecting one path while appearing\n * to protect both.\n *\n * Nothing here is written back. `originalData` is the stored row, not the\n * payload; normalising it would edit a document this write never touched.\n */\nfunction storedRedirectReference(\n originalData: Record<string, unknown> | undefined,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = storedSettings(originalData?.settings);\n if (!picked) return null;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(\n picked.settings[picked.field],\n collections\n );\n if (!reference) return null;\n\n // A collection dropped from `redirectRelationships` since the form was saved\n // is REPORTED, not discarded. Discarding it made the rule skip a form whose\n // destination can no longer be built at all, so publishing succeeded and\n // every submission afterwards resolved to no redirect — the silent version\n // of the failure the rule exists to prevent.\n return {\n reference,\n field: picked.field,\n configured: collections.includes(reference.collection),\n };\n}\n\n/**\n * Writes the parser's normalised names back into the row being saved.\n *\n * Not merely read: trimming only the parsed copy leaves the padded name in\n * `data`, so this rule accepts the write and the framework's own relationship\n * validator downstream still sees `\" pages \"`. The plugin and the framework\n * then disagree about a value this parser has already decided.\n */\nfunction normalizeStoredReference(\n settings: Record<string, unknown>,\n field: PickedDocumentField,\n reference: { collection: string; id: string }\n) {\n const stored = settings[field];\n\n if (typeof stored === \"string\") {\n if (stored !== reference.id) settings[field] = reference.id;\n return;\n }\n if (typeof stored !== \"object\" || stored === null || Array.isArray(stored)) {\n return;\n }\n\n const record = stored as Record<string, unknown>;\n if (record.relationTo !== reference.collection) {\n settings[field] = { ...record, relationTo: reference.collection };\n }\n}\n\nfunction namedRedirectReference(\n data: Record<string, unknown> | undefined,\n operation: string,\n patterns: RedirectRelationships\n): {\n settings: Record<string, unknown>;\n field: PickedDocumentField;\n /** Null when the settings name a redirect this cannot read. */\n reference: { collection: string; id: string } | null;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = pageRedirectSettings(data, operation);\n if (!picked) return null;\n const { settings, field } = picked;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(settings[field], collections);\n if (reference) normalizeStoredReference(settings, field, reference);\n\n return {\n settings,\n field,\n reference,\n configured:\n reference !== null && collections.includes(reference.collection),\n };\n}\n\n/**\n * The same rule, plus the one question that needs a read: can this document\n * actually fill its collection's pattern?\n *\n * Split from the shape check so the cheap half stays synchronous — the early\n * `beforeValidate` call wants an error, not a database round trip, and making\n * that hook async changes what a caller has to do to observe its rejection.\n *\n * Best effort by design: `req.nextly` is optional, and a slug can be emptied\n * AFTER this form is saved. The submit path keeps its own check and its log\n * line. What this buys is telling the author NOW, while the page they picked\n * is in front of them, rather than leaving a form that saves cleanly and\n * redirects nobody.\n */\n/**\n * Whether this write leaves a form that would send visitors to a page they\n * cannot reach.\n *\n * One question, so it has one implementation and one name. It is the whole\n * rule: a form that accepts submissions is the only kind that redirects\n * anyone, and an unreachable target is the only thing that 404s them. A draft\n * form pointing at a draft page satisfies neither half and is allowed — the\n * picker offers unpublished pages on purpose, because a form is usually\n * configured beside the page it points at and the two go live together.\n */\nexport function wouldStrandVisitors(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined,\n target: RedirectTargetDocument,\n targetCollectionIsLocalized: boolean | undefined\n): boolean {\n return (\n formAcceptsSubmissions(data, originalData) &&\n documentReachability(target, targetCollectionIsLocalized) === \"unreachable\"\n );\n}\n\n/**\n * Whether this write is the one that makes the form able to receive\n * submissions.\n *\n * Distinct from {@link formAcceptsSubmissions}, which answers what the form\n * WILL be: this asks what the write CHANGES, which is what decides whether an\n * inherited target is any of its business. A form that was already published\n * is not made reachable again by an edit that merely restates its status.\n */\nfunction publishesForm(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n if (data?.status !== \"published\") return false;\n // Already published before this write, so this write is not what makes the\n // form reachable. The admin sends `status` on every save exactly as it sends\n // `settings`, so reading the value alone treats every edit to a live form as\n // the moment it went live.\n return originalData?.status !== \"published\";\n}\n\n/**\n * The page this write leaves the form pointing at, and whether the write\n * CHOSE it.\n *\n * The distinction decides which refusals apply. A write that names a page\n * answers for that page; a write that only publishes the form, or renames it,\n * inherits whatever is stored and must not be refused for it.\n */\n/**\n * Whether two readings name the same document in the same field.\n *\n * `null` on either side is not a match: a reference this cannot read has not\n * been shown to be the stored one.\n */\nfunction sameTarget(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): boolean {\n if (!named.reference || !stored) return false;\n return (\n stored.field === named.field &&\n stored.reference.collection === named.reference.collection &&\n stored.reference.id === named.reference.id\n );\n}\n\n/**\n * What a write that NAMES a target is answering for.\n *\n * NAMING is not CHOOSING, and the refusal has to come after that distinction\n * rather than before it. The admin's save sends the whole `settings` object on\n * every write, so a rename carries the redirect it never touched — judging the\n * mention meant a form whose collection had since been unconfigured could not\n * be edited at all.\n */\nfunction judgeNamedReference(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n configured: boolean;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n configured: boolean;\n} {\n if (sameTarget(named, stored) && named.reference) {\n return {\n reference: named.reference,\n field: named.field,\n chosen: false,\n configured: named.configured,\n };\n }\n\n // This write moved the target, so it answers for what it moved it to.\n if (!named.reference || !named.configured) {\n throw redirectRefusal(\n \"Choose a page to redirect to, or pick a different confirmation.\",\n named.field\n );\n }\n return {\n reference: named.reference,\n field: named.field,\n chosen: true,\n configured: true,\n };\n}\n\nfunction redirectReferenceForWrite(\n context: HookContext,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n /** Whether the target's collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const stored = storedRedirectReference(\n context.originalData as Record<string, unknown> | undefined,\n patterns\n );\n const named = namedRedirectReference(\n context.data,\n context.operation,\n patterns\n );\n\n if (named) return judgeNamedReference(named, stored);\n\n // A write that REPLACES `settings` has answered the question: this form no\n // longer redirects to a page. Inheriting the old target there would refuse\n // the very edit that removes an invalid redirect, which is the one edit an\n // author in that state needs to make. Only a write that omits `settings`\n // inherits.\n const data = context.data as Record<string, unknown> | undefined;\n if (data?.settings !== undefined) return null;\n\n return stored ? { ...stored, chosen: false } : null;\n}\n\n/**\n * Whether the collection's configured pattern can build a URL for this\n * document.\n *\n * A pattern may be a FUNCTION, which is host code and can throw. Authoring\n * must not be blocked by that: the submission path contains the same throw and\n * degrades to no redirect with a log line, so a broken pattern costs a\n * destination rather than the ability to edit forms.\n */\nfunction assertPatternBuildsUrl(\n patterns: RedirectRelationships,\n reference: { collection: string; id: string },\n field: PickedDocumentField,\n target: RedirectTargetDocument\n) {\n let url: string | undefined;\n try {\n url = applyRedirectPattern(patterns[reference.collection], target);\n } catch {\n return;\n }\n if (url) return;\n\n // Pattern-agnostic. The configured pattern may depend on any field, or be a\n // function that declines for its own reasons, so naming a slug states a\n // remedy that often cannot fix the actual failure — and the save is\n // correctly blocked either way, leaving the author following wrong advice.\n throw redirectRefusal(\n `No URL could be built for that page from the pattern configured for ` +\n `\"${reference.collection}\". Choose another page, or ask a developer ` +\n `to check that collection's redirect pattern.`,\n field\n );\n}\n\nexport async function assertRedirectTargetUsable(\n context: HookContext,\n patterns: RedirectRelationships,\n localization: Record<string, boolean>\n): Promise<void> {\n const write = redirectReferenceForWrite(context, patterns);\n if (!write) return;\n const { reference, field, chosen, configured } = write;\n\n // Nothing can be built for a collection that is no longer a redirect target,\n // so there is nothing to read either. Only the write that makes the form\n // reachable answers for it; an unrelated edit inherits the state as it does\n // any other.\n if (!configured) {\n if (\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n throw redirectRefusal(\n `This form redirects to \"${reference.collection}\", which is no longer ` +\n `configured as a redirect target, so it has nowhere to send anyone. ` +\n `Choose another page, or pick a different confirmation.`,\n field\n );\n }\n return;\n }\n\n const nextly = context.req?.nextly;\n if (!nextly) return;\n\n // Not inside a caller-owned transaction. `context.executor` is present only\n // there, and the contract on it is explicit: a hook that reads the database\n // must use that executor, because a second pooled connection can stall\n // against a small pool while the caller's transaction holds the only one —\n // and a pooled read cannot see the transaction's own uncommitted rows, so a\n // page created in the same transaction would read as missing and this would\n // refuse a correct write.\n //\n // `findByID` takes no executor, and reaching for Drizzle directly here would\n // be raw database access inside a plugin. So the transactional path keeps\n // the shape check and skips the read; the submit path is the backstop it\n // already was.\n if (context.executor) return;\n\n const found = await readRedirectTarget(nextly, reference);\n if (found.status === \"unreadable\") return;\n if (found.status === \"missing\") {\n // A write that neither NAMES the page nor makes the form able to send\n // anyone there does not answer for it: a rename inherits whatever is\n // stored, and refusing it would block an edit for a setting it never\n // touched.\n //\n // Publishing is not that write. It is the moment the pairing starts\n // reaching visitors, and a deleted target is a stricter case than the\n // unpublished one refused below — every submission resolves it to no\n // redirect at all. Returning here on `!chosen` alone let the worse case\n // through while the milder one was refused.\n if (\n !chosen &&\n !publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n return;\n }\n throw redirectRefusal(\n `That page no longer exists in \"${reference.collection}\". Pick another.`,\n field\n );\n }\n\n // An inherited target is only judged by the write that PUBLISHES the form.\n // A published form can acquire a draft target without being touched — the\n // page is unpublished later, or the pairing predates this rule — and\n // refusing every subsequent rename would hold the form hostage to a state\n // the write neither created nor mentions. A write that CHOOSES a target\n // answers for it whatever else it does.\n const judged =\n chosen ||\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n );\n\n if (\n judged &&\n wouldStrandVisitors(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined,\n found.document,\n localization[reference.collection]\n )\n ) {\n throw redirectRefusal(\n `That page is not published yet, and this form is. A visitor sent ` +\n `there would reach a \"page not found\". Publish the page, or save ` +\n `this form as a draft until you do.`,\n field\n );\n }\n\n // The pattern belongs to the write that CHOOSES a page. A form being\n // published does not get refused for a pattern its author never edited.\n if (chosen)\n assertPatternBuildsUrl(patterns, reference, field, found.document);\n}\n\n/**\n * The target document, as one of three outcomes.\n *\n * Three rather than a nullable document, because \"the page is not there\" and\n * \"I could not ask\" are different answers and only the first is the author's\n * to fix. Collapsing them has been wrong in both directions on this rule:\n * refusing on any failure blocks a save a retry would complete, and skipping\n * on any failure loses the deleted-page refusal entirely.\n */\nasync function readRedirectTarget(\n nextly: NonNullable<NonNullable<HookContext[\"req\"]>[\"nextly\"]>,\n reference: { collection: string; id: string }\n): Promise<\n | { status: \"ok\"; document: RedirectTargetDocument }\n | { status: \"missing\" }\n | { status: \"unreadable\" }\n> {\n try {\n const row = (await nextly.findByID({\n collection: reference.collection,\n id: reference.id,\n })) as RedirectTargetDocument | null;\n return row ? { status: \"ok\", document: row } : { status: \"missing\" };\n } catch (error) {\n return isMissingTarget(error)\n ? { status: \"missing\" }\n : { status: \"unreadable\" };\n }\n}\n\n/**\n * Whether a failed read means the document is GONE, or that it could not be\n * asked for.\n *\n * Both arrive as exceptions — `findByID` throws `NOT_FOUND` for a missing\n * entry rather than returning null, despite a signature that says `| null` —\n * so the two are separated by the error's own code. Not by its message, which\n * is prose, and not by catching everything, which has now been wrong in both\n * directions: refusing on any failure blocks a save a retry would complete,\n * and skipping on any failure loses the deleted-page refusal.\n */\nexport function isMissingTarget(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { code?: unknown }).code === \"NOT_FOUND\"\n );\n}\n\n/**\n * Records the moment a form first goes live.\n *\n * `status` on this collection is an ordinary select, not the framework's\n * publish lifecycle, so nothing else writes down that a form was ever public.\n * Without that, `closed` stands for two different histories — a form an author\n * released and later stopped, and one created closed that nobody ever saw —\n * and the public by-slug endpoint served both.\n *\n * Written once. A form republished after being closed keeps the original date,\n * because the question this answers is \"has this ever been public\", not \"when\n * was it last\".\n */\nfunction stampFirstPublish(context: HookContext): void {\n const data = context.data as Record<string, unknown> | undefined;\n if (!data) return;\n const stored = context.originalData as Record<string, unknown> | undefined;\n\n // Written here or not at all. Duplicating an entry copies every non-system\n // field, so a copy of a closed form arrives carrying the original's stamp\n // and would qualify as previously public at a slug nobody has ever seen. The\n // same goes for any caller that simply sends the field. Removing it from the\n // patch leaves a stamp that IS stored untouched, because a column absent\n // from an update is a column not written.\n delete data.wentLiveAt;\n\n if (recordedStamp(stored) !== undefined) return;\n\n // Either side of the transition is proof. A form live before this field\n // existed carries no stamp, so its first edit is the only chance to record\n // one — and if that edit is the one CLOSING it, reading the incoming status\n // alone would miss it and take a form that had been public offline.\n if (data.status === \"published\" || stored?.status === \"published\") {\n data.wentLiveAt = new Date();\n }\n}\n\n/** The stamp a form already carries, treating blank as absent. */\nfunction recordedStamp(stored: Record<string, unknown> | undefined): unknown {\n const value = stored?.wentLiveAt;\n return value === null || value === \"\" ? undefined : value;\n}\n\n/** One shape for every refusal this rule makes, so callers see one field. */\nfunction redirectRefusal(message: string, field: PickedDocumentField) {\n // The path names the field the author actually filled in. Reporting every\n // refusal against `redirectPage` points a form configured through the URL\n // option at a control it does not show.\n return NextlyError.validation({\n errors: [{ path: `settings.${field}`, code: \"REQUIRED\", message }],\n });\n}\n\n/**\n * A field read rule admitting the callers who can update forms of this\n * collection, and no one else: what the people managing a form may see, and\n * a visitor may not.\n *\n * A super-admin is admitted by role. They may save any form, since the\n * collection gate admits them before any rule, but field rules still apply\n * to them, and their role need not hold `update-<slug>` as a stored grant. A\n * field they could save but not read would come back empty to the editor\n * and be saved empty. So everyone who can save a form can read these fields.\n */\nfunction readableByFormEditors(\n slug: string\n): (context: {\n permissions: readonly string[];\n roles: readonly string[];\n}) => boolean {\n return ({ permissions, roles }) =>\n permissions.includes(`update-${slug}`) || roles.includes(\"super-admin\");\n}\n\nexport function formsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n hooks: hookOverrides,\n admin: adminOverrides,\n ...overrides\n } = pluginConfig.formOverrides;\n\n // Derived from the map rather than carried beside it: the collections a form\n // may redirect to are exactly the ones with a URL pattern configured, so\n // there is no second list to fall out of step with the first.\n const redirectCollections = Object.keys(pluginConfig.redirectRelationships);\n\n // Build settings group fields based on plugin configuration\n const settingsFields: FieldConfig[] = [\n text({\n name: \"submitButtonText\",\n label: \"Submit Button Text\",\n defaultValue: \"Submit\",\n admin: {\n description: \"Text displayed on the form submit button\",\n },\n }),\n\n select({\n name: \"confirmationType\",\n label: \"After Submission\",\n defaultValue: \"message\",\n options: [\n { label: \"Show Message\", value: \"message\" },\n { label: \"Redirect to URL\", value: \"redirect\" },\n ...(redirectCollections.length > 0\n ? [{ label: \"Redirect to Page\", value: \"relationship\" }]\n : []),\n ],\n admin: {\n description: \"What happens after a successful form submission\",\n },\n }),\n\n textarea({\n name: \"successMessage\",\n label: \"Success Message\",\n defaultValue: \"Thank you for your submission!\",\n admin: {\n description: \"Message shown after successful submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"message\",\n },\n },\n }),\n\n text({\n name: \"redirectUrl\",\n label: \"Redirect URL\",\n admin: {\n description: \"URL to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"redirect\",\n },\n },\n }),\n ];\n\n // Add redirect relationship field if configured\n if (redirectCollections.length > 0) {\n settingsFields.push(\n relationship({\n name: \"redirectPage\",\n label: \"Redirect Page\",\n relationTo: redirectCollections,\n admin: {\n description: \"Select a page to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"relationship\",\n },\n },\n })\n );\n }\n\n // Add remaining settings fields\n settingsFields.push(\n // Read as `oneResponse` and no longer written. Declared still, hidden,\n // so a form saved before keeps the value its reader maps.\n checkbox({\n name: \"allowMultipleSubmissions\",\n label: \"Allow Multiple Submissions\",\n admin: { hidden: true },\n }),\n\n // No stored default: unset is what lets the reader map a form's legacy\n // key, including one an API caller still writes. The reader supplies the\n // default.\n select({\n name: \"oneResponse\",\n label: \"One response per person\",\n options: [\n { label: \"Allow several responses\", value: \"off\" },\n { label: \"One per email address\", value: \"email\" },\n { label: \"One per browser\", value: \"browser\" },\n { label: \"One per network (IP address)\", value: \"ip\" },\n ],\n }),\n\n text({\n name: \"oneResponseField\",\n label: \"Email field for one response per person\",\n }),\n\n number({\n name: \"oneResponseVersion\",\n label: \"One-response version\",\n defaultValue: 0,\n admin: { hidden: true },\n }),\n\n // The version whose earlier submissions have all been given their keys;\n // behind `oneResponseVersion` while that is still being done. Written\n // only by the plugin (`one-response-rebuild`); no stored default, so a\n // form saved before it existed reads as up to date.\n number({\n name: \"oneResponseKeyedVersion\",\n label: \"One-response keys made for version\",\n admin: { hidden: true },\n }),\n\n checkbox({\n name: \"honeypotEnabled\",\n label: \"Honeypot\",\n admin: {\n description:\n \"Per-form honeypot override; unset inherits the plugin default\",\n },\n }),\n\n // Read as `botCheck` and no longer written; hidden, as above.\n checkbox({\n name: \"captchaEnabled\",\n label: \"Enable reCAPTCHA\",\n admin: { hidden: true },\n }),\n\n // No stored default: unset is what lets the reader map a form's legacy\n // key, including one an API caller still writes. The reader supplies the\n // default.\n select({\n name: \"botCheck\",\n label: \"Bot check\",\n options: [\n { label: \"Site default\", value: \"default\" },\n { label: \"Off\", value: \"off\" },\n { label: \"reCAPTCHA\", value: \"recaptcha\" },\n { label: \"Turnstile\", value: \"turnstile\" },\n ],\n }),\n\n date({ name: \"closesAt\", label: \"Closes at\" }),\n\n number({ name: \"submissionLimit\", label: \"Submission limit\", min: 1 }),\n\n checkbox({\n name: \"storeIpAndBrowser\",\n label: \"Store IP address and browser\",\n defaultValue: true,\n }),\n\n number({ name: \"keepDays\", label: \"Keep submissions for (days)\", min: 1 }),\n\n textarea({\n name: \"internalNote\",\n label: \"Internal note\",\n // For the people who manage the form. The public read of a form returns\n // its row, and the Public role may read forms, so the note is withheld\n // from anyone who cannot edit them.\n access: { read: readableByFormEditors(slug) },\n }),\n\n text({\n name: \"captchaSiteKey\",\n label: \"reCAPTCHA Site Key\",\n admin: {\n description: \"Override the default reCAPTCHA site key for this form\",\n condition: {\n field: \"settings.captchaEnabled\",\n equals: true,\n },\n },\n })\n );\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Basic Information\n // ============================================================\n text({\n name: \"name\",\n label: \"Form Name\",\n required: true,\n admin: {\n description: \"Internal name for this form (not shown to users)\",\n },\n }),\n\n text({\n name: \"slug\",\n label: \"Slug\",\n required: true,\n unique: true,\n admin: {\n description: \"URL-friendly identifier. Used in API: /api/forms/{slug}\",\n },\n }),\n\n textarea({\n name: \"description\",\n label: \"Description\",\n admin: {\n description: \"Internal notes about this form's purpose\",\n },\n }),\n\n // ============================================================\n // Form Fields (JSON schema managed by Form Builder UI)\n // ============================================================\n json({\n name: \"fields\",\n label: \"Form Fields\",\n required: true,\n defaultValue: [],\n admin: {\n description:\n \"Form field configuration. Managed by the Form Builder UI.\",\n },\n }),\n\n // ============================================================\n // Form Settings\n // ============================================================\n group({\n name: \"settings\",\n label: \"Form Settings\",\n fields: settingsFields,\n }),\n\n // ============================================================\n // Email Notifications\n // ============================================================\n json({\n name: \"notifications\",\n label: \"Notifications\",\n defaultValue: [],\n admin: {\n description:\n \"Email notification rules. Managed by the Form Builder UI.\",\n },\n // Who is emailed is for the people who manage the form. The public read\n // of a form returns its row, and the Public role may read forms, so the\n // rules, with their recipients' addresses, are withheld from anyone who\n // cannot edit forms. Sending reads the form as the system, which this\n // rule does not limit.\n access: { read: readableByFormEditors(slug) },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"draft\",\n options: [\n { label: \"Draft\", value: \"draft\" },\n { label: \"Published\", value: \"published\" },\n { label: \"Closed\", value: \"closed\" },\n ],\n admin: {\n description:\n \"Only published forms can receive submissions. Closed forms display a message instead.\",\n },\n }),\n\n // NOT `firstPublishedAt`, which is the obvious name and the one to reach\n // for next. The framework owns that name on collections it manages the\n // publish lifecycle for, and a write naming it from a plugin collection is\n // stripped on the way to the insert — silently, leaving the column null and\n // every closed form reading as one that was never public.\n date({\n name: \"wentLiveAt\",\n label: \"First Published\",\n admin: {\n readOnly: true,\n description:\n \"When this form first went live. Written once and never changed.\",\n },\n }),\n\n textarea({\n name: \"closedMessage\",\n label: \"Closed Form Message\",\n defaultValue: \"This form is no longer accepting submissions.\",\n admin: {\n description: \"Message shown when form status is 'closed'\",\n condition: {\n field: \"status\",\n equals: \"closed\",\n },\n },\n }),\n ];\n\n // Process additional fields from formOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name for database compatibility\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 50,\n useAsTitle: \"name\",\n description:\n \"Create and manage your form templates using the visual form builder. Create and manage forms for collecting user submissions\",\n\n // Custom components for visual form builder\n components: {\n views: {\n // Replace default edit view with visual Form Builder\n Edit: {\n Component: FORM_EDITOR_VIEW,\n },\n },\n },\n\n ...adminOverrides,\n },\n\n // Access control with sensible defaults\n // `read: true` — a form is public because a site renders it. The rest are\n // the shared defaults.\n access: accessWithDefaults(accessOverrides, { read: true }),\n\n hooks: withHostHooks(\n {\n // Auto-generate slug from name if not provided\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n\n // Auto-generate slug from name if not provided (only on create)\n if (operation === \"create\" && data && !data.slug && data.name) {\n data.slug = String(data.name)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n }\n\n // A form must have at least one field, checked against what the\n // write actually sets. An update carries the patch rather than the\n // merged document, so treating an absent `fields` as empty rejects\n // every partial update -- renaming a form, or changing a setting --\n // even though its fields are untouched and still there.\n const setsFields =\n operation === \"create\" || data?.fields !== undefined;\n if (\n data &&\n setsFields &&\n (data.fields === undefined ||\n (Array.isArray(data.fields) && data.fields.length === 0))\n ) {\n // Typed, so the rejection survives as a validation failure with\n // its field issue rather than being read as a crash and replaced\n // with a generic server-fault message.\n throw NextlyError.validation({\n errors: [\n {\n path: \"fields\",\n code: \"REQUIRED\",\n message: \"Form must have at least one field.\",\n },\n ],\n });\n }\n\n // Reported here so an author sees it beside the form's other\n // errors. The GUARANTEE is the trailing `beforeChange` call below,\n // which a host hook cannot run after.\n // Shape only at this phase: the same reading, without the\n // stored document a refusal for an unconfigured collection would\n // need. `assertRedirectTargetUsable` below makes that call.\n namedRedirectReference(\n data,\n operation,\n pluginConfig.redirectRelationships\n );\n\n return data;\n },\n ],\n },\n hookOverrides,\n {\n // The last COLLECTION-level phase, after every host handler in it. A\n // host `beforeValidate` may rewrite the payload once the earlier call\n // has passed, so the check has to run again here.\n //\n // It is not the last mutating point in the write, and saying so would\n // be false: `collection-mutation-service` runs FIELD-level\n // `beforeChange` hooks after this one, and a host may contribute a\n // field through `formOverrides.fields`. Nothing collection-level can\n // sit after that — the next steps are hashing and the insert. What\n // bounds the consequence is the submit path, which resolves an\n // unusable target to NO redirect and logs it, never to a wrong one.\n beforeChange: [\n async (context: HookContext) => {\n await assertRedirectTargetUsable(\n context,\n pluginConfig.redirectRelationships,\n pluginConfig.redirectTargetLocalization\n );\n stampFirstPublish(context);\n return context.data;\n },\n ],\n }\n ),\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n };\n}\n","/**\n * Form Submissions Collection\n *\n * Stores form submission data from user-submitted forms.\n * Each submission contains the submitted data, metadata, and status.\n *\n * @module collections/submissions\n * @since 0.1.0\n */\n\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport { text, textarea, select, json, date, relationship } from \"nextly\";\n\nimport type { ResolvedFormBuilderConfig } from \"../types\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n/**\n * Extended CollectionConfig with isPlugin support.\n * This extends the base CollectionConfig to include the isPlugin property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n };\n}\n\n/**\n * Whether a create keeps the `submittedAt` it names: only one made on the\n * server with no request behind it (`req.http` absent), such as an import\n * restoring old submissions with their dates, and only where it names one.\n *\n * A create a request produced, a visitor's through the public grant above\n * all, is given the server's time whatever it says: the dashboard lists new\n * submissions newest first by this field, and a date a visitor chose (one far\n * in the future) would keep theirs ahead of every real submission.\n */\nfunction trustedSubmittedAt(context: HookContext): boolean {\n return context.req?.http === undefined && Boolean(context.data?.submittedAt);\n}\n\n/** The unique index one response per person rests on. */\nconst RESPONSE_KEY_INDEX = {\n fields: [\"form\", \"responseKey\"],\n unique: true,\n};\n\n/** Whether two index declarations name the same columns and uniqueness. */\nfunction sameIndex(\n index: { fields: readonly string[]; unique?: boolean },\n other: { fields: readonly string[]; unique?: boolean }\n): boolean {\n return (\n index.fields.join(\"\\u0000\") === other.fields.join(\"\\u0000\") &&\n (index.unique ?? false) === (other.unique ?? false)\n );\n}\n\n/**\n * Generate the Form Submissions collection configuration.\n *\n * Creates a collection for storing form submissions with:\n * - Reference to the parent form\n * - Submitted data (JSON)\n * - Status tracking (new, read, archived)\n * - Metadata (IP, user agent, timestamp)\n * - Internal notes for admin use\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns CollectionConfig for the Submissions collection\n *\n * @example\n * ```typescript\n * const submissionsCol = submissionsCollection(resolvedConfig);\n * // Returns a collection with slug \"form-submissions\" (or custom slug from config)\n * ```\n */\nexport function submissionsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n ...overrides\n } = pluginConfig.formSubmissionOverrides;\n\n // Get the forms collection slug for the relationship\n const formSlug = pluginConfig.formOverrides.slug;\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Form Relationship\n // ============================================================\n relationship({\n name: \"form\",\n label: \"Form\",\n relationTo: formSlug,\n required: true,\n admin: {\n readOnly: true,\n description: \"The form this submission belongs to\",\n },\n }),\n\n // ============================================================\n // Submission Data (JSON)\n // ============================================================\n json({\n name: \"data\",\n label: \"Submission Data\",\n required: true,\n admin: {\n readOnly: true,\n description: \"The submitted form data as JSON\",\n },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"new\",\n options: [\n { label: \"New\", value: \"new\" },\n { label: \"Read\", value: \"read\" },\n { label: \"Archived\", value: \"archived\" },\n // Spam is stored and flagged, never silently dropped: a false\n // positive stays reviewable and recoverable (\"Not spam\" = back to new).\n { label: \"Spam\", value: \"spam\" },\n ],\n // Indexed with `submittedAt`: the dashboard's New form submissions card\n // and its header count filter every submission by status and list them\n // newest first, on every dashboard load.\n index: true,\n admin: {\n description: \"Track whether this submission has been reviewed\",\n },\n }),\n\n text({\n name: \"spamReason\",\n label: \"Spam Reason\",\n admin: {\n readOnly: true,\n description:\n \"Which spam check flagged this submission (empty for clean submissions)\",\n },\n }),\n\n // ============================================================\n // Internal Notes\n // ============================================================\n textarea({\n name: \"notes\",\n label: \"Internal Notes\",\n admin: {\n description: \"Notes visible only to admins (not shown to submitter)\",\n },\n }),\n\n // ============================================================\n // Metadata\n // ============================================================\n text({\n name: \"ipAddress\",\n label: \"IP Address\",\n admin: {\n readOnly: true,\n description: \"IP address of the submitter\",\n },\n }),\n\n text({\n name: \"userAgent\",\n label: \"User Agent\",\n admin: {\n readOnly: true,\n description: \"Browser/device information of the submitter\",\n },\n }),\n\n date({\n name: \"submittedAt\",\n label: \"Submitted At\",\n required: true,\n // The order the dashboard lists new submissions in (see `status`).\n index: true,\n admin: {\n readOnly: true,\n description: \"When the form was submitted\",\n },\n }),\n\n // Honesty stamps for admin edits of submitted data: a corrected\n // submission must never be indistinguishable from what the visitor sent.\n date({\n name: \"editedAt\",\n label: \"Edited At\",\n admin: {\n readOnly: true,\n description: \"When an admin last edited the submitted data\",\n },\n }),\n\n text({\n name: \"editedBy\",\n label: \"Edited By\",\n admin: {\n readOnly: true,\n description: \"ID of the admin who last edited the submitted data\",\n },\n }),\n\n // The submission's one-response key: a keyed digest of the email answer\n // or network address its form's \"one response per person\" compares,\n // never the value itself. Written by the plugin at the write seam, never\n // taken from a caller, and empty when the form allows several responses\n // or the submission is spam. Unique per form (see `indexes`), so two\n // submissions with the same value cannot both be stored.\n text({\n name: \"responseKey\",\n label: \"One-response key\",\n admin: { hidden: true },\n }),\n ];\n\n // Process additional fields from formSubmissionOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name (e.g., \"form-submissions\" -> \"form_submissions\")\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n // Submissions carry visitor-entered answers plus ipAddress/userAgent, so the\n // default `entry.*` events stay suppressed — otherwise every submission would\n // be delivered to any endpoint subscribed to `entry.created` or `*`. Instead\n // emit a curated `form.submission.created` carrying ONLY safe metadata (which\n // form, when, and the status) — never the answers, IP, or user agent.\n webhooks: {\n record: false,\n emit: {\n event: \"form.submission.created\",\n fields: [\"form\", \"submittedAt\", \"status\"],\n },\n },\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 51,\n useAsTitle: \"id\",\n description: \"View and manage form submissions\",\n // Submissions are created by visitors submitting forms — a hand-typed\n // \"New Submission\" would be fiction, so the admin never offers one.\n disableCreate: true,\n\n components: {\n // The full submissions experience (per-form columns, status tabs,\n // drawer detail, export) replaces the generic list.\n views: {\n List: {\n Component: \"@nextlyhq/plugin-form-builder/admin#SubmissionsView\",\n },\n },\n },\n\n ...overrides.admin,\n },\n\n // Access control with sensible defaults\n // Public can create (submit forms); reading, updating and deleting need the\n // matching permission on the caller's role (`read-form-submissions`, ...).\n // `create: true` — a visitor submits without an account. Reading, updating\n // and deleting are the shared defaults.\n access: accessWithDefaults(accessOverrides, { create: true }),\n\n hooks: {\n // The submission time on creation. (The edit stamp for updates is\n // registered in plugin.ts init — direct registry hooks are the path\n // that runs for every API surface.)\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n if (operation === \"create\" && data && !trustedSubmittedAt(context)) {\n data.submittedAt = new Date();\n }\n return data;\n },\n ],\n\n afterChange: [\n (context: HookContext) => {\n const { data } = context;\n return data;\n },\n ],\n },\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n\n // One response per person, held by the database: a second submission\n // with the same key on the same form is refused at its insert, even when\n // two arrive at once. A submission with no key is not constrained. Kept\n // beside any indexes the site adds, after the overrides, so customising\n // the indexes cannot drop the one the rule depends on.\n indexes: [\n RESPONSE_KEY_INDEX,\n ...(overrides.indexes ?? []).filter(\n index => !sameIndex(index, RESPONSE_KEY_INDEX)\n ),\n ],\n };\n}\n","/**\n * New form submissions: the form-builder plugin's dashboard widget, declared\n * once with `defineWidget` and contributed under `contributes.dashboard`.\n *\n * Offered to a reader of both the forms and the submissions, and shown while\n * at least one form is theirs to read; its card lists the new submissions of\n * those forms, newest first, and stays on the dashboard with \"No new\n * submissions\" while there are none. Its summary puts the card's own count in\n * the dashboard's header. Core's default dashboard names it by its id\n * (`NEW_SUBMISSIONS_WIDGET_ID`), so a site without this plugin has no such\n * card and no such part.\n *\n * @module dashboard/widget\n */\n\nimport {\n defineWidget,\n listEntries,\n MAX_WIDGET_ROWS,\n setting,\n type AnyWidget,\n} from \"@nextlyhq/plugin-sdk\";\n\nimport {\n NEW_SUBMISSIONS_WIDGET_ID,\n readNewSubmissions,\n type NewSubmissionsData,\n type SubmissionsCollections,\n} from \"./new-submissions\";\n\n/** The component the card is drawn by, as the admin's component map imports it. */\nconst CARD_COMPONENT = \"@nextlyhq/plugin-form-builder/admin#NewSubmissionsCard\";\n\n/** A case for the preview page and the add panel's tile. */\nconst PREVIEW: NewSubmissionsData = {\n items: [\n {\n id: \"preview-1\",\n title: \"Ana Ruiz\",\n form: \"Contact\",\n excerpt: \"Could you send me your price list?\",\n at: \"2026-01-05T09:30:00.000Z\",\n href: \"/admin/collections/form-submissions/preview-1\",\n },\n {\n id: \"preview-2\",\n title: \"sam@example.com\",\n form: \"Newsletter\",\n at: \"2026-01-05T08:10:00.000Z\",\n href: \"/admin/collections/form-submissions/preview-2\",\n },\n ],\n total: 2,\n allHref: \"/admin/collections/form-submissions\",\n collection: \"form-submissions\",\n};\n\n/**\n * The widget, over the plugin's collections as it declares them and the\n * submissions slug the admin knows them by once installed.\n *\n * `submissionsPath` is read when a card loads rather than when the widget is\n * declared: the plugin learns the slug a site renamed its collection to only\n * at `init`, after this declaration was made. Every link the card draws, its\n * empty state's among them, is made from it, so none names a slug the site\n * renamed away.\n */\nexport function newSubmissionsWidget(\n declared: SubmissionsCollections,\n submissionsPath: () => string\n): AnyWidget {\n return defineWidget({\n id: NEW_SUBMISSIONS_WIDGET_ID,\n title: \"New form submissions\",\n description: \"Form submissions nobody has opened yet, newest first.\",\n icon: \"Inbox\",\n category: \"Forms\",\n size: { default: { w: 6, h: 3 }, max: { w: 12, h: 8 } },\n // Each must be readable before the card is answered at all.\n access: { collections: [declared.forms, declared.submissions] },\n // Shown while a form is the reader's to read: without one there are no\n // submissions they could ever be shown.\n shows: async ({ services, signal }) =>\n (await services.count(declared.forms, { status: \"all\", signal })) > 0,\n skeleton: \"list\",\n // How many submissions the card lists before its \"View all\": its reader's\n // choice, not its size, as a card is as tall as its content.\n settings: {\n limit: setting.number({\n label: \"Number of entries\",\n defaultValue: 5,\n min: 1,\n max: MAX_WIDGET_ROWS,\n }),\n },\n load: async ({ services, settings, signal, scope }) =>\n readNewSubmissions(services, declared, {\n // Exactly the rows the card draws: the read counts the rest apart.\n limit: listEntries(settings.limit),\n signal,\n scope,\n submissionsPath: submissionsPath(),\n }),\n view: { component: CARD_COMPONENT },\n // The card draws its own empty state: its link is to the submissions as\n // the site installed them, which a declaration made before `init` cannot\n // name, and which the card's data carries.\n isEmpty: () => false,\n preview: [{ name: \"Two new\", data: PREVIEW }],\n // The card's own read, asked for no rows, so the header counts exactly\n // what the card lists, a floor (\"1,000+\") where the card's is one.\n summary: {\n label: \"new submissions\",\n count: async ({ services, signal, scope }) => {\n const { total, atLeast } = await readNewSubmissions(\n services,\n declared,\n { limit: 0, signal, scope, submissionsPath: submissionsPath() }\n );\n return { count: total, atLeast: atLeast === true };\n },\n },\n });\n}\n","/**\n * Reading a stored row as one of this plugin's documents.\n *\n * The services layer answers with a loose row -- `id`, `createdAt`, `updatedAt`\n * and an `unknown` index signature -- while this plugin declares concrete\n * documents. There is no overlap for TypeScript to check between the two, so\n * the conversion goes through `unknown` and **nothing here is verified at\n * compile time**. A single assertion does not compile:\n *\n * ```\n * error TS2352: Conversion of type 'CollectionEntry' to type\n * 'SubmissionDocument' may be a mistake because neither type sufficiently\n * overlaps with the other.\n * ```\n *\n * The parameters are therefore `unknown`, which is what the rows honestly are\n * at this boundary. One assertion from `unknown` is legal where two from a\n * partially-known shape were needed, so the widening removes a cast rather\n * than adding one -- but it removes the notation, not the risk.\n *\n * The point of this module is therefore not safety it cannot provide. It is\n * that the unchecked step happens in ONE place a reviewer can find, rather than\n * at six call sites where it reads as ordinary code.\n *\n * @module document-shapes\n */\n\nimport type { FormDocument, SubmissionDocument } from \"./types\";\n\n/**\n * A row as the services layer hands it over.\n *\n * `unknown` rather than `Record<string, unknown>`: the read paths type their\n * results loosely enough that a narrower parameter only moves the assertion to\n * the call site, which is the thing this module exists to stop.\n */\ntype StoredRow = unknown;\n\n/**\n * Read a stored row as a submission.\n *\n * Does not validate, and deliberately cannot throw: every caller is on a path\n * where the row has already committed, so rejecting here would turn a saved\n * submission into an error the visitor is invited to retry.\n */\nexport function asSubmissionDocument(row: StoredRow): SubmissionDocument {\n return row as SubmissionDocument;\n}\n\n/** Read a list of stored rows as submissions. */\nexport function asSubmissionDocuments(\n rows: readonly StoredRow[]\n): SubmissionDocument[] {\n return rows as SubmissionDocument[];\n}\n\n/** Read a stored row as a form. Same contract as {@link asSubmissionDocument}. */\nexport function asFormDocument(row: StoredRow): FormDocument {\n return row as FormDocument;\n}\n","/**\n * A submission's answers as an email reads them: each by its question's label,\n * a choice by its option's label rather than its stored value, in the form's\n * order.\n *\n * Left out:\n * - questions the visitor did not answer;\n * - questions the form's conditions hid, judged from the answers by the same\n * `evaluateConditions` the form runs, so a value sent for a hidden question\n * by a client that ignored the condition is never shown as answered;\n * - hidden fields, which carry values the page set for the site's own use,\n * never something the visitor typed.\n *\n * A file's stored value is a media id that means nothing in an inbox, so a\n * file answer says how many were uploaded and whether they come with the\n * email.\n *\n * Everything else is written by `formatExportValue`, the one place a stored\n * answer becomes text, so an email and an export never disagree about it.\n */\nimport type { AnyFormField, ConditionalLogic } from \"../types\";\nimport { evaluateConditions } from \"../utils/evaluate-conditions\";\nimport { formatExportValue } from \"../utils/export-formats\";\n\n/** One answer, as the starter messages' `answers` loop reads it. */\nexport interface EmailAnswer {\n label: string;\n value: string;\n}\n\n/** Whether a stored answer counts as given. */\nfunction isAnswered(value: unknown): boolean {\n if (value === undefined || value === null) return false;\n if (typeof value === \"string\") return value.trim() !== \"\";\n if (Array.isArray(value)) return value.length > 0;\n return true;\n}\n\n/** A choice field's stored value(s) replaced by its option labels. */\nfunction choiceLabels(field: AnyFormField, value: unknown): unknown {\n const options = (\n field as { options?: Array<{ label: string; value: string }> }\n ).options;\n if (!Array.isArray(options)) return value;\n const label = (v: unknown) =>\n options.find(option => option.value === v)?.label ?? v;\n return Array.isArray(value) ? value.map(label) : label(value);\n}\n\n/** A file answer: how many, and whether they come with the email. */\nfunction fileAnswer(field: AnyFormField, value: unknown): string {\n const count = Array.isArray(value) ? value.length : 1;\n const files = count === 1 ? \"1 file\" : `${count} files`;\n return (field as { attachToEmail?: boolean }).attachToEmail\n ? `${files}, attached`\n : `${files}, uploaded`;\n}\n\n/** Whether the form's conditions showed `field` for these answers. */\nfunction wasShown(field: AnyFormField, data: Record<string, unknown>): boolean {\n const logic = (field as { conditionalLogic?: ConditionalLogic })\n .conditionalLogic;\n return evaluateConditions(logic, data);\n}\n\n/** The answers in a submission, for a notification's `_submission.answers`. */\nexport function submissionAnswers(\n fields: readonly AnyFormField[],\n data: Record<string, unknown>\n): EmailAnswer[] {\n const answers: EmailAnswer[] = [];\n for (const field of fields) {\n if (field.type === \"hidden\") continue;\n const value = data[field.name];\n if (!isAnswered(value) || !wasShown(field, data)) continue;\n if (field.type === \"file\") {\n answers.push({\n label: field.label || field.name,\n value: fileAnswer(field, value),\n });\n continue;\n }\n const shown =\n field.type === \"select\" || field.type === \"radio\"\n ? choiceLabels(field, value)\n : value;\n answers.push({\n label: field.label || field.name,\n value: formatExportValue(shown, field),\n });\n }\n return answers;\n}\n","/**\n * The one request a bot check makes: the visitor's token, with the site's\n * secret, to the provider's verification endpoint, answered with what the\n * provider said. Its own module so the request is made in one place, and so a\n * test can stand in for the provider.\n *\n * @module handlers/bot-check-send\n */\n\n/** The bot-check providers a form can use. */\nexport type BotProvider = \"recaptcha\" | \"turnstile\";\n\n/** Where each provider verifies a token. */\nconst VERIFY_URLS: Readonly<Record<BotProvider, string>> = {\n recaptcha: \"https://www.google.com/recaptcha/api/siteverify\",\n turnstile: \"https://challenges.cloudflare.com/turnstile/v0/siteverify\",\n};\n\n/** The hosts those requests go to, for the plugin's outbound allowlist. */\nexport const VERIFY_HOSTS: Readonly<Record<BotProvider, string>> = {\n recaptcha: \"www.google.com\",\n turnstile: \"challenges.cloudflare.com\",\n};\n\n/** A fetch the plugin may use: core's `ctx.fetch`, held to its allowlist. */\nexport type VerifyFetch = (\n url: string,\n init: { method: \"POST\"; headers: Record<string, string>; body: string }\n) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;\n\n/**\n * Ask `provider` whether `token` is good, as both providers take it: a form\n * post of the secret, the token and, where known, the visitor's address.\n * Answers the provider's JSON; throws when it could not be reached or did\n * not answer 200.\n */\nexport async function sendVerification(\n fetch: VerifyFetch,\n provider: BotProvider,\n request: { secret: string; token: string; ip?: string }\n): Promise<unknown> {\n const body = new URLSearchParams({\n secret: request.secret,\n response: request.token,\n ...(request.ip ? { remoteip: request.ip } : {}),\n });\n const response = await fetch(VERIFY_URLS[provider], {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n });\n if (!response.ok) {\n throw new Error(`${provider} verification answered ${response.status}`);\n }\n return response.json();\n}\n","/**\n * Bot checks: Google reCAPTCHA v3, which scores each visitor in the\n * background, and Cloudflare Turnstile, which checks them in the background\n * and rarely shows a puzzle. Either gives the visitor's browser a token,\n * which the site's form sends with the submission under the provider's own\n * field name, and which the server verifies with the provider before the\n * submission is accepted.\n *\n * A token that fails, or a submission that brings none, is stored flagged as\n * spam, as a honeypot hit is, so a false positive stays reviewable and\n * recoverable. A provider that cannot be reached leaves the submission\n * unchecked, and says so in the log: an outage must not send every real\n * submission to spam. A form whose check has no secret configured is\n * accepted unchecked, as before bot checks were verified, with a warning at\n * start and in Settings.\n *\n * @module handlers/bot-check\n */\nimport { z } from \"zod\";\n\nimport type { BotCheckMode, ResolvedFormBuilderConfig } from \"../types\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\n\nimport {\n type BotProvider,\n sendVerification,\n VERIFY_HOSTS,\n type VerifyFetch,\n} from \"./bot-check-send\";\n\nexport type { BotProvider } from \"./bot-check-send\";\n\n/** The field each provider's widget puts its token in, as forms send it. */\nconst TOKEN_FIELDS: Readonly<Record<BotProvider, string>> = {\n recaptcha: \"g-recaptcha-response\",\n turnstile: \"cf-turnstile-response\",\n};\n\n/** The score below which reCAPTCHA calls a visitor a bot, unless set. */\nconst DEFAULT_SCORE_THRESHOLD = 0.5;\n\ntype SpamProtection = ResolvedFormBuilderConfig[\"spamProtection\"];\n\n/**\n * The provider a form's bot check names, its site default resolved: the\n * plugin's reCAPTCHA when enabled, else its Turnstile when enabled, else none.\n */\nexport function botProviderOf(\n botCheck: BotCheckMode | undefined,\n spam: SpamProtection\n): BotProvider | undefined {\n if (botCheck === \"off\") return undefined;\n if (botCheck === \"recaptcha\" || botCheck === \"turnstile\") return botCheck;\n if (spam.recaptcha?.enabled === true) return \"recaptcha\";\n if (spam.turnstile?.enabled === true) return \"turnstile\";\n return undefined;\n}\n\n/** The plugin's secret for `provider`, when one is set. */\nfunction secretOf(\n provider: BotProvider,\n spam: SpamProtection\n): string | undefined {\n const secret = spam[provider]?.secretKey;\n return secret ? secret : undefined;\n}\n\n/**\n * The site key a visitor's form renders `provider`'s widget with: the\n * plugin's, or, for reCAPTCHA, a key the form stored itself before keys\n * moved into the plugin's options.\n */\nfunction siteKeyOf(\n provider: BotProvider,\n spam: SpamProtection,\n settings: { captchaSiteKey?: string }\n): string | undefined {\n const own = spam[provider]?.siteKey;\n if (own) return own;\n return provider === \"recaptcha\" ? settings.captchaSiteKey : undefined;\n}\n\n/**\n * `provider`'s keys for a form, when both are known: the secret to verify\n * with and a site key for the visitor's widget. A check missing either is\n * not run: without a site key no visitor could produce a token, and every\n * real submission would be flagged.\n */\nfunction readyKeys(\n provider: BotProvider,\n spam: SpamProtection,\n settings: { captchaSiteKey?: string }\n): { secret: string; siteKey: string } | undefined {\n const secret = secretOf(provider, spam);\n const siteKey = siteKeyOf(provider, spam, settings);\n return secret && siteKey ? { secret, siteKey } : undefined;\n}\n\n/**\n * Whether a form's bot check will be run: it names a provider and that\n * provider's keys are both known. The same answer `judgeBotCheck` acts on.\n */\nexport function botCheckReady(\n settings: { botCheck?: BotCheckMode; captchaSiteKey?: string },\n spam: SpamProtection\n): boolean {\n const provider = botProviderOf(settings.botCheck, spam);\n return (\n provider !== undefined && readyKeys(provider, spam, settings) !== undefined\n );\n}\n\nconst RECAPTCHA_ANSWER = z.object({\n success: z.boolean(),\n score: z.number().optional(),\n});\nconst TURNSTILE_ANSWER = z.object({ success: z.boolean() });\n\n/** What a bot check found. */\nexport type BotVerdict = \"pass\" | \"fail\" | \"unchecked\";\n\n/**\n * Verify `token` with `provider`. A missing token fails: the form's widget\n * never ran. A provider that cannot be reached, or answers something that is\n * not its answer, leaves the submission unchecked.\n */\nexport async function judgeBotToken(input: {\n provider: BotProvider;\n secret: string;\n token: string | undefined;\n ip?: string;\n scoreThreshold?: number;\n fetch: VerifyFetch | undefined;\n}): Promise<BotVerdict> {\n if (!input.token) return \"fail\";\n if (!input.fetch) return \"unchecked\";\n let answer: unknown;\n try {\n answer = await sendVerification(input.fetch, input.provider, {\n secret: input.secret,\n token: input.token,\n ...(input.ip ? { ip: input.ip } : {}),\n });\n } catch {\n return \"unchecked\";\n }\n if (input.provider === \"turnstile\") {\n const parsed = TURNSTILE_ANSWER.safeParse(answer);\n if (!parsed.success) return \"unchecked\";\n return parsed.data.success ? \"pass\" : \"fail\";\n }\n const parsed = RECAPTCHA_ANSWER.safeParse(answer);\n if (!parsed.success) return \"unchecked\";\n const threshold = input.scoreThreshold ?? DEFAULT_SCORE_THRESHOLD;\n return parsed.data.success && (parsed.data.score ?? 0) >= threshold\n ? \"pass\"\n : \"fail\";\n}\n\nconst KEY = z.string().trim().min(1).optional();\n\n/** The bot-check options, as the plugin's options give them. */\nconst BOT_CHECK_OPTIONS = z.object({\n recaptcha: z\n .object({\n enabled: z.boolean(),\n siteKey: KEY,\n secretKey: KEY,\n scoreThreshold: z.number().min(0).max(1).optional(),\n })\n .optional(),\n turnstile: z\n .object({ enabled: z.boolean(), siteKey: KEY, secretKey: KEY })\n .optional(),\n});\n\n/**\n * The bot-check part of the plugin's options, parsed at start: a key that is\n * not a string, or a score threshold outside 0 to 1, fails the start with\n * the option named, rather than every submission later.\n */\nexport function botCheckOptions(\n spamProtection: unknown\n): Pick<SpamProtection, \"recaptcha\" | \"turnstile\"> {\n const source =\n spamProtection !== null && typeof spamProtection === \"object\"\n ? (spamProtection as Record<string, unknown>)\n : {};\n const parsed = BOT_CHECK_OPTIONS.safeParse({\n recaptcha: source.recaptcha,\n turnstile: source.turnstile,\n });\n if (!parsed.success) {\n const issue = parsed.error.issues[0];\n throw new Error(\n `formBuilder: spamProtection.${issue?.path.join(\".\") ?? \"\"} is not valid: ${issue?.message ?? \"invalid\"}`\n );\n }\n return {\n recaptcha: parsed.data.recaptcha ?? { enabled: false },\n turnstile: parsed.data.turnstile ?? { enabled: false },\n };\n}\n\n/**\n * The hosts the plugin's bot checks call, for its outbound allowlist: a\n * provider's only when its secret is set, so a site using neither declares\n * no network access at all.\n */\nexport function botCheckHosts(spam: SpamProtection): string[] {\n return ([\"recaptcha\", \"turnstile\"] as const)\n .filter(provider => secretOf(provider, spam) !== undefined)\n .map(provider => VERIFY_HOSTS[provider]);\n}\n\n/** Each provider's token a submission brought, by the providers' field names. */\nexport type BotTokens = Partial<Record<BotProvider, string>>;\n\n/** The tokens in a submission's payload, before its own fields are kept. */\nexport function botTokensOf(payload: Record<string, unknown>): BotTokens {\n const tokens: BotTokens = {};\n for (const provider of [\"recaptcha\", \"turnstile\"] as const) {\n const value = payload[TOKEN_FIELDS[provider]];\n if (typeof value === \"string\" && value.trim() !== \"\") {\n tokens[provider] = value.trim();\n }\n }\n return tokens;\n}\n\n/**\n * A request-made submission's bot check: none when its form's check is off,\n * or names a provider missing its secret or site key (accepted unchecked, as\n * warned at start and in Settings); a spam verdict when the token fails or\n * is missing.\n */\nexport async function judgeBotCheck(input: {\n settings: { botCheck?: BotCheckMode; captchaSiteKey?: string };\n spam: SpamProtection;\n tokens: BotTokens;\n ip: string | undefined;\n fetch: VerifyFetch | undefined;\n warn: (message: string, context: Record<string, unknown>) => void;\n}): Promise<{ isSpam: true; reason: BotProvider } | undefined> {\n const provider = botProviderOf(input.settings.botCheck, input.spam);\n if (provider === undefined) return undefined;\n const keys = readyKeys(provider, input.spam, input.settings);\n if (keys === undefined) return undefined;\n const verdict = await judgeBotToken({\n provider,\n secret: keys.secret,\n token: input.tokens[provider],\n ...(input.ip ? { ip: input.ip } : {}),\n scoreThreshold: input.spam.recaptcha?.scoreThreshold,\n fetch: input.fetch,\n });\n if (verdict === \"unchecked\") {\n input.warn(\n \"Form Builder: the bot check could not be completed, so the submission was accepted unchecked\",\n { provider }\n );\n }\n return verdict === \"fail\" ? { isSpam: true, reason: provider } : undefined;\n}\n\n/**\n * Warn, once at start, for each bot check that is in use (the site default,\n * or named by a form) without both of its keys, naming the options: those\n * forms' submissions are accepted unchecked until it is. Reads at most the\n * first 500 forms; a failure to read them is not a reason to stop the start.\n */\nexport async function warnUncheckedBotChecks(input: {\n spam: SpamProtection;\n forms: () => Promise<readonly object[]>;\n readSettings: (stored: unknown) => {\n botCheck?: BotCheckMode;\n captchaSiteKey?: string;\n };\n warn: (message: string, context: Record<string, unknown>) => void;\n}): Promise<void> {\n let forms: readonly object[] = [];\n try {\n forms = await input.forms();\n } catch {\n forms = [];\n }\n const counts = new Map<BotProvider, number>();\n for (const form of forms) {\n const settings = input.readSettings(\n (form as { settings?: unknown }).settings\n );\n const provider = botProviderOf(settings.botCheck, input.spam);\n if (provider && readyKeys(provider, input.spam, settings) === undefined) {\n counts.set(provider, (counts.get(provider) ?? 0) + 1);\n }\n }\n const siteDefault = botProviderOf(\"default\", input.spam);\n if (siteDefault && readyKeys(siteDefault, input.spam, {}) === undefined) {\n counts.set(siteDefault, counts.get(siteDefault) ?? 0);\n }\n for (const [provider, forms] of counts) {\n input.warn(\n `Form Builder: the ${provider === \"recaptcha\" ? \"reCAPTCHA\" : \"Turnstile\"} bot check is in use without both of its keys, so its submissions are accepted unchecked. Set spamProtection.${provider}.secretKey and spamProtection.${provider}.siteKey in the plugin's options.`,\n { provider, forms }\n );\n }\n}\n\n/** What Settings is told about the site's bot checks and rate limit. */\nexport interface BotCheckDefaults {\n /** The provider \"Site default\" resolves to, or null for none. */\n botCheckDefault: BotProvider | null;\n /** Which providers have their keys set, and so may be chosen. */\n botChecks: Record<BotProvider, { secret: boolean; siteKey: boolean }>;\n /** The plugin's rate limit, shown read-only. */\n rateLimit: { maxSubmissions: number; windowMs: number } | null;\n}\n\n/** {@link BotCheckDefaults} for the admin: whether keys are set, never the keys. */\nexport function botCheckDefaults(spam: SpamProtection): BotCheckDefaults {\n const keys = (provider: BotProvider) => ({\n secret: secretOf(provider, spam) !== undefined,\n siteKey: Boolean(spam[provider]?.siteKey),\n });\n return {\n botCheckDefault: botProviderOf(\"default\", spam) ?? null,\n botChecks: { recaptcha: keys(\"recaptcha\"), turnstile: keys(\"turnstile\") },\n rateLimit: spam.rateLimit ?? null,\n };\n}\n\n/**\n * The bot check a visitor's form renders, as the public read of a form gives\n * it: the provider and its site key, or none when the form's check is off,\n * names a provider with no secret (not verified, so not shown), or has no\n * site key to render with.\n */\nexport function publicBotCheck(\n storedSettings: unknown,\n spam: SpamProtection\n): { provider: BotProvider; siteKey: string; field: string } | undefined {\n const settings = normalizeFormSettings(storedSettings);\n const provider = botProviderOf(settings.botCheck, spam);\n if (provider === undefined) return undefined;\n const keys = readyKeys(provider, spam, settings);\n return keys\n ? { provider, siteKey: keys.siteKey, field: TOKEN_FIELDS[provider] }\n : undefined;\n}\n","/**\n * Whether a form is taking submissions now: its status, as core reads it for\n * every way a form is reached, then the form's closing time and its limit.\n *\n * Whatever closes a form (its author by hand, its closing time or its limit),\n * a visitor is told one thing: the form's closed message, or the generic\n * sentence when its author wrote none. That message is core's own answer for\n * a closed form, asked of this form, so the three causes cannot drift apart.\n *\n * @module utils/form-closing\n */\nimport {\n formAvailability,\n type FormAvailability,\n type FormAvailabilityInput,\n} from \"nextly\";\n\nimport type { FormSettings } from \"../types\";\n\n/** What closes a published form, beyond its status. */\nexport type ClosedBy = \"date\" | \"limit\";\n\n/**\n * The message a visitor is given when this form is closed: core's answer for\n * the form had its author closed it, whether or not they did.\n */\nexport function closedMessageOf(form: FormAvailabilityInput): string {\n const asClosed = formAvailability({\n closedMessage: form.closedMessage,\n status: \"closed\",\n // Closed after being public, which is how core explains a form rather\n // than hiding it: a form past its closing time or its limit was live.\n wentLiveAt: \"live\",\n });\n if (asClosed.kind !== \"closed\") {\n throw new Error(\"a closed form that has been public explains itself\");\n }\n return asClosed.message;\n}\n\n/** Whether the form's closing time has passed at `now`. */\nexport function pastClosingTime(\n settings: Pick<FormSettings, \"closesAt\">,\n now: Date\n): boolean {\n if (settings.closesAt === undefined) return false;\n const closesAt = Date.parse(settings.closesAt);\n return Number.isFinite(closesAt) && now.getTime() >= closesAt;\n}\n\n/** Whether `taken` non-spam submissions fill the form's limit. */\nexport function limitReached(\n settings: Pick<FormSettings, \"submissionLimit\">,\n taken: number\n): boolean {\n return (\n settings.submissionLimit !== undefined && taken >= settings.submissionLimit\n );\n}\n\n/**\n * The form's answer to a visitor now. Core decides by status first, so a\n * draft stays absent and a form closed by hand keeps its own answer; a\n * published one is then closed by its closing time, and by its limit when\n * `taken` (its non-spam submissions) is known.\n */\nexport function formAvailabilityNow(\n form: FormAvailabilityInput,\n settings: Pick<FormSettings, \"closesAt\" | \"submissionLimit\">,\n at: { now: Date; taken?: number }\n): FormAvailability & { closedBy?: ClosedBy } {\n const byStatus = formAvailability(form);\n if (byStatus.kind !== \"open\") return byStatus;\n const closedBy = closedByNow(settings, at);\n return closedBy === undefined\n ? byStatus\n : { kind: \"closed\", message: closedMessageOf(form), closedBy };\n}\n\n/** What closes an open form now, if anything does. */\nfunction closedByNow(\n settings: Pick<FormSettings, \"closesAt\" | \"submissionLimit\">,\n at: { now: Date; taken?: number }\n): ClosedBy | undefined {\n if (pastClosingTime(settings, at.now)) return \"date\";\n if (at.taken !== undefined && limitReached(settings, at.taken)) {\n return \"limit\";\n }\n return undefined;\n}\n","/**\n * One response per person: the key a submission is compared by.\n *\n * A form that takes one response per person keeps, beside each non-spam\n * submission, a keyed one-way digest of what tells one person from another:\n * the answer to its chosen Email field (compared ignoring case) or the\n * network address the submission came from. The address itself is never\n * stored a second time, and the digest cannot be reversed without the\n * plugin's key (`ctx.security.digests`). A unique index on the form and the\n * key refuses a second submission with the same key at its insert, so two\n * that arrive at once cannot both be stored.\n *\n * @module handlers/one-response\n */\nimport type { PluginContext } from \"@nextlyhq/plugin-sdk\";\nimport { z } from \"zod\";\n\nimport type { FormSettings } from \"../types\";\n\n/** What a visitor sending a second response is told. */\nexport const ALREADY_RESPONDED = \"You have already submitted this form.\";\n\ntype Digests = PluginContext[\"security\"][\"digests\"];\n\n/**\n * The answer a visitor's browser sends its token in, for a form that takes\n * one response per browser. The form's script keeps one random token per\n * browser and sends it with every submission; the row never stores it.\n */\nexport const BROWSER_TOKEN_FIELD = \"nextly-browser-token\";\n\n/**\n * What every browser key starts with. The token is not stored, so a browser\n * key cannot be made again from the row the way an address's can; the prefix\n * is what lets the submission's hold tell a key made for one per browser from\n * one made for another mode, and refuse it once the form's mode has changed.\n * Digests are hex, so no address or network key can start with it.\n */\nconst BROWSER_KEY_PREFIX = \"browser:\";\n\n/** The settings a submission's key is made under. */\nexport type KeySettings = Pick<\n FormSettings,\n \"oneResponse\" | \"oneResponseField\" | \"oneResponseVersion\"\n>;\n\n/**\n * What a key made under the form's one-response `version` starts with. The\n * version moves on each time the form changes how it tells people apart (its\n * mode, or the Email field it reads), so every key made before the change\n * stops matching a new one without a write to any earlier submission; the\n * keys of earlier submissions are then made again for the new version\n * (`one-response-rebuild`). Version 0 has no prefix, so keys stored before\n * versions were written keep matching.\n */\nfunction keyPrefixOf(version: number | undefined): string {\n return version !== undefined && version > 0 ? `v${version}:` : \"\";\n}\n\n/** The one-response version a stored key was made under. */\nexport function keyVersionOf(key: string): number {\n const match = /^v(\\d+):/.exec(key);\n return match ? Number(match[1]) : 0;\n}\n\n/**\n * Whether the form's earlier submissions are still being given keys for its\n * current version (`one-response-rebuild`). Unset reads as up to date: a form\n * saved before the version was recorded has keys for version 0.\n */\nexport function keysBehind(\n settings: Pick<FormSettings, \"oneResponseVersion\" | \"oneResponseKeyedVersion\">\n): boolean {\n const keyed = settings.oneResponseKeyedVersion;\n return keyed !== undefined && keyed < (settings.oneResponseVersion ?? 0);\n}\n\n/**\n * What tells one person from another on a form: its mode, and for one per\n * email the field it reads. A change to it is a change of version.\n */\nexport function oneResponseIdentityOf(\n settings: Pick<FormSettings, \"oneResponse\" | \"oneResponseField\">\n): string {\n const mode = settings.oneResponse ?? \"off\";\n return mode === \"email\" ? `email:${settings.oneResponseField ?? \"\"}` : mode;\n}\n\n/**\n * A browser token as the form's script makes one: a random identifier\n * (`crypto.randomUUID()` is one), letters, digits, `-` and `_`. Anything else\n * is treated as no token, so a malformed value can neither be stored as a\n * key nor fill the index with arbitrary text.\n */\nconst BROWSER_TOKEN = z\n .string()\n .trim()\n .regex(/^[A-Za-z0-9_-]{16,128}$/);\n\n/** The browser token a submission's answers carry, if a well-formed one. */\nexport function browserTokenOf(payload: unknown): string | undefined {\n const parsed = BROWSER_TOKEN.safeParse(\n answersOf(payload)?.[BROWSER_TOKEN_FIELD]\n );\n return parsed.success ? parsed.data : undefined;\n}\n\n/**\n * What a visitor's form needs to take part in one response per browser: the\n * answer to send its token in. Other modes need nothing from the browser, and\n * a form that does not compare by browser says nothing.\n */\nexport function publicOneResponse(\n settings: Pick<FormSettings, \"oneResponse\">\n): { by: \"browser\"; field: string } | undefined {\n return settings.oneResponse === \"browser\"\n ? { by: \"browser\", field: BROWSER_TOKEN_FIELD }\n : undefined;\n}\n\n/** Whether `key` was made for one response per browser, under `version`. */\nfunction isBrowserKey(\n key: unknown,\n version: number | undefined\n): key is string {\n return (\n typeof key === \"string\" &&\n key.startsWith(`${keyPrefixOf(version)}${BROWSER_KEY_PREFIX}`)\n );\n}\n\n/** The parts of a submission its key is made from. */\nexport interface KeyedSubmission {\n status?: unknown;\n ipAddress?: unknown;\n data?: unknown;\n /**\n * The browser token the submission came with (`browserTokenOf`). Only a\n * create has one: the row does not keep it.\n */\n browserToken?: string;\n}\n\n/** A non-empty string, trimmed, or undefined. */\nfunction textOf(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const trimmed = value.trim();\n return trimmed === \"\" ? undefined : trimmed;\n}\n\n/**\n * A submission's answers as an object: as written, or as a stored row holds\n * them before a read decodes them, a JSON string.\n */\nfunction answersOf(data: unknown): Record<string, unknown> | undefined {\n let value = data;\n if (typeof value === \"string\") {\n try {\n value = JSON.parse(value) as unknown;\n } catch {\n return undefined;\n }\n }\n return value !== null && typeof value === \"object\"\n ? (value as Record<string, unknown>)\n : undefined;\n}\n\n/** The answer to `field` in a submission's data. */\nfunction answerOf(data: unknown, field: string | undefined): unknown {\n return field === undefined ? undefined : answersOf(data)?.[field];\n}\n\n/**\n * The submission's key under the form's one-response setting, or null when\n * it has none: the form allows several responses, the submission is spam,\n * or what the mode compares is missing (a submission with no address, or\n * none of the browser token, which only a create brings).\n */\nexport async function responseKeyOf(\n /** Read only when a key is made: most forms allow several responses. */\n digests: () => Digests,\n settings: KeySettings,\n submission: KeyedSubmission\n): Promise<string | null> {\n if (submission.status === \"spam\") return null;\n const prefix = keyPrefixOf(settings.oneResponseVersion);\n switch (settings.oneResponse) {\n case \"ip\": {\n const address = textOf(submission.ipAddress);\n return address === undefined\n ? null\n : `${prefix}${await digests().digest(\"ip\", address)}`;\n }\n case \"email\": {\n const email = textOf(\n answerOf(submission.data, settings.oneResponseField)\n );\n return email === undefined\n ? null\n : `${prefix}${await digests().digest(\"email\", email.toLowerCase())}`;\n }\n case \"browser\": {\n const token = submission.browserToken;\n return token === undefined\n ? null\n : `${prefix}${BROWSER_KEY_PREFIX}${await digests().digest(\"browser\", token)}`;\n }\n default:\n return null;\n }\n}\n\n/**\n * Whether the key a new submission was given is the one `settings` give it.\n * An address's key is made again from the row and compared. A browser's\n * cannot be, since its token is not stored: under one per browser a browser\n * key, or none (a submission sent without a token), is the right one, and a\n * key made for another mode is not. Nor can a network's on a form that keeps\n * no addresses, where a key for the current version is the right one.\n */\nexport async function holdsKeyFor(\n digests: () => Digests,\n settings: KeySettings,\n row: KeyedSubmission & { responseKey?: unknown }\n): Promise<boolean> {\n const key = row.responseKey ?? null;\n if (settings.oneResponse === \"browser\" && row.status !== \"spam\") {\n return key === null || isBrowserKey(key, settings.oneResponseVersion);\n }\n // A form that keeps no addresses (Privacy) stores the network's key and\n // not the address it was made from, so it cannot be made again here. A key\n // made for the form's current version is the right one: the version moves\n // on whenever what the form compares changes.\n if (\n settings.oneResponse === \"ip\" &&\n textOf(row.ipAddress) === undefined &&\n typeof key === \"string\" &&\n row.status !== \"spam\"\n ) {\n return (\n keyVersionOf(key) === (settings.oneResponseVersion ?? 0) &&\n !key.includes(BROWSER_KEY_PREFIX)\n );\n }\n return key === (await responseKeyOf(digests, settings, row));\n}\n\n/** Whether an update changes what a submission's key is made from. */\nexport function touchesResponseKey(patch: KeyedSubmission): boolean {\n return (\n patch.status !== undefined ||\n patch.data !== undefined ||\n patch.ipAddress !== undefined\n );\n}\n\n/**\n * A submission's key after an update, made from the stored row with the\n * update applied: none once it is spam; its own again when restored from\n * spam, unless another submission has since taken that value, when it keeps\n * none and the value stays with that one; a new one when its answer or\n * address changed, which the unique index refuses if another submission\n * holds it already, so an edit cannot give two submissions one value; and,\n * when the value is unchanged, what it held.\n */\nexport async function responseKeyAfterUpdate(input: {\n digests: () => Digests;\n settings: KeySettings;\n patch: KeyedSubmission;\n stored: (KeyedSubmission & { responseKey?: unknown }) | undefined;\n /** Whether another submission of the form holds `key`. */\n heldElsewhere: (key: string) => Promise<boolean>;\n /** Whether the update moves the submission to another form. */\n moved: boolean;\n}): Promise<string | null> {\n const { patch, stored } = input;\n if (input.settings.oneResponse === \"browser\") {\n return browserKeyAfterUpdate(\n patch,\n stored,\n input.settings.oneResponseVersion,\n input.moved\n );\n }\n // A network's key on a submission that never stored its address (a form\n // that keeps none): it cannot be made again, so it is kept, as a\n // browser's is. An update that sets or clears the address is judged below.\n if (\n input.settings.oneResponse === \"ip\" &&\n textOf(stored?.ipAddress) === undefined &&\n patch.ipAddress === undefined\n ) {\n return keptKeyAfterUpdate(\n patch,\n stored,\n input.settings.oneResponseVersion,\n input.moved\n );\n }\n // What the update leaves out is as stored; what it clears (null) is gone.\n const after = (field: keyof KeyedSubmission) =>\n patch[field] === undefined ? stored?.[field] : patch[field];\n const key = await responseKeyOf(input.digests, input.settings, {\n status: after(\"status\"),\n data: after(\"data\"),\n ipAddress: after(\"ipAddress\"),\n });\n if (key === null) return null;\n const storedValue = await responseKeyOf(input.digests, input.settings, {\n data: stored?.data,\n ipAddress: stored?.ipAddress,\n });\n // Edited to another value, or moved to another form whose responses it\n // now joins: that value's key, which the unique index refuses if another\n // submission of the form holds it.\n if (input.moved || key !== storedValue) return key;\n // Restored from spam with the value it was stored with: its key back,\n // unless another submission has taken that value since.\n if (stored?.status === \"spam\") {\n return (await input.heldElsewhere(key)) ? null : key;\n }\n // The same value, not spam before or after: whatever it held, a key or,\n // restored while its value was taken, none, so a later status change or an\n // edit of another answer never collides.\n return typeof stored?.responseKey === \"string\" ? stored.responseKey : null;\n}\n\n/**\n * A submission's browser key after an update. The token is not stored, so\n * the key cannot be made again: the submission keeps the browser key it\n * holds until it is marked spam, which gives the browser its place back, and\n * one restored from spam stays without one. Answers edited keep it, and a\n * submission moved to another form taking one per browser takes it there,\n * for that form's version, since it is the same browser's response; the\n * unique index refuses a move onto a form where that browser has responded.\n */\nfunction browserKeyAfterUpdate(\n patch: KeyedSubmission,\n stored: (KeyedSubmission & { responseKey?: unknown }) | undefined,\n version: number | undefined,\n moved: boolean\n): string | null {\n const status = patch.status === undefined ? stored?.status : patch.status;\n if (status === \"spam\") return null;\n const key = stored?.responseKey;\n // Moved to another form: the same browser, so the same token's digest,\n // spelled for the destination's version, where the unique index judges it.\n if (moved) return browserKeyFor(key, version);\n // A key from an earlier version compares with nothing now: not kept.\n return isBrowserKey(key, version) ? key : null;\n}\n\n/** A browser key of any version, spelled for `version`; none for any other key. */\nfunction browserKeyFor(\n key: unknown,\n version: number | undefined\n): string | null {\n if (typeof key !== \"string\") return null;\n const match = /^(?:v\\d+:)?browser:([0-9a-f]+)$/.exec(key);\n return match\n ? `${keyPrefixOf(version)}${BROWSER_KEY_PREFIX}${match[1]}`\n : null;\n}\n\n/**\n * A key the row holds but cannot be made again from it (a network's on a\n * form that keeps no addresses), after an update: none once the submission\n * is spam; spelled for the destination's version when it moves to another\n * form; else the key it holds, while that is for the form's current version.\n */\nfunction keptKeyAfterUpdate(\n patch: KeyedSubmission,\n stored: (KeyedSubmission & { responseKey?: unknown }) | undefined,\n version: number | undefined,\n moved: boolean\n): string | null {\n const key = networkKeyHeld(patch, stored);\n if (key === null) return null;\n if (moved) return `${keyPrefixOf(version)}${key.replace(/^v\\d+:/, \"\")}`;\n return keyVersionOf(key) === (version ?? 0) ? key : null;\n}\n\n/**\n * The key a stored submission should hold under `settings`: its value's,\n * made again from the row; or, for a network's on a row that kept no\n * address (a form that keeps none), the key it holds while that is for the\n * current version, since it cannot be made again. The rebuild asks this, so\n * a submission stored under a form that keeps no addresses keeps its key.\n */\nexport async function storedRowKeyOf(\n digests: () => Digests,\n settings: KeySettings,\n row: KeyedSubmission & { responseKey?: unknown }\n): Promise<string | null> {\n if (settings.oneResponse !== \"ip\" || textOf(row.ipAddress) !== undefined) {\n return responseKeyOf(digests, settings, row);\n }\n const key = networkKeyHeld({}, row);\n return key !== null &&\n keyVersionOf(key) === (settings.oneResponseVersion ?? 0)\n ? key\n : null;\n}\n\n/** The network key a submission holds, unless the update makes it spam. */\nfunction networkKeyHeld(\n patch: KeyedSubmission,\n stored: (KeyedSubmission & { responseKey?: unknown }) | undefined\n): string | null {\n const status = patch.status === undefined ? stored?.status : patch.status;\n const key = stored?.responseKey;\n if (status === \"spam\" || typeof key !== \"string\") return null;\n return key.includes(BROWSER_KEY_PREFIX) ? null : key;\n}\n","/**\n * What a submission has to be before it is stored, wherever it came from.\n *\n * Two things accept a submission. `submitForm` answers `POST\n * /api/forms/:slug/submit`, and `nextly.forms.submit()` is the Direct API a\n * server-side caller reaches through `getNextly()`. The first transformed,\n * validated and sanitized; the second did none of it, so a submission arriving\n * that way was stored with undeclared keys, values that did not match the\n * form's own schema, and markup intact.\n *\n * The rule lives here rather than in either caller because a rule stated in one\n * of two places is a rule the other can disagree with, and the two had already\n * drifted that far apart. `beforeCreate` on the submissions collection now asks\n * this on every write, so a third caller inherits it without knowing it exists.\n *\n * What is NOT here is the spam machinery: the honeypot, the rate limiter and\n * reCAPTCHA are facts about a REQUEST, not about a row. A trusted server\n * importing a thousand submissions would be rate-limited by its own import, and\n * a honeypot field means nothing without a browser to have skipped it. Those\n * stay on the HTTP path.\n */\n\nimport { stripHtmlTags } from \"nextly\";\n\nimport type { AnyFormField } from \"../types\";\nimport { fieldsHiddenByCondition } from \"../utils/condition-visibility\";\nimport {\n generateZodSchema,\n getValidationErrors,\n getValidationIssues,\n type ValidationIssue,\n transformFormData,\n} from \"../utils/generate-schema\";\n\nimport type { BotTokens } from \"./bot-check\";\n\n/**\n * Form field types that accept free-text string input from end users.\n * These fields can contain HTML injection vectors and must be sanitized.\n *\n * Fields NOT in this set (select, radio, checkbox, number, date, time, file)\n * are constrained by Zod enum/type validation and don't need sanitization.\n */\nconst TEXT_FORM_FIELDS = new Set([\n \"text\",\n \"email\",\n \"textarea\",\n \"phone\",\n \"url\",\n \"hidden\",\n]);\n\n/**\n * Sanitize form submission data by stripping HTML tags from free-text fields.\n *\n * Iterates over the form's field definitions and applies `stripHtmlTags()`\n * to values whose field type is in `TEXT_FORM_FIELDS`. Non-string values\n * and constrained fields (select, radio, checkbox, etc.) are left unchanged.\n *\n * Mutates the data object in place for efficiency.\n *\n * @param data - Validated submission data (mutated in place)\n * @param fields - Form field definitions (used for type-aware dispatch)\n */\nexport function sanitizeSubmissionData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): void {\n for (const field of fields) {\n // Plugin field types are not in TEXT_FORM_FIELDS, so they skip naturally.\n if (!TEXT_FORM_FIELDS.has(field.type)) continue;\n\n const value = data[field.name];\n if (typeof value !== \"string\") continue;\n\n data[field.name] = stripHtmlTags(value);\n }\n}\n\n/**\n * How the plugin's own submit handler says a row is its own.\n *\n * The write-seam check has to be lenient in exactly one case: a honeypot or\n * reCAPTCHA hit is stored flagged rather than dropped, so a false positive stays\n * reviewable, and requiring it to be valid would throw away the thing being\n * reviewed. Deciding that from the row's own `status` made it caller-controlled:\n * the submissions collection grants public create, nothing restricts `status`,\n * so anyone could post `status: \"spam\"` and switch validation off.\n *\n * A SYMBOL key on the row is the channel, and it is the only one that both\n * reaches the hook and cannot be reached from a request. `JSON.parse` never\n * produces a symbol key, so nothing a caller posts can carry it, and\n * `JSON.stringify` ignores it, so it cannot be stored by accident. It travels\n * with the row it describes, which is what the alternatives could not do.\n *\n * The alternatives were measured, not assumed. `createEntry`'s `params.context`\n * is unreachable: `wrapCollectionsForPlugin` rebuilds the trailing argument as\n * `{ user, overrideAccess }` and drops everything else. `AsyncLocalStorage`\n * reaches the hook but describes the CALL rather than the row, so a submission\n * written by another hook inside the same call took the exception, and\n * identifying the intended row by its content broke as soon as a host hook\n * normalised the payload, since core rebuilds the payload object on the way.\n * A symbol on the row has neither problem: booting a real Nextly and reading\n * the hook's context back, the row's symbol arrives and the payload's does not.\n */\nconst PLUGIN_SUBMISSION = Symbol.for(\"nextly.plugin-form-builder.submission\");\n\n/** What the handler says about the row it is writing. */\nexport interface SubmissionOriginMarks {\n /**\n * The handler decided this row is spam and is keeping it as evidence, so the\n * write seam sanitizes it but does not require it to be valid.\n */\n keepAsEvidence: boolean;\n /**\n * The parent form the handler has already read, so the write seam can check\n * the payload against it without reading it again. Used only when its id is\n * the one the row names.\n */\n form?: Record<string, unknown>;\n /**\n * The bot-check tokens the submission brought. The handler keeps only the\n * form's own fields in the row, so the tokens travel here to the seam,\n * which verifies them once: a token is spent when it is verified.\n */\n botTokens?: BotTokens;\n /**\n * The rate limit has already counted this submission, on the handler's way\n * to judging its bot check; the seam does not count it again.\n */\n rateCounted?: boolean;\n /**\n * The token the visitor's browser sent for one response per browser,\n * read before the row keeps only the form's fields: the seam makes the\n * submission's key from it.\n */\n browserToken?: string;\n /**\n * The one-response key the plugin made for this submission, written by\n * itself alone (`one-response-rebuild`, and a key handed to the next\n * submission when its holder leaves). The seam writes this key and nothing\n * else; the hold checks it was made for the form's current version.\n */\n setKey?: string | null;\n}\n\n/** Mark `row` as this plugin's own write, and hand it back. */\nexport function asPluginSubmission(\n row: Record<string, unknown>,\n marks: SubmissionOriginMarks\n): Record<string, unknown> {\n return Object.defineProperty(row, PLUGIN_SUBMISSION, {\n value: marks,\n enumerable: true,\n configurable: true,\n });\n}\n\n/** A row as it looks once this plugin has marked it. */\ninterface MarkedSubmission {\n [PLUGIN_SUBMISSION]?: SubmissionOriginMarks;\n}\n\n/** What this row was marked as, or nothing when it is somebody else's. */\nexport function submissionMarks(\n row: Record<string, unknown>\n): SubmissionOriginMarks | undefined {\n return (row as MarkedSubmission)[PLUGIN_SUBMISSION];\n}\n\n/** A form value, compared the way a form value can be. */\nfunction sameValue(left: unknown, right: unknown): boolean {\n if (Object.is(left, right)) return true;\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() === right.getTime();\n }\n if (Array.isArray(left) && Array.isArray(right)) {\n return (\n left.length === right.length &&\n left.every((value, index) => sameValue(value, right[index]))\n );\n }\n return false;\n}\n\n/** Whether two submitted payloads say the same thing. */\nexport function sameSubmittedPayload(\n granted: Record<string, unknown>,\n incoming: Record<string, unknown>\n): boolean {\n const answers = Object.entries(granted);\n if (answers.length !== Object.keys(incoming).length) return false;\n return answers.every(([field, answer]) => sameValue(answer, incoming[field]));\n}\n\n/** A submission ready to store, or the reasons it is not. */\nexport interface PreparedSubmission {\n /** Transformed and sanitized. Present whether or not validation passed. */\n data: Record<string, unknown>;\n /**\n * Field name to its first message, which is the shape `getValidationErrors`\n * already returns and the HTTP handler already answers with. Absent when the\n * submission is storable.\n */\n validationErrors?: Record<string, string>;\n /**\n * The same failures carrying their machine codes.\n *\n * Beside the map rather than replacing it: the map is this plugin's public\n * result shape, while an HTTP route needs the code to report `REQUIRED` for a\n * blank answer the way the endpoint always has. Both come from one walk of\n * the issues, so they cannot disagree.\n */\n validationIssues?: ValidationIssue[];\n}\n\n/**\n * Bring a submission to the shape the form declares.\n *\n * Transform, then sanitize, then validate, in that order and for reasons.\n * Transforming projects the payload onto the declared fields, so an undeclared\n * key never reaches the schema or the row. Sanitizing before validating is what\n * makes every rule judge the value that will actually be stored: the other\n * order let `<b></b>` satisfy a required field and then reduced it to an empty\n * string, and made a length rule count markup the visitor never sees.\n *\n * `validate: false` is for content the plugin has already decided to keep as\n * evidence. A honeypot hit is stored flagged rather than dropped so a false\n * positive stays recoverable, and requiring it to be valid would throw the\n * evidence away. It is still transformed and sanitized, because a row nobody\n * validated is exactly the one that should not carry markup.\n *\n * Idempotent, which is what lets the write-seam hook run over data the HTTP\n * handler has already prepared: transforming a projected payload projects it\n * again, and stripping tags from text that has none returns it unchanged.\n */\nexport function prepareSubmission({\n data,\n fields,\n validate,\n}: {\n data: Record<string, unknown>;\n fields: AnyFormField[];\n validate: boolean;\n}): PreparedSubmission {\n const transformed = transformFormData(data, fields);\n sanitizeSubmissionData(transformed, fields);\n\n if (!validate) {\n return { data: transformed };\n }\n\n // Only the fields the visitor was shown are asked for: a required field a\n // condition hid could never be answered, and refusing over it would leave\n // the form impossible to send. What is stored is unchanged.\n const hidden = fieldsHiddenByCondition(fields, transformed);\n const result = generateZodSchema(\n fields.filter(field => !hidden.has(field.name))\n ).safeParse(transformed);\n if (!result.success) {\n // The transformed payload comes back with the errors rather than nothing,\n // so a caller that wants to store it anyway has the same value the valid\n // path would have produced, minus the schema's blessing.\n return {\n data: transformed,\n validationErrors: getValidationErrors(result),\n // The RAW submission, not the transformed payload. Transformation\n // normalises a value before the schema sees it, so a required\n // multi-select sent a scalar `0` arrives here as `[]` and would read as\n // blank: the visitor answered, and would be told they had not.\n validationIssues: getValidationIssues(result, data),\n };\n }\n\n // The answers as transformed, with the schema's parse over them: a field\n // left out of the checks (one a condition hid) keeps the answer it was sent,\n // since the parse only returns the fields it checked.\n return { data: { ...transformed, ...result.data } };\n}\n","/**\n * A form's closing time and limit, held where a submission is stored: inside\n * the submission's own write transaction, whatever wrote it (the public route,\n * `submitForm` called from another plugin, `nextly.forms.submit()` or the\n * admin). So no way of storing a submission can outlive the form's closing.\n *\n * Past the closing time, every new submission is refused, spam included.\n *\n * Two submissions for a form's last place must not both be stored. Checking a\n * count before the write lets both read \"one place left\", so the check runs\n * after the submission's row is inserted, in the same transaction, under the\n * form's row locked for update: a second submission to the same form waits\n * there until the first has committed or rolled back, then counts again, its\n * own row and the first's both included, and is refused if they are more\n * than the limit. The refusal rolls the whole write back, so nothing is kept.\n *\n * Per dialect:\n *\n * - PostgreSQL and MySQL. A write that opens its own transaction and runs\n * in-transaction handlers reads committed, so the count after the lock sees\n * what the submission before it committed. The count takes no lock of its\n * own: a locking read would wait on another submission's uncommitted row\n * while that submission waits for this one's lock. The submission's `form`\n * column is a plain column with no database foreign key, so its insert holds\n * no lock on the form's row, and two submissions waiting on each other's\n * insert cannot arise.\n * - SQLite. One writer at a time already, so the count sees every commit.\n *\n * One limit to this: a submission created inside a transaction its caller\n * opened (`withTransaction` with `createEntryInTransaction`) reads at that\n * transaction's isolation. On MySQL that is REPEATABLE READ, where the count\n * answers from the transaction's first read, so two such transactions racing\n * for the last place can both be stored. Every way of submitting this plugin\n * and core offer opens its own transaction.\n *\n * Spam does not count, and a submission stored as spam takes no place.\n * Marking a submission spam or not spam, deleting submissions and the\n * retention pass change the count by changing the rows it reads: there is no\n * counter to keep in step. Only a new submission is judged: marking a stored\n * one \"not spam\" restores a place it was entitled to when it arrived.\n *\n * @module handlers/form-closing-hold\n */\nimport { NextlyError, type PluginContext } from \"@nextlyhq/plugin-sdk\";\n\nimport { closedMessageOf, pastClosingTime } from \"../utils/form-closing\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\n\nimport {\n ALREADY_RESPONDED,\n holdsKeyFor,\n type KeyedSubmission,\n keysBehind,\n keyVersionOf,\n responseKeyOf,\n} from \"./one-response\";\nimport { asPluginSubmission } from \"./prepare-submission\";\n\n/** The mark on a refusal for a closed form, for the caller to recognise it. */\nconst FORM_CLOSED = \"form-builder:form-closed\";\n\ntype InTransactionHandler = Parameters<\n PluginContext[\"hooks\"][\"onInTransaction\"]\n>[1];\ntype InTransactionContext = Parameters<InTransactionHandler>[0];\ntype OwnCollections = PluginContext[\"services\"][\"ownCollections\"];\ntype Digests = PluginContext[\"security\"][\"digests\"];\n\n/** What the hold reads with. */\ninterface HoldDeps {\n own: OwnCollections;\n digests: Digests;\n collections: PluginContext[\"services\"][\"collections\"];\n}\n\n/** The mark on a refusal for a setting changed while the write was in flight. */\nconst SETTINGS_CHANGED_MARK = \"form-builder:settings-changed\";\n\n/** The mark on a refusal for a person who has responded already. */\nconst RESPONDED_MARK = \"form-builder:responded\";\n\n/** Rows read at a time when earlier submissions are compared one by one. */\nconst COMPARE_PAGE = 200;\n\n/** What a submission refused for a setting changed in flight is told. */\nexport const SETTINGS_CHANGED =\n \"This form's settings changed while your response was being sent. Please send it again.\";\n\nexport interface FormLimitSlugs {\n forms: string;\n submissions: string;\n}\n\n/** The id a submission's `form` names, as an id or a populated document. */\nfunction formIdOf(value: unknown): string | undefined {\n if (typeof value === \"string\" && value !== \"\") return value;\n if (typeof value === \"number\") return String(value);\n if (value && typeof value === \"object\" && \"id\" in value) {\n return formIdOf(value.id);\n }\n return undefined;\n}\n\n/** The form a new submission is made to, and whether it takes a place. */\nfunction newSubmission(\n write: InTransactionContext\n): { formId: string; takesAPlace: boolean } | undefined {\n if (write.operation !== \"create\") return undefined;\n const data = write.data as { form?: unknown; status?: unknown } | undefined;\n const formId = formIdOf(data?.form);\n return formId === undefined\n ? undefined\n : { formId, takesAPlace: data?.status !== \"spam\" };\n}\n\n/** The refusal a submission to a closed form gets, and rolls back with. */\nfunction closedRefusal(\n form: Record<string, unknown>,\n logContext: Record<string, unknown>\n) {\n return NextlyError.conflict({\n reason: \"state\",\n message: closedMessageOf(form),\n logContext: { cause: FORM_CLOSED, ...logContext },\n });\n}\n\n/** The form, as stored, with its settings read. */\nasync function formOf(\n own: OwnCollections,\n write: InTransactionContext,\n slugs: FormLimitSlugs,\n formId: string,\n lock: \"none\" | \"update\"\n): Promise<{\n form: Record<string, unknown>;\n settings: ReturnType<typeof normalizeFormSettings>;\n}> {\n const form = (await own.findEntryByIdInTransaction(\n write.transaction,\n slugs.forms,\n formId,\n { lock }\n )) as unknown as Record<string, unknown>;\n return { form, settings: normalizeFormSettings(form.settings) };\n}\n\n/**\n * Refuse a new submission to a form past its closing time, one that would\n * take it past its limit, or one whose one-response key is not the one the\n * form's committed settings give it.\n */\nasync function holdFormClosing(\n deps: HoldDeps,\n write: InTransactionContext,\n slugs: FormLimitSlugs\n): Promise<void> {\n const submission = newSubmission(write);\n if (submission === undefined) return;\n const { formId } = submission;\n // Turns, one form at a time: the next submission to this form, and a save\n // of its settings, wait here, so this one is judged by the settings as\n // committed. Taken for every form, not only one that compares responses\n // as last committed: a save that starts comparing them may be in flight,\n // and a submission that read past it unlocked would be stored with no key.\n const locked = await formOf(deps.own, write, slugs, formId, \"update\");\n if (pastClosingTime(locked.settings, new Date())) {\n throw closedRefusal(locked.form, { formId, closedBy: \"date\" });\n }\n refuseKeptAddress(write, formId, locked.settings);\n if (submission.takesAPlace) {\n await holdLimit(deps.own, write, slugs, formId, locked);\n }\n await holdResponseKey(deps, write, slugs, formId, locked.settings);\n}\n\n/**\n * Refuse a new submission that carries an IP address or browser to a form\n * that, as committed, keeps neither: the write seam read the setting before\n * it changed. The visitor sends it again, and it is stored without them.\n */\nfunction refuseKeptAddress(\n write: InTransactionContext,\n formId: string,\n settings: { storeIpAndBrowser?: boolean }\n): void {\n if (settings.storeIpAndBrowser !== false) return;\n const row = write.data as { ipAddress?: unknown; userAgent?: unknown };\n const kept = (value: unknown) => typeof value === \"string\" && value !== \"\";\n if (kept(row?.ipAddress) || kept(row?.userAgent)) {\n throw changedRefusal(formId);\n }\n}\n\n/** Refuse a submission that takes the form past its limit, under the lock. */\nasync function holdLimit(\n own: OwnCollections,\n write: InTransactionContext,\n slugs: FormLimitSlugs,\n formId: string,\n { form, settings }: Awaited<ReturnType<typeof formOf>>\n): Promise<void> {\n const limit = settings.submissionLimit;\n if (limit === undefined) return;\n const { pagination } = await own.listEntriesInTransaction(\n write.transaction,\n slugs.submissions,\n {\n where: { form: { equals: formId }, status: { not_equals: \"spam\" } },\n pagination: { limit: 1 },\n },\n { lock: \"none\" }\n );\n if (pagination.total > limit) {\n throw closedRefusal(form, { formId, closedBy: \"limit\", limit });\n }\n}\n\n/**\n * Refuse a submission whose one-response key is not the one the form's\n * setting, as stored now, gives it. The key was made before the write, from\n * the settings read then; a change of mode or Email field committed since\n * would leave a key no longer compared by anything, or none where one is\n * owed. The visitor sends it again, under the setting that applies.\n */\nasync function holdResponseKey(\n deps: HoldDeps,\n write: InTransactionContext,\n slugs: FormLimitSlugs,\n formId: string,\n settings: Awaited<ReturnType<typeof formOf>>[\"settings\"]\n): Promise<void> {\n const row = write.data as KeyedSubmission & { responseKey?: unknown };\n if (!(await holdsKeyFor(() => deps.digests, settings, row))) {\n throw changedRefusal(formId);\n }\n if (row.responseKey === null || row.responseKey === undefined) return;\n await refuseEarlierResponse(deps, write, slugs, formId, settings);\n}\n\n/** The refusal for a key made under a setting the form has since left. */\nfunction changedRefusal(formId: string) {\n return NextlyError.conflict({\n reason: \"state\",\n message: SETTINGS_CHANGED,\n logContext: { cause: SETTINGS_CHANGED_MARK, formId },\n });\n}\n\n/**\n * Refuse a keyed submission when an earlier one holds its value without the\n * key that would show it.\n *\n * One per network compares the stored address itself, every time: a form\n * that took one response per network before keys were stored has earlier\n * submissions with an address and no key. One per email compares the earlier\n * answers while the form's keys are still being made for its current version\n * (`keysBehind`), after a change of mode or Email field. Both run under the\n * form's lock, so a submission stored meanwhile is counted.\n */\nasync function refuseEarlierResponse(\n deps: HoldDeps,\n write: InTransactionContext,\n slugs: FormLimitSlugs,\n formId: string,\n settings: Awaited<ReturnType<typeof formOf>>[\"settings\"]\n): Promise<void> {\n const row = write.data as KeyedSubmission & { responseKey?: unknown };\n const earlier = {\n form: { equals: formId },\n status: { not_equals: \"spam\" },\n id: { not_equals: write.id },\n };\n const read = (where: Record<string, unknown>, limit: number, page = 1) =>\n deps.own.listEntriesInTransaction(\n write.transaction,\n slugs.submissions,\n { where, pagination: { limit, page }, depth: 0 },\n { lock: \"none\" }\n );\n if (settings.oneResponse === \"ip\" && typeof row.ipAddress === \"string\") {\n const { pagination } = await read(\n { ...earlier, ipAddress: { equals: row.ipAddress } },\n 1\n );\n if (pagination.total > 0) throw respondedRefusal(formId);\n return;\n }\n if (settings.oneResponse === \"email\" && keysBehind(settings)) {\n await refuseEarlierEmail(deps, formId, settings, row.responseKey, page =>\n read(earlier, COMPARE_PAGE, page)\n );\n }\n}\n\n/**\n * Compare a new submission's key with the key each earlier submission's\n * answer would have, a page at a time.\n */\nasync function refuseEarlierEmail(\n deps: HoldDeps,\n formId: string,\n settings: Awaited<ReturnType<typeof formOf>>[\"settings\"],\n key: unknown,\n page: (n: number) => Promise<{ data: unknown[] }>\n): Promise<void> {\n for (let n = 1; ; n++) {\n const { data } = await page(n);\n for (const other of data as KeyedSubmission[]) {\n if ((await responseKeyOf(() => deps.digests, settings, other)) === key) {\n throw respondedRefusal(formId);\n }\n }\n if (data.length < COMPARE_PAGE) return;\n }\n}\n\n/** The refusal for a person who has responded to the form already. */\nexport function respondedRefusal(formId: string): NextlyError {\n return NextlyError.conflict({\n reason: \"state\",\n message: ALREADY_RESPONDED,\n logContext: { cause: RESPONDED_MARK, formId },\n });\n}\n\n/**\n * Refuse a key written to a stored submission (an edit that moves its key,\n * or the plugin giving it one) when the key was made for a version the form\n * has left: the form's mode or Email field changed after it was made. The\n * form's lock orders it after the change, so the version it reads is the one\n * committed.\n */\nasync function holdKeyWrite(\n deps: HoldDeps,\n write: InTransactionContext,\n slugs: FormLimitSlugs\n): Promise<void> {\n const changed = changedKeyOf(write);\n if (changed === undefined) return;\n const { key, formId } = changed;\n const { settings } = await formOf(deps.own, write, slugs, formId, \"update\");\n if (keyVersionOf(key) !== (settings.oneResponseVersion ?? 0)) {\n throw changedRefusal(formId);\n }\n // An edit that gives the submission another answer, address or form: while\n // earlier submissions are still without keys for this version, the index\n // cannot see theirs, so their answers are compared as a new submission's\n // are. A key change with the same value is not compared: the plugin's own\n // key write walks the form oldest first, so an earlier holder has its key\n // already, and a submission restored from spam takes its key back only\n // where no other submission holds it (`responseKeyAfterUpdate`).\n if (sameValue(write)) return;\n await refuseEarlierResponse(deps, write, slugs, formId, settings);\n}\n\n/**\n * The key an update gave a stored submission, and its form, when the update\n * changed the key to one: undefined for a create, a key cleared or kept.\n */\nfunction changedKeyOf(\n write: InTransactionContext\n): { key: string; formId: string } | undefined {\n if (write.operation !== \"update\") return undefined;\n const after = write.data as { responseKey?: unknown; form?: unknown };\n const before = write.originalData as { responseKey?: unknown } | undefined;\n const key = after?.responseKey;\n if (typeof key !== \"string\" || key === before?.responseKey) return undefined;\n const formId = formIdOf(after.form);\n return formId === undefined ? undefined : { key, formId };\n}\n\n/**\n * Whether an update left a submission's answers, address and form as they\n * were. A submission moved to another form joins that form's responses, so a\n * move is compared as an edit is.\n */\nfunction sameValue(write: InTransactionContext): boolean {\n const after = write.data as Record<string, unknown> | undefined;\n const before = write.originalData as Record<string, unknown> | undefined;\n const same = (field: string) =>\n JSON.stringify(after?.[field] ?? null) ===\n JSON.stringify(before?.[field] ?? null);\n return (\n same(\"data\") &&\n same(\"ipAddress\") &&\n formIdOf(after?.form) === formIdOf(before?.form)\n );\n}\n\n/**\n * When the submission holding a one-response key stops holding it (deleted,\n * marked spam, or edited or moved to another value), the earliest other\n * non-spam submission of the form with the same value takes the key, in the\n * same transaction. Without it, a keyless duplicate (one stored before the\n * form compared that value, or kept as evidence and restored) would leave\n * the value free, and a person who had responded could respond again.\n *\n * Under the form's lock, so a new submission with that value waits and then\n * meets the key. A key made for a version the form has left is not handed\n * on: nothing compares it. One per browser hands nothing on, since no\n * earlier submission kept its browser's token.\n */\nasync function handOffKey(\n deps: HoldDeps,\n write: InTransactionContext,\n slugs: FormLimitSlugs\n): Promise<void> {\n const released = releasedKeyOf(write);\n if (released === undefined) return;\n const { key, formId, ipAddress } = released;\n const { settings } = await formOf(deps.own, write, slugs, formId, \"update\");\n if (keyVersionOf(key) !== (settings.oneResponseVersion ?? 0)) return;\n const next = await nextHolderOf(deps, write, slugs, {\n formId,\n settings,\n key,\n ipAddress,\n });\n if (next === undefined) return;\n await deps.collections.updateEntryInTransaction(\n write.transaction,\n slugs.submissions,\n next,\n asPluginSubmission(\n { responseKey: key, _overwrite: true },\n { keepAsEvidence: false, setKey: key }\n )\n );\n}\n\n/**\n * The key a write took from the submission that held it, with that\n * submission's form and address as they were: undefined when it still holds\n * it, or held none.\n */\nfunction releasedKeyOf(\n write: InTransactionContext\n): { key: string; formId: string; ipAddress: unknown } | undefined {\n const before = heldBefore(write);\n const key = before?.responseKey;\n if (typeof key !== \"string\" || stillHeld(write, key, before)) {\n return undefined;\n }\n const formId = formIdOf(before?.form);\n return formId === undefined\n ? undefined\n : { key, formId, ipAddress: before?.ipAddress };\n}\n\n/**\n * The submission as it was before a write: a delete describes it as it was,\n * an update by its original; a create had none.\n */\nfunction heldBefore(\n write: InTransactionContext\n): { responseKey?: unknown; form?: unknown; ipAddress?: unknown } | undefined {\n if (write.operation === \"create\") return undefined;\n return (write.operation === \"delete\" ? write.data : write.originalData) as\n | { responseKey?: unknown; form?: unknown; ipAddress?: unknown }\n | undefined;\n}\n\n/** Whether an update left the submission holding `key` on the same form. */\nfunction stillHeld(\n write: InTransactionContext,\n key: string,\n before: { form?: unknown } | undefined\n): boolean {\n const after = write.data as\n | { responseKey?: unknown; form?: unknown }\n | undefined;\n // A move to another form leaves this one even when the key reads the same\n // there: keys are compared per form.\n return (\n write.operation === \"update\" &&\n after?.responseKey === key &&\n formIdOf(after?.form) === formIdOf(before?.form)\n );\n}\n\n/**\n * The earliest non-spam submission of the form, other than the one writing,\n * that holds no key and whose value gives `key`. One per network reads only\n * the submissions from the same address; one per email reads the keyless\n * ones a page at a time.\n */\nasync function nextHolderOf(\n deps: HoldDeps,\n write: InTransactionContext,\n slugs: FormLimitSlugs,\n wanted: {\n formId: string;\n settings: Awaited<ReturnType<typeof formOf>>[\"settings\"];\n key: string;\n ipAddress: unknown;\n }\n): Promise<string | undefined> {\n const { settings } = wanted;\n if (settings.oneResponse !== \"ip\" && settings.oneResponse !== \"email\") {\n return undefined;\n }\n const where: Record<string, unknown> = {\n form: { equals: wanted.formId },\n status: { not_equals: \"spam\" },\n id: { not_equals: write.id },\n responseKey: { exists: false },\n };\n if (settings.oneResponse === \"ip\") {\n if (typeof wanted.ipAddress !== \"string\") return undefined;\n where.ipAddress = { equals: wanted.ipAddress };\n }\n for (let page = 1; ; page++) {\n const { data } = await deps.own.listEntriesInTransaction(\n write.transaction,\n slugs.submissions,\n {\n where,\n sort: { field: \"createdAt\", direction: \"asc\" },\n pagination: { limit: COMPARE_PAGE, page },\n depth: 0,\n },\n { lock: \"none\" }\n );\n for (const row of data as (KeyedSubmission & { id?: unknown })[]) {\n const key = await responseKeyOf(() => deps.digests, settings, row);\n if (key === wanted.key) return String(row.id);\n }\n if (data.length < COMPARE_PAGE) return undefined;\n }\n}\n\n/** Hold every form's closing on the submissions this plugin stores. */\nexport function registerFormClosing(\n ctx: Pick<PluginContext, \"hooks\" | \"services\" | \"security\">,\n slugs: FormLimitSlugs\n): void {\n const deps: HoldDeps = {\n own: ctx.services.ownCollections,\n digests: ctx.security.digests,\n collections: ctx.services.collections,\n };\n ctx.hooks.onInTransaction(slugs.submissions, async write => {\n await holdFormClosing(deps, write, slugs);\n await holdKeyWrite(deps, write, slugs);\n await handOffKey(deps, write, slugs);\n });\n}\n\n/**\n * Which of the hold's refusals `error` is, or one it was made from: a closed\n * form's, a setting changed while the submission was in flight, or a person\n * who has responded already.\n */\nexport function heldRefusal(\n error: unknown\n): \"closed\" | \"changed\" | \"responded\" | undefined {\n let current: unknown = error;\n for (let depth = 0; depth < 10 && current; depth += 1) {\n if (current instanceof NextlyError) {\n const cause = current.logContext?.cause;\n if (cause === FORM_CLOSED) return \"closed\";\n if (cause === SETTINGS_CHANGED_MARK) return \"changed\";\n if (cause === RESPONDED_MARK) return \"responded\";\n }\n current = (current as { cause?: unknown }).cause;\n }\n return undefined;\n}\n","/**\n * The save of a form refuses a one response per email it could not keep:\n * no Email field chosen, or one that is missing, optional or hidden by a\n * condition. The builder and Settings refuse the same, and this holds it for\n * every other way a form is written (the API, an import).\n *\n * @module handlers/one-response-field-check\n */\nimport { NextlyError } from \"@nextlyhq/plugin-sdk\";\n\nimport type { AnyFormField } from \"../types\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\nimport { oneResponseFieldProblem } from \"../utils/one-response-field\";\n\n/** A stored value as written: itself, or a JSON string before a read decodes it. */\nfunction decoded(value: unknown): unknown {\n if (typeof value !== \"string\") return value;\n try {\n return JSON.parse(value) as unknown;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Refuse a form save whose one response per email has no Email field it can\n * compare. What the save leaves out is read as stored, so a save of the\n * fields alone is judged against the stored settings, and the other way\n * round.\n */\nexport function refuseOneResponseFieldProblem(context: unknown): void {\n const ctx = context as {\n data?: Record<string, unknown>;\n originalData?: Record<string, unknown>;\n };\n const data = ctx.data;\n if (!data || (data.settings === undefined && data.fields === undefined)) {\n return;\n }\n // Only an omitted part is read as stored: a save that clears its settings\n // with `null` turns one response off, and is judged that way.\n const settings = normalizeFormSettings(\n decoded(\n data.settings !== undefined ? data.settings : ctx.originalData?.settings\n )\n );\n const fields = decoded(\n data.fields !== undefined ? data.fields : ctx.originalData?.fields\n );\n const problem = oneResponseFieldProblem(\n settings,\n Array.isArray(fields) ? (fields as AnyFormField[]) : []\n );\n if (problem === undefined) return;\n throw NextlyError.validation({\n errors: [\n { path: \"settings.oneResponseField\", code: \"INVALID\", message: problem },\n ],\n });\n}\n","/**\n * One response per person, after a form changes how it tells people apart.\n *\n * A submission's key is made under the form's one-response version\n * (`one-response`). Saving a form with another mode or another Email field\n * moves the version on (`stampOneResponseVersion`), so every earlier key stops\n * matching new ones at once, with no write to any earlier submission. The\n * earlier submissions are then given keys for the new version by a sweep\n * (`rebuildResponseKeys`): oldest first, each the key of its own answer or\n * address, or none where an earlier one holds that value already, so the\n * earliest of several with one value keeps it. The sweep runs right after the\n * save commits, and the job runner carries on with what it could not finish.\n *\n * Until it has finished, the form records the version its keys are made for\n * (`oneResponseKeyedVersion`) behind the current one, and the submission's\n * hold compares a new submission with the earlier ones' answers themselves\n * (`form-closing-hold`), so no person gets a second response in between.\n *\n * Each key written is checked again where it is stored: the hold refuses a\n * key made for a version the form has since left, so a sweep that started\n * before a second change writes nothing for the old version. Each write also\n * names the revision the sweep read, so a submission edited meanwhile keeps\n * the key its edit gave it.\n *\n * @module handlers/one-response-rebuild\n */\nimport {\n defineJob,\n type JobDefinition,\n NextlyError,\n type PluginContext,\n} from \"@nextlyhq/plugin-sdk\";\n\nimport type { FormSettings } from \"../types\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\n\nimport { heldRefusal } from \"./form-closing-hold\";\nimport {\n keysBehind,\n oneResponseIdentityOf,\n storedRowKeyOf,\n} from \"./one-response\";\nimport { asPluginSubmission } from \"./prepare-submission\";\n\n/** The sweep that gives earlier submissions their keys. */\nexport const REBUILD_KEYS_JOB = \"form-builder:one-response-keys\";\n\n/** How long a save waits for the sweep after it commits, at most. */\nexport const REBUILD_ON_SAVE_MS = 5_000;\n\n/**\n * How far past the start of a pass its cutoff lies. MySQL stores a row's\n * `createdAt` to the second, rounded, so a submission stored a moment before\n * the pass can read as up to a second after it; a cutoff at the start would\n * leave it out of every pass and without a key. Rows inside the margin are\n * ones stored after the change, keyed already, so reading them costs a look.\n */\nconst CUTOFF_MARGIN_MS = 2_000;\n\nfunction passCutoff(): Date {\n return new Date(Date.now() + CUTOFF_MARGIN_MS);\n}\n\n/** Rows read at a time: forms, then one form's submissions. */\nconst PAGE = 100;\n\n/**\n * The mark on the sweep's own write of a form, saying which version's keys\n * it finished. Only the plugin sets it; `stampOneResponseVersion` takes the\n * finished version from nowhere else.\n */\nconst FORM_KEYED = Symbol.for(\"nextly.plugin-form-builder.form-keyed\");\n\ninterface KeyedFormMark {\n [FORM_KEYED]?: { version: number };\n}\n\n/** A form's settings as stored: an object, or a JSON string before a read decodes it. */\nfunction settingsObjectOf(value: unknown): Record<string, unknown> | undefined {\n let parsed = value;\n if (typeof parsed === \"string\") {\n try {\n parsed = JSON.parse(parsed) as unknown;\n } catch {\n return undefined;\n }\n }\n return parsed !== null && typeof parsed === \"object\"\n ? (parsed as Record<string, unknown>)\n : undefined;\n}\n\n/**\n * The version a save leaves the form at, and the version its keys are made\n * for, both decided here from what is stored and never taken from the\n * payload: a caller that could set them could make every earlier response\n * count again, or none.\n *\n * A save that changes the mode or the Email field moves the version on by\n * one. Its keys are up to date at once when the new mode compares nothing\n * an earlier submission stored (several responses allowed, or one per\n * browser, whose earlier tokens were never kept), or when the form has few\n * enough submissions for the save to key them itself (`rebuildInSave`);\n * otherwise they are behind until the sweep has made them.\n */\nexport async function stampOneResponseVersion(\n context: unknown,\n /** Whether the form's earlier submissions can be keyed inside the save. */\n fitsInSave: (formId: string) => Promise<boolean>\n): Promise<void> {\n const ctx = context as {\n operation?: string;\n data?: Record<string, unknown>;\n originalData?: Record<string, unknown>;\n };\n const incoming = settingsObjectOf(ctx.data?.settings);\n if (!ctx.data || incoming === undefined) return;\n const next: Record<string, unknown> = { ...incoming };\n delete next.oneResponseKeyedVersion;\n if (ctx.operation === \"create\") {\n next.oneResponseVersion = 0;\n ctx.data.settings = next;\n return;\n }\n const stored = normalizeFormSettings(\n settingsObjectOf(ctx.originalData?.settings) ?? {}\n );\n const changed =\n oneResponseIdentityOf(stored) !==\n oneResponseIdentityOf(normalizeFormSettings(incoming));\n const version = (stored.oneResponseVersion ?? 0) + (changed ? 1 : 0);\n next.oneResponseVersion = version;\n // The form's id as stored: a string, or a number on an integer-keyed table.\n const storedId = ctx.originalData?.id;\n const formId =\n typeof storedId === \"string\" || typeof storedId === \"number\"\n ? String(storedId)\n : undefined;\n const keyed = keyedVersionAfterSave({\n stored,\n changed,\n version,\n mode: normalizeFormSettings(incoming).oneResponse,\n finished: (ctx.data as KeyedFormMark)[FORM_KEYED]?.version,\n inSave: changed && formId !== undefined && (await fitsInSave(formId)),\n });\n if (keyed !== undefined) next.oneResponseKeyedVersion = keyed;\n ctx.data.settings = next;\n}\n\n/** The version a form's keys are made for once a save has been stamped. */\nfunction keyedVersionAfterSave(input: {\n stored: FormSettings;\n changed: boolean;\n version: number;\n mode: FormSettings[\"oneResponse\"];\n finished: number | undefined;\n /** Whether the save itself gives the earlier submissions their keys. */\n inSave: boolean;\n}): number | undefined {\n const { stored, changed, version } = input;\n if (changed) {\n const compared = input.mode === \"ip\" || input.mode === \"email\";\n return compared && !input.inSave\n ? (stored.oneResponseKeyedVersion ?? stored.oneResponseVersion ?? 0)\n : version;\n }\n // The sweep's own write, for the version still current.\n if (input.finished === version) return version;\n return stored.oneResponseKeyedVersion;\n}\n\n/**\n * After a save leaves the form's keys behind (a form too large to key inside\n * the save), start the sweep once the save has committed. Not awaited: the\n * save answers with the form as it committed, and the sweep's own write of\n * the form, when it finishes, comes after. What one run leaves, the job\n * runner finishes.\n */\nexport async function rebuildAfterSave(\n context: unknown,\n run: () => Promise<unknown>,\n warn: (message: string, context: Record<string, unknown>) => void\n): Promise<void> {\n const ctx = context as {\n data?: Record<string, unknown>;\n afterCommit?: (effect: () => unknown) => Promise<void>;\n };\n const settings = normalizeFormSettings(\n settingsObjectOf(ctx.data?.settings) ?? {}\n );\n if (!keysBehind(settings) || !ctx.afterCommit) return;\n await ctx.afterCommit(() => {\n run().catch((error: unknown) =>\n warn(\"The one-response key sweep failed; the job runner retries it.\", {\n error: error instanceof Error ? error.message : String(error),\n })\n );\n });\n}\n\n/** The plugin's one-response options, with their defaults. */\nexport function oneResponseOptions(\n options: { keysInSave?: number } | undefined\n): { keysInSave: number } {\n const keysInSave = options?.keysInSave;\n return {\n keysInSave:\n typeof keysInSave === \"number\" && keysInSave >= 0\n ? Math.floor(keysInSave)\n : KEYS_IN_SAVE,\n };\n}\n\n/** Earlier submissions a form may have for the save itself to key them. */\nconst KEYS_IN_SAVE = 200;\n\n/** What keying inside the save reads and writes with. */\nexport interface InSaveDeps {\n own: PluginContext[\"services\"][\"ownCollections\"];\n collections: PluginContext[\"services\"][\"collections\"];\n digests: () => PluginContext[\"security\"][\"digests\"];\n submissionsSlug: string;\n}\n\ntype InTransactionWrite = Parameters<\n Parameters<PluginContext[\"hooks\"][\"onInTransaction\"]>[1]\n>[0];\n\n/**\n * Give a form's earlier submissions keys for its new version inside the\n * save that changed it, when the save decided they fit\n * (`stampOneResponseVersion` recorded the keys as made). Read whole first,\n * then keyed oldest first, the earliest of several with one value keeping it. The save holds the form's\n * row, so no submission to the form is stored meanwhile, and the save\n * answers with every key in place and no later write to the form.\n */\nexport async function rebuildInSave(\n deps: InSaveDeps,\n write: InTransactionWrite\n): Promise<void> {\n if (write.operation !== \"update\") return;\n const after = normalizeFormSettings(\n settingsObjectOf((write.data as Row | undefined)?.settings) ?? {}\n );\n const before = normalizeFormSettings(\n settingsObjectOf((write.originalData as Row | undefined)?.settings) ?? {}\n );\n const version = after.oneResponseVersion ?? 0;\n if (version === (before.oneResponseVersion ?? 0)) return;\n if (after.oneResponseKeyedVersion !== version) return;\n if (after.oneResponse !== \"ip\" && after.oneResponse !== \"email\") return;\n const rows = await snapshotOf(\n async where =>\n (\n await deps.own.listEntriesInTransaction(\n write.transaction,\n deps.submissionsSlug,\n {\n where,\n sort: { field: \"id\", direction: \"asc\" },\n pagination: { limit: PAGE },\n depth: 0,\n },\n { lock: \"none\" }\n )\n ).data,\n { form: { equals: write.id }, status: { not_equals: \"spam\" } }\n );\n const held = new Set<string>();\n for (const row of rows) {\n await keyInSave(deps, write, after, held, row);\n }\n}\n\n/** One row keyed inside the save: its value's key unless held already. */\nasync function keyInSave(\n deps: InSaveDeps,\n write: InTransactionWrite,\n settings: FormSettings,\n held: Set<string>,\n row: Row\n): Promise<void> {\n const own = await storedRowKeyOf(deps.digests, settings, row);\n const target = own === null || held.has(own) ? null : own;\n if (target !== null) held.add(target);\n if ((row.responseKey ?? null) === target) return;\n await deps.collections.updateEntryInTransaction(\n write.transaction,\n deps.submissionsSlug,\n String(row.id),\n asPluginSubmission(\n { responseKey: target, _overwrite: true },\n { keepAsEvidence: false, setKey: target }\n )\n );\n}\n\n/** The sweep's job, run by `run` until the deadline the runner gives it. */\nexport function rebuildKeysJob(\n run: (deadline: Date) => Promise<void>\n): JobDefinition {\n return defineJob({\n slug: REBUILD_KEYS_JOB,\n sweep: true,\n handler: async (_input, context) => {\n await run(context.deadline);\n },\n });\n}\n\n/** What the sweep reads and writes with. */\nexport interface RebuildDeps {\n collections: PluginContext[\"services\"][\"collections\"];\n digests: () => PluginContext[\"security\"][\"digests\"];\n formsSlug: string;\n submissionsSlug: string;\n}\n\ntype Row = Record<string, unknown>;\n\n/**\n * Give every form whose keys are behind its earlier submissions' keys, until\n * `deadline`. A form is marked up to date only after a pass over all its\n * submissions in which every one took its key, with none to look at again.\n */\nexport async function rebuildResponseKeys(\n deps: RebuildDeps,\n deadline: Date\n): Promise<void> {\n for (let page = 1; ; page++) {\n const { data } = await deps.collections.listEntries(\n deps.formsSlug,\n { pagination: { limit: PAGE, page }, depth: 0 },\n { as: \"system\" }\n );\n const forms = data as Row[];\n for (const form of forms) {\n if (pastDeadline(deadline)) return;\n await bringUpToDate(deps, form, deadline);\n }\n if (forms.length < PAGE) return;\n }\n}\n\n/** One form: its keys made for its current version, if they are behind. */\nasync function bringUpToDate(\n deps: RebuildDeps,\n form: Row,\n deadline: Date\n): Promise<void> {\n const settings = normalizeFormSettings(settingsObjectOf(form.settings) ?? {});\n if (!keysBehind(settings)) return;\n if ((await rebuildForm(deps, form, settings, deadline)) === \"done\") {\n await markKeyed(deps, form, settings.oneResponseVersion ?? 0);\n }\n}\n\nfunction pastDeadline(deadline: Date): boolean {\n return Date.now() >= deadline.getTime();\n}\n\n/**\n * Passes over one form's submissions until one leaves nothing to look at\n * again: done, or `stop` when the deadline passed or the form's version moved\n * on, which leaves the rest to a later sweep. A sweep that resumes passes\n * quickly over the rows an earlier run keyed: each holds its own key, which\n * is recognised without a query.\n */\nasync function rebuildForm(\n deps: RebuildDeps,\n form: Row,\n settings: FormSettings,\n deadline: Date\n): Promise<\"done\" | \"stop\"> {\n // Only one per email and one per network compare what earlier\n // submissions stored.\n if (settings.oneResponse !== \"ip\" && settings.oneResponse !== \"email\") {\n return \"done\";\n }\n for (;;) {\n const pass = await rebuildPass(deps, String(form.id), settings, deadline);\n if (pass !== \"again\") return pass;\n }\n}\n\n/**\n * One pass over a form's non-spam submissions, oldest first. The rows are\n * read first, whole (`snapshotOf`), then keyed in order: a submission deleted\n * or restored while the pass runs cannot move another out of a page it reads.\n * Rows stored after the pass began are left out (`passCutoff`): they were\n * keyed for the current version when they were stored. A row whose write was\n * refused, as edited since or as holding a value taken since, sends the\n * sweep round again.\n */\nasync function rebuildPass(\n deps: RebuildDeps,\n formId: string,\n settings: FormSettings,\n deadline: Date\n): Promise<\"done\" | \"again\" | \"stop\"> {\n const rows = await snapshotOf(\n async where =>\n (\n await deps.collections.listEntries(\n deps.submissionsSlug,\n {\n where,\n sort: { field: \"id\", direction: \"asc\" },\n pagination: { limit: PAGE },\n depth: 0,\n },\n { as: \"system\" }\n )\n ).data,\n {\n form: { equals: formId },\n status: { not_equals: \"spam\" },\n createdAt: { less_than_equal: passCutoff() },\n }\n );\n let retries = 0;\n for (const row of rows) {\n if (pastDeadline(deadline)) return \"stop\";\n const outcome = await keyRow(deps, formId, settings, row);\n if (outcome === \"stale-form\") return \"stop\";\n if (outcome === \"retry\") retries += 1;\n }\n return retries === 0 ? \"done\" : \"again\";\n}\n\n/**\n * Every row `where` matches, oldest first. Read a page at a time by id, each\n * page after the last id read: ids are unique and fixed, so a row deleted or\n * added meanwhile never moves one that is there throughout out of reach, as\n * a page by position would. Then put in the order they were stored.\n */\nasync function snapshotOf(\n page: (where: Record<string, unknown>) => Promise<Row[]>,\n where: Record<string, unknown>\n): Promise<Row[]> {\n const rows: Row[] = [];\n for (let after: string | undefined; ; ) {\n const read = await page(\n after === undefined ? where : { ...where, id: { greater_than: after } }\n );\n rows.push(...read);\n if (read.length < PAGE) break;\n after = String(read[read.length - 1]?.id);\n }\n return rows.sort(storedFirst);\n}\n\n/** Older first, by when each was stored; ties by id, the same every time. */\nfunction storedFirst(a: Row, b: Row): number {\n const at = (row: Row) => new Date(row.createdAt as string | Date).getTime();\n return at(a) - at(b) || (String(a.id) < String(b.id) ? -1 : 1);\n}\n\n/**\n * Give one submission the key of its answer or address, or none where\n * another submission of the form holds that value already.\n */\nasync function keyRow(\n deps: RebuildDeps,\n formId: string,\n settings: FormSettings,\n row: Row\n): Promise<\"unchanged\" | \"written\" | \"retry\" | \"stale-form\"> {\n const target = await targetKeyOf(deps, formId, settings, row);\n if (target === undefined) return \"unchanged\";\n if ((row.responseKey ?? null) === target) return \"unchanged\";\n return writeKey(deps, row, target);\n}\n\n/**\n * The key a row should hold: its own value's, or none (null) where another\n * submission holds that value; undefined when it holds its own already.\n */\nasync function targetKeyOf(\n deps: RebuildDeps,\n formId: string,\n settings: FormSettings,\n row: Row\n): Promise<string | null | undefined> {\n const own = await storedRowKeyOf(deps.digests, settings, row);\n if (own === null) return null;\n // Holding its own value's key already: the holder, with nothing to ask.\n // Most rows of a sweep that resumes are these, so they cost no query.\n if (row.responseKey === own) return undefined;\n return (await heldByAnother(deps, formId, row, own)) ? null : own;\n}\n\n/** Write `key` to a row as the plugin's own key write. */\nasync function writeKey(\n deps: RebuildDeps,\n row: Row,\n key: string | null\n): Promise<\"written\" | \"retry\" | \"stale-form\"> {\n try {\n await deps.collections.updateEntry(\n deps.submissionsSlug,\n String(row.id),\n asPluginSubmission(\n // The revision read: a submission edited since keeps its own key.\n { responseKey: key, _rev: row._rev },\n { keepAsEvidence: false, setKey: key }\n ),\n { as: \"system\" }\n );\n } catch (error) {\n if (heldRefusal(error) === \"changed\") return \"stale-form\";\n // Edited since it was read, or its value taken a moment ago: another\n // pass reads it again.\n const code = codeOf(error);\n if (code === \"STALE_VERSION\" || code === \"DUPLICATE\") return \"retry\";\n throw error;\n }\n return \"written\";\n}\n\nfunction codeOf(error: unknown): unknown {\n return error instanceof NextlyError ? error.code : undefined;\n}\n\n/** Whether a submission of the form other than `row` holds `key`. */\nasync function heldByAnother(\n deps: RebuildDeps,\n formId: string,\n row: Row,\n key: string\n): Promise<boolean> {\n return (\n (await deps.collections.count(\n deps.submissionsSlug,\n {\n where: {\n form: { equals: formId },\n responseKey: { equals: key },\n id: { not_equals: String(row.id) },\n },\n },\n { as: \"system\" }\n )) > 0\n );\n}\n\n/**\n * Record that the form's keys are made for `version`, by a write that names\n * the revision the sweep read: a form saved meanwhile is left for the next\n * sweep to judge.\n */\nasync function markKeyed(\n deps: RebuildDeps,\n form: Row,\n version: number\n): Promise<void> {\n const payload: Row & KeyedFormMark = {\n settings: settingsObjectOf(form.settings) ?? {},\n _rev: form._rev,\n };\n Object.defineProperty(payload, FORM_KEYED, {\n value: { version },\n enumerable: true,\n configurable: true,\n });\n try {\n await deps.collections.updateEntry(\n deps.formsSlug,\n String(form.id),\n payload,\n { as: \"system\" }\n );\n } catch (error) {\n if (codeOf(error) === \"STALE_VERSION\") return;\n throw error;\n }\n}\n","/**\n * Spam Detection\n *\n * Honeypot and rate limiting for form submissions.\n *\n * The honeypot is a pure read of the submitted payload. The rate limit is not:\n * it counts, and where the count lives decides whether the limit means\n * anything. It lives in the deployment's own {@link RateLimitStore}, the one\n * the REST and auth limiters already share, so `rateLimit.store` is configured\n * once and all three agree about what they are counting. A store private to\n * this module would count per process, and on a deployment that spans several\n * the effective limit becomes `configured x instances` -- a number the operator\n * never chose, that fails open, and only under load.\n *\n * @module handlers/spam-detection\n * @since 0.1.0\n */\n\nimport { RateLimiter, resolveRateLimitStore } from \"nextly\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Configuration for spam detection.\n */\nexport interface SpamCheckConfig {\n /**\n * Enable honeypot field detection.\n * When enabled, submissions with filled honeypot fields are marked as spam.\n */\n honeypot?: boolean;\n\n /**\n * Rate limiting configuration.\n */\n rateLimit?: {\n /** Maximum submissions allowed per window */\n maxSubmissions: number;\n /** Time window in milliseconds */\n windowMs: number;\n };\n\n /**\n * Not read here: a form's bot check (reCAPTCHA or Turnstile) is verified\n * with its provider where the submission is stored, from the plugin's\n * `spamProtection` options. Kept so a caller passing it still type-checks.\n *\n * @deprecated Configure bot checks in the plugin's `spamProtection` options.\n */\n recaptcha?: {\n enabled: boolean;\n secretKey?: string;\n scoreThreshold?: number;\n };\n}\n\n/**\n * Options for spam check.\n */\nexport interface SpamCheckOptions {\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Submitter's IP address */\n ipAddress?: string;\n\n /** Form slug for rate limiting key */\n formSlug: string;\n\n /** Spam protection configuration */\n config: SpamCheckConfig;\n}\n\n/**\n * Result of spam check.\n */\nexport interface SpamCheckResult {\n /** Whether the submission is detected as spam */\n isSpam: boolean;\n\n /** Reason for spam detection (for logging, not exposed to user) */\n reason?: \"honeypot\" | \"rate_limit\" | \"recaptcha\" | \"turnstile\";\n\n /** Additional details (for logging) */\n details?: string;\n}\n\n// ============================================================\n// Main Spam Check Function\n// ============================================================\n\n/**\n * Check a form submission for spam indicators.\n *\n * Performs multiple spam checks based on configuration:\n * 1. Honeypot field detection\n * 2. Rate limiting by IP/form\n *\n * A form's bot check (reCAPTCHA or Turnstile) is verified with its provider\n * where the submission is stored (`handlers/bot-check`), not here.\n *\n * @param options - Spam check options\n * @returns Spam check result\n *\n * @example\n * ```typescript\n * const result = await checkSpam({\n * data: formData,\n * ipAddress: req.ip,\n * formSlug: 'contact-form',\n * config: {\n * honeypot: true,\n * rateLimit: { maxSubmissions: 5, windowMs: 60000 },\n * },\n * });\n *\n * if (result.isSpam) {\n * // Silently reject (return fake success to bot)\n * return { success: true };\n * }\n * ```\n */\nexport async function checkSpam(\n options: SpamCheckOptions\n): Promise<SpamCheckResult> {\n const { data, ipAddress, formSlug, config } = options;\n\n // 1. Honeypot check\n if (config.honeypot) {\n const honeypotResult = checkHoneypot(data);\n if (honeypotResult.isSpam) {\n return honeypotResult;\n }\n }\n\n // 2. Rate limiting check\n if (config.rateLimit && ipAddress) {\n const rateLimitResult = await checkRateLimit(\n ipAddress,\n formSlug,\n config.rateLimit\n );\n if (rateLimitResult.isSpam) {\n return rateLimitResult;\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Honeypot Detection\n// ============================================================\n\n/**\n * Standard honeypot field names to check.\n * These fields should be hidden from users but filled by bots.\n */\nconst HONEYPOT_FIELDS = [\n \"__honeypot\",\n \"_honeypot\",\n \"honeypot\",\n \"__hp\",\n \"_hp\",\n \"website\", // Common honeypot field name\n \"url_field\", // Another common honeypot\n \"fax_number\", // Rarely used by humans\n];\n\n/**\n * Check for honeypot field presence.\n *\n * Honeypot fields are hidden form fields that legitimate users\n * won't see or fill, but bots often fill automatically.\n *\n * @param data - Form submission data\n * @returns Spam check result\n */\nfunction checkHoneypot(data: Record<string, unknown>): SpamCheckResult {\n for (const fieldName of HONEYPOT_FIELDS) {\n const value = data[fieldName];\n\n // Check if honeypot field has a non-empty value\n if (value !== undefined && value !== null && value !== \"\") {\n let raw: string;\n if (typeof value === \"object\") {\n raw = JSON.stringify(value);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- value narrowed to primitive above; rule doesn't follow control flow on unknown\n raw = String(value);\n }\n const stringValue = raw.trim();\n if (stringValue.length > 0) {\n return {\n isSpam: true,\n reason: \"honeypot\",\n details: `Honeypot field \"${fieldName}\" was filled`,\n };\n }\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Rate Limiting\n// ============================================================\n\n/**\n * Whether this address has submitted this form too often, recording the attempt.\n *\n * Keyed by form and address together, so a burst against one form does not\n * lock a visitor out of another. The window lives in the deployment's shared\n * store, so two processes serving the same visitor count once.\n *\n * An address of `unknown` is not a bucket to key on: every client whose address\n * could not be resolved would share one window, and the first bot to fill it\n * would lock out every visitor behind an untrusted proxy. The caller decides\n * what to do about an unidentifiable client; this counts only identified ones.\n */\nasync function checkRateLimit(\n ipAddress: string,\n formSlug: string,\n config: { maxSubmissions: number; windowMs: number }\n): Promise<SpamCheckResult> {\n const limiter = new RateLimiter(resolveRateLimitStore());\n const result = await limiter.check(\n `form-submit:${formSlug}:${ipAddress}`,\n config.maxSubmissions,\n config.windowMs\n );\n if (result.allowed) return { isSpam: false };\n return {\n isSpam: true,\n reason: \"rate_limit\",\n details: `Rate limit exceeded: ${config.maxSubmissions} per ${config.windowMs}ms`,\n };\n}\n","/**\n * Form Submission Handler\n *\n * Processes form submissions including validation, spam detection,\n * storage, and notification triggering.\n *\n * @module handlers/submit-form\n * @since 0.1.0\n */\n\nimport { isLocaleSelector } from \"@nextlyhq/plugin-sdk\";\nimport { NextlyError, NO_SUCH_FORM } from \"nextly\";\nimport type { PluginContext } from \"nextly\";\n\nimport { asFormDocument, asSubmissionDocument } from \"../document-shapes\";\nimport type {\n FormDocument,\n SubmissionDocument,\n ResolvedFormBuilderConfig,\n} from \"../types\";\nimport { closedMessageOf, formAvailabilityNow } from \"../utils/form-closing\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\nimport type { ValidationIssue } from \"../utils/generate-schema\";\nimport {\n applyRedirectPattern,\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type RedirectTargetDocument,\n type RedirectUrlPattern,\n} from \"../utils/redirect-target\";\n\nimport { botCheckReady, botTokensOf, judgeBotCheck } from \"./bot-check\";\nimport { heldRefusal, SETTINGS_CHANGED } from \"./form-closing-hold\";\nimport { ALREADY_RESPONDED, browserTokenOf } from \"./one-response\";\nimport { asPluginSubmission, prepareSubmission } from \"./prepare-submission\";\nimport { checkSpam, type SpamCheckResult } from \"./spam-detection\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Options for submitting a form.\n */\nexport interface SubmitFormOptions {\n /** Form slug (URL-friendly identifier) */\n formSlug: string;\n\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Optional metadata about the submission */\n metadata?: {\n /** Submitter's IP address */\n ipAddress?: string;\n /** Submitter's user agent string */\n userAgent?: string;\n };\n\n /**\n * The HTTP request this submission arrived on.\n *\n * A route calling this helper is its own route, outside everything Nextly\n * pins a request around, so nothing else can supply it. Without it the write\n * reaches the submissions hooks looking like a seed or a job, and every rule\n * scoped to a visitor stands down: the rate limit above all.\n *\n * The request rather than an address, because the core resolves the client\n * from it under this deployment's proxy-trust settings. `metadata.ipAddress`\n * is a string the caller chose, which is the right shape for the audit column\n * it fills and the wrong shape for deciding who to throttle.\n */\n request?: Request;\n\n /**\n * Whose permissions the reads and the write are judged against.\n *\n * Defaults to `system`, so every existing caller behaves as before.\n */\n access?: SubmissionAccess;\n\n /**\n * The language the visitor submitted in.\n *\n * Decides which translation a picked redirect page is read in, and so which\n * URL the visitor is sent to: a page whose slug is `thanks` in English and\n * `merci` in French answered `/thanks` to everyone, because the target was\n * read with no locale and the default answered. The built-in route takes it\n * from `?locale=` on the submit URL, the same way core's own routes take a\n * locale on the wire; a host route passes whatever it resolved for the page\n * the form was on.\n *\n * A code that is not a configured locale reads as the default rather than\n * failing the submission, which the collection services decide. Absent means\n * the default, as it always has. The read wildcards (`all`, `*`) are not a\n * language a visitor can have submitted in and are treated as absent: a read\n * in \"every language\" answers a localized URL field with one value per\n * language, which no URL pattern can be filled from.\n */\n locale?: string;\n}\n\n/**\n * How this submission reaches the collections it touches.\n *\n * `system` is the default and is right for host code: the caller already\n * decided, and there is no visitor whose permissions could be consulted.\n *\n * `public` is for a route serving an anonymous visitor. It keeps the\n * collection's configured `access` rules in force, so a host that closed\n * submissions or restricted reading forms is obeyed rather than bypassed. The\n * former core endpoint ran this way, and elevating a public route to `system`\n * silently overrode that configuration.\n */\nexport type SubmissionAccess = { as: \"system\" } | { as: \"public\" };\n\n/**\n * Why a submission ended the way it did.\n *\n * `success` alone cannot carry this. An HTTP route has to answer 404 for a slug\n * nobody used, 409 for a form its author closed and 400 for data that did not\n * validate, and a boolean plus a human sentence cannot tell those apart without\n * matching on the sentence.\n *\n * `accepted` covers every ending a visitor is told succeeded, INCLUDING the two\n * that are deliberately indistinguishable from success: a submission the\n * honeypot flagged, and one the rate limiter refused. A caller must not try to\n * separate them, which is why they do not appear here as their own names.\n */\nexport type SubmitFormOutcome =\n | \"accepted\"\n | \"no-such-form\"\n | \"closed\"\n | \"invalid\"\n | \"duplicate\"\n | \"failed\";\n\n/**\n * Result of form submission.\n */\nexport interface SubmitFormResult {\n /** Whether the submission was successful */\n success: boolean;\n\n /**\n * Which ending this was, for a caller that has to map it to a status code.\n *\n * Required rather than optional, so a return path added later cannot omit it\n * and leave the HTTP route guessing from the message text.\n */\n outcome: SubmitFormOutcome;\n\n /** The created submission document (on success) */\n submission?: SubmissionDocument;\n\n /** Error message (on failure) */\n error?: string;\n\n /** Field-level validation errors */\n validationErrors?: Record<string, string>;\n\n /**\n * The same failures with their machine codes, for a caller building an HTTP\n * error. `validationErrors` above cannot carry one, so a route reading only\n * that has to invent a code and reports every blank answer as `INVALID`.\n */\n validationIssues?: ValidationIssue[];\n\n /** Redirect URL (if form configured for redirect on success) */\n redirect?: string;\n\n /**\n * The error that ended a `failed` submission, kept rather than flattened.\n *\n * A collection can refuse a write for reasons its host chose: a\n * `beforeValidate` hook throwing `NextlyError.validation`, a uniqueness\n * conflict. Those carry their own status and field detail, and reporting them\n * all as one internal error tells a visitor the server broke when in fact\n * their submission was answered deliberately.\n */\n cause?: unknown;\n\n /**\n * The toast the form's author wrote for a successful submission.\n *\n * Carried on the result rather than read from the form again by whoever\n * presents it: the HTTP route would otherwise have to fetch the form a second\n * time purely to learn a string this function already had in hand, and a\n * second read is a second chance to disagree about which form was submitted.\n *\n * Present on every `accepted` ending, including the ones a bot caused, so the\n * message cannot be used to tell an accepted submission from a flagged one.\n */\n successMessage?: string;\n}\n\n/**\n * A submission's spam verdict and its prepared data: the honeypot first,\n * BEFORE validation, so a bot that trips the trap while also failing\n * validation still receives the same fake success as any other bot; then the\n * form's bot check for one that would otherwise be refused as invalid.\n */\nasync function judgeAndPrepare(input: {\n data: Record<string, unknown>;\n form: FormDocument;\n settings: ReturnType<typeof normalizeFormSettings>;\n pluginConfig: ResolvedFormBuilderConfig;\n pluginContext: PluginContext;\n ipAddress: string | undefined;\n formSlug: string;\n /** A request produced the submission, as the write seam judges. */\n requested: boolean;\n}): Promise<{\n spamResult: SpamCheckResult;\n prepared: ReturnType<typeof prepareSubmission>;\n throttled: boolean;\n rateCounted: boolean;\n}> {\n const { data, form, settings, pluginConfig, pluginContext, formSlug } = input;\n const { logger } = pluginContext;\n // 3. Spam detection — BEFORE validation, so a bot that trips the trap\n // while also failing validation still receives the same fake success as\n // any other bot instead of a distinguishable validation error.\n // Honeypot fields are by definition NOT declared form fields, so the\n // probe is the raw payload minus the declared schema — a declared field\n // (e.g. a real \"website\" input) must never trip the trap.\n const declaredFieldNames = new Set(form.fields.map(field => field.name));\n const undeclaredData = Object.fromEntries(\n Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))\n );\n\n // The honeypot only, and only to decide whether to validate. It is a pure\n // read of the payload, so running it here and again at the write seam\n // costs nothing and cannot disagree. The RATE LIMIT is not asked here: it\n // counts, and a check with a side effect run twice counts one submission\n // twice. The seam owns that one, and owns the verdict written to the row.\n let spamResult: SpamCheckResult = await checkSpam({\n data: undeclaredData,\n formSlug,\n config: {\n // Per-form overrides win where set; blank inherits the plugin config.\n honeypot:\n settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,\n },\n });\n\n const isContentSpam = spamResult.isSpam;\n\n // 4. Transform + validate. Content spam skips validation entirely — it\n // is stored for review exactly as the bot shaped it (declared fields\n // only, sanitized), and requiring validity would drop the evidence.\n if (isContentSpam) {\n logger.info?.(\"Spam submission detected — storing flagged\", {\n formSlug,\n reason: spamResult.reason,\n ipAddress: input.ipAddress,\n });\n }\n\n let prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: !isContentSpam,\n });\n\n // An invalid submission meets its form's bot check here, before it is\n // refused, so it is judged as one written any other way: a failing or\n // missing token keeps it as spam evidence rather than answering the bot\n // with validation errors. Checked once: kept as evidence, the seam does\n // not check it again, and a passing token is spent on a refusal.\n let early: EarlyBotCheck = { rateCounted: false };\n if (prepared.validationErrors && !isContentSpam) {\n early = await botCheckBeforeRefusal(input);\n if (early.spam) {\n spamResult = early.spam;\n prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: false,\n });\n }\n }\n\n return {\n spamResult,\n prepared,\n throttled: early.throttled === true,\n rateCounted: early.rateCounted,\n };\n}\n\n/** What the bot check made of an invalid submission, before its refusal. */\ninterface EarlyBotCheck {\n /** The submission's network is over the form's rate limit. */\n throttled?: true;\n /** The token failed or was missing: keep it as spam evidence. */\n spam?: SpamCheckResult;\n /** The rate limit has counted this submission, so the seam must not. */\n rateCounted: boolean;\n}\n\n/**\n * An invalid submission meets its form's bot check here, before it is\n * refused, so it is judged as one written any other way: a failing or\n * missing token keeps it as spam evidence rather than answering the bot with\n * validation errors. Only for a request-made submission to a form whose\n * check is ready, as the write seam judges. The rate limit is asked first,\n * so a throttled network cannot make the provider be called, and is counted\n * once: the seam is told it has been.\n */\nasync function botCheckBeforeRefusal(input: {\n data: Record<string, unknown>;\n form: FormDocument;\n settings: ReturnType<typeof normalizeFormSettings>;\n pluginConfig: ResolvedFormBuilderConfig;\n pluginContext: PluginContext;\n ipAddress: string | undefined;\n requested: boolean;\n}): Promise<EarlyBotCheck> {\n const spam = input.pluginConfig.spamProtection;\n if (!input.requested || !botCheckReady(input.settings, spam)) {\n return { rateCounted: false };\n }\n const rateCounted = Boolean(spam.rateLimit && input.ipAddress);\n if (rateCounted) {\n const limited = await checkSpam({\n data: {},\n ipAddress: input.ipAddress,\n // The key the write seam counts the same form under.\n formSlug: input.form.id,\n config: { rateLimit: spam.rateLimit },\n });\n if (limited.isSpam) return { throttled: true, rateCounted };\n }\n const { logger } = input.pluginContext;\n const bot = await judgeBotCheck({\n settings: input.settings,\n spam,\n tokens: botTokensOf(input.data),\n ip: input.ipAddress,\n fetch: input.pluginContext.fetch,\n warn: (message, warnContext) => logger.warn?.(message, warnContext),\n });\n return bot ? { spam: bot, rateCounted } : { rateCounted };\n}\n\n/**\n * Context for the submission handler.\n */\nexport interface SubmitFormContext {\n /** Plugin context with access to services */\n pluginContext: PluginContext;\n\n /** Resolved plugin configuration */\n pluginConfig: ResolvedFormBuilderConfig;\n}\n\n// ============================================================\n// Main Submission Handler\n// ============================================================\n\n/**\n * Whether a failed store was refused because the person has responded\n * already: the database refused a second one-response key, or the hold found\n * an earlier submission with the same value (`heldRefusal`).\n */\nfunction isRepeatResponse(\n error: unknown,\n held: ReturnType<typeof heldRefusal>\n): boolean {\n return (\n held === \"responded\" ||\n (error instanceof NextlyError &&\n (error as { code?: unknown }).code === \"DUPLICATE\")\n );\n}\n\n/**\n * Process a form submission.\n *\n * This is the main entry point for handling form submissions.\n * It performs the following steps:\n *\n * 1. Fetch form configuration by slug\n * 2. Verify form status (must be \"published\")\n * 3. Transform and validate submission data\n * 4. Check for spam (honeypot, rate limiting)\n * 5. Create submission record in database\n * 6. Return result with redirect info if configured\n *\n * Note: Email notifications are triggered separately via collection\n * hooks in the submissions collection (see Phase 4.2).\n *\n * @param options - Submission options\n * @param context - Handler context with services\n * @returns Submission result\n *\n * @example\n * ```typescript\n * const result = await submitForm(\n * {\n * formSlug: 'contact-form',\n * data: { name: 'John', email: 'john@example.com', message: 'Hello!' },\n * metadata: { ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0...' },\n * },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (result.success) {\n * console.log('Submission created:', result.submission?.id);\n * if (result.redirect) {\n * // Redirect user\n * }\n * } else {\n * console.error('Submission failed:', result.error);\n * }\n * ```\n */\nexport async function submitForm(\n options: SubmitFormOptions,\n context: SubmitFormContext\n): Promise<SubmitFormResult> {\n const { formSlug, data, metadata, request } = options;\n const access: SubmissionAccess = options.access ?? { as: \"system\" };\n const locale = visitorLocale(options.locale);\n const targetRead: TargetRead = { access, locale, request };\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n const { logger } = pluginContext;\n\n try {\n // 1. Fetch form configuration\n const form = await fetchFormBySlug(\n formSlug,\n pluginConfig,\n pluginContext,\n access\n );\n\n if (!form) {\n logger.warn?.(\"Form submission attempted for non-existent form\", {\n formSlug,\n });\n return { success: false, outcome: \"no-such-form\", error: NO_SUCH_FORM };\n }\n\n // The destination is resolved from one place, because two endings ask for\n // it — the accepted submission and the throttled one disguised as accepted\n // — and two call sites naming the inputs separately is how one of them\n // comes to be answered in the wrong language while the other is right.\n const redirectFor = () =>\n resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);\n\n // A throttled submission is answered as an accepted one, so a bot told\n // it was limited does not learn the rate to sit under. Nothing is kept.\n const throttledAnswer = async (): Promise<SubmitFormResult> => {\n logger.info?.(\"Form submission refused (rate limit)\", { formSlug });\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The same destination an accepted submission gets. Returning early\n // without it left a throttled visitor on the form instead of the page\n // its author sends people to, and made the one response this exists to\n // disguise the only accepted one carrying no redirect.\n redirect: await redirectFor(),\n };\n };\n\n // Settings come through the one canonical reader: stored settings may\n // carry legacy keys from earlier builder versions.\n const settings = normalizeFormSettings(form.settings);\n\n // 2. Check form status. The same reading the HTTP and Direct API paths do,\n // so what a visitor is told does not depend on which entry point their\n // client used, then the form's closing time. Its limit is judged where\n // the submission is stored, under the lock that makes it exact.\n const availability = formAvailabilityNow(form, settings, {\n now: new Date(),\n });\n if (availability.kind !== \"open\") {\n logger.info?.(\"Form submission rejected - form not accepting\", {\n formSlug,\n status: form.status,\n availability: availability.kind,\n });\n // `absent` answers exactly as a slug nobody used does. Saying \"not\n // currently accepting submissions\" here would confirm the form exists,\n // which is the enumeration this whole reading exists to prevent — and it\n // would disagree with the REST and Direct API paths, which is how the\n // four came to give four different answers in the first place.\n return {\n success: false,\n outcome: availability.kind === \"closed\" ? \"closed\" : \"no-such-form\",\n error:\n availability.kind === \"closed\" ? availability.message : NO_SUCH_FORM,\n };\n }\n\n // 2c. Single-submission forms: the same visitor (by IP) submits once.\n // Checked before spam so a repeat visitor gets the honest message\n // instead of an inexplicable success that stored nothing new. This is a\n // best-effort deterrent, not a guarantee: IPs are shared (NAT/proxies),\n // and the check-then-create pair is not atomic, so two truly concurrent\n // submissions can both pass — an airtight version needs a partial\n // unique index the portable field config cannot express yet.\n // One per network address is the one-response check this path makes; the\n // other modes are not checked here.\n const onePerAddress = settings.oneResponse === \"ip\";\n if (onePerAddress && !metadata?.ipAddress) {\n // Without an identifier there is nothing to deduplicate on. Failing\n // closed would block every server-to-server integration, so the gate\n // stays open — loudly.\n logger.warn?.(\n \"Single-submission form received a submission without an IP — the restriction cannot apply\",\n { formSlug }\n );\n }\n if (onePerAddress && metadata?.ipAddress) {\n const existing = await collections.count(\n pluginConfig.formSubmissionOverrides.slug,\n {\n where: {\n form: { equals: form.id },\n ipAddress: { equals: metadata.ipAddress },\n status: { not_equals: \"spam\" },\n },\n },\n // Deliberately elevated, unlike the write below. This counts rows the\n // visitor is not entitled to see in order to refuse their second\n // submission; judged as the public it would count zero on any install\n // that hides submissions and the restriction would never apply.\n { as: \"system\" }\n );\n if (existing > 0) {\n logger.info?.(\"Repeat submission rejected (single-submission form)\", {\n formSlug,\n ipAddress: metadata.ipAddress,\n });\n return {\n success: false,\n outcome: \"duplicate\",\n error: ALREADY_RESPONDED,\n };\n }\n }\n\n // 3. Spam detection, then 4. transform and validate (`judgeAndPrepare`).\n const { spamResult, prepared, throttled, rateCounted } =\n await judgeAndPrepare({\n data,\n form,\n settings,\n pluginConfig,\n pluginContext,\n ipAddress: metadata?.ipAddress,\n formSlug,\n requested: request !== undefined,\n });\n if (throttled) return throttledAnswer();\n let isContentSpam = spamResult.isSpam;\n\n if (prepared.validationErrors) {\n logger.debug?.(\"Form validation failed\", {\n formSlug,\n errors: prepared.validationErrors,\n });\n return {\n success: false,\n outcome: \"invalid\",\n error: \"Validation failed\",\n validationErrors: prepared.validationErrors,\n validationIssues: prepared.validationIssues,\n };\n }\n\n const storedData = prepared.data;\n\n // 5. Create submission record. Content spam (honeypot/recaptcha) is\n // stored FLAGGED, never silently dropped: a false positive stays\n // reviewable in the Spam view and recoverable via \"Not spam\".\n const submissionData = {\n form: form.id,\n data: storedData,\n status: isContentSpam ? (\"spam\" as const) : (\"new\" as const),\n spamReason: isContentSpam ? (spamResult.reason ?? null) : null,\n ipAddress: metadata?.ipAddress || null,\n userAgent: metadata?.userAgent || null,\n submittedAt: new Date(),\n };\n\n // Marked as the plugin's own write for the length of the call, so the\n // write-seam hook knows a flagged row is evidence this handler chose to\n // keep rather than a caller asking for validation to be skipped.\n // Marked as this plugin's own write, so the write seam knows a flagged row\n // is evidence this handler chose to keep rather than a caller asking for\n // validation to be skipped. The form goes with it so the seam does not read\n // it a second time.\n const browserToken = browserTokenOf(data);\n let submission: Awaited<ReturnType<typeof collections.createEntry>>;\n try {\n submission = await collections.createEntry(\n pluginConfig.formSubmissionOverrides.slug,\n asPluginSubmission(submissionData, {\n keepAsEvidence: isContentSpam,\n // Its settings too: the seam makes the row's one-response key from\n // them.\n form: { id: form.id, fields: form.fields, settings: form.settings },\n // The bot-check tokens the visitor's form sent, read before the\n // row keeps only the form's fields: the seam verifies them.\n botTokens: botTokensOf(data),\n // And the browser's token, for one response per browser.\n ...(browserToken === undefined ? {} : { browserToken }),\n // Counted already on the way here; the seam does not count it again.\n ...(rateCounted ? { rateCounted } : {}),\n }),\n // Public form submission — create as system. No ambient user; an\n // empty context already resolves to system, but be explicit.\n //\n // The request travels with it so the seam judges this submission on the\n // same facts as one arriving through a Nextly route. Omitted by a caller\n // that has none, and then the seam correctly reads the write as\n // server-side work.\n //\n // `access` rather than a fixed `system`, so a route serving an anonymous\n // visitor is still judged against the collection's own create rule. A\n // host that closed public submissions configured that on the\n // collection, and a write elevated past it reports a 201 for a\n // submission they had refused.\n { ...access, ...(request ? { request } : {}) }\n );\n } catch (error) {\n // The form's closing is held again where the submission is stored, the\n // limit under a lock that makes it exact (`form-closing-hold`): the last place\n // went to a submission stored first, or the closing time passed since\n // the check above, and this one is told what a visitor to a closed\n // form is told.\n const held = heldRefusal(error);\n if (held === \"closed\") {\n logger.info?.(\"Form submission refused (form closed)\", { formSlug });\n return {\n success: false,\n outcome: \"closed\",\n error: closedMessageOf(form),\n };\n }\n if (held === \"changed\") {\n logger.info?.(\"Form submission refused (settings changed)\", {\n formSlug,\n });\n return { success: false, outcome: \"failed\", error: SETTINGS_CHANGED };\n }\n // The database refused a second submission with the same one-response\n // key, or the hold found an earlier one with its value: one with the\n // same email or network was stored first, even if only a moment before.\n if (isRepeatResponse(error, held)) {\n logger.info?.(\"Repeat submission rejected (one response per person)\", {\n formSlug,\n });\n return {\n success: false,\n outcome: \"duplicate\",\n error: ALREADY_RESPONDED,\n };\n }\n // The seam refuses a submission over the limit, and refuses it the same\n // way at every door. This answers its own caller with a success anyway: a\n // browser is on the other end of it, and a bot told it was limited learns\n // the rate to sit under.\n //\n // `POST /api/forms/:slug/submit` reaches this. The route is contributed\n // by this plugin and calls this function, so the refusal a visitor sees\n // is decided here rather than being reconstructed as a 429 by an endpoint\n // that never consulted the seam.\n //\n // Nothing is stored. A limiter that wrote a row per refusal would hand an\n // attacker a way to fill the database with the very volume it exists to\n // refuse.\n if (NextlyError.isRateLimited(error)) return throttledAnswer();\n throw error;\n }\n\n // The stored row decides: the write seam may have flagged it (a failing\n // bot token) after the handler's own checks passed, and a flagged row is\n // answered as spam, its id never handed back.\n if ((submission.item as { status?: unknown }).status === \"spam\") {\n isContentSpam = true;\n }\n\n logger.info?.(\"Form submission created successfully\", {\n formSlug,\n submissionId: submission.item.id,\n flaggedAsSpam: isContentSpam,\n });\n\n // A post-commit hook cannot un-save the submission, so a failure there is\n // logged against the row rather than turned into a failed submission: the\n // notification email is the one that fails this way, and telling the\n // visitor their submission did not go through would have them send it\n // again.\n if (submission.warnings) {\n logger.warn?.(\"Form submission side effects failed\", {\n formSlug,\n submissionId: submission.item.id,\n warnings: submission.warnings,\n });\n }\n\n // Email notifications are sent via the afterCreate hook registered in\n // plugin.ts init(), so they fire for all submission paths (HTTP + direct).\n // The hook itself skips spam-flagged rows.\n\n // 6. Determine redirect URL. Spam gets the same success shape as a real\n // submission (minus the stored row reference) so bots can't diff the two.\n const redirect = await redirectFor();\n\n if (isContentSpam) {\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n redirect,\n };\n }\n\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The created ROW, not the envelope around it. Through the shared\n // conversion, which is where the unchecked step lives and says so; the\n // integration test reads `submission.id` off the result because nothing\n // at compile time will.\n submission: asSubmissionDocument(submission.item),\n redirect,\n };\n } catch (error) {\n logger.error?.(\"Form submission error\", {\n formSlug,\n error: error instanceof Error ? error.message : String(error),\n });\n\n return {\n success: false,\n outcome: \"failed\",\n error: \"An error occurred processing your submission. Please try again.\",\n cause: error,\n };\n }\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Fetch a form by its slug via a service-level `where` query.\n *\n * @param slug - Form slug\n * @param pluginConfig - Plugin configuration\n * @param pluginContext - Plugin context\n * @returns Form document or null if not found\n */\nexport async function fetchFormBySlug(\n slug: string,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n access: SubmissionAccess = { as: \"system\" }\n): Promise<FormDocument | null> {\n const { collections } = pluginContext.services;\n\n // D56: resolve the form by slug via a service-level `where` query instead of\n // fetching every form and filtering client-side.\n //\n // Nothing is caught here. A read that THREW did not establish that the form\n // is absent, and answering `null` made a database outage or a broken schema\n // indistinguishable from a slug nobody has ever used: the caller was told\n // \"no such form\", which is a 404 a client may cache and a person may act on\n // by deleting the link. A failure now propagates and is answered as the\n // server error it is.\n const result = await collections.listEntries(\n pluginConfig.formOverrides.slug,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n access\n );\n\n const form = result.data?.[0];\n return form ? asFormDocument(form) : null;\n}\n\n/**\n * Where a successful submission sends the visitor, or undefined for nowhere.\n *\n * A destination is either TYPED as a URL or PICKED as a document, and the two\n * are answered differently: a typed URL is returned verbatim, since it may\n * legitimately point off-site, while a picked document has to be read and put\n * through its collection's configured URL pattern.\n *\n * Two settings hold a picked document — `redirectPage` under the\n * \"Redirect to Page\" option, and `redirectRelation` under the URL option —\n * and both resolve through the same path, because they ask the same question.\n *\n * Every way of failing to produce a URL is logged. A form whose destination\n * cannot be built behaves exactly like one configured to stay put, so without\n * a line in the log the two are indistinguishable from the outside.\n */\nasync function resolveRedirectUrl(\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const settings = form.settings;\n if (!settings) return undefined;\n\n // A typed URL is returned verbatim: it is the whole point of that option,\n // and it may legitimately point off-site.\n if (settings.confirmationType === \"redirect\" && settings.redirectUrl) {\n return settings.redirectUrl;\n }\n\n // Otherwise whichever key names a document, read through the SAME function\n // the save-time rule uses. Two readers here would let this path redirect to\n // a document the rule never inspected — which it did: the rule matched only\n // `relationship`, so a `redirect`-with-relation form was resolved here and\n // guarded nowhere.\n const field = pickedDocumentField(settings as Record<string, unknown>);\n if (!field) return undefined;\n\n return urlForPickedDocument(\n (settings as Record<string, unknown>)[field],\n form,\n pluginConfig,\n pluginContext,\n read\n );\n}\n\n/**\n * The URL for a picked document, or undefined with a reason in the log.\n *\n * Every branch reports. A form whose destination cannot be built behaves\n * exactly like one configured to stay put, so without a line here the two are\n * indistinguishable from outside — which is how a redirect that never fired\n * went unnoticed.\n */\nasync function urlForPickedDocument(\n stored: unknown,\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const { logger } = pluginContext;\n const patterns = pluginConfig.redirectRelationships;\n const reference = parseRedirectReference(stored, Object.keys(patterns));\n\n if (!reference) {\n logger.warn?.(\"Form redirects to a page, but names no readable document\", {\n form: form.slug,\n });\n return undefined;\n }\n\n const pattern = patterns[reference.collection];\n if (!pattern) {\n logger.warn?.(\n \"Form redirects to a collection that is not in redirectRelationships\",\n { form: form.slug, collection: reference.collection }\n );\n return undefined;\n }\n\n const target = await readTarget(reference, form, pluginContext, read);\n if (!target) return undefined;\n\n // Reachability is re-decided HERE, not inherited from the save. Nothing runs\n // a forms hook when the target itself changes, so a page that was published\n // when the form was saved can be unpublished afterwards and the save-time\n // rule never sees it. Sending a visitor there produces exactly the \"page not\n // found\" that rule exists to prevent, so this degrades to no redirect — the\n // submission is still stored and still succeeds.\n // The target collection's own `localized` setting, resolved at plugin init.\n // Absent means it could not be read, which reports as undecided rather than\n // as \"not localized\" — dropping a redirect on that guess would strand a\n // destination a translation is still serving.\n if (\n documentReachability(\n target,\n pluginConfig.redirectTargetLocalization[reference.collection]\n ) === \"unreachable\"\n ) {\n logger.warn?.(\"Form redirects to a page that has never been published\", {\n form: form.slug,\n collection: reference.collection,\n target: reference.id,\n });\n return undefined;\n }\n\n return buildUrl(pattern, target, reference.collection, form, pluginContext);\n}\n\n/**\n * The pattern applied, or undefined with the reason logged.\n *\n * Separate because a configured pattern may be a FUNCTION — host code, which\n * can throw. Letting that escape would reach `submitForm`'s outer catch AFTER\n * the submission row exists, reporting a failure the caller may retry into a\n * duplicate of a submission that was already saved.\n */\nfunction buildUrl(\n pattern: RedirectUrlPattern,\n target: RedirectTargetDocument,\n collection: string,\n form: FormDocument,\n pluginContext: PluginContext\n): string | undefined {\n const { logger } = pluginContext;\n try {\n const url = applyRedirectPattern(pattern, target);\n if (!url) {\n logger.warn?.(\n \"Form redirect pattern could not be filled from the target document\",\n { form: form.slug, collection }\n );\n }\n return url;\n } catch (error) {\n logger.warn?.(\"Form redirect pattern threw while building a URL\", {\n form: form.slug,\n collection,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n}\n\n/** The target row, or undefined — a deleted target and a failed read differ. */\n/**\n * How the redirect target is read: as whom, in which language, and on which\n * request.\n *\n * As whom is the submission's own `access`, because the destination is a page\n * the VISITOR is about to be sent to, and the visitor's view of it is the one\n * that decides whether it is there. A public submission reads the target as a\n * public caller, which is scoped to published rows and — the part the locale\n * makes matter — to published TRANSLATIONS: a companion row still in draft is\n * withheld and the field falls back to the published default. Read as the\n * system instead, the trusted read admits every draft, and a French slug that\n * nobody has published yet becomes the URL a French visitor is sent to.\n *\n * A host that elevated the submission to `system` keeps the trusted read it\n * asked for, as before this carried a language.\n *\n * The request travels for the same reason the access does: a read that is\n * the visitor's should look like the visitor's to every hook on the target\n * collection. One that selects content by a tenant header, or applies only\n * to a browser, reads `ctx.req.http` — and a read carrying no request runs\n * those hooks as background work and builds a URL the visitor was never\n * meant to get.\n */\ninterface TargetRead {\n access: SubmissionAccess;\n locale: string | undefined;\n request: Request | undefined;\n}\n\n/**\n * The language a visitor submitted in, or nothing.\n *\n * A selector is not one: it asks the collection services for every\n * translation at once, which answers a localized URL field with one value per\n * language — a shape no URL pattern can be filled from — and the plugin\n * boundary refuses it outright, which after the submission is stored degrades\n * to no redirect. Neither is a language a form was filled in, so a selector\n * reads as no language named. Which spellings are selectors is the core's\n * question, asked of the core, so this cannot forward one the boundary has\n * since learned to refuse.\n */\nfunction visitorLocale(locale: string | undefined): string | undefined {\n if (locale === undefined || isLocaleSelector(locale)) return undefined;\n return locale;\n}\n\nasync function readTarget(\n reference: { collection: string; id: string },\n form: FormDocument,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<RedirectTargetDocument | undefined> {\n const { logger } = pluginContext;\n const context = {\n form: form.slug,\n collection: reference.collection,\n id: reference.id,\n };\n\n let row: unknown;\n try {\n // In the visitor's language, so a localized URL field answers with the\n // slug that page has THERE, and as the visitor, so it answers only with a\n // translation they can reach (see {@link TargetRead}). The fallback chain\n // stays in force: a page with no published translation for this locale\n // still has a URL, in the language the site falls back to, and sending\n // the visitor there beats sending them nowhere.\n row = await pluginContext.services.collections.findEntryById(\n reference.collection,\n reference.id,\n { ...read.access, locale: read.locale, request: read.request }\n );\n } catch (error) {\n // The submission already succeeded, so this degrades to \"no redirect\"\n // rather than failing the request — reported, because a broken read and a\n // deleted target are different problems with the same symptom.\n logger.warn?.(\"Form redirect target could not be read\", {\n ...context,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n\n if (!row || typeof row !== \"object\") {\n logger.warn?.(\"Form redirect target no longer exists\", context);\n return undefined;\n }\n return row as RedirectTargetDocument;\n}\n\n// ============================================================\n// Validation-Only Function\n// ============================================================\n\n/**\n * Validate form data without creating a submission.\n *\n * Useful for real-time validation in the frontend.\n *\n * @param formSlug - Form slug\n * @param data - Form data to validate\n * @param context - Handler context\n * @returns Validation result\n *\n * @example\n * ```typescript\n * const result = await validateSubmission(\n * 'contact-form',\n * { name: 'John', email: 'invalid-email' },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (!result.valid) {\n * console.log('Validation errors:', result.errors);\n * }\n * ```\n */\nexport async function validateSubmission(\n formSlug: string,\n data: Record<string, unknown>,\n context: SubmitFormContext\n): Promise<{ valid: boolean; errors?: Record<string, string> }> {\n const { pluginContext, pluginConfig } = context;\n\n // Fetch form\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return { valid: false, errors: { _form: \"Form not found\" } };\n }\n\n // The same rule storage applies, asked rather than restated. Restating it\n // meant this answered on the transformed but unsanitized value while the\n // write seam answers on the sanitized one, so a preflight check could call a\n // submission valid that storage then refused.\n const prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: true,\n });\n\n if (!prepared.validationErrors) {\n return { valid: true };\n }\n\n return {\n valid: false,\n errors: prepared.validationErrors,\n };\n}\n\n// ============================================================\n// Form Status Check\n// ============================================================\n\n/**\n * Check if a form is accepting submissions.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Whether the form accepts submissions\n */\nexport async function isFormAcceptingSubmissions(\n formSlug: string,\n context: SubmitFormContext\n): Promise<boolean> {\n const { pluginContext, pluginConfig } = context;\n\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return false;\n }\n\n return form.status === \"published\";\n}\n\n// ============================================================\n// Bulk Operations (Admin)\n// ============================================================\n\n/**\n * Get submission statistics for a form.\n *\n * Note: This function uses client-side filtering which works for\n * small-medium submission counts. For large-scale deployments,\n * consider using direct database queries with COUNT aggregation.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Submission statistics\n */\nexport async function getFormSubmissionStats(\n formSlug: string,\n context: SubmitFormContext\n): Promise<{\n total: number;\n new: number;\n read: number;\n archived: number;\n} | null> {\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n\n // Get form ID\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n if (!form) {\n return null;\n }\n\n try {\n const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;\n const sys = { as: \"system\" } as const;\n const base = { form: { equals: form.id } };\n\n // D56: count per status server-side instead of listing every\n // submission and counting client-side. Stats run as system (plugin-owned\n // aggregate over submissions for this form).\n const [total, newCount, read, archived] = await Promise.all([\n collections.count(submissionsSlug, { where: base }, sys),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"new\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"read\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"archived\" } } },\n sys\n ),\n ]);\n\n return { total, new: newCount, read, archived };\n } catch {\n return null;\n }\n}\n","/**\n * The public form endpoints, served by the plugin that owns the collections.\n *\n * `GET /api/forms`, `GET /api/forms/:slug` and `POST /api/forms/:slug/submit`\n * were served by the core dispatcher, which read collections named `forms` and\n * `form-submissions` in four hardcoded places. Core defines neither, so those\n * endpoints only ever worked when this plugin was installed, and they broke\n * outright on any install that renamed a collection through the plugin's own\n * overrides. They also validated a submission BEFORE the write seam judged it,\n * so a bot that tripped the honeypot while omitting a required field received a\n * distinguishable validation error and left no evidence behind.\n *\n * Contributed at the root mount, so the addresses are unchanged: a client\n * already calling `/api/forms/contact/submit` keeps calling it. Root routes are\n * consulted only where the built-in router declines, which is why core's\n * parser had to stop claiming `/forms` in the same change rather than a later\n * one. Left in place, it would answer first and these would never be reached.\n *\n * The bodies are the canonical envelopes, built with core's own helpers rather\n * than assembled here. A route that rebuilt the shape by hand would preserve\n * the URL while changing the contract underneath every client already calling\n * it, and would silently drop the post-commit warnings `respondAction` carries.\n *\n * @module routes/public-forms\n */\n\nimport {\n buildPaginatedResponse,\n clampLimit,\n formAvailability,\n NextlyError,\n NO_SUCH_FORM,\n respondAction,\n respondDoc,\n respondList,\n trustedClientIp,\n type FormAvailabilityInput,\n type PaginationMeta,\n type PluginRoute,\n type PluginRouteContext,\n} from \"nextly\";\n\nimport { publicBotCheck } from \"../handlers/bot-check\";\nimport { publicOneResponse } from \"../handlers/one-response\";\nimport { submitForm } from \"../handlers/submit-form\";\nimport type { ResolvedFormBuilderConfig } from \"../types\";\nimport { formAvailabilityNow } from \"../utils/form-closing\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\n\n/**\n * These routes read and write as the anonymous visitor on the other end.\n *\n * `public: true` waives the ROUTE's authentication and says nothing about the\n * collections behind it. Elevating to `system` here would hand a form to any\n * caller on an install whose host set `formOverrides.access.read` to something\n * narrower, and accept a submission on one that closed creation, which is the\n * host's configuration being silently overridden rather than obeyed.\n */\nconst PUBLIC = { as: \"public\" } as const;\n\n/** How many forms a page of the public listing holds when none is asked for. */\nconst DEFAULT_LIST_LIMIT = 100;\n\n/** What a visitor is told when the form's author wrote no message of their own. */\nconst DEFAULT_SUCCESS_MESSAGE = \"Thank you for your submission!\";\n\n/**\n * The collection names this install actually uses.\n *\n * A host may rename either collection through the plugin's overrides, and\n * `ctx.self.collections` maps what the plugin DECLARED to what was registered.\n * Reading the declared name directly is what made the core endpoints fail on a\n * renamed install while the plugin's own export route kept working.\n */\nfunction installedSlugs(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): { forms: string; submissions: string } {\n const declaredForms = config.formOverrides.slug;\n const declaredSubmissions = config.formSubmissionOverrides.slug;\n return {\n forms: self.collections[declaredForms] ?? declaredForms,\n submissions: self.collections[declaredSubmissions] ?? declaredSubmissions,\n };\n}\n\n/**\n * The config `submitForm` should read on THIS install.\n *\n * That helper takes the declared slugs from configuration, which is right for a\n * host calling it directly and wrong for a route, where the registered names\n * are knowable. Handing it a config whose slugs are already resolved keeps one\n * submission path rather than a second one that reads different tables.\n */\nfunction configForInstall(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): ResolvedFormBuilderConfig {\n const slugs = installedSlugs(config, self);\n return {\n ...config,\n formOverrides: { ...config.formOverrides, slug: slugs.forms },\n formSubmissionOverrides: {\n ...config.formSubmissionOverrides,\n slug: slugs.submissions,\n },\n };\n}\n\n/** A positive integer from a query string, or `undefined` when it is not one. */\nfunction positiveInt(raw: string | null): number | undefined {\n if (raw === null) return undefined;\n const value = Number(raw);\n return Number.isInteger(value) && value > 0 ? value : undefined;\n}\n\n/**\n * The service's offset-based pagination, as the wire's page-based meta.\n *\n * The page arithmetic is `buildPaginatedResponse`'s, not a second copy of it.\n * Its edge cases are the ones this endpoint has always answered with and are\n * easy to get subtly wrong alone: an empty collection reports ONE page rather\n * than zero, and a page past the end reports the clamped page rather than the\n * one that was asked for, so `?page=100` on a single-page list cannot answer\n * \"page 100 of 1\".\n */\nfunction toPageMeta(pagination: {\n total: number;\n limit: number;\n offset: number;\n}): PaginationMeta {\n const limit = pagination.limit > 0 ? pagination.limit : DEFAULT_LIST_LIMIT;\n const built = buildPaginatedResponse([], {\n total: pagination.total,\n page: Math.floor(pagination.offset / limit) + 1,\n limit,\n });\n return {\n total: built.totalDocs,\n page: built.page,\n limit: built.limit,\n totalPages: built.totalPages,\n hasNext: built.hasNextPage,\n hasPrev: built.hasPrevPage,\n };\n}\n\nasync function listPublishedForms(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const url = new URL(req.url);\n const slugs = installedSlugs(config, ctx.self);\n\n // Published only. The listing is the one surface anyone can enumerate, so a\n // draft or closed form must not appear in it; the by-slug read below is what\n // explains a closed form, and only to a caller who already holds its slug.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n {\n where: { status: { equals: \"published\" } },\n pagination: {\n // Clamped HERE, with the canonical bound, so the limit that computes\n // the offset is the limit that comes back. The query service clamps to\n // 500 internally and reports the clamped value, while the offset is\n // built from what was asked for, so `?limit=1000&page=2` returned the\n // second 500-row slice and dividing the two reported page 3.\n limit: clampLimit(\n positiveInt(url.searchParams.get(\"limit\")) ?? DEFAULT_LIST_LIMIT\n ),\n page: positiveInt(url.searchParams.get(\"page\")) ?? 1,\n },\n },\n PUBLIC\n );\n\n return respondList(result.data ?? [], toPageMeta(result.pagination));\n}\n\n/**\n * A found form's answer now: its status, then its closing time, then its\n * limit, so a visitor reaching a form that has closed by itself is given its\n * closed message rather than fields to fill in. The count here is a plain\n * read: what is stored at the submission decides exactly, under its lock.\n */\nasync function availabilityNow(\n form: FormAvailabilityInput & { id?: unknown; settings?: unknown },\n ctx: PluginRouteContext,\n slugs: { submissions: string }\n) {\n const settings = normalizeFormSettings(form.settings);\n const formId = typeof form.id === \"string\" ? form.id : undefined;\n const taken =\n settings.submissionLimit === undefined || formId === undefined\n ? undefined\n : await ctx.services.ownCollections.count(slugs.submissions, {\n where: {\n form: { equals: formId },\n status: { not_equals: \"spam\" },\n },\n });\n return formAvailabilityNow(form, settings, { now: new Date(), taken });\n}\n\nasync function readFormBySlug(\n slug: string,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const slugs = installedSlugs(config, ctx.self);\n\n // Fetched by slug alone, then judged. A status filter in the query would\n // answer 404 for a CLOSED form as readily as for one that never existed, and\n // those are different answers: a visitor following a link to a closed form is\n // owed the reason its author wrote.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n PUBLIC\n );\n\n const doc = result.data?.[0] as\n | (FormAvailabilityInput & { id?: unknown; settings?: unknown })\n | undefined;\n const availability = doc\n ? await availabilityNow(doc, ctx, slugs)\n : formAvailability(doc);\n\n if (availability.kind === \"absent\") {\n // The same sentence every other door gives. The canonical \"Not found.\"\n // would make what a visitor is told depend on which client they used.\n throw NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: { entity: \"form\", slug, reason: availability.reason },\n });\n }\n\n if (availability.kind === \"closed\") {\n // Only what a visitor at this address needs. The author wrote the message\n // FOR them; nothing else on the row is theirs, and a closed form renders a\n // sentence rather than fields.\n return respondDoc({\n slug,\n status: \"closed\",\n closedMessage: availability.message,\n });\n }\n\n // What the visitor's form needs to render its bot check: the provider and\n // the site key its widget runs with. The secret never leaves the server.\n const botCheck = publicBotCheck(doc?.settings, config.spamProtection);\n // And, for one response per browser, the answer its token goes in.\n const oneResponse = publicOneResponse(normalizeFormSettings(doc?.settings));\n return respondDoc({\n ...(doc as unknown as Record<string, unknown>),\n ...(botCheck ? { botCheck } : {}),\n ...(oneResponse ? { oneResponse } : {}),\n });\n}\n\n/**\n * The submitted answers, or the refusal for a body that carries none.\n *\n * A malformed body and an absent one are the same mistake to a caller, so both\n * answer the way core did: a validation error naming `data`, rather than the\n * parser's own exception surfacing as a 500.\n */\nasync function readSubmittedData(\n req: Request,\n slug: string\n): Promise<Record<string, unknown>> {\n let parsed: unknown;\n try {\n parsed = await req.json();\n } catch {\n parsed = undefined;\n }\n const body = parsed as { data?: Record<string, unknown> } | undefined;\n\n if (!body?.data || typeof body.data !== \"object\") {\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"MISSING_FIELD\",\n message: \"Request body must contain a 'data' object.\",\n },\n ],\n logContext: { slug },\n });\n }\n return body.data;\n}\n\n/**\n * The refusal an unaccepted outcome owes the caller.\n *\n * One place, so the status a given ending produces is read off a single list\n * rather than inferred from a message. `accepted` never reaches here: it is the\n * one outcome with a body rather than an error, and the caller returns it.\n */\nfunction refusalFor(\n result: Awaited<ReturnType<typeof submitForm>>,\n slug: string\n): NextlyError {\n const entity = { entity: \"form\", slug };\n switch (result.outcome) {\n case \"no-such-form\":\n return NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: entity,\n });\n\n // A state conflict, not a validation failure. `validation` fixes the\n // canonical `error.message` to \"Validation failed.\" and nests the real one\n // under `error.data.errors[0]`, so a client reading the documented envelope\n // would be handed the author's explanation and never show it.\n case \"closed\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? NO_SUCH_FORM,\n logContext: { ...entity, reason: \"closed\" },\n });\n\n case \"duplicate\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? \"You have already submitted this form.\",\n logContext: { ...entity, reason: \"duplicate\" },\n });\n\n // The codes come from the issue list, not from the message map beside it.\n // A client tells \"you left this blank\" from \"this is not an email\" by the\n // code, and reporting every failure as `INVALID` stops that working while\n // looking correct in a status-only test.\n case \"invalid\":\n return NextlyError.validation({\n errors: (result.validationIssues ?? []).map(issue => ({\n path: issue.path,\n code: issue.code,\n message: issue.message,\n })),\n logContext: { slug },\n });\n\n // A typed refusal is re-thrown as itself. The collection may refuse a write\n // for a reason its host chose, and those carry their own status and field\n // detail; answering all of them with one internal error tells a visitor the\n // server broke when their submission was in fact answered deliberately.\n // Anything untyped stays a 500, which is what an unexpected failure is.\n default:\n if (result.cause instanceof NextlyError) return result.cause;\n return NextlyError.internal({\n logContext: { ...entity, reason: \"submission-failed\" },\n });\n }\n}\n\nasync function acceptSubmission(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig,\n slug: string\n): Promise<Response> {\n const data = await readSubmittedData(req, slug);\n\n const result = await submitForm(\n {\n formSlug: slug,\n data,\n // The visitor's language, from `?locale=` on the submit URL — the same\n // place core's own routes read a locale on the wire — so a picked\n // redirect page answers with its URL in that language. A form on a\n // French page posts to `/api/forms/contact/submit?locale=fr` and is\n // sent to `/merci`; one that names no locale is answered in the\n // default, as before. An unconfigured code reads as the default rather\n // than refusing the submission: the visitor filled the form in, and a\n // wrong query string is not a reason to lose what they wrote.\n locale: new URL(req.url).searchParams.get(\"locale\") || undefined,\n // The request itself, so the write seam resolves the client under this\n // deployment's proxy-trust settings and judges this submission on the\n // same facts as one arriving through any other door.\n request: req,\n // The address for the audit column and the single-submission check,\n // resolved by the core rather than read off a header here, which would\n // record whatever the sender chose to claim and let one visitor pass as\n // many.\n metadata: {\n ipAddress: trustedClientIp(req) ?? undefined,\n userAgent: req.headers.get(\"user-agent\") ?? undefined,\n },\n access: PUBLIC,\n },\n {\n pluginContext: ctx,\n pluginConfig: configForInstall(config, ctx.self),\n }\n );\n\n if (result.outcome !== \"accepted\") throw refusalFor(result, slug);\n\n // One envelope for every accepted ending, including the two that are\n // deliberately indistinguishable from a refusal a bot could learn from: a\n // submission the honeypot flagged, and one the rate limiter refused.\n //\n // Both omit `submissionId`, because neither has a row a caller could then ask\n // about. That is a residual difference in the BODY, not in the status, and it\n // is the honest one: the alternatives are to invent an id that addresses\n // nothing, or to stop returning it to the real clients this endpoint has\n // always returned it to.\n return respondAction(\n result.successMessage ?? DEFAULT_SUCCESS_MESSAGE,\n {\n ...(result.submission ? { submissionId: result.submission.id } : {}),\n // Forwarded because this route is the only thing that can act on it. A\n // form configured to redirect on success resolves one here, and core's\n // endpoint dropped it, so the setting did nothing for any client posting\n // over HTTP. Additive, and present on a flagged submission too, so it\n // cannot be diffed against an accepted one.\n ...(result.redirect ? { redirect: result.redirect } : {}),\n },\n { status: 201 }\n );\n}\n\n/**\n * The three public form endpoints, at the addresses core used to serve.\n *\n * `public: true` on all three: a form is filled in by a visitor with no session\n * at all, which is the whole point of the feature.\n */\nexport function publicFormRoutes(\n config: ResolvedFormBuilderConfig\n): PluginRoute[] {\n return [\n {\n method: \"GET\",\n path: \"/forms\",\n mount: \"root\",\n public: true,\n // Collection documents, so their stored timestamps are presented in the\n // installation's timezone exactly as the built-in read did.\n formatTimestamps: true,\n handler: (req, ctx) => listPublishedForms(req, ctx, config),\n },\n {\n method: \"GET\",\n path: \"/forms/:slug\",\n mount: \"root\",\n public: true,\n formatTimestamps: true,\n handler: (_req, ctx) => readFormBySlug(ctx.params.slug, ctx, config),\n },\n {\n method: \"POST\",\n path: \"/forms/:slug/submit\",\n mount: \"root\",\n public: true,\n handler: (req, ctx) =>\n acceptSubmission(req, ctx, config, ctx.params.slug),\n },\n ];\n}\n","/**\n * Form Builder Plugin\n *\n * Visual form builder with drag-and-drop UI, submission management,\n * email notifications, and spam protection.\n *\n * @module plugin\n * @since 0.1.0\n */\n\nimport { createHash } from \"node:crypto\";\nimport { createRequire } from \"node:module\";\n\nimport {\n type AuthUser,\n definePlugin,\n type HookContext,\n NextlyError,\n type PluginDefinition,\n type PluginEmailService,\n} from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig } from \"nextly\";\n// Author against the SDK — the stable, experimental plugin boundary.\n\nimport { formsCollection } from \"./collections/forms\";\nimport { submissionsCollection } from \"./collections/submissions\";\nimport { newSubmissionsWidget } from \"./dashboard/widget\";\nimport {\n asFormDocument,\n asSubmissionDocument,\n asSubmissionDocuments,\n} from \"./document-shapes\";\nimport { submissionAnswers } from \"./emails/answers\";\nimport { formsEmailTemplates } from \"./emails/starter-messages\";\nimport { addressedFormPages, FORM_EDITOR_VIEW } from \"./form-pages\";\nimport {\n botCheckDefaults,\n botCheckHosts,\n botCheckOptions,\n botTokensOf,\n judgeBotCheck,\n warnUncheckedBotChecks,\n} from \"./handlers/bot-check\";\nimport {\n registerFormClosing,\n respondedRefusal,\n} from \"./handlers/form-closing-hold\";\nimport {\n browserTokenOf,\n responseKeyAfterUpdate,\n responseKeyOf,\n touchesResponseKey,\n} from \"./handlers/one-response\";\nimport { refuseOneResponseFieldProblem } from \"./handlers/one-response-field-check\";\nimport {\n oneResponseOptions,\n REBUILD_KEYS_JOB,\n REBUILD_ON_SAVE_MS,\n rebuildInSave,\n type RebuildDeps,\n rebuildAfterSave,\n rebuildKeysJob,\n rebuildResponseKeys,\n stampOneResponseVersion,\n} from \"./handlers/one-response-rebuild\";\nimport {\n sameSubmittedPayload,\n submissionMarks,\n type SubmissionOriginMarks,\n prepareSubmission,\n} from \"./handlers/prepare-submission\";\nimport { checkSpam, type SpamCheckResult } from \"./handlers/spam-detection\";\nimport { publicFormRoutes } from \"./routes/public-forms\";\nimport type {\n AnyFormField,\n BeforeEmailFilterContext,\n FormNotification,\n FormBuilderPluginOptions,\n FormEmailNotification,\n ResolvedFormBuilderConfig,\n} from \"./types\";\nimport { evaluateSingleCondition } from \"./utils/evaluate-conditions\";\nimport { exportToCSV, generateExportFilename } from \"./utils/export-formats\";\nimport { parseFieldRef } from \"./utils/field-references\";\nimport { normalizeFormSettings } from \"./utils/form-settings\";\nimport { normalizeRedirectRelationships } from \"./utils/redirect-target\";\nimport {\n SCHEMA_ONLY_CONTEXT,\n SCHEMA_ONLY_READ,\n} from \"./utils/schema-only-read\";\n\nexport type NextlyPlugin = PluginDefinition;\n\n// Read the version from package.json so it can never drift from the published\n// package. Node/config-side only (this module is not part of the admin bundle).\nconst { version: PLUGIN_VERSION } = createRequire(import.meta.url)(\n \"../package.json\"\n) as { version: string };\n\n/** The runtime instance passed to plugin hooks (type extracted from `init`'s parameter). */\ntype NextlyInstance = Parameters<NonNullable<NextlyPlugin[\"init\"]>>[0];\n\n/** Internal augmentation: we stash the resolved config on the nextly instance for later retrieval. */\ntype NextlyWithFormBuilderConfig = NextlyInstance & {\n __formBuilderConfig?: ResolvedFormBuilderConfig;\n};\n\n// ---------------------------------------------------------------------------\n// Configuration resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Merge user options with sensible defaults.\n */\nfunction resolveConfig(\n options: FormBuilderPluginOptions\n): ResolvedFormBuilderConfig {\n const formOverrides = options.formOverrides || {};\n const submissionOverrides = options.formSubmissionOverrides || {};\n\n return {\n formOverrides: {\n ...formOverrides,\n slug: formOverrides.slug || \"forms\",\n labels: {\n singular: formOverrides.labels?.singular || \"Form\",\n plural: formOverrides.labels?.plural || \"Forms\",\n },\n },\n\n formSubmissionOverrides: {\n ...submissionOverrides,\n slug: submissionOverrides.slug || \"form-submissions\",\n labels: {\n singular: submissionOverrides.labels?.singular || \"Submission\",\n plural: submissionOverrides.labels?.plural || \"Submissions\",\n },\n },\n\n fields: {\n text: options.fields?.text ?? true,\n email: options.fields?.email ?? true,\n number: options.fields?.number ?? true,\n phone: options.fields?.phone ?? true,\n url: options.fields?.url ?? true,\n textarea: options.fields?.textarea ?? true,\n select: options.fields?.select ?? true,\n checkbox: options.fields?.checkbox ?? true,\n radio: options.fields?.radio ?? true,\n file: options.fields?.file ?? true,\n date: options.fields?.date ?? true,\n time: options.fields?.time ?? true,\n hidden: options.fields?.hidden ?? true,\n },\n\n redirectRelationships: normalizeRedirectRelationships(\n options.redirectRelationships\n ),\n // Filled at `init` from each collection's own metadata. Empty until then,\n // and a slug that stays absent reads as unknown rather than as not\n // localized — see `ResolvedFormBuilderConfig`.\n redirectTargetLocalization: {},\n beforeEmail: options.beforeEmail,\n\n notifications: {\n defaultFrom: options.notifications?.defaultFrom,\n defaultToEmail: options.notifications?.defaultToEmail,\n enabled: options.notifications?.enabled ?? true,\n },\n\n oneResponse: oneResponseOptions(options.oneResponse),\n\n spamProtection: {\n honeypot: options.spamProtection?.honeypot ?? true,\n ...botCheckOptions(options.spamProtection),\n rateLimit: {\n maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,\n windowMs: options.spamProtection?.rateLimit?.windowMs ?? 60_000,\n },\n },\n\n uploads: {\n maxFileSize: options.uploads?.maxFileSize ?? 10_485_760, // 10 MB\n allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [\n \"image/*\",\n \"application/pdf\",\n \"text/*\",\n ],\n uploadCollection: options.uploads?.uploadCollection ?? \"media\",\n },\n\n features: {\n builder: options.features?.builder ?? true,\n conditionalLogic: options.features?.conditionalLogic ?? true,\n fileUploads: options.features?.fileUploads ?? true,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport interface FormBuilderPluginResult {\n /** Plugin definition — pass to `defineConfig({ plugins: [...] })`. */\n plugin: NextlyPlugin;\n /** Forms & Submissions collections (also auto-added by the plugin). */\n collections: CollectionConfig[];\n /** Resolved configuration with all defaults applied. */\n config: ResolvedFormBuilderConfig;\n}\n\n/**\n * Create a Form Builder plugin instance.\n *\n * @example Basic usage\n * ```ts\n * import { defineConfig } from \"nextly/config\";\n * import { formBuilder } from \"@nextlyhq/plugin-form-builder\";\n *\n * const fb = formBuilder();\n *\n * export default defineConfig({\n * plugins: [fb.plugin],\n * });\n * ```\n *\n * @example With options\n * ```ts\n * const fb = formBuilder({\n * notifications: { defaultFrom: \"noreply@example.com\" },\n * spamProtection: { honeypot: true },\n * formOverrides: {\n * slug: \"contact-forms\",\n * labels: { singular: \"Contact Form\", plural: \"Contact Forms\" },\n * },\n * });\n * ```\n */\nexport function formBuilder(\n options: FormBuilderPluginOptions = {}\n): FormBuilderPluginResult {\n const resolvedConfig = resolveConfig(options);\n\n const formsCol = formsCollection(resolvedConfig);\n const submissionsCol = submissionsCollection(resolvedConfig);\n\n // The submissions slug the admin links a card's rows to: the declared one\n // until `init` reads the slug the site installed it under.\n let installedSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;\n const newSubmissions = newSubmissionsWidget(\n {\n forms: resolvedConfig.formOverrides.slug,\n submissions: resolvedConfig.formSubmissionOverrides.slug,\n },\n () => installedSubmissionsSlug\n );\n\n // The bot-check providers the plugin verifies tokens with: declared only\n // for a provider whose secret is set, so a site using neither gives the\n // plugin no network access.\n const outbound = botCheckHosts(resolvedConfig.spamProtection);\n\n // What the one-response sweep reads and writes with, once `init` has the\n // live context; until then a pass defers its work to the next.\n let rebuildDeps: RebuildDeps | undefined;\n\n const plugin = definePlugin({\n name: \"@nextlyhq/plugin-form-builder\",\n version: PLUGIN_VERSION,\n nextly: \">=0.0.2-alpha.21\",\n ...(outbound.length > 0 ? { capabilities: { net: { outbound } } } : {}),\n // Identity metadata for the admin plugins page, mirroring package.json.\n author: \"Nextly\",\n homepage: \"https://nextlyhq.com\",\n repository: \"https://github.com/nextlyhq/nextly\",\n license: \"MIT\",\n category: \"forms\",\n tags: [\"forms\", \"submissions\", \"email-notifications\"],\n\n // Declarative schema: the merged pipeline folds these into the\n // app schema — no manual `setup()` append needed. Just register the plugin.\n contributes: {\n collections: [formsCol, submissionsCol],\n // Gives earlier submissions their one-response keys after a form\n // changes how it tells people apart.\n jobs: [\n rebuildKeysJob(async deadline => {\n if (rebuildDeps) await rebuildResponseKeys(rebuildDeps, deadline);\n }),\n ],\n // New form submissions, the one plugin widget core's default dashboard\n // names. Contributed, never suggested: where it goes is core's default's\n // to say, and a site's own default may leave it out.\n dashboard: { widgets: [newSubmissions] },\n // Each page of the form editor (Preview, Settings, Notifications,\n // Submissions) at an address of its own under the form's. Every one\n // draws the form editor itself, so the admin hands the editor the page\n // and keeps it mounted from tab to tab; Builder is the edit address.\n admin: {\n views: {\n [resolvedConfig.formOverrides.slug]: {\n documentViews: addressedFormPages().map(page => ({\n ...page,\n component: FORM_EDITOR_VIEW,\n })),\n },\n },\n },\n // The team's and the visitor's starter emails, so a new notification has\n // a message to send from the start; a site edits them in Settings › Email.\n emailTemplates: formsEmailTemplates(),\n // A visitor submits a form without an account, so the Public role, the\n // role visitors are judged by, may create submissions. Granted once;\n // an owner who takes it away closes public submissions for good.\n publicGrants: [\n {\n collection: resolvedConfig.formSubmissionOverrides.slug,\n action: \"create\",\n },\n ],\n // Custom permission — gates submission export beyond CRUD. The\n // canonical example third-party plugin authors copy.\n permissions: [\n {\n action: \"export\",\n resource: \"submissions\",\n label: \"Export Submissions\",\n description: \"Export form submissions as CSV/JSON\",\n // No `group`: the admin already files this under the plugin that\n // declared it, so naming the plugin again would nest it inside\n // itself. `group` is for a plugin with enough permissions to sort\n // its own into headings, which one is not.\n //\n // `danger` because the point of the permission is to take\n // submissions — names, emails, whatever a form asked for — out of\n // the site in a file.\n danger: true,\n },\n ],\n // HTTP route — exported at\n // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by\n // default: gated by the custom `export-submissions` permission. Reads\n // via the secure-by-default service path as the authed user and\n // resolves its OWN slug through `ctx.self`. The canonical\n // contributes.routes example for third-party authors.\n routes: [\n // The public form endpoints, at the addresses core used to serve\n // before this plugin took them over. Root-mounted, so no caller's URL\n // changes; see `routes/public-forms`.\n ...publicFormRoutes(resolvedConfig),\n {\n method: \"GET\",\n path: \"/submissions/export\",\n requiredPermission: \"export-submissions\",\n handler: async (req, ctx) => {\n const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;\n const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;\n const url = new URL(req.url);\n const format = url.searchParams.get(\"format\") ?? \"json\";\n const formId = url.searchParams.get(\"form\");\n const status = url.searchParams.get(\"status\");\n\n // Spam stays out of exports unless it is explicitly requested —\n // an export is \"what people submitted\", not \"what bots sent\".\n const where: Record<string, unknown> = {};\n if (formId) where.form = { equals: formId };\n if (status) where.status = { equals: status };\n else where.status = { not_equals: \"spam\" };\n\n const opts = { as: \"user\", user: ctx.user ?? undefined } as const;\n\n // CSV needs its form BEFORE any submissions are read: the\n // columns come from one form's fields, and a malformed request\n // must fail fast instead of paginating the whole table first.\n let form: Record<string, unknown> | undefined;\n if (format === \"csv\") {\n if (!formId) {\n return Response.json(\n {\n error: {\n code: \"VALIDATION_ERROR\",\n message: \"CSV export requires a form parameter.\",\n },\n },\n { status: 400 }\n );\n }\n const formsSlugDeclared = resolvedConfig.formOverrides.slug;\n const formsSlug =\n ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;\n const formResult = await ctx.services.collections.listEntries(\n formsSlug,\n { where: { id: { equals: formId } } },\n opts\n );\n form = formResult.data[0];\n if (!form) {\n return Response.json(\n { error: { code: \"NOT_FOUND\", message: \"Form not found.\" } },\n { status: 404 }\n );\n }\n }\n\n // Page through the export with a hard ceiling: an unbounded loop\n // over a high-traffic form could hold the whole table in memory.\n // Hitting the ceiling is reported in a header, never silent.\n const MAX_EXPORT_ROWS = 50_000;\n const items: unknown[] = [];\n const pageSize = 200;\n let truncated = false;\n for (let page = 1; ; page += 1) {\n const result = await ctx.services.collections.listEntries(\n slug,\n { where, pagination: { limit: pageSize, page } },\n opts\n );\n items.push(...result.data);\n const pageWasFull = result.data.length === pageSize;\n if (items.length >= MAX_EXPORT_ROWS) {\n truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;\n items.length = MAX_EXPORT_ROWS;\n break;\n }\n if (!pageWasFull) break;\n }\n const truncationHeaders: Record<string, string> = truncated\n ? { \"X-Export-Truncated\": \"true\" }\n : {};\n\n if (format !== \"csv\" || !form) {\n return Response.json({ items }, { headers: truncationHeaders });\n }\n\n // The service returns parsed entries; the export helpers declare\n // the document shapes — the boundary cast is through unknown.\n const csv = exportToCSV(\n asSubmissionDocuments(items),\n asFormDocument(form)\n );\n const filename = generateExportFilename(\n typeof form.slug === \"string\" ? form.slug : \"form\",\n \"csv\"\n );\n return new Response(csv, {\n headers: {\n \"Content-Type\": \"text/csv; charset=utf-8\",\n \"Content-Disposition\": `attachment; filename=\"${filename}\"`,\n ...truncationHeaders,\n },\n });\n },\n },\n {\n // The builder UI reads the host's resolved field enable/disable map\n // from here, so the plugin option actually gates the type picker.\n // Options resolve server-side only; this is the one channel the\n // admin client has to them.\n method: \"GET\",\n path: \"/builder-config\",\n // Derived from the forms slug like the menu entry: with an\n // overridden slug, users hold read-<slug>, not read-forms.\n requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,\n handler: (_req, ctx) =>\n Promise.resolve(\n Response.json({\n fields: resolvedConfig.fields,\n // Notification defaults the builder surfaces: defaultToEmail\n // seeds a new form's first rule, defaultFrom renders as the\n // inherited sender placeholder.\n notifications: {\n defaultFrom: resolvedConfig.notifications.defaultFrom,\n defaultToEmail: resolvedConfig.notifications.defaultToEmail,\n },\n // Spam defaults the Settings tab surfaces, so per-form\n // override selects can show what \"inherit\" resolves to.\n spamProtection: {\n honeypot: resolvedConfig.spamProtection.honeypot,\n recaptchaEnabled:\n resolvedConfig.spamProtection.recaptcha?.enabled ?? false,\n ...botCheckDefaults(resolvedConfig.spamProtection),\n },\n // Runtime-resolved collection slugs (through ctx.self, so a\n // framework .rename() is honored too) — admin components\n // never hardcode \"forms\"/\"form-submissions\".\n // Names only. A pattern may be a function, which does not\n // survive JSON and which the browser has no use for — the\n // admin picks a document, the server builds the URL.\n redirectCollections: Object.keys(\n resolvedConfig.redirectRelationships\n ),\n slugs: {\n forms:\n ctx.self.collections[resolvedConfig.formOverrides.slug] ??\n resolvedConfig.formOverrides.slug,\n submissions:\n ctx.self.collections[\n resolvedConfig.formSubmissionOverrides.slug\n ] ?? resolvedConfig.formSubmissionOverrides.slug,\n },\n })\n ),\n },\n ],\n },\n\n // Forms is a first-class destination, not a plugin detail: standalone\n // placement gives it its own main-rail icon after Media, and its\n // sub-sidebar lists the plugin's collections (Forms, Submissions) —\n // no separate menu contribution, so \"Forms\" exists exactly once.\n // Hosts preferring the Plugins section override placement in config.\n // (The old admin.settings.component that rendered a second full builder\n // at /admin/plugins/<slug> is gone; the collection Edit-view override\n // is the single FormBuilderView mount.)\n admin: {\n placement: \"standalone\",\n after: \"media\",\n order: 50,\n appearance: { icon: \"FileText\", label: \"Forms\" },\n description: \"Create and manage forms with submission tracking\",\n },\n\n // -- Init ----------------------------------------------------------------\n // Registers an afterCreate hook on submissions to send email notifications.\n async init(nextly: NextlyInstance) {\n // Resolve our OWN submissions slug through ctx.self, so the hook\n // follows a framework `.rename()` as well as our formSubmissionOverrides\n // option. The declared slug is the key; ctx.self maps it to the resolved\n // (possibly renamed) slug. Identity when not renamed.\n const declaredSubmissionsSlug =\n resolvedConfig.formSubmissionOverrides.slug;\n const submissionSlug =\n nextly.self.collections[declaredSubmissionsSlug] ??\n declaredSubmissionsSlug;\n installedSubmissionsSlug = submissionSlug;\n\n // The plugin's digest key, made and read now, at start: a submission's\n // one-response key is taken inside its write's transaction, where a\n // first use is refused.\n await nextly.security.digests.prepare();\n\n // Hook/event subscriptions are idempotent across HMR — the platform\n // clears a plugin's prior subscriptions before re-init (B2), so the old\n // globalThis dedup guard is no longer needed.\n nextly.logger.info(\"Form Builder plugin initialized\", {\n formsCollection: resolvedConfig.formOverrides.slug,\n submissionsCollection: submissionSlug,\n enabledFields: Object.entries(resolvedConfig.fields)\n .filter(([, on]) => on)\n .map(([name]) => name),\n });\n\n // Ask each redirect target collection whether it is localized. The save\n // rule and the submission path both need it and neither can reach the\n // registry themselves: a collection hook is handed `req.nextly`, which\n // is the Direct API and carries no metadata accessor. Resolved once here\n // and written onto the config both of them already close over.\n //\n // Per collection rather than in one call: a slug that cannot be read\n // must stay ABSENT from the map, and a single failing lookup would\n // otherwise take its readable siblings with it.\n // Emptied first. `init` runs again on HMR and re-registration against the\n // SAME config object, so a lookup that succeeded on the previous boot and\n // fails on this one would leave its old answer standing while the catch\n // below believes it left the slug absent. A stale `false` then reads a\n // localized target as unreachable and drops its redirect; a stale `true`\n // lets a plain draft target through.\n for (const slug of Object.keys(\n resolvedConfig.redirectTargetLocalization\n )) {\n delete resolvedConfig.redirectTargetLocalization[slug];\n }\n\n // AWAITED, not fired and forgotten: a write reaching the save rule before\n // this resolves would find the map empty and read every target as\n // undecided, which is the refusal silently not happening.\n await Promise.all(\n Object.keys(resolvedConfig.redirectRelationships).map(\n async declared => {\n const slug = nextly.self.collections[declared] ?? declared;\n try {\n // `getCollection` reads registered metadata and does not consult\n // the context — its parameter is declared for a future ACL hook\n // and is unused today — so an empty context is the honest\n // argument rather than a fabricated user.\n const collection =\n await nextly.services.collections.getCollection(slug, {});\n const localized = (collection as { localized?: unknown })\n ?.localized;\n if (typeof localized === \"boolean\") {\n resolvedConfig.redirectTargetLocalization[declared] = localized;\n }\n } catch {\n // Left absent on purpose: unknown, not \"not localized\".\n nextly.logger.debug?.(\n \"Redirect target localization could not be read\",\n { collection: slug }\n );\n }\n }\n )\n );\n\n (nextly as NextlyWithFormBuilderConfig).__formBuilderConfig =\n resolvedConfig;\n\n // D63: run the user's beforeEmail config as a filter on the form-builder seam.\n if (resolvedConfig.beforeEmail) {\n nextly.filters.add(\n \"form-builder.beforeEmail\",\n (emails: FormEmailNotification[], ctx: BeforeEmailFilterContext) =>\n resolvedConfig.beforeEmail!({\n emails,\n // Boundary: loose runtime documents → the user's typed contract.\n form: asFormDocument(ctx.form),\n submission: asSubmissionDocument(ctx.submission),\n })\n );\n }\n\n // Every submission is brought to the form's own shape here, whatever\n // created it. Registered directly on the registry, like the two below, so\n // it runs for every API surface that writes one: the plugin's HTTP\n // handler, `nextly.forms.submit()`, and an admin creating a row by hand.\n //\n // The Direct API did none of this. A submission arriving that way was\n // stored with whatever keys the caller sent, values that never met the\n // form's schema, and markup intact, while the same submission over HTTP\n // was transformed, validated and sanitized. Putting the rule at the write\n // seam rather than in the second caller is what stops a third one\n // inheriting the gap.\n // The forms slug, resolved the way the submissions one above is. A host\n // that renames a contributed collection takes the declared slug out of\n // the registry, so reading the parent form by `formOverrides.slug` finds\n // nothing, and this hook would then refuse every submission on a renamed\n // install. Resolved once here and used by both readers.\n const declaredFormsSlug = resolvedConfig.formOverrides.slug;\n const formsSlug =\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;\n\n // `beforeChange` rather than `beforeCreate`: it is the last\n // COLLECTION-level mutating phase before the insert. Core runs stored\n // `beforeCreate` hooks after the code-registered ones, so a host hook\n // could put undeclared keys or markup back into a payload a\n // `beforeCreate` check had just passed.\n //\n // Field-level `beforeChange` hooks run after this one and are handed the\n // whole record, so a host that registers one on this collection can still\n // reshape `data` afterwards. No collection phase follows them to register\n // on, and core's own sanitizer sits earlier still, on `beforeCreate`.\n // This is the latest point a plugin can check from, which is not the same\n // as a point nothing can follow.\n nextly.hooks.on(\n \"beforeChange\",\n submissionSlug,\n async (context: unknown) =>\n prepareSubmissionForWrite(context, formsSlug, nextly)\n );\n\n // Register afterCreate hook for email notifications\n nextly.hooks.on(\n \"afterCreate\",\n submissionSlug,\n async (context: unknown) => {\n await handleSubmissionCreated(context, resolvedConfig, nextly);\n }\n );\n\n // Stamp admin edits of submitted data: changing what the visitor\n // submitted must leave a visible trace. Registered directly on the\n // registry (like the notification hook) so it runs for every API\n // surface that updates a submission.\n // Reads the patch as it arrived. Core runs this phase before\n // `beforeChange`, so the payload the write seam adds to a status-only\n // patch is not here yet and cannot be mistaken for an admin's edit.\n nextly.hooks.on(\"beforeUpdate\", submissionSlug, (context: unknown) => {\n const ctx = context as {\n data?: Record<string, unknown>;\n user?: { id?: string };\n };\n if (ctx.data && ctx.data.data !== undefined) {\n ctx.data.editedAt = new Date();\n ctx.data.editedBy = ctx.user?.id ?? null;\n }\n return ctx.data;\n });\n\n // A bot check in use with no secret accepts submissions unchecked:\n // said once at start, naming the option, without holding the start up.\n void warnUncheckedBotChecks({\n spam: resolvedConfig.spamProtection,\n forms: async () =>\n (\n await nextly.services.ownCollections.listEntries(formsSlug, {\n pagination: { limit: 500 },\n })\n ).data ?? [],\n readSettings: normalizeFormSettings,\n warn: (message, context) => nextly.logger.warn?.(message, context),\n });\n\n // A form saved to compare one response per email has an Email field\n // it can compare, however it is saved.\n nextly.hooks.on(\"beforeChange\", formsSlug, (context: unknown) => {\n refuseOneResponseFieldProblem(context);\n return (context as { data?: unknown }).data;\n });\n\n // One response per person after a form changes how it tells people\n // apart: the save moves the form's version on, and once it commits the\n // earlier submissions are given keys for it (`one-response-rebuild`).\n rebuildDeps = {\n collections: nextly.services.collections,\n digests: () => nextly.security.digests,\n formsSlug,\n submissionsSlug: submissionSlug,\n };\n nextly.hooks.on(\"beforeChange\", formsSlug, async (context: unknown) => {\n await stampOneResponseVersion(context, async formId =>\n keysFitInSave(\n nextly,\n submissionSlug,\n formId,\n resolvedConfig.oneResponse.keysInSave\n )\n );\n return (context as { data?: unknown }).data;\n });\n // A form small enough is given its keys inside the save's own\n // transaction; a larger one by the sweep, started once the save commits.\n nextly.hooks.onInTransaction(formsSlug, write =>\n rebuildInSave(\n {\n own: nextly.services.ownCollections,\n collections: nextly.services.collections,\n digests: () => nextly.security.digests,\n submissionsSlug: submissionSlug,\n },\n write\n )\n );\n nextly.hooks.on(\"afterUpdate\", formsSlug, (context: unknown) =>\n rebuildAfterSave(\n context,\n () =>\n nextly.services.jobs.runSweepNow(REBUILD_KEYS_JOB, {\n maxDurationMs: REBUILD_ON_SAVE_MS,\n }),\n (message, context) => nextly.logger.warn?.(message, context)\n )\n );\n\n // Each form's closing time and limit, held inside the transaction that\n // stores a submission, whatever stores it.\n registerFormClosing(nextly, {\n forms: formsSlug,\n submissions: submissionSlug,\n });\n\n // Inject a real submissionCount into form reads (spam excluded — the\n // number answers \"how many people submitted\", not \"how many bots\").\n nextly.hooks.on(\"afterRead\", formsSlug, (context: unknown) =>\n injectSubmissionCount(context, submissionSlug, nextly)\n );\n },\n });\n\n return {\n plugin,\n collections: [formsCol, submissionsCol],\n config: resolvedConfig,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Utilities\n// ---------------------------------------------------------------------------\n\n/**\n * Retrieve the resolved Form Builder config from a Nextly plugin context.\n */\nexport function getFormBuilderConfig(\n nextly: unknown\n): ResolvedFormBuilderConfig | undefined {\n if (!nextly || typeof nextly !== \"object\") return undefined;\n return (nextly as Partial<NextlyWithFormBuilderConfig>).__formBuilderConfig;\n}\n\n/**\n * Resolve a `{{fieldName}}` template reference against submitted values.\n * Plain strings (e.g. email addresses) are returned as-is.\n */\nfunction resolveFieldRef(ref: string, data: Record<string, unknown>): string {\n // The pattern is shared with the admin, which decides whether a value is a\n // reference at all. Written twice, a value the editor accepted and this did\n // not would be delivered with its braces intact.\n const name = parseFieldRef(ref);\n if (name === null) return ref;\n const value = data[name];\n return typeof value === \"string\" ? value : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Attachment collection for file fields\n// ---------------------------------------------------------------------------\n\n/**\n * Scan form fields for file-upload fields marked `attachToEmail` and\n * collect the corresponding mediaIds from the submitted data. Returns\n * an array of `{ mediaId }` objects ready to pass as `attachments` to\n * `emailService.sendWithTemplate()`.\n *\n * Skips empty/missing values silently (optional file fields). Handles\n * both single-value and `multiple: true` file fields.\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function collectAttachmentInputs(\n fields: Array<Record<string, unknown>>,\n submittedData: Record<string, unknown>\n): Array<{ mediaId: string }> {\n const attachments: Array<{ mediaId: string }> = [];\n\n for (const field of fields) {\n if (field.type !== \"file\" || !field.attachToEmail) continue;\n\n const name = field.name as string;\n const value = submittedData[name];\n if (value == null) continue;\n\n if (Array.isArray(value)) {\n for (const item of value) {\n if (typeof item === \"string\" && item) {\n attachments.push({ mediaId: item });\n }\n }\n } else if (typeof value === \"string\" && value) {\n attachments.push({ mediaId: value });\n }\n }\n\n return attachments;\n}\n\n// ---------------------------------------------------------------------------\n// Submission notification handler\n// ---------------------------------------------------------------------------\n\n/**\n * Normalize a JSON column value from a raw DB row into an object. Hook\n * contexts carry the row as stored, so the value may be an already-parsed\n * object (jsonb dialects) or a serialized string (text-storage dialects).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function parseJsonColumn(value: unknown): Record<string, unknown> {\n if (typeof value === \"string\") {\n try {\n const parsed: unknown = JSON.parse(value);\n return parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n }\n return value && typeof value === \"object\" && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {};\n}\n\n/** Trim an address list and drop blanks; undefined when nothing remains. */\nfunction normalizeAddressList(\n addresses: readonly string[] | undefined\n): string[] | undefined {\n if (!Array.isArray(addresses)) return undefined;\n const cleaned = addresses\n .map(address => (typeof address === \"string\" ? address.trim() : \"\"))\n .filter(Boolean);\n return cleaned.length > 0 ? cleaned : undefined;\n}\n\n/** Why a notification rule produced no email for a given submission. */\nexport interface SkippedNotification {\n notificationId: string;\n reason: \"empty-recipient\" | \"condition-unmet\" | \"no-template\";\n}\n\n/**\n * Resolve a form's notification rules against one submission into outgoing\n * email descriptors, honoring everything a rule can configure: the send\n * condition gates the rule, the recipient/reply-to resolve `{{fieldName}}`\n * references, and the sender falls back from the rule's own address to the\n * plugin's `notifications.defaultFrom` (undefined lets the template/provider\n * default apply downstream).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function buildNotificationEmails(input: {\n notifications: readonly FormNotification[];\n submittedData: Record<string, unknown>;\n formName: unknown;\n submissionId: unknown;\n defaultFrom?: string;\n /**\n * The form's fields, which turn the answers into `_submission.answers`, the\n * list the starter messages loop over. Absent, the list is empty and\n * templates that name answers by field (`{{ full_name }}`) still read them\n * as before.\n */\n fields?: readonly AnyFormField[];\n}): { emails: FormEmailNotification[]; skipped: SkippedNotification[] } {\n const { notifications, submittedData, formName, submissionId, defaultFrom } =\n input;\n // Built once: every notification of a submission lists the same answers.\n const answers = submissionAnswers(input.fields ?? [], submittedData);\n\n const seen = new Set<string>();\n const emails: FormEmailNotification[] = [];\n const skipped: SkippedNotification[] = [];\n\n for (const notification of notifications) {\n if (!notification.enabled) continue;\n\n if (!notification.templateSlug) {\n skipped.push({ notificationId: notification.id, reason: \"no-template\" });\n continue;\n }\n\n // Deduplicate (UI can append duplicates on repeated saves). For id-less\n // rules (API-authored), only an exact structural duplicate counts — a\n // to+template key would silently drop distinct rules that share a\n // recipient and template but differ elsewhere (e.g. their condition).\n const key = notification.id || JSON.stringify(notification);\n if (seen.has(key)) continue;\n seen.add(key);\n\n // An unmet send condition skips the rule for this submission — an\n // expected state, never an error.\n if (\n notification.condition &&\n !evaluateSingleCondition(notification.condition, submittedData)\n ) {\n skipped.push({\n notificationId: notification.id,\n reason: \"condition-unmet\",\n });\n continue;\n }\n\n // Trim every address on the way out: whitespace-only values must count\n // as empty, and stray spaces must not reach the provider as headers.\n const to = (\n notification.recipientType === \"field\"\n ? resolveFieldRef(notification.to, submittedData)\n : notification.to\n ).trim();\n\n if (!to) {\n skipped.push({\n notificationId: notification.id,\n reason: \"empty-recipient\",\n });\n continue;\n }\n\n const cc = normalizeAddressList(notification.cc);\n const bcc = normalizeAddressList(notification.bcc);\n\n // Sender resolution: the rule's own address wins, then the plugin's\n // configured default; undefined defers to the template/provider chain.\n const from =\n notification.senderEmail?.trim() || defaultFrom?.trim() || undefined;\n\n // Reply-To resolves {{fieldName}} like recipients do; a reference to a\n // field the visitor left empty degrades to \"no Reply-To header\" rather\n // than an invalid address.\n const replyTo = notification.replyTo\n ? resolveFieldRef(notification.replyTo, submittedData).trim() || undefined\n : undefined;\n\n emails.push({\n to,\n templateSlug: notification.templateSlug,\n // `_submission` holds the answers by label and the id. A field name must\n // start with a letter, so no submitted field can share this name, and\n // every field still reaches templates under its own name as before.\n variables: {\n ...submittedData,\n formName,\n submissionId,\n _submission: { id: submissionId, answers },\n },\n providerId: notification.providerId,\n from,\n replyTo,\n cc,\n bcc,\n notificationId: notification.id,\n });\n }\n\n return { emails, skipped };\n}\n\n/**\n * Send email notifications after a form submission is created.\n */\n/**\n * Bring an incoming submission to the shape its form declares.\n *\n * Runs on `beforeCreate` for the submissions collection, so it sees every write\n * rather than every caller. The cost is one read of the parent form per\n * submission: the HTTP handler has already loaded it and cannot hand it over,\n * because a hook receives the row and not the request. Submissions are written\n * one visitor at a time and the read is by primary key, which is the cheaper\n * side of the trade against a rule two callers have to remember.\n *\n * Refuses rather than storing when it cannot judge. A form it cannot read, or\n * one whose `fields` is not a list, leaves nothing to transform against, and\n * transforming against an empty list would project the submission down to `{}`\n * and store a row that says the visitor sent nothing. Silently emptying someone's\n * submission is worse than declining it.\n *\n * A row that names no form is passed through untouched. `form` is `required` on\n * the collection, so it is already refused a step later, and rejecting it here\n * would answer a question this hook was not asked.\n */\n/**\n * The parent form's id, however the row spells the relationship.\n *\n * A relationship arrives as an id string from a write and as a populated object\n * from a read, and both hooks on this collection have to cope with either.\n * Stated once so they cannot come to disagree about what counts as a reference.\n */\nfunction parentFormId(submission: Record<string, unknown>): string | null {\n const raw = submission.form;\n if (typeof raw === \"string\") return raw;\n if (raw && typeof raw === \"object\") {\n const maybeId = (raw as { id?: unknown }).id;\n if (typeof maybeId === \"string\") return maybeId;\n }\n return null;\n}\n\n/**\n * The submitted payload, as an object, or a refusal.\n *\n * `data` arrives as an object from every caller in this repository. A string is\n * read as the JSON a dialect stores rather than assumed to be one field's\n * value, and anything else is refused: that column is `required`, so an omitted\n * payload is meant to be refused, and substituting `{}` satisfied the check on\n * the way past. A form whose fields are all optional then accepted the empty\n * object and the row was stored.\n */\nfunction submittedPayload(raw: unknown): Record<string, unknown> {\n if (typeof raw === \"string\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // Falls through to the refusal below.\n }\n } else if (raw && typeof raw === \"object\" && !Array.isArray(raw)) {\n return raw as Record<string, unknown>;\n }\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"INVALID\",\n message: \"Submission data must be an object.\",\n },\n ],\n });\n}\n\n/**\n * Whether an update has to be brought back to the form's shape.\n *\n * Two kinds of update do, and one does not.\n *\n * A patch that replaces `data` is replacing what the visitor sent, so it is\n * checked exactly as the create was. Exempting every update meant a caller who\n * may edit a submission could put undeclared keys, values the form's schema\n * rejects and markup into the row a moment after the create had refused to\n * accept them.\n *\n * A patch that touches `form` moves the row under a different schema, and the\n * payload it already holds is what has to satisfy that one. Such a patch\n * carries no `data` of its own, so a rule keyed on `data` alone let a caller\n * move a submission onto any form and leave behind a payload the form it now\n * belongs to rejects.\n *\n * A patch that takes the row out of spam is the third. An evidence row was\n * stored WITHOUT being validated, deliberately, so that a false positive stays\n * reviewable. The admin's \"Not spam\" action sends only a status, and letting it\n * through unchecked turned a payload the form rejects into an ordinary counted\n * submission. Leaving spam is the moment that payload has to satisfy the form,\n * and a row that cannot has to be corrected in the same update.\n *\n * An update that does none of these leaves nothing to check: an admin changing\n * a status between two non-spam values sends only that, and preparing an absent\n * payload would store an empty submission over a real one. The last two are\n * left alone for the same reason when the stored row did not reach this hook,\n * since there is then no payload to judge.\n */\nfunction updateNeedsChecking(\n submission: Record<string, unknown>,\n stored: Record<string, unknown> | undefined\n): boolean {\n if (submission.data !== undefined) return true;\n // Everything below judges the STORED payload, so there has to be one.\n if (stored?.data === undefined) return false;\n if (submission.form !== undefined) return true;\n return (\n stored.status === \"spam\" &&\n submission.status !== undefined &&\n submission.status !== \"spam\"\n );\n}\n\n/**\n * Whether a form has few enough non-spam submissions for a change of its\n * one-response setting to give them keys inside the save.\n */\nasync function keysFitInSave(\n nextly: NextlyInstance,\n submissionsSlug: string,\n formId: string,\n limit: number\n): Promise<boolean> {\n const count = await nextly.services.collections.count(\n submissionsSlug,\n { where: { form: { equals: formId }, status: { not_equals: \"spam\" } } },\n // Elevated: every submission counts, whoever is saving the form.\n { as: \"system\" }\n );\n return count <= limit;\n}\n\n/**\n * On a form that keeps no addresses, compare a keyed submission's address\n * with the earlier submissions' before it is dropped: the hold compares\n * stored addresses, and this one will have none. An earlier submission with\n * the address but without a key for the current version (one stored before\n * the form stopped keeping addresses, while its keys are made) would\n * otherwise let the network respond again. Read outside the form's lock,\n * which is enough: the earlier submissions it guards against are not being\n * written, and new ones race on their keys, which the unique index decides.\n */\nasync function refuseEarlierAddress(\n settings: { oneResponse?: string; storeIpAndBrowser?: boolean },\n ctx: { collection?: string; data?: Record<string, unknown> },\n formId: string,\n nextly: NextlyInstance\n): Promise<void> {\n const row = ctx.data;\n if (settings.storeIpAndBrowser !== false || settings.oneResponse !== \"ip\") {\n return;\n }\n if (typeof row?.ipAddress !== \"string\" || !row.responseKey) return;\n const earlier = await nextly.services.collections.count(\n ctx.collection ?? \"form-submissions\",\n {\n where: {\n form: { equals: formId },\n ipAddress: { equals: row.ipAddress },\n status: { not_equals: \"spam\" },\n },\n },\n // Elevated: the earlier submission counts whoever may read it.\n { as: \"system\" }\n );\n if (earlier > 0) throw respondedRefusal(formId);\n}\n\n/**\n * A form that keeps no IP address or browser (Settings › Privacy) stores\n * neither with a new submission. Cleared after the one-response key is made,\n * so one per network still compares the address, by its keyed digest only.\n */\nfunction withholdAddress(\n settings: { storeIpAndBrowser?: boolean },\n row: Record<string, unknown>\n): void {\n if (settings.storeIpAndBrowser !== false) return;\n row.ipAddress = null;\n row.userAgent = null;\n}\n\n/**\n * A request's address as the server judged it, never one the caller posted:\n * the collection grants public create, and a posted address would choose the\n * network one response per person compares. A create no request produced\n * (an import) keeps the address it was given.\n */\nfunction stampRequestAddress(\n ctx: { operation?: string; req?: { http?: { ip: string | null } } },\n row: Record<string, unknown>\n): void {\n if (ctx.operation === \"create\" && ctx.req?.http) {\n row.ipAddress = ctx.req.http.ip ?? null;\n }\n}\n\n/**\n * The plugin's own write of a one-response key it made (`setKey`): the\n * key is written and nothing else is done to the patch. Whether `row` was one.\n */\nfunction pluginKeyWrite(\n operation: string | undefined,\n row: Record<string, unknown>,\n stored: Record<string, unknown> | undefined\n): boolean {\n const key = submissionMarks(row)?.setKey;\n if (operation !== \"update\" || key === undefined) return false;\n // Spam holds no key, whatever was decided before it was marked.\n row.responseKey = stored?.status === \"spam\" ? null : key;\n return true;\n}\n\nexport async function prepareSubmissionForWrite(\n context: unknown,\n formsSlug: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | undefined> {\n const ctx = context as {\n data?: Record<string, unknown>;\n operation?: string;\n originalData?: Record<string, unknown>;\n collection?: string;\n user?: { id?: string };\n req?: { http?: { ip: string | null; method: string } };\n };\n const submission = ctx.data;\n if (!submission || typeof submission !== \"object\") return ctx.data;\n if (pluginKeyWrite(ctx.operation, submission, ctx.originalData)) {\n return submission;\n }\n // A submission's one-response key is the plugin's to write, never a\n // caller's: one posting a key could take a value's place before its owner.\n delete submission.responseKey;\n const stored = ctx.originalData;\n\n // A patch need not repeat the relationship, so the stored row is what says\n // which form this payload has to match.\n const formId =\n parentFormId(submission) ?? (stored ? parentFormId(stored) : null);\n if (!formId) return ctx.data;\n\n if (ctx.operation !== \"create\" && !updateNeedsChecking(submission, stored)) {\n // A status change still moves the key: spam gives it back.\n await rekeyAfterUpdate(ctx, formsSlug, formId, nextly);\n return ctx.data;\n }\n\n const incoming = submittedPayload(\n submission.data !== undefined ? submission.data : stored?.data\n );\n\n // Read off the row itself, so it describes this write and no other.\n const marks = submissionMarks(submission);\n\n const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);\n const fields = form.fields as AnyFormField[];\n\n // Spam is judged HERE, at the seam every door passes through, rather than in\n // the route: the submissions collection grants public create, so the generic\n // collection create is a second public door and the Direct API a third. A\n // rule that lives in one of them guards one of them.\n //\n // Only when a request produced this write. `ctx.req.http` is absent for a\n // seed, an import or a job, and a rule aimed at a visitor must not judge a\n // server that is importing ten thousand rows as one.\n const spam =\n ctx.operation === \"create\" && ctx.req?.http\n ? await judgeSubmission(incoming, form, ctx.req.http, nextly, marks)\n : undefined;\n\n if (spam?.reason === \"rate_limit\") {\n // Refused, not stored: a limiter that wrote a row per refusal would turn\n // volume into a database it fills for the attacker. The plugin's own route\n // answers its visitor with a success anyway, so a bot learns nothing from\n // it; the other doors are machine-facing and get the honest 429.\n throw NextlyError.rateLimited();\n }\n\n // Content spam keeps its evidence. The handler stores a honeypot or reCAPTCHA\n // hit flagged rather than dropping it, so a false positive stays recoverable,\n // and requiring it to be valid would throw away the thing being reviewed. It\n // is still transformed and sanitized.\n // Leniency is a fact about the CALL, not about the row. Reading it off\n // `status` made it caller-controlled: the collection grants public create and\n // nothing restricts that field, so anyone could post `status: \"spam\"` and\n // switch validation off for their own row.\n const prepared = prepareSubmission({\n data: incoming,\n fields,\n validate: marks?.keepAsEvidence !== true && spam === undefined,\n });\n\n if (prepared.validationErrors) {\n throw NextlyError.validation({\n errors: Object.entries(prepared.validationErrors).map(\n ([path, message]) => ({ path, code: \"INVALID\", message })\n ),\n });\n }\n\n ctx.data = {\n ...submission,\n data: prepared.data,\n ...startingStatus(ctx, marks),\n };\n if (spam) {\n // Flagged, never dropped: a false positive stays reviewable in the Spam\n // view and recoverable through \"Not spam\". Written here rather than trusted\n // from the payload, because the collection grants public create and nothing\n // restricts these fields, so a caller could otherwise mark its own row.\n ctx.data.status = \"spam\";\n ctx.data.spamReason = spam.reason ?? null;\n }\n if (ctx.operation !== \"create\" && submission.data === undefined) {\n stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);\n }\n stampRequestAddress(ctx, ctx.data);\n if (ctx.operation === \"create\") {\n // Made from what is stored, after the spam verdict: spam takes no key.\n // The browser token is the one answer the row does not keep, so it comes\n // from the handler that read it, or the payload at every other door.\n const settings = normalizeFormSettings(form.settings);\n const browserToken = marks?.browserToken ?? browserTokenOf(incoming);\n ctx.data.responseKey = await responseKeyOf(\n () => nextly.security.digests,\n settings,\n { ...ctx.data, browserToken }\n );\n warnTokenless(settings, browserToken, ctx, formId, nextly);\n await refuseEarlierAddress(settings, ctx, formId, nextly);\n withholdAddress(settings, ctx.data);\n } else {\n await rekeyAfterUpdate(ctx, formsSlug, formId, nextly, form);\n }\n return ctx.data;\n}\n\n/**\n * Say so when a visitor's submission reaches a form taking one response per\n * browser without the browser's token: it is accepted, as a browser that\n * cleared its data is, and nothing stops that visitor sending another. Left\n * silent, a site whose form script does not send the token would never learn\n * the setting has no effect.\n */\nfunction warnTokenless(\n settings: { oneResponse?: string },\n browserToken: string | undefined,\n ctx: { req?: { http?: unknown }; data?: Record<string, unknown> },\n formId: string,\n nextly: NextlyInstance\n): void {\n if (settings.oneResponse !== \"browser\" || browserToken !== undefined) return;\n if (!ctx.req?.http || ctx.data?.status === \"spam\") return;\n nextly.logger.warn?.(\n \"A submission to a form taking one response per browser came without the browser's token, so it is accepted without a check. The form's script sends it as the `nextly-browser-token` answer.\",\n { formId }\n );\n}\n\n/**\n * Move an updated submission's one-response key with what the update changes\n * (`responseKeyAfterUpdate`): spam gives it back, a restored or edited\n * submission takes the key of what it now holds. An update that touches none\n * of status, answers or address leaves it as stored.\n */\nasync function rekeyAfterUpdate(\n ctx: {\n data?: Record<string, unknown>;\n originalData?: Record<string, unknown>;\n collection?: string;\n user?: { id?: string };\n },\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance,\n form?: Record<string, unknown>\n): Promise<void> {\n const row = ctx.data;\n if (!row || !touchesResponseKey(row)) return;\n const parent =\n form ?? (await formToCheckAgainst(undefined, formsSlug, formId, nextly));\n const stored = ctx.originalData;\n const movedFrom = stored ? parentFormId(stored) : null;\n row.responseKey = await responseKeyAfterUpdate({\n moved: movedFrom !== null && movedFrom !== formId,\n digests: () => nextly.security.digests,\n settings: normalizeFormSettings(parent.settings),\n patch: row,\n stored,\n heldElsewhere: async key =>\n (await nextly.services.collections.count(\n ctx.collection ?? \"form-submissions\",\n {\n where: {\n form: { equals: formId },\n responseKey: { equals: key },\n ...(stored?.id ? { id: { not_equals: stored.id } } : {}),\n },\n },\n // Elevated: the value is held whoever may read the holder.\n { as: \"system\" }\n )) > 0,\n });\n await refuseTakenAddress(ctx, formId, nextly, parent);\n}\n\n/**\n * Refuse an edit that moves a submission onto an email address another\n * submission of the form already holds, saying which one only to a person who\n * may read it. The holder is found as the system, since the address is taken\n * whoever may see it; whether to name it is the editor's own read, judged by\n * the access gate. Anyone else is told only that the address is used, so the\n * refusal never shows a submission they could not open. The unique index\n * still refuses a holder that commits after this look, without the name.\n */\nasync function refuseTakenAddress(\n ctx: {\n data?: Record<string, unknown>;\n originalData?: Record<string, unknown>;\n collection?: string;\n user?: { id?: string };\n },\n formId: string,\n nextly: NextlyInstance,\n form: Record<string, unknown>\n): Promise<void> {\n const key = ctx.data?.responseKey;\n const stored = ctx.originalData;\n if (typeof key !== \"string\" || key === stored?.responseKey) return;\n if (normalizeFormSettings(form.settings).oneResponse !== \"email\") return;\n const collection = ctx.collection ?? \"form-submissions\";\n const { data: holders } = await nextly.services.collections.listEntries(\n collection,\n {\n where: {\n form: { equals: formId },\n responseKey: { equals: key },\n ...(stored?.id ? { id: { not_equals: stored.id } } : {}),\n },\n pagination: { limit: 1 },\n },\n { as: \"system\" }\n );\n const id = (holders?.[0] as { id?: unknown } | undefined)?.id;\n if (typeof id !== \"string\" && typeof id !== \"number\") return;\n const holderId = String(id);\n const named = await mayRead(nextly, collection, holderId, ctx.user);\n throw NextlyError.duplicate({\n message: named\n ? `This email address is already used by submission ${holderId} of this form.`\n : \"This email address is already used by another submission of this form.\",\n logContext: { formId, holderId },\n });\n}\n\n/** Whether a hook's user is a signed-in person the access gate can judge. */\nfunction isAuthUser(user: unknown): user is AuthUser {\n if (!user || typeof user !== \"object\") return false;\n const { id, email } = user as Record<string, unknown>;\n return typeof id === \"string\" && typeof email === \"string\";\n}\n\n/** Whether `user` may read the submission `id`, asked of the access gate. */\nasync function mayRead(\n nextly: NextlyInstance,\n collection: string,\n id: string,\n user: unknown\n): Promise<boolean> {\n if (!isAuthUser(user)) return false;\n try {\n const count = await nextly.services.collections.count(\n collection,\n { where: { id: { equals: id } } },\n { as: \"user\", user }\n );\n return count > 0;\n } catch {\n // Refused by the gate: this person may not read it.\n return false;\n }\n}\n\n/**\n * The status a submission a request created starts with: `new`, whatever the\n * request named, before the spam verdict may make it `spam`. The collection\n * grants public create and nothing restricts `status`, so a visitor posting\n * `read` or `archived` would otherwise file their submission where nobody\n * looks for one that was never opened.\n *\n * A create no request produced (an import, a seed, a job) keeps the status it\n * names, and so does this plugin's own handler storing content spam as\n * evidence (`keepAsEvidence`), which chose that status itself. An update is\n * left as it is.\n */\nfunction startingStatus(\n ctx: { operation?: string; req?: { http?: unknown } },\n marks: SubmissionOriginMarks | undefined\n): { status?: \"new\" } {\n const requestCreate = ctx.operation === \"create\" && ctx.req?.http;\n return requestCreate && marks?.keepAsEvidence !== true\n ? { status: \"new\" }\n : {};\n}\n\n/**\n * The fields a submission has to satisfy.\n *\n * The handler that already read this form hands it over rather than have the\n * write read it a second time. That read is not free: `findEntryById` runs the\n * forms collection's `afterRead` hooks, and this plugin registers one that\n * COUNTs the form's submissions, so a write was paying for a count of every\n * write before it. Only ever the form this row names, because the id has to\n * match.\n */\nasync function formToCheckAgainst(\n marks: SubmissionOriginMarks | undefined,\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown>> {\n const handedOver = marks?.form?.id === formId ? marks.form : null;\n const form = handedOver ?? (await fetchParentForm(formsSlug, formId, nextly));\n if (!form || !Array.isArray(form.fields)) {\n throw NextlyError.validation({\n errors: [\n {\n path: \"form\",\n code: \"INVALID\",\n message:\n \"The form this submission belongs to could not be read, so the submission could not be checked against it.\",\n },\n ],\n });\n }\n return form;\n}\n\n/**\n * The part of a rate-limit key that names the form.\n *\n * The ID, never the slug. A door that hands the seam the form it already read\n * carries `{ id, fields }`, and a door that makes the seam fetch it carries the\n * whole row: keyed on whatever each happens to have, one form gets one window\n * per door and a caller alternating between two doors spends two budgets.\n *\n * Only a string identifies a form. A row whose id is unusable is limited as its\n * own bucket rather than silently joining a neighbour's.\n */\nfunction rateLimitKeyFor(form: Record<string, unknown>): string {\n const { id } = form;\n return typeof id === \"string\" && id.length > 0 ? id : \"unidentified-form\";\n}\n\n/**\n * Whether this submission looks like a bot's, and why.\n *\n * `undefined` means nothing was detected, which is not the same as \"no rule\n * ran\": a form that disables the honeypot and a deployment that configures no\n * rate limit both answer that way, and both mean the submission is stored as an\n * ordinary one.\n *\n * The address comes from the core, resolved under the deployment's proxy-trust\n * settings, so it is the closest untrusted hop rather than whatever the sender\n * put in `x-forwarded-for`. `null` means no address could be trusted, and the\n * rate limit does not run: keying it on a placeholder would put every\n * unidentifiable client in one window, where the first bot to fill it locks out\n * every visitor behind an untrusted proxy. The honeypot still applies, because\n * it reads the payload rather than the caller.\n */\nasync function judgeSubmission(\n payload: Record<string, unknown>,\n form: Record<string, unknown>,\n http: { ip: string | null; method: string },\n nextly: NextlyInstance,\n marks: SubmissionOriginMarks | undefined\n): Promise<SpamCheckResult | undefined> {\n const config = getFormBuilderConfig(nextly);\n if (!config) return undefined;\n\n // The trap is set among the keys the form does NOT declare. Several honeypot\n // names are ones a real form might use -- `website`, `url_field` -- so\n // probing the whole payload would flag every submission to a form that\n // declares one, and flag it as bot traffic.\n const declared = new Set(\n (Array.isArray(form.fields) ? form.fields : [])\n .map(field => (field as { name?: unknown }).name)\n .filter((name): name is string => typeof name === \"string\")\n );\n const undeclared = Object.fromEntries(\n Object.entries(payload).filter(([key]) => !declared.has(key))\n );\n\n const settings = (form.settings ?? {}) as { honeypotEnabled?: boolean };\n const verdict = await checkSpam({\n data: undeclared,\n ipAddress: http.ip ?? undefined,\n formSlug: rateLimitKeyFor(form),\n config: {\n honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,\n // Counted once: not again when the handler counted it already.\n rateLimit:\n marks?.rateCounted === true\n ? undefined\n : config.spamProtection.rateLimit,\n },\n });\n if (verdict.isSpam) return verdict;\n // A submission the handler already flagged is not checked again: it is\n // spam whatever its token says, and a token is spent once verified.\n if (marks?.keepAsEvidence === true) return undefined;\n return judgeBotCheck({\n settings: normalizeFormSettings(form.settings),\n spam: config.spamProtection,\n // Handed over by the plugin's own handler, which keeps only the form's\n // fields in the row; read off the payload at every other door.\n tokens: marks?.botTokens ?? botTokensOf(payload),\n ip: http.ip ?? undefined,\n fetch: nextly.fetch,\n warn: (message, context) => nextly.logger.warn?.(message, context),\n });\n}\n\n/**\n * Record a payload change this hook derived, since nothing else will.\n *\n * A patch that did not carry a payload but changed one is still an edit of what\n * the visitor sent: moving a submission to another form re-projects its answers\n * onto that form's fields and can drop one it does not declare. The stamp on\n * `beforeUpdate` has already run by then and only marks a patch that arrived\n * with `data`, so without this the stored answers change with nothing in the\n * row saying who changed them.\n */\nfunction stampDerivedEdit(\n row: Record<string, unknown>,\n user: { id?: string } | undefined,\n before: Record<string, unknown>,\n after: Record<string, unknown>\n): void {\n if (sameSubmittedPayload(before, after)) return;\n row.editedAt = new Date();\n row.editedBy = user?.id ?? null;\n}\n\nasync function handleSubmissionCreated(\n context: unknown,\n config: ResolvedFormBuilderConfig,\n nextly: NextlyInstance\n): Promise<void> {\n // The plugin-level kill switch: `notifications.enabled: false` turns off\n // all form emails regardless of per-rule state.\n if (!config.notifications.enabled) return;\n\n const submission = (context as { data?: Record<string, unknown> }).data;\n if (!submission) return;\n\n // Spam is stored for review, but nobody gets emailed about it — otherwise\n // every bot hit would trigger the form's notification rules.\n if (submission.status === \"spam\") return;\n\n const formId = parentFormId(submission);\n if (!formId) return;\n\n // Fetch the parent form\n const declaredFormsSlug = config.formOverrides.slug;\n const form = await fetchParentForm(\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,\n formId,\n nextly\n );\n if (!form) return;\n\n const notifications = Array.isArray(form.notifications)\n ? (form.notifications as FormNotification[])\n : [];\n if (notifications.length === 0) return;\n\n const emailService = nextly.services.email;\n if (!emailService) return;\n\n // The afterCreate hook receives the raw DB row, so on dialects that store\n // JSON columns as text (e.g. SQLite) `data` arrives serialized. Without\n // parsing it, {{field}} recipients, reply-to references, and send\n // conditions all silently see an empty submission.\n const submittedData = parseJsonColumn(submission.data);\n\n // Collect mediaIds from file fields marked for email attachment\n const formFields = Array.isArray(form.fields)\n ? (form.fields as Array<Record<string, unknown>>)\n : [];\n const fileAttachments = collectAttachmentInputs(formFields, submittedData);\n\n // -- Build phase: resolve each enabled notification into an outgoing\n // descriptor (the value the D63 seam transforms).\n const { emails, skipped } = buildNotificationEmails({\n notifications,\n submittedData,\n formName: form.name,\n submissionId: submission.id,\n defaultFrom: config.notifications.defaultFrom,\n fields: Array.isArray(form.fields) ? (form.fields as AnyFormField[]) : [],\n });\n\n for (const skip of skipped) {\n if (skip.reason === \"empty-recipient\") {\n nextly.logger.warn?.(\n \"Form Builder: empty recipient, skipping notification\",\n { notificationId: skip.notificationId, formSlug: form.slug }\n );\n } else {\n // Unmet conditions and missing templates are expected states, not\n // faults — surface them only at debug level.\n nextly.logger.debug?.(\"Form Builder: notification skipped\", {\n notificationId: skip.notificationId,\n formSlug: form.slug,\n reason: skip.reason,\n });\n }\n }\n\n if (emails.length === 0) return;\n\n // -- Seam: thread the outgoing notifications through the D63 filter so user\n // config (and any other registered handler) can modify/filter them.\n const finalEmails = await nextly.filters.apply<\n FormEmailNotification[],\n BeforeEmailFilterContext\n >(\"form-builder.beforeEmail\", emails, { form, submission });\n\n // -- Send phase: queue the (possibly transformed) outgoing notifications,\n // once the submission is durable. An email cannot be taken back, and a\n // submission saved inside a caller's transaction runs this hook before that\n // transaction commits: sent here, it would go out, and its delivery be\n // recorded, for a submission a rollback then removes. The hook context's\n // `afterCommit` holds the send until the outermost transaction commits and\n // drops it on rollback; outside a transaction it sends at once, as before.\n const send = () =>\n queueNotificationEmails({\n emails: finalEmails,\n emailService,\n logger: nextly.logger,\n formSlug: form.slug,\n // A submission's id is a string or a number by dialect.\n submissionId:\n typeof submission.id === \"number\"\n ? `${submission.id}`\n : typeof submission.id === \"string\"\n ? submission.id\n : \"\",\n fileAttachments,\n });\n const { afterCommit } = context as Pick<HookContext, \"afterCommit\">;\n await (afterCommit ? afterCommit(send) : send());\n}\n\n/**\n * The key a notification is queued under: its submission, its place among\n * the submission's emails, and a short digest of its notification's id. The\n * place keeps apart two emails of one notification (a `beforeEmail` hook\n * may add a copy of one), and the digest keeps the key short whatever the\n * id's length. The same submission's emails, built again, get the same keys.\n */\nfunction notificationKey(\n submissionId: string,\n index: number,\n notificationId: string | undefined\n): string {\n const digest = createHash(\"sha256\")\n .update(notificationId ?? \"\")\n .digest(\"hex\")\n .slice(0, 16);\n return `form-notification:${submissionId}:${index}:${digest}`;\n}\n\n/**\n * Queue each notification, and log where it stands.\n *\n * Queued through the email service's queued send rather than sent once: the\n * queue makes the first attempt at once, and a failure a retry can help is\n * tried again through the job queue, with backoff, until it is sent or fails\n * for good, never dropped. Each carries an idempotency key from its\n * submission and notification (`notificationKey`), so a provider that takes keys delivers it\n * once even if this hook runs again. No log line names a recipient: an\n * address in a log outlives the submission it came from, and the\n * notification's id and the queued email's id are enough to find it.\n */\nexport async function queueNotificationEmails(input: {\n emails: FormEmailNotification[];\n emailService: Pick<PluginEmailService, \"queueWithTemplate\">;\n logger: Pick<NextlyInstance[\"logger\"], \"info\" | \"error\">;\n /** The form's slug as stored, for the log lines only. */\n formSlug: unknown;\n /** The submission the notifications are for, which keys each one. */\n submissionId: string;\n fileAttachments: Array<{ mediaId: string; filename?: string }>;\n}): Promise<void> {\n const { emails, emailService, logger, formSlug, fileAttachments } = input;\n for (const [index, email] of emails.entries()) {\n const identity = {\n formSlug,\n templateSlug: email.templateSlug,\n notificationId: email.notificationId,\n };\n try {\n const queued = await emailService.queueWithTemplate(\n email.templateSlug,\n email.to,\n email.variables,\n {\n providerId: email.providerId,\n from: email.from,\n replyTo: email.replyTo,\n cc: email.cc,\n bcc: email.bcc,\n attachments: fileAttachments.length > 0 ? fileAttachments : undefined,\n idempotencyKey: notificationKey(\n input.submissionId,\n index,\n email.notificationId\n ),\n }\n );\n // The first attempt has been made: sent, waiting for a retry, or\n // failed for good (a refusal no retry can change).\n if (queued.state === \"failed\") {\n logger.error?.(\"Form Builder: notification failed\", {\n ...identity,\n queuedId: queued.id,\n retryable: false,\n });\n } else {\n logger.info?.(\"Form Builder: notification queued\", {\n ...identity,\n queuedId: queued.id,\n state: queued.state,\n attachmentCount: fileAttachments.length,\n });\n }\n } catch (err) {\n // Thrown rather than returned: nothing was queued. A refusal of the\n // email itself (an invalid address) would be refused again, so it is\n // not retryable; anything else may pass on another submission.\n logger.error?.(\"Form Builder: notification failed\", {\n ...identity,\n retryable: !NextlyError.isCode(err, \"VALIDATION_ERROR\"),\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n}\n\n/**\n * Fetch the parent form document for a submission.\n */\n/**\n * Put a real `submissionCount` on every form a read returned.\n *\n * Spam is excluded, because the number answers \"how many people submitted\", not\n * \"how many bots\". `afterRead` fires for single reads and for list reads, so\n * both shapes are handled; the counts run concurrently, and form lists are\n * paginated, so this is a bounded fan-out of small indexed queries rather than\n * a serial walk.\n *\n * A read that asked for the schema alone is left alone. A submission write\n * reads its parent form only to check the payload against that form's fields,\n * and counting there is presentation work nobody on that path reads. It also\n * grows with the form's history, so every submission was paying for a count of\n * every submission before it.\n *\n * The flag decides how much work to do and nothing else. Forged, the worst it\n * can produce is a form read whose `submissionCount` is absent, and it cannot\n * come from a request body in any case: the hook context is set by the\n * server-side caller of the service.\n */\nexport async function injectSubmissionCount(\n context: unknown,\n submissionSlug: string,\n nextly: NextlyInstance\n): Promise<void> {\n const hook = context as {\n data?: unknown;\n context?: Record<string, unknown>;\n };\n if (hook.context?.[SCHEMA_ONLY_READ] === true) return;\n\n const records = Array.isArray(hook.data)\n ? hook.data\n : hook.data\n ? [hook.data]\n : [];\n await Promise.all(\n records.map(async record => {\n const form = record as Record<string, unknown>;\n if (typeof form.id !== \"string\") return;\n try {\n // Count as system: whoever may read the form may see its submission\n // volume without holding submission read rights.\n form.submissionCount = await nextly.services.collections.count(\n submissionSlug,\n {\n where: {\n form: { equals: form.id },\n status: { not_equals: \"spam\" },\n },\n },\n { as: \"system\" }\n );\n } catch {\n // A failed count must never break reading the form itself.\n form.submissionCount = 0;\n }\n })\n );\n}\n\nexport async function fetchParentForm(\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | null> {\n try {\n // D35/D56: read the parent form through the secure managed service as\n // system — the afterCreate hook runs without an ambient user. Replaces the\n // legacy `getCollectionsHandler()` + `overrideAccess` runtime path.\n //\n // The slug arrives RESOLVED. Passing `formOverrides.slug` read the declared\n // name, which a host that renames the collection has taken out of the\n // registry, so the lookup found nothing on exactly the installs the rename\n // API supports.\n //\n // NOT on the caller's transaction, and it cannot be here. The plugin facade\n // rebuilds its context from `user` and `overrideAccess` alone, and\n // `CollectionService.findEntryById` forwards only those two to the entry\n // service, so an executor has nowhere to go on this path: passing one\n // looked like transaction awareness and was discarded before the query.\n //\n // The cost is written down rather than hidden. A submission created inside\n // a transaction alongside a form created in that same transaction cannot\n // see it and is refused, and on a pool whose only connection the\n // transaction holds, this read waits for it. Both fail safely. The pattern\n // for fixing it exists in collection-hook-service and\n // collection-access-service, which forward a context's executor for exactly\n // this reason; bringing it here means changing the entry service and the\n // plugin facade with it.\n const form = await nextly.services.collections.findEntryById(\n formsSlug,\n formId,\n { as: \"system\", context: { ...SCHEMA_ONLY_CONTEXT } }\n );\n return form;\n } catch (err) {\n // A form that is not there is an answer, and the caller decides what a\n // submission naming no form means. Anything else is the read itself\n // failing, and it is rethrown: swallowing a pool timeout or a throwing\n // `afterRead` hook turned a server fault into \"this form could not be\n // read\", so the writer was told their submission was invalid, with a status\n // that says not to retry.\n if (!NextlyError.isNotFound(err)) throw err;\n nextly.logger.error?.(\"Form Builder: form not found\", {\n formId,\n error: err instanceof Error ? err.message : String(err),\n });\n return null;\n }\n}\n","/**\n * Form Configuration Validator\n *\n * Provides comprehensive validation for form configurations including:\n * - Slug validation (format, reserved names)\n * - Field name validation (format, duplicates)\n * - Field-specific validation (select/radio options, min/max constraints)\n * - Conditional logic validation\n * - Nested field validation\n *\n * @module config/validate-form\n * @since 1.0.0\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const result = validateFormConfig(config);\n * if (!result.valid) {\n * console.error('Validation errors:', result.errors);\n * }\n * ```\n */\n\nimport { isPluginFieldTypeOnSurface } from \"nextly\";\n\nimport { BUILT_IN_FORM_FIELD_TYPES } from \"../types\";\nimport type { FormConfig, FormField, FormFieldType } from \"../types\";\nimport { parseRedirectReference } from \"../utils/redirect-reference\";\n\nimport { warnIfWebhooksConfigured } from \"./removed-options\";\n\n// ============================================================\n// Validation Error Types\n// ============================================================\n\n/**\n * Error codes for form validation failures.\n * Used for programmatic error handling.\n */\nexport type FormValidationErrorCode =\n // Slug errors\n | \"SLUG_REQUIRED\"\n | \"SLUG_INVALID_TYPE\"\n | \"SLUG_TOO_SHORT\"\n | \"SLUG_TOO_LONG\"\n | \"SLUG_INVALID_FORMAT\"\n | \"SLUG_RESERVED\"\n // Field errors\n | \"FIELDS_REQUIRED\"\n | \"FIELDS_INVALID_TYPE\"\n | \"FIELDS_EMPTY\"\n | \"FIELD_NAME_REQUIRED\"\n | \"FIELD_NAME_INVALID_FORMAT\"\n | \"FIELD_NAME_DUPLICATE\"\n | \"FIELD_TYPE_REQUIRED\"\n | \"FIELD_TYPE_INVALID\"\n | \"FIELD_LABEL_REQUIRED\"\n // Field-specific errors\n | \"OPTIONS_REQUIRED\"\n | \"OPTIONS_EMPTY\"\n | \"MIN_GREATER_THAN_MAX\"\n | \"MIN_LENGTH_GREATER_THAN_MAX\"\n | \"FILE_MAX_SIZE_INVALID\"\n // Conditional logic errors\n | \"CONDITIONS_REQUIRED\"\n | \"CONDITION_FIELD_REQUIRED\"\n | \"CONDITION_COMPARISON_REQUIRED\"\n // Settings errors\n | \"SETTINGS_INVALID_TYPE\"\n | \"REDIRECT_URL_REQUIRED\"\n | \"REDIRECT_PAGE_REQUIRED\"\n // Access errors\n | \"ACCESS_INVALID_TYPE\"\n | \"ACCESS_FUNCTION_INVALID\";\n\n/**\n * A single validation error with path and context.\n *\n * @example\n * ```typescript\n * const error: FormValidationError = {\n * path: 'fields.email.validation',\n * message: \"Min length cannot be greater than max length\",\n * code: 'MIN_LENGTH_GREATER_THAN_MAX',\n * };\n * ```\n */\nexport interface FormValidationError {\n /**\n * Dot-notation path to the invalid property.\n * @example 'slug', 'fields.0.name', 'fields.email.options'\n */\n path: string;\n\n /**\n * Human-readable error message.\n */\n message: string;\n\n /**\n * Machine-readable error code for programmatic handling.\n */\n code: FormValidationErrorCode;\n}\n\n/**\n * Result of form config validation.\n */\nexport interface FormValidationResult {\n /**\n * Whether the configuration is valid.\n */\n valid: boolean;\n\n /**\n * Array of validation errors (empty if valid).\n */\n errors: FormValidationError[];\n}\n\n// ============================================================\n// Reserved Names\n// ============================================================\n\n/**\n * Reserved form slugs that cannot be used.\n * These are used by the system or have special meaning.\n */\nexport const RESERVED_FORM_SLUGS = [\n // API routes\n \"api\",\n \"submit\",\n \"submissions\",\n \"export\",\n // System slugs\n \"admin\",\n \"new\",\n \"create\",\n \"edit\",\n \"delete\",\n] as const;\n\n/**\n * Set for O(1) lookup of reserved slugs.\n */\nconst RESERVED_SLUGS_SET = new Set<string>(RESERVED_FORM_SLUGS);\n\n// ============================================================\n// Validation Patterns\n// ============================================================\n\n/**\n * Regex pattern for valid form slugs.\n * Must start with a lowercase letter, contain only lowercase letters,\n * numbers, hyphens, and underscores. Length: 1-50 chars.\n * Allows hyphens for URL-friendly slugs (e.g., 'contact-form').\n */\nconst SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * Regex pattern for valid field names.\n * Must start with a letter (upper or lower), contain only letters,\n * numbers, and underscores. Supports camelCase (e.g., 'firstName').\n */\nconst FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\n/**\n * All valid built-in form field types (plugin types validate via the registry).\n */\nconst VALID_FIELD_TYPES: readonly FormFieldType[] = BUILT_IN_FORM_FIELD_TYPES;\n\nconst VALID_FIELD_TYPES_SET = new Set<string>(VALID_FIELD_TYPES);\n\n// ============================================================\n// Validation Helper Functions\n// ============================================================\n\n/**\n * Checks if a string is a reserved form slug.\n */\nfunction isReservedSlug(slug: string): boolean {\n return RESERVED_SLUGS_SET.has(slug.toLowerCase());\n}\n\n/**\n * Validates a form slug.\n */\nfunction validateSlug(slug: unknown, errors: FormValidationError[]): void {\n const path = \"slug\";\n\n if (!slug) {\n errors.push({\n path,\n message: \"Form slug is required\",\n code: \"SLUG_REQUIRED\",\n });\n return;\n }\n\n if (typeof slug !== \"string\") {\n errors.push({\n path,\n message: \"Form slug must be a string\",\n code: \"SLUG_INVALID_TYPE\",\n });\n return;\n }\n\n if (slug.length < 1) {\n errors.push({\n path,\n message: \"Form slug must be at least 1 character\",\n code: \"SLUG_TOO_SHORT\",\n });\n }\n\n if (slug.length > 50) {\n errors.push({\n path,\n message: \"Form slug must be at most 50 characters\",\n code: \"SLUG_TOO_LONG\",\n });\n }\n\n if (!SLUG_PATTERN.test(slug)) {\n errors.push({\n path,\n message:\n \"Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores\",\n code: \"SLUG_INVALID_FORMAT\",\n });\n }\n\n if (isReservedSlug(slug)) {\n errors.push({\n path,\n message: `Form slug '${slug}' is reserved and cannot be used`,\n code: \"SLUG_RESERVED\",\n });\n }\n}\n\n/**\n * Validates a single field name.\n */\nfunction validateFieldName(\n name: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!name) {\n errors.push({\n path: `${path}.name`,\n message: \"Field name is required\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (typeof name !== \"string\") {\n errors.push({\n path: `${path}.name`,\n message: \"Field name must be a string\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (!FIELD_NAME_PATTERN.test(name)) {\n errors.push({\n path: `${path}.name`,\n message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,\n code: \"FIELD_NAME_INVALID_FORMAT\",\n });\n }\n\n // Check for duplicates at this level\n const nameLower = name.toLowerCase();\n if (seenNames.has(nameLower)) {\n errors.push({\n path: `${path}.name`,\n message: `Duplicate field name '${name}'`,\n code: \"FIELD_NAME_DUPLICATE\",\n });\n } else {\n seenNames.add(nameLower);\n }\n}\n\n/**\n * Validates a single field label.\n */\nfunction validateFieldLabel(\n label: unknown,\n path: string,\n errors: FormValidationError[]\n): void {\n if (!label || (typeof label === \"string\" && label.trim() === \"\")) {\n errors.push({\n path: `${path}.label`,\n message: \"Field label is required\",\n code: \"FIELD_LABEL_REQUIRED\",\n });\n }\n}\n\n/**\n * Validates select/radio/checkbox-group field options.\n */\nfunction validateOptions(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n // Type assertion for fields with options\n const fieldWithOptions = field as {\n options?: Array<{ label: string; value: string }>;\n };\n const options = fieldWithOptions.options;\n\n if (!options) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have an 'options' array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(options)) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field 'options' must be an array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (options.length === 0) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have at least one option`,\n code: \"OPTIONS_EMPTY\",\n });\n }\n}\n\n/**\n * Validates conditional logic configuration.\n */\nfunction validateConditionalLogic(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n const { conditionalLogic } = field;\n\n if (!conditionalLogic || !conditionalLogic.enabled) {\n return;\n }\n\n if (\n !conditionalLogic.conditions ||\n conditionalLogic.conditions.length === 0\n ) {\n errors.push({\n path: `${path}.conditionalLogic.conditions`,\n message: \"Conditional logic must have at least one condition\",\n code: \"CONDITIONS_REQUIRED\",\n });\n return;\n }\n\n conditionalLogic.conditions.forEach((condition, index) => {\n const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;\n\n if (!condition.field) {\n errors.push({\n path: `${conditionPath}.field`,\n message: \"Condition must specify a field name\",\n code: \"CONDITION_FIELD_REQUIRED\",\n });\n }\n\n if (!condition.comparison) {\n errors.push({\n path: `${conditionPath}.comparison`,\n message: \"Condition must specify a comparison operator\",\n code: \"CONDITION_COMPARISON_REQUIRED\",\n });\n }\n });\n}\n\n/**\n * Validates field based on its type.\n */\nfunction validateFieldByType(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n switch (field.type) {\n case \"select\":\n case \"radio\":\n validateOptions(field, path, errors);\n break;\n\n case \"number\": {\n const numberField = field;\n if (numberField.validation) {\n const { min, max } = numberField.validation;\n if (min !== undefined && max !== undefined && min > max) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min value cannot be greater than max value\",\n code: \"MIN_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"text\":\n case \"textarea\": {\n const textField = field;\n if (textField.validation) {\n const { minLength, maxLength } = textField.validation;\n if (\n minLength !== undefined &&\n maxLength !== undefined &&\n minLength > maxLength\n ) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min length cannot be greater than max length\",\n code: \"MIN_LENGTH_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"file\": {\n const fileField = field;\n if (fileField.maxFileSize !== undefined && fileField.maxFileSize <= 0) {\n errors.push({\n path: `${path}.maxFileSize`,\n message: \"File maxFileSize must be a positive number\",\n code: \"FILE_MAX_SIZE_INVALID\",\n });\n }\n break;\n }\n }\n}\n\n/**\n * Validates a single field configuration.\n */\nfunction validateField(\n field: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!field || typeof field !== \"object\") {\n return;\n }\n\n const f = field as Record<string, unknown>;\n\n // Validate field type\n const fieldType = f.type;\n if (!fieldType) {\n errors.push({\n path: `${path}.type`,\n message: \"Field type is required\",\n code: \"FIELD_TYPE_REQUIRED\",\n });\n return;\n }\n\n // A plugin may contribute a field type that opted into the forms surface; it\n // is as valid here as a built-in. Built-ins keep the fast allowlist; other\n // strings fall through to the registry, surface-gated.\n const isValidType =\n typeof fieldType === \"string\" &&\n (VALID_FIELD_TYPES_SET.has(fieldType) ||\n isPluginFieldTypeOnSurface(fieldType, \"forms\"));\n\n if (!isValidType) {\n let fieldTypeRepr: string;\n if (typeof fieldType === \"object\" && fieldType !== null) {\n fieldTypeRepr = JSON.stringify(fieldType);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- fieldType narrowed to primitive above; rule doesn't follow control flow on unknown\n fieldTypeRepr = String(fieldType);\n }\n errors.push({\n path: `${path}.type`,\n message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(\", \")}`,\n code: \"FIELD_TYPE_INVALID\",\n });\n return;\n }\n\n validateFieldName(f.name, path, errors, seenNames);\n\n // All fields except hidden require a label\n if (fieldType !== \"hidden\") {\n validateFieldLabel(f.label, path, errors);\n }\n\n // Type-specific validation\n validateFieldByType(field as FormField, path, errors);\n\n // Validate conditional logic if present\n if (f.conditionalLogic) {\n validateConditionalLogic(field as FormField, path, errors);\n }\n}\n\n/**\n * Validates an array of field configurations.\n */\nfunction validateFieldsArray(\n fields: unknown[],\n basePath: string,\n errors: FormValidationError[]\n): void {\n // Track field names for duplicate detection\n const seenNames = new Set<string>();\n\n fields.forEach((field, index) => {\n const fieldPath = `${basePath}[${index}]`;\n validateField(field, fieldPath, errors, seenNames);\n });\n}\n\n/**\n * Validates the top-level fields array.\n */\nfunction validateFields(fields: unknown, errors: FormValidationError[]): void {\n const path = \"fields\";\n\n if (!fields) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(fields)) {\n errors.push({\n path,\n message: \"Form fields must be an array\",\n code: \"FIELDS_INVALID_TYPE\",\n });\n return;\n }\n\n if (fields.length === 0) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_EMPTY\",\n });\n return;\n }\n\n validateFieldsArray(fields, path, errors);\n}\n\n/**\n * Validates form settings.\n */\nfunction validateSettings(\n settings: unknown,\n errors: FormValidationError[]\n): void {\n if (!settings) {\n return; // Settings are optional\n }\n\n if (typeof settings !== \"object\" || Array.isArray(settings)) {\n errors.push({\n path: \"settings\",\n message: \"Form settings must be an object\",\n code: \"SETTINGS_INVALID_TYPE\",\n });\n return;\n }\n\n const s = settings as Record<string, unknown>;\n\n // If confirmationType is \"redirect\", redirectUrl or redirectRelation is required\n if (s.confirmationType === \"redirect\") {\n if (!s.redirectUrl && !s.redirectRelation) {\n errors.push({\n path: \"settings.redirectUrl\",\n message:\n \"Redirect URL or redirect relation is required when confirmation type is 'redirect'\",\n code: \"REDIRECT_URL_REQUIRED\",\n });\n }\n }\n\n // A form set to redirect to a page needs the page. Without this the form\n // saves, the admin shows it as redirecting, and every submission finishes\n // with nowhere to go — the destination is missing at submit time, when\n // there is no one to tell.\n //\n // The SHAPE, not truthiness. `{}` and `{ relationTo: \"pages\" }` are truthy\n // and name no document, so a presence test admits precisely the values that\n // resolve to nothing. `null` for the collection list because this entry\n // point does not have the plugin config: a reference carrying its own\n // `relationTo` is checkable without it, and a bare id is not — which is the\n // honest limit of validating a form in isolation.\n if (\n s.confirmationType === \"relationship\" &&\n !namesADocument(s.redirectPage)\n ) {\n errors.push({\n path: \"settings.redirectPage\",\n message: \"A page is required when confirmation type is 'relationship'\",\n code: \"REDIRECT_PAGE_REQUIRED\",\n });\n }\n}\n\n/**\n * Whether a stored redirect value names a document at all.\n *\n * Derived from the shared reader rather than restating its rules, with an\n * empty collection list: that accepts every shape carrying its own\n * `relationTo` and rejects the ones naming nothing. A bare id is the case this\n * cannot judge here, since which collection it belongs to is plugin\n * configuration this entry point never sees — so it is accepted rather than\n * reported, because refusing a valid form is the worse error.\n */\nfunction namesADocument(value: unknown): boolean {\n if (typeof value === \"string\") return value.length > 0;\n return parseRedirectReference(value, []) !== null;\n}\n\n/**\n * Validates form access control.\n */\nfunction validateAccess(access: unknown, errors: FormValidationError[]): void {\n if (!access) {\n return; // Access is optional\n }\n\n if (typeof access !== \"object\" || Array.isArray(access)) {\n errors.push({\n path: \"access\",\n message: \"Access control must be an object\",\n code: \"ACCESS_INVALID_TYPE\",\n });\n return;\n }\n\n const accessObj = access as Record<string, unknown>;\n const validAccessKeys = [\"submit\", \"read\"];\n\n for (const key of validAccessKeys) {\n const fn = accessObj[key];\n if (fn !== undefined && typeof fn !== \"function\") {\n errors.push({\n path: `access.${key}`,\n message: `Access control '${key}' must be a function`,\n code: \"ACCESS_FUNCTION_INVALID\",\n });\n }\n }\n}\n\n// ============================================================\n// Main Validation Function\n// ============================================================\n\n/**\n * Validates a complete form configuration.\n *\n * This is the main entry point for form validation. It performs\n * comprehensive validation including:\n * - Slug format and reserved name checking\n * - Field name validation\n * - Field-specific validation (options, min/max, etc.)\n * - Duplicate field name detection\n * - Conditional logic validation\n * - Settings validation\n * - Access function type validation\n *\n * @param config - The form configuration to validate\n * @returns Validation result with any errors found\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const config = {\n * slug: 'contact-form',\n * fields: [\n * { type: 'text', name: 'firstName', label: 'First Name' },\n * { type: 'email', name: 'email', label: 'Email', required: true },\n * ],\n * };\n *\n * const result = validateFormConfig(config);\n *\n * if (!result.valid) {\n * result.errors.forEach(err => {\n * console.error(`[${err.code}] ${err.path}: ${err.message}`);\n * });\n * }\n * ```\n */\nexport function validateFormConfig(config: FormConfig): FormValidationResult {\n const errors: FormValidationError[] = [];\n\n // A removed option is not an error, since the rest of the config still\n // works, but it is said here too, so a config checked this way is told.\n warnIfWebhooksConfigured(config);\n\n // Validate slug\n validateSlug(config.slug, errors);\n\n // Validate fields\n validateFields(config.fields, errors);\n\n // Validate settings\n validateSettings(config.settings, errors);\n\n // Validate access control\n validateAccess(config.access, errors);\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Throws an error if the form configuration is invalid.\n *\n * This is a convenience wrapper around `validateFormConfig` that\n * throws a descriptive error instead of returning a result object.\n *\n * @param config - The form configuration to validate\n * @throws Error if validation fails with all error messages\n *\n * @example\n * ```typescript\n * import { assertValidFormConfig } from '@nextly/plugin-form-builder';\n *\n * // Throws if invalid\n * assertValidFormConfig(config);\n * ```\n */\nexport function assertValidFormConfig(config: FormConfig): void {\n const result = validateFormConfig(config);\n\n if (!result.valid) {\n const errorMessages = result.errors\n .map(err => ` - [${err.code}] ${err.path}: ${err.message}`)\n .join(\"\\n\");\n\n throw new Error(\n `Invalid form config for '${config.slug || \"unknown\"}':\\n${errorMessages}`\n );\n }\n}\n","/**\n * Form Field Helpers\n *\n * Convenient factory functions for creating form field configurations.\n * These helpers eliminate the need to manually specify the `type` property,\n * providing a cleaner, more ergonomic API for form definitions.\n *\n * @module config/field-helpers\n *\n * @example\n * ```typescript\n * import {\n * text,\n * email,\n * select,\n * checkbox,\n * option,\n * } from '@nextly/plugin-form-builder';\n *\n * const contactFormFields = [\n * text({ name: 'firstName', label: 'First Name', required: true }),\n * text({ name: 'lastName', label: 'Last Name', required: true }),\n * email({ name: 'email', label: 'Email Address', required: true }),\n * select({\n * name: 'subject',\n * label: 'Subject',\n * options: [\n * option('General Inquiry'),\n * option('Support Request'),\n * option('Feedback'),\n * ],\n * }),\n * checkbox({ name: 'subscribe', label: 'Subscribe to newsletter' }),\n * ];\n * ```\n */\n\nimport type {\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"../types\";\n\n// ============================================================\n// Utility Types\n// ============================================================\n\n/**\n * Option for select and radio fields.\n */\nexport interface FormFieldOption {\n label: string;\n value: string;\n}\n\n// ============================================================\n// Utility Helpers\n// ============================================================\n\n/**\n * Creates an option for select or radio fields.\n *\n * If no value is provided, the label is converted to lowercase with\n * spaces replaced by underscores.\n *\n * @param label - Display label for the option\n * @param value - Optional value (defaults to lowercase label with underscores)\n * @returns Option object with label and value\n *\n * @example\n * ```typescript\n * // Value derived from label\n * option('United States') // { label: 'United States', value: 'united_states' }\n *\n * // Explicit value\n * option('United States', 'us') // { label: 'United States', value: 'us' }\n *\n * // Usage in select field\n * select({\n * name: 'country',\n * label: 'Country',\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n * ```\n */\nexport const option = (label: string, value?: string): FormFieldOption => ({\n label,\n value: value ?? label.toLowerCase().replace(/\\s+/g, \"_\"),\n});\n\n// ============================================================\n// Text Input Field Helpers\n// ============================================================\n\n/**\n * Creates a text input field configuration.\n *\n * Text fields store simple string values with optional constraints\n * like min/max length and pattern validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete text field configuration\n *\n * @example\n * ```typescript\n * // Basic text field\n * text({ name: 'firstName', label: 'First Name', required: true })\n *\n * // Text with validation\n * text({\n * name: 'username',\n * label: 'Username',\n * required: true,\n * placeholder: 'Enter username',\n * validation: {\n * minLength: 3,\n * maxLength: 20,\n * pattern: '^[a-zA-Z0-9_]+$',\n * },\n * })\n * ```\n */\nexport const text = (config: Omit<TextFormField, \"type\">): TextFormField => ({\n ...config,\n type: \"text\",\n});\n\n/**\n * Creates an email input field configuration.\n *\n * Email fields provide built-in email format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete email field configuration\n *\n * @example\n * ```typescript\n * // Basic email field\n * email({ name: 'email', label: 'Email Address', required: true })\n *\n * // Email with placeholder\n * email({\n * name: 'workEmail',\n * label: 'Work Email',\n * required: true,\n * placeholder: 'you@nextly.local',\n * })\n * ```\n */\nexport const email = (\n config: Omit<EmailFormField, \"type\">\n): EmailFormField => ({\n ...config,\n type: \"email\",\n});\n\n/**\n * Creates a phone input field configuration.\n *\n * Phone fields are optimized for phone number entry with\n * appropriate keyboard on mobile devices.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete phone field configuration\n *\n * @example\n * ```typescript\n * // Basic phone field\n * phone({ name: 'phone', label: 'Phone Number', required: true })\n *\n * // Phone with placeholder\n * phone({\n * name: 'mobile',\n * label: 'Mobile Number',\n * placeholder: '+1 (555) 123-4567',\n * })\n * ```\n */\nexport const phone = (\n config: Omit<PhoneFormField, \"type\">\n): PhoneFormField => ({\n ...config,\n type: \"phone\",\n});\n\n/**\n * Creates a URL input field configuration.\n *\n * URL fields provide built-in URL format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete URL field configuration\n *\n * @example\n * ```typescript\n * // Basic URL field\n * url({ name: 'website', label: 'Website URL' })\n *\n * // URL with placeholder\n * url({\n * name: 'portfolio',\n * label: 'Portfolio URL',\n * placeholder: 'https://example.com',\n * })\n * ```\n */\nexport const url = (config: Omit<UrlFormField, \"type\">): UrlFormField => ({\n ...config,\n type: \"url\",\n});\n\n/**\n * Creates a textarea field configuration.\n *\n * Textarea fields provide multi-line text input with configurable\n * row count and length validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete textarea field configuration\n *\n * @example\n * ```typescript\n * // Basic textarea\n * textarea({ name: 'message', label: 'Message', required: true })\n *\n * // Textarea with rows and validation\n * textarea({\n * name: 'description',\n * label: 'Description',\n * rows: 5,\n * validation: {\n * minLength: 10,\n * maxLength: 500,\n * },\n * })\n * ```\n */\nexport const textarea = (\n config: Omit<TextareaFormField, \"type\">\n): TextareaFormField => ({\n ...config,\n type: \"textarea\",\n});\n\n// ============================================================\n// Numeric Field Helpers\n// ============================================================\n\n/**\n * Creates a number input field configuration.\n *\n * Number fields store numeric values with optional min/max/step\n * constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete number field configuration\n *\n * @example\n * ```typescript\n * // Basic number field\n * number({ name: 'age', label: 'Age', required: true })\n *\n * // Number with range validation\n * number({\n * name: 'quantity',\n * label: 'Quantity',\n * required: true,\n * validation: {\n * min: 1,\n * max: 100,\n * step: 1,\n * },\n * })\n * ```\n */\nexport const number = (\n config: Omit<NumberFormField, \"type\">\n): NumberFormField => ({\n ...config,\n type: \"number\",\n});\n\n// ============================================================\n// Selection Field Helpers\n// ============================================================\n\n/**\n * Creates a select dropdown field configuration.\n *\n * Select fields display a dropdown list of options. Use the `option()`\n * helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete select field configuration\n *\n * @example\n * ```typescript\n * // Basic select\n * select({\n * name: 'country',\n * label: 'Country',\n * required: true,\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n *\n * // Multi-select\n * select({\n * name: 'interests',\n * label: 'Interests',\n * allowMultiple: true,\n * options: [\n * option('Technology'),\n * option('Design'),\n * option('Business'),\n * ],\n * })\n * ```\n */\nexport const select = (\n config: Omit<SelectFormField, \"type\">\n): SelectFormField => ({\n ...config,\n type: \"select\",\n});\n\n/**\n * Creates a single checkbox field configuration.\n *\n * Checkbox fields store boolean values (true/false).\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete checkbox field configuration\n *\n * @example\n * ```typescript\n * // Terms agreement checkbox\n * checkbox({\n * name: 'agreeToTerms',\n * label: 'I agree to the terms and conditions',\n * required: true,\n * })\n *\n * // Newsletter subscription\n * checkbox({\n * name: 'subscribe',\n * label: 'Subscribe to our newsletter',\n * defaultValue: true,\n * })\n * ```\n */\nexport const checkbox = (\n config: Omit<CheckboxFormField, \"type\">\n): CheckboxFormField => ({\n ...config,\n type: \"checkbox\",\n});\n\n/**\n * Creates a radio group field configuration.\n *\n * Radio group fields allow selecting exactly one option from a list.\n * Use the `option()` helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete radio field configuration\n *\n * @example\n * ```typescript\n * radio({\n * name: 'contactMethod',\n * label: 'Preferred Contact Method',\n * required: true,\n * options: [\n * option('Email', 'email'),\n * option('Phone', 'phone'),\n * option('SMS', 'sms'),\n * ],\n * })\n * ```\n */\nexport const radio = (\n config: Omit<RadioFormField, \"type\">\n): RadioFormField => ({\n ...config,\n type: \"radio\",\n});\n\n// ============================================================\n// File Field Helpers\n// ============================================================\n\n/**\n * Creates a file upload field configuration.\n *\n * File fields allow users to upload files with configurable\n * accepted types, size limits, and multiple file support.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete file field configuration\n *\n * @example\n * ```typescript\n * // Single file upload\n * file({\n * name: 'resume',\n * label: 'Upload Resume',\n * required: true,\n * accept: 'application/pdf,.doc,.docx',\n * maxFileSize: 5242880, // 5MB\n * })\n *\n * // Multiple images\n * file({\n * name: 'photos',\n * label: 'Upload Photos',\n * accept: 'image/*',\n * multiple: true,\n * maxFileSize: 10485760, // 10MB\n * })\n * ```\n */\nexport const file = (config: Omit<FileFormField, \"type\">): FileFormField => ({\n ...config,\n type: \"file\",\n});\n\n// ============================================================\n// Date/Time Field Helpers\n// ============================================================\n\n/**\n * Creates a date picker field configuration.\n *\n * Date fields provide a date picker with optional min/max constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete date field configuration\n *\n * @example\n * ```typescript\n * // Basic date field\n * date({ name: 'birthdate', label: 'Date of Birth', required: true })\n *\n * // Date with range constraints\n * date({\n * name: 'eventDate',\n * label: 'Event Date',\n * required: true,\n * min: '2024-01-01',\n * max: '2024-12-31',\n * })\n * ```\n */\nexport const date = (config: Omit<DateFormField, \"type\">): DateFormField => ({\n ...config,\n type: \"date\",\n});\n\n/**\n * Creates a time picker field configuration.\n *\n * Time fields provide a time picker for selecting time values.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete time field configuration\n *\n * @example\n * ```typescript\n * // Basic time field\n * time({ name: 'appointmentTime', label: 'Preferred Time', required: true })\n *\n * // Time with default\n * time({\n * name: 'meetingTime',\n * label: 'Meeting Time',\n * defaultValue: '09:00',\n * })\n * ```\n */\nexport const time = (config: Omit<TimeFormField, \"type\">): TimeFormField => ({\n ...config,\n type: \"time\",\n});\n\n// ============================================================\n// Special Field Helpers\n// ============================================================\n\n/**\n * Creates a hidden field configuration.\n *\n * Hidden fields store values that are not visible to users but\n * are included in the submission data. Useful for tracking\n * metadata like source, campaign, or referrer.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete hidden field configuration\n *\n * @example\n * ```typescript\n * // Track form source\n * hidden({ name: 'source', label: 'Source', defaultValue: 'website' })\n *\n * // Campaign tracking\n * hidden({ name: 'campaign', label: 'Campaign', defaultValue: 'summer-sale' })\n * ```\n */\nexport const hidden = (\n config: Omit<HiddenFormField, \"type\">\n): HiddenFormField => ({\n ...config,\n type: \"hidden\",\n});\n","/**\n * @nextly/plugin-form-builder\n *\n * Visual form builder plugin for Nextly CMS.\n * Provides drag-and-drop form building, submission management,\n * email notifications, and spam protection.\n *\n * @packageDocumentation\n */\n\n// ============================================================\n// Main Plugin Export\n// ============================================================\n\n// ============================================================\n// Default Plugin Instance\n// ============================================================\n\n/**\n * Pre-configured form builder plugin for simple usage.\n *\n * This is a default instance with all defaults applied.\n * For customization, use `formBuilder()` instead.\n *\n * @example Simple usage - just add the plugin!\n * ```typescript\n * // nextly.config.ts\n * import { defineConfig } from 'nextly';\n * import { formBuilderPlugin } from '@nextly/plugin-form-builder';\n *\n * export default defineConfig({\n * plugins: [formBuilderPlugin],\n * collections: [Posts, Users, Media], // Your collections only\n * });\n * ```\n *\n * @example With customization\n * ```typescript\n * import { formBuilder } from '@nextly/plugin-form-builder';\n *\n * const myFormPlugin = formBuilder({\n * notifications: { defaultFrom: 'noreply@example.com' },\n * });\n *\n * export default defineConfig({\n * plugins: [myFormPlugin.plugin],\n * });\n * ```\n */\nimport { formBuilder } from \"./plugin\";\n\nexport {\n formBuilder,\n getFormBuilderConfig,\n type NextlyPlugin,\n type FormBuilderPluginResult,\n} from \"./plugin\";\nexport const formBuilderPlugin: ReturnType<typeof formBuilder>[\"plugin\"] =\n formBuilder().plugin;\n\n// The New form submissions widget's id, for a site's own `defaultLayout` to\n// name, and the data its card draws.\nexport {\n NEW_SUBMISSIONS_WIDGET_ID,\n type NewSubmissionRow,\n type NewSubmissionsData,\n} from \"./dashboard/new-submissions\";\n\n// ============================================================\n// Type Exports\n// ============================================================\n\n// Plugin configuration types\nexport type {\n FormBuilderPluginOptions,\n ResolvedFormBuilderConfig,\n FieldBlockConfig,\n} from \"./types\";\n\n// Form field types (discriminated union)\nexport type {\n FormField,\n FormFieldType,\n FormFieldTypeId,\n AnyFormField,\n CustomFormField,\n BaseFormField,\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"./types\";\nexport { isKnownFormField, BUILT_IN_FORM_FIELD_TYPES } from \"./types\";\n\n// Form configuration types\nexport type {\n FormConfig,\n FormSettings,\n FormNotifications,\n FormAccess,\n FormHooks,\n FormHookContext,\n} from \"./types\";\n\n// Document types\nexport type { FormDocument, SubmissionDocument, FormSubmission } from \"./types\";\n\n// Notification rule type (stored on the form; edited by the builder UI)\nexport type { FormNotification, FormNotificationItem } from \"./types\";\n\n// Validation and conditional logic types\nexport type {\n ValidationRules,\n ConditionalLogic,\n ConditionalLogicCondition,\n} from \"./types\";\n\n// Email types\nexport type { EmailConfig } from \"./types\";\n\n// ============================================================\n// Utility Exports\n// ============================================================\n\n// Type guards\nexport {\n isFormField,\n isFormFieldType,\n isTextFormField,\n isEmailFormField,\n isSelectFormField,\n isCheckboxFormField,\n isFileFormField,\n} from \"./types\";\n\n// ============================================================\n// Config Utilities (validation & defaults)\n// ============================================================\n\n// Validation utilities\nexport {\n validateFormConfig,\n assertValidFormConfig,\n RESERVED_FORM_SLUGS,\n type FormValidationError,\n type FormValidationErrorCode,\n type FormValidationResult,\n} from \"./config\";\n\n// Default application utilities\nexport {\n applyFormDefaults,\n applyFieldDefaults,\n createFormConfig,\n toTitleCase,\n pluralize,\n DEFAULT_FORM_SETTINGS,\n DEFAULT_NOTIFICATION_SETTINGS,\n} from \"./config\";\n\n// ============================================================\n// Field Helper Functions\n// ============================================================\n\n// Utility helper\nexport { option, type FormFieldOption } from \"./config\";\n\n// Text input field helpers\nexport { text, email, phone, url, textarea } from \"./config\";\n\n// Numeric field helpers\nexport { number } from \"./config\";\n\n// Selection field helpers\nexport { select, checkbox, radio } from \"./config\";\n\n// File field helpers\nexport { file } from \"./config\";\n\n// Date/time field helpers\nexport { date, time } from \"./config\";\n\n// Special field helpers\nexport { hidden } from \"./config\";\n\n// ============================================================\n// Conditional Logic Utilities\n// ============================================================\n\n// Conditional logic evaluation\nexport {\n evaluateConditions,\n isValidComparisonOperator,\n getSupportedComparisonOperators,\n type ComparisonOperator,\n} from \"./utils\";\n\n// ============================================================\n// Schema Generation & Validation Utilities\n// ============================================================\n\n// Zod schema generation for form validation\nexport {\n generateZodSchema,\n transformFormData,\n validateFormData,\n getValidationErrors,\n} from \"./utils\";\n\n// ============================================================\n// Export Utilities (CSV, JSON)\n// ============================================================\n\n// Export format utilities\nexport {\n exportToCSV,\n exportToJSON,\n formatExportValue,\n downloadFile,\n generateExportFilename,\n exportAndDownload,\n type CSVExportOptions,\n type JSONExportOptions,\n type ExportedJSON,\n} from \"./utils\";\n\n// ============================================================\n// Form Submission Handlers\n// ============================================================\n\n// Main submission handler\nexport {\n submitForm,\n validateSubmission,\n isFormAcceptingSubmissions,\n getFormSubmissionStats,\n type SubmitFormOptions,\n type SubmitFormResult,\n type SubmitFormContext,\n} from \"./handlers\";\n\n// Spam detection utilities\nexport {\n checkSpam,\n type SpamCheckConfig,\n type SpamCheckOptions,\n type SpamCheckResult,\n} from \"./handlers\";\n\n// ============================================================\n// Collection Exports (for advanced usage)\n// ============================================================\n\nexport { formsCollection, submissionsCollection } from \"./collections\";\n"],"mappings":"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"}
|