@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/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- import { A as isKnownFormField, D as isFileFormField, E as isEmailFormField, F as SCHEMA_ONLY_CONTEXT, I as SCHEMA_ONLY_READ, L as documentReachability, M as isTextFormField, N as NEW_SUBMISSIONS_WIDGET_ID, O as isFormField, P as readNewSubmissions, R as parseRedirectReference, S as ENFORCED_VALIDATION_RULES, T as isCheckboxFormField, _ as applyFormDefaults, a as exportToCSV, b as toTitleCase, c as generateExportFilename, d as getSupportedComparisonOperators, f as isValidComparisonOperator, g as applyFieldDefaults, h as DEFAULT_NOTIFICATION_SETTINGS, i as exportAndDownload, j as isSelectFormField, k as isFormFieldType, l as evaluateConditions, m as DEFAULT_FORM_SETTINGS, n as parseFieldRef, o as exportToJSON, p as normalizeFormSettings, r as downloadFile, s as formatExportValue, u as evaluateSingleCondition, v as createFormConfig, w as BUILT_IN_FORM_FIELD_TYPES, x as warnIfWebhooksConfigured, y as pluralize, z as pickedDocumentField } from "./field-references-CtzGg51W.js";
1
+ import { $ as SCHEMA_ONLY_READ, A as applyFieldDefaults, B as downloadFile, C as isKnownFormField, D as normalizeFormSettings, E as fieldsHiddenByCondition, F as warnIfWebhooksConfigured, G as generateExportFilename, H as exportToCSV, J as getSupportedComparisonOperators, K as evaluateConditions, M as createFormConfig, N as pluralize, O as DEFAULT_FORM_SETTINGS, P as toTitleCase, Q as SCHEMA_ONLY_CONTEXT, R as formsEmailTemplates, S as isFormFieldType, T as isTextFormField, U as exportToJSON, V as exportAndDownload, W as formatExportValue, X as NEW_SUBMISSIONS_WIDGET_ID, Y as isValidComparisonOperator, Z as readNewSubmissions, _ as BUILT_IN_FORM_FIELD_TYPES, at as addressedFormPages, b as isFileFormField, d as generateZodSchema, et as documentReachability, f as getValidationErrors, h as validateFormData, j as applyFormDefaults, k as DEFAULT_NOTIFICATION_SETTINGS, m as transformFormData, nt as pickedDocumentField, p as getValidationIssues, q as evaluateSingleCondition, r as parseFieldRef, rt as FORM_EDITOR_VIEW, tt as parseRedirectReference, u as oneResponseFieldProblem, v as isCheckboxFormField, w as isSelectFormField, x as isFormField, y as isEmailFormField } from "./field-references-oCuFEwkr.js";
2
2
  import { createRequire } from "node:module";
3
- import { NextlyError, definePlugin, defineWidget, isLocaleSelector, rowsThatFit } from "@nextlyhq/plugin-sdk";
4
- import { NO_SUCH_FORM, NextlyError as NextlyError$1, RateLimiter, buildPaginatedResponse, checkbox as checkbox$1, clampLimit, date as date$1, formAvailability, group, isPluginFieldTypeOnSurface, json, relationship, resolveRateLimitStore, respondAction, respondDoc, respondList, select as select$1, stripHtmlTags, text as text$1, textarea as textarea$1, trustedClientIp } from "nextly";
3
+ import { createHash } from "node:crypto";
4
+ import { MAX_WIDGET_ROWS, NextlyError, defineJob, definePlugin, defineWidget, isLocaleSelector, listEntries, setting } from "@nextlyhq/plugin-sdk";
5
+ import { NO_SUCH_FORM, NextlyError as NextlyError$1, RateLimiter, buildPaginatedResponse, checkbox as checkbox$1, clampLimit, date as date$1, formAvailability, group, isPluginFieldTypeOnSurface, json, number as number$1, relationship, resolveRateLimitStore, respondAction, respondDoc, respondList, select as select$1, stripHtmlTags, text as text$1, textarea as textarea$1, trustedClientIp } from "nextly";
5
6
  import { previewUrlFromTemplate } from "nextly/config";
6
7
  import { z } from "zod";
7
8
 
@@ -476,6 +477,20 @@ function redirectRefusal(message, field) {
476
477
  message
477
478
  }] });
478
479
  }
480
+ /**
481
+ * A field read rule admitting the callers who can update forms of this
482
+ * collection, and no one else: what the people managing a form may see, and
483
+ * a visitor may not.
484
+ *
485
+ * A super-admin is admitted by role. They may save any form, since the
486
+ * collection gate admits them before any rule, but field rules still apply
487
+ * to them, and their role need not hold `update-<slug>` as a stored grant. A
488
+ * field they could save but not read would come back empty to the editor
489
+ * and be saved empty. So everyone who can save a form can read these fields.
490
+ */
491
+ function readableByFormEditors(slug) {
492
+ return ({ permissions, roles }) => permissions.includes(`update-${slug}`) || roles.includes("super-admin");
493
+ }
479
494
  function formsCollection(pluginConfig) {
480
495
  const { slug, labels, fields: additionalFields, access: accessOverrides, hooks: hookOverrides, admin: adminOverrides, ...overrides } = pluginConfig.formOverrides;
481
496
  const redirectCollections = Object.keys(pluginConfig.redirectRelationships);
@@ -545,8 +560,40 @@ function formsCollection(pluginConfig) {
545
560
  settingsFields.push(checkbox$1({
546
561
  name: "allowMultipleSubmissions",
547
562
  label: "Allow Multiple Submissions",
548
- defaultValue: true,
549
- admin: { description: "Allow the same user/IP to submit this form multiple times" }
563
+ admin: { hidden: true }
564
+ }), select$1({
565
+ name: "oneResponse",
566
+ label: "One response per person",
567
+ options: [
568
+ {
569
+ label: "Allow several responses",
570
+ value: "off"
571
+ },
572
+ {
573
+ label: "One per email address",
574
+ value: "email"
575
+ },
576
+ {
577
+ label: "One per browser",
578
+ value: "browser"
579
+ },
580
+ {
581
+ label: "One per network (IP address)",
582
+ value: "ip"
583
+ }
584
+ ]
585
+ }), text$1({
586
+ name: "oneResponseField",
587
+ label: "Email field for one response per person"
588
+ }), number$1({
589
+ name: "oneResponseVersion",
590
+ label: "One-response version",
591
+ defaultValue: 0,
592
+ admin: { hidden: true }
593
+ }), number$1({
594
+ name: "oneResponseKeyedVersion",
595
+ label: "One-response keys made for version",
596
+ admin: { hidden: true }
550
597
  }), checkbox$1({
551
598
  name: "honeypotEnabled",
552
599
  label: "Honeypot",
@@ -554,7 +601,47 @@ function formsCollection(pluginConfig) {
554
601
  }), checkbox$1({
555
602
  name: "captchaEnabled",
556
603
  label: "Enable reCAPTCHA",
557
- admin: { description: "Per-form reCAPTCHA override; unset inherits the plugin default" }
604
+ admin: { hidden: true }
605
+ }), select$1({
606
+ name: "botCheck",
607
+ label: "Bot check",
608
+ options: [
609
+ {
610
+ label: "Site default",
611
+ value: "default"
612
+ },
613
+ {
614
+ label: "Off",
615
+ value: "off"
616
+ },
617
+ {
618
+ label: "reCAPTCHA",
619
+ value: "recaptcha"
620
+ },
621
+ {
622
+ label: "Turnstile",
623
+ value: "turnstile"
624
+ }
625
+ ]
626
+ }), date$1({
627
+ name: "closesAt",
628
+ label: "Closes at"
629
+ }), number$1({
630
+ name: "submissionLimit",
631
+ label: "Submission limit",
632
+ min: 1
633
+ }), checkbox$1({
634
+ name: "storeIpAndBrowser",
635
+ label: "Store IP address and browser",
636
+ defaultValue: true
637
+ }), number$1({
638
+ name: "keepDays",
639
+ label: "Keep submissions for (days)",
640
+ min: 1
641
+ }), textarea$1({
642
+ name: "internalNote",
643
+ label: "Internal note",
644
+ access: { read: readableByFormEditors(slug) }
558
645
  }), text$1({
559
646
  name: "captchaSiteKey",
560
647
  label: "reCAPTCHA Site Key",
@@ -601,7 +688,8 @@ function formsCollection(pluginConfig) {
601
688
  name: "notifications",
602
689
  label: "Notifications",
603
690
  defaultValue: [],
604
- admin: { description: "Email notification rules. Managed by the Form Builder UI." }
691
+ admin: { description: "Email notification rules. Managed by the Form Builder UI." },
692
+ access: { read: readableByFormEditors(slug) }
605
693
  }),
606
694
  select$1({
607
695
  name: "status",
@@ -660,7 +748,7 @@ function formsCollection(pluginConfig) {
660
748
  order: 50,
661
749
  useAsTitle: "name",
662
750
  description: "Create and manage your form templates using the visual form builder. Create and manage forms for collecting user submissions",
663
- components: { views: { Edit: { Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView" } } },
751
+ components: { views: { Edit: { Component: FORM_EDITOR_VIEW } } },
664
752
  ...adminOverrides
665
753
  },
666
754
  access: accessWithDefaults(accessOverrides, { read: true }),
@@ -698,6 +786,15 @@ function formsCollection(pluginConfig) {
698
786
  function trustedSubmittedAt(context) {
699
787
  return context.req?.http === void 0 && Boolean(context.data?.submittedAt);
700
788
  }
789
+ /** The unique index one response per person rests on. */
790
+ const RESPONSE_KEY_INDEX = {
791
+ fields: ["form", "responseKey"],
792
+ unique: true
793
+ };
794
+ /** Whether two index declarations name the same columns and uniqueness. */
795
+ function sameIndex(index, other) {
796
+ return index.fields.join("\0") === other.fields.join("\0") && (index.unique ?? false) === (other.unique ?? false);
797
+ }
701
798
  /**
702
799
  * Generate the Form Submissions collection configuration.
703
800
  *
@@ -820,6 +917,11 @@ function submissionsCollection(pluginConfig) {
820
917
  readOnly: true,
821
918
  description: "ID of the admin who last edited the submitted data"
822
919
  }
920
+ }),
921
+ text$1({
922
+ name: "responseKey",
923
+ label: "One-response key",
924
+ admin: { hidden: true }
823
925
  })
824
926
  ];
825
927
  let finalFields;
@@ -864,7 +966,8 @@ function submissionsCollection(pluginConfig) {
864
966
  return data;
865
967
  }]
866
968
  },
867
- ...overrides
969
+ ...overrides,
970
+ indexes: [RESPONSE_KEY_INDEX, ...(overrides.indexes ?? []).filter((index) => !sameIndex(index, RESPONSE_KEY_INDEX))]
868
971
  };
869
972
  }
870
973
 
@@ -938,8 +1041,14 @@ function newSubmissionsWidget(declared, submissionsPath) {
938
1041
  signal
939
1042
  }) > 0,
940
1043
  skeleton: "list",
941
- load: async ({ services, size, signal, scope }) => readNewSubmissions(services, declared, {
942
- limit: rowsThatFit(size, { density: "default" }),
1044
+ settings: { limit: setting.number({
1045
+ label: "Number of entries",
1046
+ defaultValue: 5,
1047
+ min: 1,
1048
+ max: MAX_WIDGET_ROWS
1049
+ }) },
1050
+ load: async ({ services, settings, signal, scope }) => readNewSubmissions(services, declared, {
1051
+ limit: listEntries(settings.limit),
943
1052
  signal,
944
1053
  scope,
945
1054
  submissionsPath: submissionsPath()
@@ -988,408 +1097,609 @@ function asFormDocument(row) {
988
1097
  return row;
989
1098
  }
990
1099
 
991
- // src/utils/generate-schema.ts
992
- /**
993
- * Generate a Zod validation schema from form field configuration.
994
- *
995
- * Creates a dynamic Zod schema based on the form fields array,
996
- * applying appropriate validation rules for each field type.
997
- *
998
- * @param fields - Array of form field configurations
999
- * @returns Zod object schema for validating submission data
1000
- *
1001
- * @example
1002
- * ```typescript
1003
- * const fields: FormField[] = [
1004
- * { type: 'text', name: 'firstName', label: 'First Name', required: true },
1005
- * { type: 'email', name: 'email', label: 'Email', required: true },
1006
- * { type: 'number', name: 'age', label: 'Age', validation: { min: 18, max: 120 } },
1007
- * ];
1008
- *
1009
- * const schema = generateZodSchema(fields);
1010
- * const result = schema.safeParse({ firstName: 'John', email: 'john@example.com', age: 25 });
1011
- * ```
1012
- */
1013
- function generateZodSchema(fields) {
1014
- const schemaShape = {};
1100
+ // src/emails/answers.ts
1101
+ /** Whether a stored answer counts as given. */
1102
+ function isAnswered(value) {
1103
+ if (value === void 0 || value === null) return false;
1104
+ if (typeof value === "string") return value.trim() !== "";
1105
+ if (Array.isArray(value)) return value.length > 0;
1106
+ return true;
1107
+ }
1108
+ /** A choice field's stored value(s) replaced by its option labels. */
1109
+ function choiceLabels(field, value) {
1110
+ const options = field.options;
1111
+ if (!Array.isArray(options)) return value;
1112
+ const label = (v) => options.find((option) => option.value === v)?.label ?? v;
1113
+ return Array.isArray(value) ? value.map(label) : label(value);
1114
+ }
1115
+ /** A file answer: how many, and whether they come with the email. */
1116
+ function fileAnswer(field, value) {
1117
+ const count = Array.isArray(value) ? value.length : 1;
1118
+ const files = count === 1 ? "1 file" : `${count} files`;
1119
+ return field.attachToEmail ? `${files}, attached` : `${files}, uploaded`;
1120
+ }
1121
+ /** Whether the form's conditions showed `field` for these answers. */
1122
+ function wasShown(field, data) {
1123
+ const logic = field.conditionalLogic;
1124
+ return evaluateConditions(logic, data);
1125
+ }
1126
+ /** The answers in a submission, for a notification's `_submission.answers`. */
1127
+ function submissionAnswers(fields, data) {
1128
+ const answers = [];
1015
1129
  for (const field of fields) {
1016
- if (!isKnownFormField(field)) {
1017
- schemaShape[field.name] = field.required ? z.unknown().refine((value) => value !== void 0 && value !== null && value !== "", { message: `${field.label || field.name} is required` }) : z.unknown();
1018
- continue;
1019
- }
1020
- if (field.type === "hidden" && !field.required) {
1021
- schemaShape[field.name] = z.string().optional();
1130
+ if (field.type === "hidden") continue;
1131
+ const value = data[field.name];
1132
+ if (!isAnswered(value) || !wasShown(field, data)) continue;
1133
+ if (field.type === "file") {
1134
+ answers.push({
1135
+ label: field.label || field.name,
1136
+ value: fileAnswer(field, value)
1137
+ });
1022
1138
  continue;
1023
1139
  }
1024
- const fieldSchema = generateFieldSchema(field);
1025
- if (fieldSchema) schemaShape[field.name] = fieldSchema;
1140
+ const shown = field.type === "select" || field.type === "radio" ? choiceLabels(field, value) : value;
1141
+ answers.push({
1142
+ label: field.label || field.name,
1143
+ value: formatExportValue(shown, field)
1144
+ });
1026
1145
  }
1027
- return z.object(schemaShape);
1146
+ return answers;
1028
1147
  }
1148
+
1149
+ // src/handlers/bot-check-send.ts
1150
+ /** Where each provider verifies a token. */
1151
+ const VERIFY_URLS = {
1152
+ recaptcha: "https://www.google.com/recaptcha/api/siteverify",
1153
+ turnstile: "https://challenges.cloudflare.com/turnstile/v0/siteverify"
1154
+ };
1155
+ /** The hosts those requests go to, for the plugin's outbound allowlist. */
1156
+ const VERIFY_HOSTS = {
1157
+ recaptcha: "www.google.com",
1158
+ turnstile: "challenges.cloudflare.com"
1159
+ };
1029
1160
  /**
1030
- * Generate a Zod schema for a single field.
1161
+ * Ask `provider` whether `token` is good, as both providers take it: a form
1162
+ * post of the secret, the token and, where known, the visitor's address.
1163
+ * Answers the provider's JSON; throws when it could not be reached or did
1164
+ * not answer 200.
1031
1165
  */
1032
- function generateFieldSchema(field) {
1033
- switch (field.type) {
1034
- case "text": return generateTextSchema(field);
1035
- case "email": return generateEmailSchema(field);
1036
- case "number": return generateNumberSchema(field);
1037
- case "phone": return generatePhoneSchema(field);
1038
- case "url": return generateUrlSchema(field);
1039
- case "textarea": return generateTextareaSchema(field);
1040
- case "select": return generateSelectSchema(field);
1041
- case "checkbox": return generateCheckboxSchema(field);
1042
- case "radio": return generateRadioSchema(field);
1043
- case "file": return generateFileSchema(field);
1044
- case "date": return generateDateSchema(field);
1045
- case "time": return generateTimeSchema(field);
1046
- case "hidden": return generateHiddenSchema(field);
1047
- default: return z.unknown();
1048
- }
1166
+ async function sendVerification(fetch, provider, request) {
1167
+ const body = new URLSearchParams({
1168
+ secret: request.secret,
1169
+ response: request.token,
1170
+ ...request.ip ? { remoteip: request.ip } : {}
1171
+ });
1172
+ const response = await fetch(VERIFY_URLS[provider], {
1173
+ method: "POST",
1174
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1175
+ body: body.toString()
1176
+ });
1177
+ if (!response.ok) throw new Error(`${provider} verification answered ${response.status}`);
1178
+ return response.json();
1049
1179
  }
1180
+
1181
+ // src/handlers/bot-check.ts
1182
+ /**
1183
+ * Bot checks: Google reCAPTCHA v3, which scores each visitor in the
1184
+ * background, and Cloudflare Turnstile, which checks them in the background
1185
+ * and rarely shows a puzzle. Either gives the visitor's browser a token,
1186
+ * which the site's form sends with the submission under the provider's own
1187
+ * field name, and which the server verifies with the provider before the
1188
+ * submission is accepted.
1189
+ *
1190
+ * A token that fails, or a submission that brings none, is stored flagged as
1191
+ * spam, as a honeypot hit is, so a false positive stays reviewable and
1192
+ * recoverable. A provider that cannot be reached leaves the submission
1193
+ * unchecked, and says so in the log: an outage must not send every real
1194
+ * submission to spam. A form whose check has no secret configured is
1195
+ * accepted unchecked, as before bot checks were verified, with a warning at
1196
+ * start and in Settings.
1197
+ *
1198
+ * @module handlers/bot-check
1199
+ */
1200
+ /** The field each provider's widget puts its token in, as forms send it. */
1201
+ const TOKEN_FIELDS = {
1202
+ recaptcha: "g-recaptcha-response",
1203
+ turnstile: "cf-turnstile-response"
1204
+ };
1205
+ /** The score below which reCAPTCHA calls a visitor a bot, unless set. */
1206
+ const DEFAULT_SCORE_THRESHOLD = .5;
1050
1207
  /**
1051
- * Generate schema for text field.
1052
- */
1053
- /**
1054
- * The author's own wording for a failure, or `undefined` when this field's type
1055
- * does not enforce `message`.
1056
- *
1057
- * Asked of the row rather than read off `validation` directly, for the reason
1058
- * the table exists at all: the editor decides whether to OFFER the control from
1059
- * that row, so a generator honouring a stored message regardless would enforce
1060
- * a value the author was never shown a box for — and, worse, the table would
1061
- * describe a rule it did not govern.
1062
- *
1063
- * `message` was the one rule that stayed outside this. It agreed with the table
1064
- * only because every row happens to list it, which is agreement by coincidence:
1065
- * drop `message` from a row and the control disappears while the wording went
1066
- * on being applied.
1208
+ * The provider a form's bot check names, its site default resolved: the
1209
+ * plugin's reCAPTCHA when enabled, else its Turnstile when enabled, else none.
1067
1210
  */
1068
- function customMessage(field) {
1069
- if (!(ENFORCED_VALIDATION_RULES[field.type] ?? []).includes("message")) return void 0;
1070
- return field.validation?.errorMessage;
1211
+ function botProviderOf(botCheck, spam) {
1212
+ if (botCheck === "off") return void 0;
1213
+ if (botCheck === "recaptcha" || botCheck === "turnstile") return botCheck;
1214
+ if (spam.recaptcha?.enabled === true) return "recaptcha";
1215
+ if (spam.turnstile?.enabled === true) return "turnstile";
1216
+ }
1217
+ /** The plugin's secret for `provider`, when one is set. */
1218
+ function secretOf(provider, spam) {
1219
+ const secret = spam[provider]?.secretKey;
1220
+ return secret ? secret : void 0;
1071
1221
  }
1072
1222
  /**
1073
- * A field's stored bounds, with the custom message already resolved by the row.
1074
- *
1075
- * Handed to the appliers so they cannot read an ungated message: they take a
1076
- * validation object, and giving them one whose `errorMessage` has been through
1077
- * {@link customMessage} makes the rule hold for every applier without each of
1078
- * them having to remember it.
1223
+ * The site key a visitor's form renders `provider`'s widget with: the
1224
+ * plugin's, or, for reCAPTCHA, a key the form stored itself before keys
1225
+ * moved into the plugin's options.
1079
1226
  */
1080
- function enforcedValidation(field) {
1081
- return {
1082
- ...field.validation ?? {},
1083
- errorMessage: customMessage(field)
1084
- };
1227
+ function siteKeyOf(provider, spam, settings) {
1228
+ const own = spam[provider]?.siteKey;
1229
+ if (own) return own;
1230
+ return provider === "recaptcha" ? settings.captchaSiteKey : void 0;
1085
1231
  }
1086
1232
  /**
1087
- * Which rules a type's schema applies, and how each one constrains a string.
1088
- *
1089
- * The WHICH comes from {@link ENFORCED_VALIDATION_RULES}, which the field
1090
- * editor also reads to decide what to offer. One definition answers both
1091
- * questions, so a rule cannot be offered by the editor and ignored here, or
1092
- * honoured here and hidden there — the two used to state the same thing
1093
- * separately and could only agree by being edited together.
1094
- *
1095
- * The HOW stays here, because it is about zod and the table is deliberately
1096
- * free of it: the editor imports the table, and pulling the schema builder in
1097
- * behind it would ship this file and its validation library to every admin
1098
- * client before anyone opened a field.
1099
- */
1100
- const STRING_RULE_APPLIERS = {
1101
- minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(validation.minLength, validation.errorMessage || `Minimum ${validation.minLength} characters required`),
1102
- maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(validation.maxLength, validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`),
1103
- pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(new RegExp(validation.pattern), validation.errorMessage || fallback) : schema
1104
- };
1105
- /** The same, for the bounds a number carries. */
1106
- const NUMBER_RULE_APPLIERS = {
1107
- min: (schema, validation) => validation.min === void 0 ? schema : schema.min(validation.min, validation.errorMessage || `Minimum value is ${validation.min}`),
1108
- max: (schema, validation) => validation.max === void 0 ? schema : schema.max(validation.max, validation.errorMessage || `Maximum value is ${validation.max}`)
1109
- };
1233
+ * `provider`'s keys for a form, when both are known: the secret to verify
1234
+ * with and a site key for the visitor's widget. A check missing either is
1235
+ * not run: without a site key no visitor could produce a token, and every
1236
+ * real submission would be flagged.
1237
+ */
1238
+ function readyKeys(provider, spam, settings) {
1239
+ const secret = secretOf(provider, spam);
1240
+ const siteKey = siteKeyOf(provider, spam, settings);
1241
+ return secret && siteKey ? {
1242
+ secret,
1243
+ siteKey
1244
+ } : void 0;
1245
+ }
1110
1246
  /**
1111
- * Whether an author-supplied pattern actually replaces a type's intrinsic one.
1112
- *
1113
- * Two conditions, and both are load-bearing: a pattern must be stored, AND the
1114
- * type's row must enforce `pattern`. A type that keeps its own shape check —
1115
- * a phone, a URL — stands it down only for an override that will really be
1116
- * applied, because standing down for one that will not leaves nothing behind.
1247
+ * Whether a form's bot check will be run: it names a provider and that
1248
+ * provider's keys are both known. The same answer `judgeBotCheck` acts on.
1117
1249
  */
1118
- function overridesPattern(field) {
1119
- if (!(ENFORCED_VALIDATION_RULES[field.type] ?? []).includes("pattern")) return false;
1120
- const validation = field.validation ?? {};
1121
- return Boolean(validation.pattern);
1250
+ function botCheckReady(settings, spam) {
1251
+ const provider = botProviderOf(settings.botCheck, spam);
1252
+ return provider !== void 0 && readyKeys(provider, spam, settings) !== void 0;
1122
1253
  }
1254
+ const RECAPTCHA_ANSWER = z.object({
1255
+ success: z.boolean(),
1256
+ score: z.number().optional()
1257
+ });
1258
+ const TURNSTILE_ANSWER = z.object({ success: z.boolean() });
1123
1259
  /**
1124
- * Constrain a string schema by the rules this field's TYPE enforces.
1125
- *
1126
- * A rule the type does not list is not applied even when the field carries a
1127
- * value for it, which is the point: a `pattern` stored on a textarea is a bound
1128
- * the editor never offered, and honouring it here would enforce a restriction
1129
- * its author could not see.
1130
- *
1131
- * `patternMessage` is the type's own wording for a failed pattern — "Invalid
1132
- * URL format" reads differently from "Invalid format" — so the rule set decides
1133
- * WHICH constraints apply while each type keeps its own voice.
1260
+ * Verify `token` with `provider`. A missing token fails: the form's widget
1261
+ * never ran. A provider that cannot be reached, or answers something that is
1262
+ * not its answer, leaves the submission unchecked.
1134
1263
  */
1135
- function applyStringRules(schema, field, patternMessage) {
1136
- const validation = enforcedValidation(field);
1137
- let constrained = schema;
1138
- for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
1139
- const apply = STRING_RULE_APPLIERS[rule];
1140
- if (apply) constrained = apply(constrained, validation, patternMessage);
1264
+ async function judgeBotToken(input) {
1265
+ if (!input.token) return "fail";
1266
+ if (!input.fetch) return "unchecked";
1267
+ let answer;
1268
+ try {
1269
+ answer = await sendVerification(input.fetch, input.provider, {
1270
+ secret: input.secret,
1271
+ token: input.token,
1272
+ ...input.ip ? { ip: input.ip } : {}
1273
+ });
1274
+ } catch {
1275
+ return "unchecked";
1141
1276
  }
1142
- return constrained;
1143
- }
1144
- function generateTextSchema(field) {
1145
- return applyRequired(applyStringRules(z.string(), field, "Invalid format"), field.required, customMessage(field));
1277
+ if (input.provider === "turnstile") {
1278
+ const parsed = TURNSTILE_ANSWER.safeParse(answer);
1279
+ if (!parsed.success) return "unchecked";
1280
+ return parsed.data.success ? "pass" : "fail";
1281
+ }
1282
+ const parsed = RECAPTCHA_ANSWER.safeParse(answer);
1283
+ if (!parsed.success) return "unchecked";
1284
+ const threshold = input.scoreThreshold ?? DEFAULT_SCORE_THRESHOLD;
1285
+ return parsed.data.success && (parsed.data.score ?? 0) >= threshold ? "pass" : "fail";
1146
1286
  }
1287
+ const KEY = z.string().trim().min(1).optional();
1288
+ /** The bot-check options, as the plugin's options give them. */
1289
+ const BOT_CHECK_OPTIONS = z.object({
1290
+ recaptcha: z.object({
1291
+ enabled: z.boolean(),
1292
+ siteKey: KEY,
1293
+ secretKey: KEY,
1294
+ scoreThreshold: z.number().min(0).max(1).optional()
1295
+ }).optional(),
1296
+ turnstile: z.object({
1297
+ enabled: z.boolean(),
1298
+ siteKey: KEY,
1299
+ secretKey: KEY
1300
+ }).optional()
1301
+ });
1147
1302
  /**
1148
- * Generate schema for email field.
1303
+ * The bot-check part of the plugin's options, parsed at start: a key that is
1304
+ * not a string, or a score threshold outside 0 to 1, fails the start with
1305
+ * the option named, rather than every submission later.
1149
1306
  */
1150
- function generateEmailSchema(field) {
1151
- return applyRequired(applyStringRules(z.string().email(customMessage(field) || "Invalid email address"), field, "Invalid email format"), field.required, customMessage(field));
1307
+ function botCheckOptions(spamProtection) {
1308
+ const source = spamProtection !== null && typeof spamProtection === "object" ? spamProtection : {};
1309
+ const parsed = BOT_CHECK_OPTIONS.safeParse({
1310
+ recaptcha: source.recaptcha,
1311
+ turnstile: source.turnstile
1312
+ });
1313
+ if (!parsed.success) {
1314
+ const issue = parsed.error.issues[0];
1315
+ throw new Error(`formBuilder: spamProtection.${issue?.path.join(".") ?? ""} is not valid: ${issue?.message ?? "invalid"}`);
1316
+ }
1317
+ return {
1318
+ recaptcha: parsed.data.recaptcha ?? { enabled: false },
1319
+ turnstile: parsed.data.turnstile ?? { enabled: false }
1320
+ };
1152
1321
  }
1153
1322
  /**
1154
- * Generate schema for number field.
1323
+ * The hosts the plugin's bot checks call, for its outbound allowlist: a
1324
+ * provider's only when its secret is set, so a site using neither declares
1325
+ * no network access at all.
1155
1326
  */
1156
- function generateNumberSchema(field) {
1157
- let schema = z.number({ error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number" });
1158
- const validation = enforcedValidation(field);
1159
- for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
1160
- const apply = NUMBER_RULE_APPLIERS[rule];
1161
- if (apply) schema = apply(schema, validation);
1327
+ function botCheckHosts(spam) {
1328
+ return ["recaptcha", "turnstile"].filter((provider) => secretOf(provider, spam) !== void 0).map((provider) => VERIFY_HOSTS[provider]);
1329
+ }
1330
+ /** The tokens in a submission's payload, before its own fields are kept. */
1331
+ function botTokensOf(payload) {
1332
+ const tokens = {};
1333
+ for (const provider of ["recaptcha", "turnstile"]) {
1334
+ const value = payload[TOKEN_FIELDS[provider]];
1335
+ if (typeof value === "string" && value.trim() !== "") tokens[provider] = value.trim();
1162
1336
  }
1163
- if (field.required) return schema;
1164
- return schema.optional().nullable();
1337
+ return tokens;
1165
1338
  }
1166
1339
  /**
1167
- * Generate schema for phone field.
1168
- */
1169
- function generatePhoneSchema(field) {
1170
- return applyRequired(applyStringRules(overridesPattern(field) ? z.string() : z.string().regex(/^[\d\s\-+()]+$/, customMessage(field) || "Invalid phone number"), field, "Invalid phone number format"), field.required, customMessage(field));
1340
+ * A request-made submission's bot check: none when its form's check is off,
1341
+ * or names a provider missing its secret or site key (accepted unchecked, as
1342
+ * warned at start and in Settings); a spam verdict when the token fails or
1343
+ * is missing.
1344
+ */
1345
+ async function judgeBotCheck(input) {
1346
+ const provider = botProviderOf(input.settings.botCheck, input.spam);
1347
+ if (provider === void 0) return void 0;
1348
+ const keys = readyKeys(provider, input.spam, input.settings);
1349
+ if (keys === void 0) return void 0;
1350
+ const verdict = await judgeBotToken({
1351
+ provider,
1352
+ secret: keys.secret,
1353
+ token: input.tokens[provider],
1354
+ ...input.ip ? { ip: input.ip } : {},
1355
+ scoreThreshold: input.spam.recaptcha?.scoreThreshold,
1356
+ fetch: input.fetch
1357
+ });
1358
+ if (verdict === "unchecked") input.warn("Form Builder: the bot check could not be completed, so the submission was accepted unchecked", { provider });
1359
+ return verdict === "fail" ? {
1360
+ isSpam: true,
1361
+ reason: provider
1362
+ } : void 0;
1171
1363
  }
1172
1364
  /**
1173
- * Generate schema for URL field.
1365
+ * Warn, once at start, for each bot check that is in use (the site default,
1366
+ * or named by a form) without both of its keys, naming the options: those
1367
+ * forms' submissions are accepted unchecked until it is. Reads at most the
1368
+ * first 500 forms; a failure to read them is not a reason to stop the start.
1174
1369
  */
1175
- function generateUrlSchema(field) {
1176
- return applyRequired(applyStringRules(z.string().url(customMessage(field) || "Invalid URL"), field, "Invalid URL format"), field.required, customMessage(field));
1370
+ async function warnUncheckedBotChecks(input) {
1371
+ let forms = [];
1372
+ try {
1373
+ forms = await input.forms();
1374
+ } catch {
1375
+ forms = [];
1376
+ }
1377
+ const counts = /* @__PURE__ */ new Map();
1378
+ for (const form of forms) {
1379
+ const settings = input.readSettings(form.settings);
1380
+ const provider = botProviderOf(settings.botCheck, input.spam);
1381
+ if (provider && readyKeys(provider, input.spam, settings) === void 0) counts.set(provider, (counts.get(provider) ?? 0) + 1);
1382
+ }
1383
+ const siteDefault = botProviderOf("default", input.spam);
1384
+ if (siteDefault && readyKeys(siteDefault, input.spam, {}) === void 0) counts.set(siteDefault, counts.get(siteDefault) ?? 0);
1385
+ for (const [provider, forms] of counts) input.warn(`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.`, {
1386
+ provider,
1387
+ forms
1388
+ });
1177
1389
  }
1178
- /**
1179
- * Generate schema for textarea field.
1180
- */
1181
- function generateTextareaSchema(field) {
1182
- return applyRequired(applyStringRules(z.string(), field, "Invalid format"), field.required, customMessage(field));
1390
+ /** {@link BotCheckDefaults} for the admin: whether keys are set, never the keys. */
1391
+ function botCheckDefaults(spam) {
1392
+ const keys = (provider) => ({
1393
+ secret: secretOf(provider, spam) !== void 0,
1394
+ siteKey: Boolean(spam[provider]?.siteKey)
1395
+ });
1396
+ return {
1397
+ botCheckDefault: botProviderOf("default", spam) ?? null,
1398
+ botChecks: {
1399
+ recaptcha: keys("recaptcha"),
1400
+ turnstile: keys("turnstile")
1401
+ },
1402
+ rateLimit: spam.rateLimit ?? null
1403
+ };
1183
1404
  }
1184
1405
  /**
1185
- * Generate schema for select field.
1186
- */
1187
- function generateSelectSchema(field) {
1188
- const validValues = field.options.map((opt) => opt.value);
1189
- if (field.allowMultiple) {
1190
- const schema = z.array(z.enum(validValues));
1191
- if (field.required) return schema.min(1, customMessage(field) || "Please select at least one option");
1192
- return schema.optional();
1193
- }
1194
- const schema = z.enum(validValues, { error: () => customMessage(field) || "Please select a valid option" });
1195
- if (field.required) return schema;
1196
- return schema.optional();
1406
+ * The bot check a visitor's form renders, as the public read of a form gives
1407
+ * it: the provider and its site key, or none when the form's check is off,
1408
+ * names a provider with no secret (not verified, so not shown), or has no
1409
+ * site key to render with.
1410
+ */
1411
+ function publicBotCheck(storedSettings, spam) {
1412
+ const settings = normalizeFormSettings(storedSettings);
1413
+ const provider = botProviderOf(settings.botCheck, spam);
1414
+ if (provider === void 0) return void 0;
1415
+ const keys = readyKeys(provider, spam, settings);
1416
+ return keys ? {
1417
+ provider,
1418
+ siteKey: keys.siteKey,
1419
+ field: TOKEN_FIELDS[provider]
1420
+ } : void 0;
1197
1421
  }
1422
+
1423
+ // src/utils/form-closing.ts
1198
1424
  /**
1199
- * Generate schema for checkbox field.
1425
+ * Whether a form is taking submissions now: its status, as core reads it for
1426
+ * every way a form is reached, then the form's closing time and its limit.
1427
+ *
1428
+ * Whatever closes a form (its author by hand, its closing time or its limit),
1429
+ * a visitor is told one thing: the form's closed message, or the generic
1430
+ * sentence when its author wrote none. That message is core's own answer for
1431
+ * a closed form, asked of this form, so the three causes cannot drift apart.
1432
+ *
1433
+ * @module utils/form-closing
1200
1434
  */
1201
- function generateCheckboxSchema(field) {
1202
- const schema = z.boolean();
1203
- if (field.required) return schema.refine((val) => val === true, { message: customMessage(field) || "This field is required" });
1204
- return schema.optional();
1205
- }
1206
1435
  /**
1207
- * Generate schema for radio field.
1436
+ * The message a visitor is given when this form is closed: core's answer for
1437
+ * the form had its author closed it, whether or not they did.
1208
1438
  */
1209
- function generateRadioSchema(field) {
1210
- const validValues = field.options.map((opt) => opt.value);
1211
- const schema = z.enum(validValues, { error: () => customMessage(field) || "Please select an option" });
1212
- if (field.required) return schema;
1213
- return schema.optional();
1439
+ function closedMessageOf(form) {
1440
+ const asClosed = formAvailability({
1441
+ closedMessage: form.closedMessage,
1442
+ status: "closed",
1443
+ wentLiveAt: "live"
1444
+ });
1445
+ if (asClosed.kind !== "closed") throw new Error("a closed form that has been public explains itself");
1446
+ return asClosed.message;
1447
+ }
1448
+ /** Whether the form's closing time has passed at `now`. */
1449
+ function pastClosingTime(settings, now) {
1450
+ if (settings.closesAt === void 0) return false;
1451
+ const closesAt = Date.parse(settings.closesAt);
1452
+ return Number.isFinite(closesAt) && now.getTime() >= closesAt;
1453
+ }
1454
+ /** Whether `taken` non-spam submissions fill the form's limit. */
1455
+ function limitReached(settings, taken) {
1456
+ return settings.submissionLimit !== void 0 && taken >= settings.submissionLimit;
1214
1457
  }
1215
1458
  /**
1216
- * Generate schema for file field.
1217
- *
1218
- * Note: File validation is typically handled server-side.
1219
- * This schema validates file references (IDs or URLs).
1220
- */
1221
- function generateFileSchema(field) {
1222
- if (field.multiple) {
1223
- const schema = z.array(z.string());
1224
- if (field.required) return schema.min(1, customMessage(field) || "Please upload at least one file");
1225
- return schema.optional();
1226
- }
1227
- return applyRequired(z.string(), field.required, customMessage(field));
1459
+ * The form's answer to a visitor now. Core decides by status first, so a
1460
+ * draft stays absent and a form closed by hand keeps its own answer; a
1461
+ * published one is then closed by its closing time, and by its limit when
1462
+ * `taken` (its non-spam submissions) is known.
1463
+ */
1464
+ function formAvailabilityNow(form, settings, at) {
1465
+ const byStatus = formAvailability(form);
1466
+ if (byStatus.kind !== "open") return byStatus;
1467
+ const closedBy = closedByNow(settings, at);
1468
+ return closedBy === void 0 ? byStatus : {
1469
+ kind: "closed",
1470
+ message: closedMessageOf(form),
1471
+ closedBy
1472
+ };
1473
+ }
1474
+ /** What closes an open form now, if anything does. */
1475
+ function closedByNow(settings, at) {
1476
+ if (pastClosingTime(settings, at.now)) return "date";
1477
+ if (at.taken !== void 0 && limitReached(settings, at.taken)) return "limit";
1478
+ }
1479
+
1480
+ // src/handlers/one-response.ts
1481
+ /** What a visitor sending a second response is told. */
1482
+ const ALREADY_RESPONDED = "You have already submitted this form.";
1483
+ /**
1484
+ * The answer a visitor's browser sends its token in, for a form that takes
1485
+ * one response per browser. The form's script keeps one random token per
1486
+ * browser and sends it with every submission; the row never stores it.
1487
+ */
1488
+ const BROWSER_TOKEN_FIELD = "nextly-browser-token";
1489
+ /**
1490
+ * What every browser key starts with. The token is not stored, so a browser
1491
+ * key cannot be made again from the row the way an address's can; the prefix
1492
+ * is what lets the submission's hold tell a key made for one per browser from
1493
+ * one made for another mode, and refuse it once the form's mode has changed.
1494
+ * Digests are hex, so no address or network key can start with it.
1495
+ */
1496
+ const BROWSER_KEY_PREFIX = "browser:";
1497
+ /**
1498
+ * What a key made under the form's one-response `version` starts with. The
1499
+ * version moves on each time the form changes how it tells people apart (its
1500
+ * mode, or the Email field it reads), so every key made before the change
1501
+ * stops matching a new one without a write to any earlier submission; the
1502
+ * keys of earlier submissions are then made again for the new version
1503
+ * (`one-response-rebuild`). Version 0 has no prefix, so keys stored before
1504
+ * versions were written keep matching.
1505
+ */
1506
+ function keyPrefixOf(version) {
1507
+ return version !== void 0 && version > 0 ? `v${version}:` : "";
1508
+ }
1509
+ /** The one-response version a stored key was made under. */
1510
+ function keyVersionOf(key) {
1511
+ const match = /^v(\d+):/.exec(key);
1512
+ return match ? Number(match[1]) : 0;
1228
1513
  }
1229
1514
  /**
1230
- * Generate schema for date field.
1515
+ * Whether the form's earlier submissions are still being given keys for its
1516
+ * current version (`one-response-rebuild`). Unset reads as up to date: a form
1517
+ * saved before the version was recorded has keys for version 0.
1231
1518
  */
1232
- function generateDateSchema(field) {
1233
- let schema = z.string();
1234
- schema = schema.refine((val) => {
1235
- if (!val) return true;
1236
- const date = new Date(val);
1237
- return !isNaN(date.getTime());
1238
- }, { message: customMessage(field) || "Invalid date" });
1239
- if (field.min || field.max) schema = schema.refine((val) => {
1240
- if (!val) return true;
1241
- const date = new Date(val);
1242
- if (field.min && date < new Date(field.min)) return false;
1243
- if (field.max && date > new Date(field.max)) return false;
1244
- return true;
1245
- }, { message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}` });
1246
- if (field.required) return schema.refine((val) => val !== void 0 && val !== null && val !== "", { message: customMessage(field) || "This field is required" });
1247
- return schema.optional().or(z.literal(""));
1519
+ function keysBehind(settings) {
1520
+ const keyed = settings.oneResponseKeyedVersion;
1521
+ return keyed !== void 0 && keyed < (settings.oneResponseVersion ?? 0);
1248
1522
  }
1249
1523
  /**
1250
- * Generate schema for time field.
1524
+ * What tells one person from another on a form: its mode, and for one per
1525
+ * email the field it reads. A change to it is a change of version.
1251
1526
  */
1252
- function generateTimeSchema(field) {
1253
- return applyRequired(z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, customMessage(field) || "Invalid time format (HH:mm)"), field.required, customMessage(field));
1527
+ function oneResponseIdentityOf(settings) {
1528
+ const mode = settings.oneResponse ?? "off";
1529
+ return mode === "email" ? `email:${settings.oneResponseField ?? ""}` : mode;
1254
1530
  }
1255
1531
  /**
1256
- * Generate schema for hidden field.
1532
+ * A browser token as the form's script makes one: a random identifier
1533
+ * (`crypto.randomUUID()` is one), letters, digits, `-` and `_`. Anything else
1534
+ * is treated as no token, so a malformed value can neither be stored as a
1535
+ * key nor fill the index with arbitrary text.
1257
1536
  */
1258
- function generateHiddenSchema(field) {
1259
- return applyRequired(z.string(), field.required, customMessage(field));
1537
+ const BROWSER_TOKEN = z.string().trim().regex(/^[A-Za-z0-9_-]{16,128}$/);
1538
+ /** The browser token a submission's answers carry, if a well-formed one. */
1539
+ function browserTokenOf(payload) {
1540
+ const parsed = BROWSER_TOKEN.safeParse(answersOf(payload)?.[BROWSER_TOKEN_FIELD]);
1541
+ return parsed.success ? parsed.data : void 0;
1260
1542
  }
1261
1543
  /**
1262
- * Apply required/optional to a schema.
1544
+ * What a visitor's form needs to take part in one response per browser: the
1545
+ * answer to send its token in. Other modes need nothing from the browser, and
1546
+ * a form that does not compare by browser says nothing.
1263
1547
  */
1264
- function applyRequired(schema, required, errorMessage) {
1265
- if (required) return schema.min(1, errorMessage || "This field is required");
1266
- return schema.optional().or(z.literal(""));
1548
+ function publicOneResponse(settings) {
1549
+ return settings.oneResponse === "browser" ? {
1550
+ by: "browser",
1551
+ field: BROWSER_TOKEN_FIELD
1552
+ } : void 0;
1553
+ }
1554
+ /** Whether `key` was made for one response per browser, under `version`. */
1555
+ function isBrowserKey(key, version) {
1556
+ return typeof key === "string" && key.startsWith(`${keyPrefixOf(version)}${BROWSER_KEY_PREFIX}`);
1557
+ }
1558
+ /** A non-empty string, trimmed, or undefined. */
1559
+ function textOf(value) {
1560
+ if (typeof value !== "string") return void 0;
1561
+ const trimmed = value.trim();
1562
+ return trimmed === "" ? void 0 : trimmed;
1267
1563
  }
1268
1564
  /**
1269
- * Transform form data before validation.
1270
- *
1271
- * Handles type coercion and normalization:
1272
- * - Converts string numbers to actual numbers
1273
- * - Converts string booleans to actual booleans
1274
- * - Trims string values
1275
- * - Handles empty strings
1276
- *
1277
- * @param data - Raw form data
1278
- * @param fields - Form field configurations
1279
- * @returns Transformed data ready for validation
1565
+ * A submission's answers as an object: as written, or as a stored row holds
1566
+ * them before a read decodes them, a JSON string.
1280
1567
  */
1281
- function transformFormData(data, fields) {
1282
- const transformed = {};
1283
- for (const field of fields) {
1284
- const value = data[field.name];
1285
- if (value === void 0) continue;
1286
- transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
1568
+ function answersOf(data) {
1569
+ let value = data;
1570
+ if (typeof value === "string") try {
1571
+ value = JSON.parse(value);
1572
+ } catch {
1573
+ return;
1287
1574
  }
1288
- return transformed;
1575
+ return value !== null && typeof value === "object" ? value : void 0;
1576
+ }
1577
+ /** The answer to `field` in a submission's data. */
1578
+ function answerOf(data, field) {
1579
+ return field === void 0 ? void 0 : answersOf(data)?.[field];
1289
1580
  }
1290
1581
  /**
1291
- * Transform a single field value based on its type.
1292
- */
1293
- function transformFieldValue(value, field) {
1294
- if (value === null || value === void 0) return value;
1295
- switch (field.type) {
1296
- case "number":
1297
- if (typeof value === "string") {
1298
- const num = parseFloat(value);
1299
- return isNaN(num) ? value : num;
1300
- }
1301
- return value;
1302
- case "checkbox":
1303
- if (typeof value === "string") return value === "true" || value === "1" || value === "on";
1304
- return Boolean(value);
1305
- case "text":
1306
- case "email":
1307
- case "phone":
1308
- case "url":
1309
- case "textarea":
1310
- case "hidden":
1311
- if (typeof value === "string") return value.trim();
1312
- return value;
1313
- case "select":
1314
- if (field.allowMultiple) {
1315
- if (!Array.isArray(value)) return value ? [value] : [];
1316
- }
1317
- return value;
1318
- default: return value;
1582
+ * The submission's key under the form's one-response setting, or null when
1583
+ * it has none: the form allows several responses, the submission is spam,
1584
+ * or what the mode compares is missing (a submission with no address, or
1585
+ * none of the browser token, which only a create brings).
1586
+ */
1587
+ async function responseKeyOf(digests, settings, submission) {
1588
+ if (submission.status === "spam") return null;
1589
+ const prefix = keyPrefixOf(settings.oneResponseVersion);
1590
+ switch (settings.oneResponse) {
1591
+ case "ip": {
1592
+ const address = textOf(submission.ipAddress);
1593
+ return address === void 0 ? null : `${prefix}${await digests().digest("ip", address)}`;
1594
+ }
1595
+ case "email": {
1596
+ const email = textOf(answerOf(submission.data, settings.oneResponseField));
1597
+ return email === void 0 ? null : `${prefix}${await digests().digest("email", email.toLowerCase())}`;
1598
+ }
1599
+ case "browser": {
1600
+ const token = submission.browserToken;
1601
+ return token === void 0 ? null : `${prefix}${BROWSER_KEY_PREFIX}${await digests().digest("browser", token)}`;
1602
+ }
1603
+ default: return null;
1319
1604
  }
1320
1605
  }
1321
1606
  /**
1322
- * Validate form data against a form's field configuration.
1323
- *
1324
- * Combines transformation and validation in one step.
1325
- *
1326
- * @param data - Raw form data
1327
- * @param fields - Form field configurations
1328
- * @returns Zod safe parse result
1329
- *
1330
- * @example
1331
- * ```typescript
1332
- * const result = validateFormData({ name: 'John', email: 'john@example.com' }, fields);
1333
- *
1334
- * if (result.success) {
1335
- * console.log('Valid data:', result.data);
1336
- * } else {
1337
- * console.log('Validation errors:', result.error.flatten());
1338
- * }
1339
- * ```
1340
- */
1341
- function validateFormData(data, fields) {
1342
- const transformed = transformFormData(data, fields);
1343
- return generateZodSchema(fields).safeParse(transformed);
1607
+ * Whether the key a new submission was given is the one `settings` give it.
1608
+ * An address's key is made again from the row and compared. A browser's
1609
+ * cannot be, since its token is not stored: under one per browser a browser
1610
+ * key, or none (a submission sent without a token), is the right one, and a
1611
+ * key made for another mode is not. Nor can a network's on a form that keeps
1612
+ * no addresses, where a key for the current version is the right one.
1613
+ */
1614
+ async function holdsKeyFor(digests, settings, row) {
1615
+ const key = row.responseKey ?? null;
1616
+ if (settings.oneResponse === "browser" && row.status !== "spam") return key === null || isBrowserKey(key, settings.oneResponseVersion);
1617
+ if (settings.oneResponse === "ip" && textOf(row.ipAddress) === void 0 && typeof key === "string" && row.status !== "spam") return keyVersionOf(key) === (settings.oneResponseVersion ?? 0) && !key.includes(BROWSER_KEY_PREFIX);
1618
+ return key === await responseKeyOf(digests, settings, row);
1619
+ }
1620
+ /** Whether an update changes what a submission's key is made from. */
1621
+ function touchesResponseKey(patch) {
1622
+ return patch.status !== void 0 || patch.data !== void 0 || patch.ipAddress !== void 0;
1344
1623
  }
1345
1624
  /**
1346
- * Whether the visitor supplied nothing at this field.
1347
- *
1348
- * The same four shapes the endpoint has always counted as blank. A `false`
1349
- * checkbox and a `0` are answers, so neither is absent.
1350
- */
1351
- function isBlank(value) {
1352
- return value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
1625
+ * A submission's key after an update, made from the stored row with the
1626
+ * update applied: none once it is spam; its own again when restored from
1627
+ * spam, unless another submission has since taken that value, when it keeps
1628
+ * none and the value stays with that one; a new one when its answer or
1629
+ * address changed, which the unique index refuses if another submission
1630
+ * holds it already, so an edit cannot give two submissions one value; and,
1631
+ * when the value is unchanged, what it held.
1632
+ */
1633
+ async function responseKeyAfterUpdate(input) {
1634
+ const { patch, stored } = input;
1635
+ if (input.settings.oneResponse === "browser") return browserKeyAfterUpdate(patch, stored, input.settings.oneResponseVersion, input.moved);
1636
+ if (input.settings.oneResponse === "ip" && textOf(stored?.ipAddress) === void 0 && patch.ipAddress === void 0) return keptKeyAfterUpdate(patch, stored, input.settings.oneResponseVersion, input.moved);
1637
+ const after = (field) => patch[field] === void 0 ? stored?.[field] : patch[field];
1638
+ const key = await responseKeyOf(input.digests, input.settings, {
1639
+ status: after("status"),
1640
+ data: after("data"),
1641
+ ipAddress: after("ipAddress")
1642
+ });
1643
+ if (key === null) return null;
1644
+ const storedValue = await responseKeyOf(input.digests, input.settings, {
1645
+ data: stored?.data,
1646
+ ipAddress: stored?.ipAddress
1647
+ });
1648
+ if (input.moved || key !== storedValue) return key;
1649
+ if (stored?.status === "spam") return await input.heldElsewhere(key) ? null : key;
1650
+ return typeof stored?.responseKey === "string" ? stored.responseKey : null;
1353
1651
  }
1354
1652
  /**
1355
- * The failures with their codes, first one per field.
1356
- *
1357
- * A code as well as a sentence, because the public submission error has always
1358
- * carried one and a client uses it to tell "you left this blank" from "this is
1359
- * not an email" without parsing English. Flattening every failure to `INVALID`
1360
- * silently stopped required-field detection working for those clients.
1361
- *
1362
- * The code is decided by looking at the SUBMITTED VALUE, not at the Zod issue.
1363
- * Zod's codes do not divide the same way: `too_small` covers a blank answer and
1364
- * a four-character password under a minimum of eight, and `invalid_type` covers
1365
- * an absent key and a string sent to a number field. Reading the code alone
1366
- * told a visitor to fill in a field they had already filled in.
1653
+ * A submission's browser key after an update. The token is not stored, so
1654
+ * the key cannot be made again: the submission keeps the browser key it
1655
+ * holds until it is marked spam, which gives the browser its place back, and
1656
+ * one restored from spam stays without one. Answers edited keep it, and a
1657
+ * submission moved to another form taking one per browser takes it there,
1658
+ * for that form's version, since it is the same browser's response; the
1659
+ * unique index refuses a move onto a form where that browser has responded.
1660
+ */
1661
+ function browserKeyAfterUpdate(patch, stored, version, moved) {
1662
+ if ((patch.status === void 0 ? stored?.status : patch.status) === "spam") return null;
1663
+ const key = stored?.responseKey;
1664
+ if (moved) return browserKeyFor(key, version);
1665
+ return isBrowserKey(key, version) ? key : null;
1666
+ }
1667
+ /** A browser key of any version, spelled for `version`; none for any other key. */
1668
+ function browserKeyFor(key, version) {
1669
+ if (typeof key !== "string") return null;
1670
+ const match = /^(?:v\d+:)?browser:([0-9a-f]+)$/.exec(key);
1671
+ return match ? `${keyPrefixOf(version)}${BROWSER_KEY_PREFIX}${match[1]}` : null;
1672
+ }
1673
+ /**
1674
+ * A key the row holds but cannot be made again from it (a network's on a
1675
+ * form that keeps no addresses), after an update: none once the submission
1676
+ * is spam; spelled for the destination's version when it moves to another
1677
+ * form; else the key it holds, while that is for the form's current version.
1367
1678
  */
1368
- function getValidationIssues(result, submitted = {}) {
1369
- if (result.success) return [];
1370
- const seen = /* @__PURE__ */ new Set();
1371
- const issues = [];
1372
- for (const issue of result.error.issues) {
1373
- const path = issue.path.join(".");
1374
- if (seen.has(path)) continue;
1375
- seen.add(path);
1376
- issues.push({
1377
- path,
1378
- code: isBlank(submitted[path]) ? "REQUIRED" : "INVALID",
1379
- message: issue.message
1380
- });
1381
- }
1382
- return issues;
1679
+ function keptKeyAfterUpdate(patch, stored, version, moved) {
1680
+ const key = networkKeyHeld(patch, stored);
1681
+ if (key === null) return null;
1682
+ if (moved) return `${keyPrefixOf(version)}${key.replace(/^v\d+:/, "")}`;
1683
+ return keyVersionOf(key) === (version ?? 0) ? key : null;
1383
1684
  }
1384
1685
  /**
1385
- * The same failures as a field-to-message map.
1386
- *
1387
- * Derived from {@link getValidationIssues} rather than walking the issues a
1388
- * second time, so the two cannot disagree about which message belongs to a
1389
- * field or which failure won when one field had several.
1686
+ * The key a stored submission should hold under `settings`: its value's,
1687
+ * made again from the row; or, for a network's on a row that kept no
1688
+ * address (a form that keeps none), the key it holds while that is for the
1689
+ * current version, since it cannot be made again. The rebuild asks this, so
1690
+ * a submission stored under a form that keeps no addresses keeps its key.
1390
1691
  */
1391
- function getValidationErrors(result) {
1392
- return Object.fromEntries(getValidationIssues(result).map((issue) => [issue.path, issue.message]));
1692
+ async function storedRowKeyOf(digests, settings, row) {
1693
+ if (settings.oneResponse !== "ip" || textOf(row.ipAddress) !== void 0) return responseKeyOf(digests, settings, row);
1694
+ const key = networkKeyHeld({}, row);
1695
+ return key !== null && keyVersionOf(key) === (settings.oneResponseVersion ?? 0) ? key : null;
1696
+ }
1697
+ /** The network key a submission holds, unless the update makes it spam. */
1698
+ function networkKeyHeld(patch, stored) {
1699
+ const status = patch.status === void 0 ? stored?.status : patch.status;
1700
+ const key = stored?.responseKey;
1701
+ if (status === "spam" || typeof key !== "string") return null;
1702
+ return key.includes(BROWSER_KEY_PREFIX) ? null : key;
1393
1703
  }
1394
1704
 
1395
1705
  // src/handlers/prepare-submission.ts
@@ -1489,17 +1799,17 @@ function submissionMarks(row) {
1489
1799
  return row[PLUGIN_SUBMISSION];
1490
1800
  }
1491
1801
  /** A form value, compared the way a form value can be. */
1492
- function sameValue(left, right) {
1802
+ function sameValue$1(left, right) {
1493
1803
  if (Object.is(left, right)) return true;
1494
1804
  if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
1495
- if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
1805
+ if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameValue$1(value, right[index]));
1496
1806
  return false;
1497
1807
  }
1498
1808
  /** Whether two submitted payloads say the same thing. */
1499
1809
  function sameSubmittedPayload(granted, incoming) {
1500
1810
  const answers = Object.entries(granted);
1501
1811
  if (answers.length !== Object.keys(incoming).length) return false;
1502
- return answers.every(([field, answer]) => sameValue(answer, incoming[field]));
1812
+ return answers.every(([field, answer]) => sameValue$1(answer, incoming[field]));
1503
1813
  }
1504
1814
  /**
1505
1815
  * Bring a submission to the shape the form declares.
@@ -1525,13 +1835,804 @@ function prepareSubmission({ data, fields, validate }) {
1525
1835
  const transformed = transformFormData(data, fields);
1526
1836
  sanitizeSubmissionData(transformed, fields);
1527
1837
  if (!validate) return { data: transformed };
1528
- const result = generateZodSchema(fields).safeParse(transformed);
1838
+ const hidden = fieldsHiddenByCondition(fields, transformed);
1839
+ const result = generateZodSchema(fields.filter((field) => !hidden.has(field.name))).safeParse(transformed);
1529
1840
  if (!result.success) return {
1530
1841
  data: transformed,
1531
1842
  validationErrors: getValidationErrors(result),
1532
1843
  validationIssues: getValidationIssues(result, data)
1533
1844
  };
1534
- return { data: result.data };
1845
+ return { data: {
1846
+ ...transformed,
1847
+ ...result.data
1848
+ } };
1849
+ }
1850
+
1851
+ // src/handlers/form-closing-hold.ts
1852
+ /**
1853
+ * A form's closing time and limit, held where a submission is stored: inside
1854
+ * the submission's own write transaction, whatever wrote it (the public route,
1855
+ * `submitForm` called from another plugin, `nextly.forms.submit()` or the
1856
+ * admin). So no way of storing a submission can outlive the form's closing.
1857
+ *
1858
+ * Past the closing time, every new submission is refused, spam included.
1859
+ *
1860
+ * Two submissions for a form's last place must not both be stored. Checking a
1861
+ * count before the write lets both read "one place left", so the check runs
1862
+ * after the submission's row is inserted, in the same transaction, under the
1863
+ * form's row locked for update: a second submission to the same form waits
1864
+ * there until the first has committed or rolled back, then counts again, its
1865
+ * own row and the first's both included, and is refused if they are more
1866
+ * than the limit. The refusal rolls the whole write back, so nothing is kept.
1867
+ *
1868
+ * Per dialect:
1869
+ *
1870
+ * - PostgreSQL and MySQL. A write that opens its own transaction and runs
1871
+ * in-transaction handlers reads committed, so the count after the lock sees
1872
+ * what the submission before it committed. The count takes no lock of its
1873
+ * own: a locking read would wait on another submission's uncommitted row
1874
+ * while that submission waits for this one's lock. The submission's `form`
1875
+ * column is a plain column with no database foreign key, so its insert holds
1876
+ * no lock on the form's row, and two submissions waiting on each other's
1877
+ * insert cannot arise.
1878
+ * - SQLite. One writer at a time already, so the count sees every commit.
1879
+ *
1880
+ * One limit to this: a submission created inside a transaction its caller
1881
+ * opened (`withTransaction` with `createEntryInTransaction`) reads at that
1882
+ * transaction's isolation. On MySQL that is REPEATABLE READ, where the count
1883
+ * answers from the transaction's first read, so two such transactions racing
1884
+ * for the last place can both be stored. Every way of submitting this plugin
1885
+ * and core offer opens its own transaction.
1886
+ *
1887
+ * Spam does not count, and a submission stored as spam takes no place.
1888
+ * Marking a submission spam or not spam, deleting submissions and the
1889
+ * retention pass change the count by changing the rows it reads: there is no
1890
+ * counter to keep in step. Only a new submission is judged: marking a stored
1891
+ * one "not spam" restores a place it was entitled to when it arrived.
1892
+ *
1893
+ * @module handlers/form-closing-hold
1894
+ */
1895
+ /** The mark on a refusal for a closed form, for the caller to recognise it. */
1896
+ const FORM_CLOSED = "form-builder:form-closed";
1897
+ /** The mark on a refusal for a setting changed while the write was in flight. */
1898
+ const SETTINGS_CHANGED_MARK = "form-builder:settings-changed";
1899
+ /** The mark on a refusal for a person who has responded already. */
1900
+ const RESPONDED_MARK = "form-builder:responded";
1901
+ /** Rows read at a time when earlier submissions are compared one by one. */
1902
+ const COMPARE_PAGE = 200;
1903
+ /** What a submission refused for a setting changed in flight is told. */
1904
+ const SETTINGS_CHANGED = "This form's settings changed while your response was being sent. Please send it again.";
1905
+ /** The id a submission's `form` names, as an id or a populated document. */
1906
+ function formIdOf(value) {
1907
+ if (typeof value === "string" && value !== "") return value;
1908
+ if (typeof value === "number") return String(value);
1909
+ if (value && typeof value === "object" && "id" in value) return formIdOf(value.id);
1910
+ }
1911
+ /** The form a new submission is made to, and whether it takes a place. */
1912
+ function newSubmission(write) {
1913
+ if (write.operation !== "create") return void 0;
1914
+ const data = write.data;
1915
+ const formId = formIdOf(data?.form);
1916
+ return formId === void 0 ? void 0 : {
1917
+ formId,
1918
+ takesAPlace: data?.status !== "spam"
1919
+ };
1920
+ }
1921
+ /** The refusal a submission to a closed form gets, and rolls back with. */
1922
+ function closedRefusal(form, logContext) {
1923
+ return NextlyError.conflict({
1924
+ reason: "state",
1925
+ message: closedMessageOf(form),
1926
+ logContext: {
1927
+ cause: FORM_CLOSED,
1928
+ ...logContext
1929
+ }
1930
+ });
1931
+ }
1932
+ /** The form, as stored, with its settings read. */
1933
+ async function formOf(own, write, slugs, formId, lock) {
1934
+ const form = await own.findEntryByIdInTransaction(write.transaction, slugs.forms, formId, { lock });
1935
+ return {
1936
+ form,
1937
+ settings: normalizeFormSettings(form.settings)
1938
+ };
1939
+ }
1940
+ /**
1941
+ * Refuse a new submission to a form past its closing time, one that would
1942
+ * take it past its limit, or one whose one-response key is not the one the
1943
+ * form's committed settings give it.
1944
+ */
1945
+ async function holdFormClosing(deps, write, slugs) {
1946
+ const submission = newSubmission(write);
1947
+ if (submission === void 0) return;
1948
+ const { formId } = submission;
1949
+ const locked = await formOf(deps.own, write, slugs, formId, "update");
1950
+ if (pastClosingTime(locked.settings, /* @__PURE__ */ new Date())) throw closedRefusal(locked.form, {
1951
+ formId,
1952
+ closedBy: "date"
1953
+ });
1954
+ refuseKeptAddress(write, formId, locked.settings);
1955
+ if (submission.takesAPlace) await holdLimit(deps.own, write, slugs, formId, locked);
1956
+ await holdResponseKey(deps, write, slugs, formId, locked.settings);
1957
+ }
1958
+ /**
1959
+ * Refuse a new submission that carries an IP address or browser to a form
1960
+ * that, as committed, keeps neither: the write seam read the setting before
1961
+ * it changed. The visitor sends it again, and it is stored without them.
1962
+ */
1963
+ function refuseKeptAddress(write, formId, settings) {
1964
+ if (settings.storeIpAndBrowser !== false) return;
1965
+ const row = write.data;
1966
+ const kept = (value) => typeof value === "string" && value !== "";
1967
+ if (kept(row?.ipAddress) || kept(row?.userAgent)) throw changedRefusal(formId);
1968
+ }
1969
+ /** Refuse a submission that takes the form past its limit, under the lock. */
1970
+ async function holdLimit(own, write, slugs, formId, { form, settings }) {
1971
+ const limit = settings.submissionLimit;
1972
+ if (limit === void 0) return;
1973
+ const { pagination } = await own.listEntriesInTransaction(write.transaction, slugs.submissions, {
1974
+ where: {
1975
+ form: { equals: formId },
1976
+ status: { not_equals: "spam" }
1977
+ },
1978
+ pagination: { limit: 1 }
1979
+ }, { lock: "none" });
1980
+ if (pagination.total > limit) throw closedRefusal(form, {
1981
+ formId,
1982
+ closedBy: "limit",
1983
+ limit
1984
+ });
1985
+ }
1986
+ /**
1987
+ * Refuse a submission whose one-response key is not the one the form's
1988
+ * setting, as stored now, gives it. The key was made before the write, from
1989
+ * the settings read then; a change of mode or Email field committed since
1990
+ * would leave a key no longer compared by anything, or none where one is
1991
+ * owed. The visitor sends it again, under the setting that applies.
1992
+ */
1993
+ async function holdResponseKey(deps, write, slugs, formId, settings) {
1994
+ const row = write.data;
1995
+ if (!await holdsKeyFor(() => deps.digests, settings, row)) throw changedRefusal(formId);
1996
+ if (row.responseKey === null || row.responseKey === void 0) return;
1997
+ await refuseEarlierResponse(deps, write, slugs, formId, settings);
1998
+ }
1999
+ /** The refusal for a key made under a setting the form has since left. */
2000
+ function changedRefusal(formId) {
2001
+ return NextlyError.conflict({
2002
+ reason: "state",
2003
+ message: SETTINGS_CHANGED,
2004
+ logContext: {
2005
+ cause: SETTINGS_CHANGED_MARK,
2006
+ formId
2007
+ }
2008
+ });
2009
+ }
2010
+ /**
2011
+ * Refuse a keyed submission when an earlier one holds its value without the
2012
+ * key that would show it.
2013
+ *
2014
+ * One per network compares the stored address itself, every time: a form
2015
+ * that took one response per network before keys were stored has earlier
2016
+ * submissions with an address and no key. One per email compares the earlier
2017
+ * answers while the form's keys are still being made for its current version
2018
+ * (`keysBehind`), after a change of mode or Email field. Both run under the
2019
+ * form's lock, so a submission stored meanwhile is counted.
2020
+ */
2021
+ async function refuseEarlierResponse(deps, write, slugs, formId, settings) {
2022
+ const row = write.data;
2023
+ const earlier = {
2024
+ form: { equals: formId },
2025
+ status: { not_equals: "spam" },
2026
+ id: { not_equals: write.id }
2027
+ };
2028
+ const read = (where, limit, page = 1) => deps.own.listEntriesInTransaction(write.transaction, slugs.submissions, {
2029
+ where,
2030
+ pagination: {
2031
+ limit,
2032
+ page
2033
+ },
2034
+ depth: 0
2035
+ }, { lock: "none" });
2036
+ if (settings.oneResponse === "ip" && typeof row.ipAddress === "string") {
2037
+ const { pagination } = await read({
2038
+ ...earlier,
2039
+ ipAddress: { equals: row.ipAddress }
2040
+ }, 1);
2041
+ if (pagination.total > 0) throw respondedRefusal(formId);
2042
+ return;
2043
+ }
2044
+ if (settings.oneResponse === "email" && keysBehind(settings)) await refuseEarlierEmail(deps, formId, settings, row.responseKey, (page) => read(earlier, COMPARE_PAGE, page));
2045
+ }
2046
+ /**
2047
+ * Compare a new submission's key with the key each earlier submission's
2048
+ * answer would have, a page at a time.
2049
+ */
2050
+ async function refuseEarlierEmail(deps, formId, settings, key, page) {
2051
+ for (let n = 1;; n++) {
2052
+ const { data } = await page(n);
2053
+ for (const other of data) if (await responseKeyOf(() => deps.digests, settings, other) === key) throw respondedRefusal(formId);
2054
+ if (data.length < COMPARE_PAGE) return;
2055
+ }
2056
+ }
2057
+ /** The refusal for a person who has responded to the form already. */
2058
+ function respondedRefusal(formId) {
2059
+ return NextlyError.conflict({
2060
+ reason: "state",
2061
+ message: ALREADY_RESPONDED,
2062
+ logContext: {
2063
+ cause: RESPONDED_MARK,
2064
+ formId
2065
+ }
2066
+ });
2067
+ }
2068
+ /**
2069
+ * Refuse a key written to a stored submission (an edit that moves its key,
2070
+ * or the plugin giving it one) when the key was made for a version the form
2071
+ * has left: the form's mode or Email field changed after it was made. The
2072
+ * form's lock orders it after the change, so the version it reads is the one
2073
+ * committed.
2074
+ */
2075
+ async function holdKeyWrite(deps, write, slugs) {
2076
+ const changed = changedKeyOf(write);
2077
+ if (changed === void 0) return;
2078
+ const { key, formId } = changed;
2079
+ const { settings } = await formOf(deps.own, write, slugs, formId, "update");
2080
+ if (keyVersionOf(key) !== (settings.oneResponseVersion ?? 0)) throw changedRefusal(formId);
2081
+ if (sameValue(write)) return;
2082
+ await refuseEarlierResponse(deps, write, slugs, formId, settings);
2083
+ }
2084
+ /**
2085
+ * The key an update gave a stored submission, and its form, when the update
2086
+ * changed the key to one: undefined for a create, a key cleared or kept.
2087
+ */
2088
+ function changedKeyOf(write) {
2089
+ if (write.operation !== "update") return void 0;
2090
+ const after = write.data;
2091
+ const before = write.originalData;
2092
+ const key = after?.responseKey;
2093
+ if (typeof key !== "string" || key === before?.responseKey) return void 0;
2094
+ const formId = formIdOf(after.form);
2095
+ return formId === void 0 ? void 0 : {
2096
+ key,
2097
+ formId
2098
+ };
2099
+ }
2100
+ /**
2101
+ * Whether an update left a submission's answers, address and form as they
2102
+ * were. A submission moved to another form joins that form's responses, so a
2103
+ * move is compared as an edit is.
2104
+ */
2105
+ function sameValue(write) {
2106
+ const after = write.data;
2107
+ const before = write.originalData;
2108
+ const same = (field) => JSON.stringify(after?.[field] ?? null) === JSON.stringify(before?.[field] ?? null);
2109
+ return same("data") && same("ipAddress") && formIdOf(after?.form) === formIdOf(before?.form);
2110
+ }
2111
+ /**
2112
+ * When the submission holding a one-response key stops holding it (deleted,
2113
+ * marked spam, or edited or moved to another value), the earliest other
2114
+ * non-spam submission of the form with the same value takes the key, in the
2115
+ * same transaction. Without it, a keyless duplicate (one stored before the
2116
+ * form compared that value, or kept as evidence and restored) would leave
2117
+ * the value free, and a person who had responded could respond again.
2118
+ *
2119
+ * Under the form's lock, so a new submission with that value waits and then
2120
+ * meets the key. A key made for a version the form has left is not handed
2121
+ * on: nothing compares it. One per browser hands nothing on, since no
2122
+ * earlier submission kept its browser's token.
2123
+ */
2124
+ async function handOffKey(deps, write, slugs) {
2125
+ const released = releasedKeyOf(write);
2126
+ if (released === void 0) return;
2127
+ const { key, formId, ipAddress } = released;
2128
+ const { settings } = await formOf(deps.own, write, slugs, formId, "update");
2129
+ if (keyVersionOf(key) !== (settings.oneResponseVersion ?? 0)) return;
2130
+ const next = await nextHolderOf(deps, write, slugs, {
2131
+ formId,
2132
+ settings,
2133
+ key,
2134
+ ipAddress
2135
+ });
2136
+ if (next === void 0) return;
2137
+ await deps.collections.updateEntryInTransaction(write.transaction, slugs.submissions, next, asPluginSubmission({
2138
+ responseKey: key,
2139
+ _overwrite: true
2140
+ }, {
2141
+ keepAsEvidence: false,
2142
+ setKey: key
2143
+ }));
2144
+ }
2145
+ /**
2146
+ * The key a write took from the submission that held it, with that
2147
+ * submission's form and address as they were: undefined when it still holds
2148
+ * it, or held none.
2149
+ */
2150
+ function releasedKeyOf(write) {
2151
+ const before = heldBefore(write);
2152
+ const key = before?.responseKey;
2153
+ if (typeof key !== "string" || stillHeld(write, key, before)) return;
2154
+ const formId = formIdOf(before?.form);
2155
+ return formId === void 0 ? void 0 : {
2156
+ key,
2157
+ formId,
2158
+ ipAddress: before?.ipAddress
2159
+ };
2160
+ }
2161
+ /**
2162
+ * The submission as it was before a write: a delete describes it as it was,
2163
+ * an update by its original; a create had none.
2164
+ */
2165
+ function heldBefore(write) {
2166
+ if (write.operation === "create") return void 0;
2167
+ return write.operation === "delete" ? write.data : write.originalData;
2168
+ }
2169
+ /** Whether an update left the submission holding `key` on the same form. */
2170
+ function stillHeld(write, key, before) {
2171
+ const after = write.data;
2172
+ return write.operation === "update" && after?.responseKey === key && formIdOf(after?.form) === formIdOf(before?.form);
2173
+ }
2174
+ /**
2175
+ * The earliest non-spam submission of the form, other than the one writing,
2176
+ * that holds no key and whose value gives `key`. One per network reads only
2177
+ * the submissions from the same address; one per email reads the keyless
2178
+ * ones a page at a time.
2179
+ */
2180
+ async function nextHolderOf(deps, write, slugs, wanted) {
2181
+ const { settings } = wanted;
2182
+ if (settings.oneResponse !== "ip" && settings.oneResponse !== "email") return;
2183
+ const where = {
2184
+ form: { equals: wanted.formId },
2185
+ status: { not_equals: "spam" },
2186
+ id: { not_equals: write.id },
2187
+ responseKey: { exists: false }
2188
+ };
2189
+ if (settings.oneResponse === "ip") {
2190
+ if (typeof wanted.ipAddress !== "string") return void 0;
2191
+ where.ipAddress = { equals: wanted.ipAddress };
2192
+ }
2193
+ for (let page = 1;; page++) {
2194
+ const { data } = await deps.own.listEntriesInTransaction(write.transaction, slugs.submissions, {
2195
+ where,
2196
+ sort: {
2197
+ field: "createdAt",
2198
+ direction: "asc"
2199
+ },
2200
+ pagination: {
2201
+ limit: COMPARE_PAGE,
2202
+ page
2203
+ },
2204
+ depth: 0
2205
+ }, { lock: "none" });
2206
+ for (const row of data) if (await responseKeyOf(() => deps.digests, settings, row) === wanted.key) return String(row.id);
2207
+ if (data.length < COMPARE_PAGE) return void 0;
2208
+ }
2209
+ }
2210
+ /** Hold every form's closing on the submissions this plugin stores. */
2211
+ function registerFormClosing(ctx, slugs) {
2212
+ const deps = {
2213
+ own: ctx.services.ownCollections,
2214
+ digests: ctx.security.digests,
2215
+ collections: ctx.services.collections
2216
+ };
2217
+ ctx.hooks.onInTransaction(slugs.submissions, async (write) => {
2218
+ await holdFormClosing(deps, write, slugs);
2219
+ await holdKeyWrite(deps, write, slugs);
2220
+ await handOffKey(deps, write, slugs);
2221
+ });
2222
+ }
2223
+ /**
2224
+ * Which of the hold's refusals `error` is, or one it was made from: a closed
2225
+ * form's, a setting changed while the submission was in flight, or a person
2226
+ * who has responded already.
2227
+ */
2228
+ function heldRefusal(error) {
2229
+ let current = error;
2230
+ for (let depth = 0; depth < 10 && current; depth += 1) {
2231
+ if (current instanceof NextlyError) {
2232
+ const cause = current.logContext?.cause;
2233
+ if (cause === FORM_CLOSED) return "closed";
2234
+ if (cause === SETTINGS_CHANGED_MARK) return "changed";
2235
+ if (cause === RESPONDED_MARK) return "responded";
2236
+ }
2237
+ current = current.cause;
2238
+ }
2239
+ }
2240
+
2241
+ // src/handlers/one-response-field-check.ts
2242
+ /**
2243
+ * The save of a form refuses a one response per email it could not keep:
2244
+ * no Email field chosen, or one that is missing, optional or hidden by a
2245
+ * condition. The builder and Settings refuse the same, and this holds it for
2246
+ * every other way a form is written (the API, an import).
2247
+ *
2248
+ * @module handlers/one-response-field-check
2249
+ */
2250
+ /** A stored value as written: itself, or a JSON string before a read decodes it. */
2251
+ function decoded(value) {
2252
+ if (typeof value !== "string") return value;
2253
+ try {
2254
+ return JSON.parse(value);
2255
+ } catch {
2256
+ return;
2257
+ }
2258
+ }
2259
+ /**
2260
+ * Refuse a form save whose one response per email has no Email field it can
2261
+ * compare. What the save leaves out is read as stored, so a save of the
2262
+ * fields alone is judged against the stored settings, and the other way
2263
+ * round.
2264
+ */
2265
+ function refuseOneResponseFieldProblem(context) {
2266
+ const ctx = context;
2267
+ const data = ctx.data;
2268
+ if (!data || data.settings === void 0 && data.fields === void 0) return;
2269
+ const settings = normalizeFormSettings(decoded(data.settings !== void 0 ? data.settings : ctx.originalData?.settings));
2270
+ const fields = decoded(data.fields !== void 0 ? data.fields : ctx.originalData?.fields);
2271
+ const problem = oneResponseFieldProblem(settings, Array.isArray(fields) ? fields : []);
2272
+ if (problem === void 0) return;
2273
+ throw NextlyError.validation({ errors: [{
2274
+ path: "settings.oneResponseField",
2275
+ code: "INVALID",
2276
+ message: problem
2277
+ }] });
2278
+ }
2279
+
2280
+ // src/handlers/one-response-rebuild.ts
2281
+ /**
2282
+ * One response per person, after a form changes how it tells people apart.
2283
+ *
2284
+ * A submission's key is made under the form's one-response version
2285
+ * (`one-response`). Saving a form with another mode or another Email field
2286
+ * moves the version on (`stampOneResponseVersion`), so every earlier key stops
2287
+ * matching new ones at once, with no write to any earlier submission. The
2288
+ * earlier submissions are then given keys for the new version by a sweep
2289
+ * (`rebuildResponseKeys`): oldest first, each the key of its own answer or
2290
+ * address, or none where an earlier one holds that value already, so the
2291
+ * earliest of several with one value keeps it. The sweep runs right after the
2292
+ * save commits, and the job runner carries on with what it could not finish.
2293
+ *
2294
+ * Until it has finished, the form records the version its keys are made for
2295
+ * (`oneResponseKeyedVersion`) behind the current one, and the submission's
2296
+ * hold compares a new submission with the earlier ones' answers themselves
2297
+ * (`form-closing-hold`), so no person gets a second response in between.
2298
+ *
2299
+ * Each key written is checked again where it is stored: the hold refuses a
2300
+ * key made for a version the form has since left, so a sweep that started
2301
+ * before a second change writes nothing for the old version. Each write also
2302
+ * names the revision the sweep read, so a submission edited meanwhile keeps
2303
+ * the key its edit gave it.
2304
+ *
2305
+ * @module handlers/one-response-rebuild
2306
+ */
2307
+ /** The sweep that gives earlier submissions their keys. */
2308
+ const REBUILD_KEYS_JOB = "form-builder:one-response-keys";
2309
+ /** How long a save waits for the sweep after it commits, at most. */
2310
+ const REBUILD_ON_SAVE_MS = 5e3;
2311
+ /**
2312
+ * How far past the start of a pass its cutoff lies. MySQL stores a row's
2313
+ * `createdAt` to the second, rounded, so a submission stored a moment before
2314
+ * the pass can read as up to a second after it; a cutoff at the start would
2315
+ * leave it out of every pass and without a key. Rows inside the margin are
2316
+ * ones stored after the change, keyed already, so reading them costs a look.
2317
+ */
2318
+ const CUTOFF_MARGIN_MS = 2e3;
2319
+ function passCutoff() {
2320
+ return new Date(Date.now() + CUTOFF_MARGIN_MS);
2321
+ }
2322
+ /** Rows read at a time: forms, then one form's submissions. */
2323
+ const PAGE = 100;
2324
+ /**
2325
+ * The mark on the sweep's own write of a form, saying which version's keys
2326
+ * it finished. Only the plugin sets it; `stampOneResponseVersion` takes the
2327
+ * finished version from nowhere else.
2328
+ */
2329
+ const FORM_KEYED = Symbol.for("nextly.plugin-form-builder.form-keyed");
2330
+ /** A form's settings as stored: an object, or a JSON string before a read decodes it. */
2331
+ function settingsObjectOf(value) {
2332
+ let parsed = value;
2333
+ if (typeof parsed === "string") try {
2334
+ parsed = JSON.parse(parsed);
2335
+ } catch {
2336
+ return;
2337
+ }
2338
+ return parsed !== null && typeof parsed === "object" ? parsed : void 0;
2339
+ }
2340
+ /**
2341
+ * The version a save leaves the form at, and the version its keys are made
2342
+ * for, both decided here from what is stored and never taken from the
2343
+ * payload: a caller that could set them could make every earlier response
2344
+ * count again, or none.
2345
+ *
2346
+ * A save that changes the mode or the Email field moves the version on by
2347
+ * one. Its keys are up to date at once when the new mode compares nothing
2348
+ * an earlier submission stored (several responses allowed, or one per
2349
+ * browser, whose earlier tokens were never kept), or when the form has few
2350
+ * enough submissions for the save to key them itself (`rebuildInSave`);
2351
+ * otherwise they are behind until the sweep has made them.
2352
+ */
2353
+ async function stampOneResponseVersion(context, fitsInSave) {
2354
+ const ctx = context;
2355
+ const incoming = settingsObjectOf(ctx.data?.settings);
2356
+ if (!ctx.data || incoming === void 0) return;
2357
+ const next = { ...incoming };
2358
+ delete next.oneResponseKeyedVersion;
2359
+ if (ctx.operation === "create") {
2360
+ next.oneResponseVersion = 0;
2361
+ ctx.data.settings = next;
2362
+ return;
2363
+ }
2364
+ const stored = normalizeFormSettings(settingsObjectOf(ctx.originalData?.settings) ?? {});
2365
+ const changed = oneResponseIdentityOf(stored) !== oneResponseIdentityOf(normalizeFormSettings(incoming));
2366
+ const version = (stored.oneResponseVersion ?? 0) + (changed ? 1 : 0);
2367
+ next.oneResponseVersion = version;
2368
+ const storedId = ctx.originalData?.id;
2369
+ const formId = typeof storedId === "string" || typeof storedId === "number" ? String(storedId) : void 0;
2370
+ const keyed = keyedVersionAfterSave({
2371
+ stored,
2372
+ changed,
2373
+ version,
2374
+ mode: normalizeFormSettings(incoming).oneResponse,
2375
+ finished: ctx.data[FORM_KEYED]?.version,
2376
+ inSave: changed && formId !== void 0 && await fitsInSave(formId)
2377
+ });
2378
+ if (keyed !== void 0) next.oneResponseKeyedVersion = keyed;
2379
+ ctx.data.settings = next;
2380
+ }
2381
+ /** The version a form's keys are made for once a save has been stamped. */
2382
+ function keyedVersionAfterSave(input) {
2383
+ const { stored, changed, version } = input;
2384
+ if (changed) return (input.mode === "ip" || input.mode === "email") && !input.inSave ? stored.oneResponseKeyedVersion ?? stored.oneResponseVersion ?? 0 : version;
2385
+ if (input.finished === version) return version;
2386
+ return stored.oneResponseKeyedVersion;
2387
+ }
2388
+ /**
2389
+ * After a save leaves the form's keys behind (a form too large to key inside
2390
+ * the save), start the sweep once the save has committed. Not awaited: the
2391
+ * save answers with the form as it committed, and the sweep's own write of
2392
+ * the form, when it finishes, comes after. What one run leaves, the job
2393
+ * runner finishes.
2394
+ */
2395
+ async function rebuildAfterSave(context, run, warn) {
2396
+ const ctx = context;
2397
+ const settings = normalizeFormSettings(settingsObjectOf(ctx.data?.settings) ?? {});
2398
+ if (!keysBehind(settings) || !ctx.afterCommit) return;
2399
+ await ctx.afterCommit(() => {
2400
+ run().catch((error) => warn("The one-response key sweep failed; the job runner retries it.", { error: error instanceof Error ? error.message : String(error) }));
2401
+ });
2402
+ }
2403
+ /** The plugin's one-response options, with their defaults. */
2404
+ function oneResponseOptions(options) {
2405
+ const keysInSave = options?.keysInSave;
2406
+ return { keysInSave: typeof keysInSave === "number" && keysInSave >= 0 ? Math.floor(keysInSave) : KEYS_IN_SAVE };
2407
+ }
2408
+ /** Earlier submissions a form may have for the save itself to key them. */
2409
+ const KEYS_IN_SAVE = 200;
2410
+ /**
2411
+ * Give a form's earlier submissions keys for its new version inside the
2412
+ * save that changed it, when the save decided they fit
2413
+ * (`stampOneResponseVersion` recorded the keys as made). Read whole first,
2414
+ * then keyed oldest first, the earliest of several with one value keeping it. The save holds the form's
2415
+ * row, so no submission to the form is stored meanwhile, and the save
2416
+ * answers with every key in place and no later write to the form.
2417
+ */
2418
+ async function rebuildInSave(deps, write) {
2419
+ if (write.operation !== "update") return;
2420
+ const after = normalizeFormSettings(settingsObjectOf(write.data?.settings) ?? {});
2421
+ const before = normalizeFormSettings(settingsObjectOf(write.originalData?.settings) ?? {});
2422
+ const version = after.oneResponseVersion ?? 0;
2423
+ if (version === (before.oneResponseVersion ?? 0)) return;
2424
+ if (after.oneResponseKeyedVersion !== version) return;
2425
+ if (after.oneResponse !== "ip" && after.oneResponse !== "email") return;
2426
+ const rows = await snapshotOf(async (where) => (await deps.own.listEntriesInTransaction(write.transaction, deps.submissionsSlug, {
2427
+ where,
2428
+ sort: {
2429
+ field: "id",
2430
+ direction: "asc"
2431
+ },
2432
+ pagination: { limit: PAGE },
2433
+ depth: 0
2434
+ }, { lock: "none" })).data, {
2435
+ form: { equals: write.id },
2436
+ status: { not_equals: "spam" }
2437
+ });
2438
+ const held = /* @__PURE__ */ new Set();
2439
+ for (const row of rows) await keyInSave(deps, write, after, held, row);
2440
+ }
2441
+ /** One row keyed inside the save: its value's key unless held already. */
2442
+ async function keyInSave(deps, write, settings, held, row) {
2443
+ const own = await storedRowKeyOf(deps.digests, settings, row);
2444
+ const target = own === null || held.has(own) ? null : own;
2445
+ if (target !== null) held.add(target);
2446
+ if ((row.responseKey ?? null) === target) return;
2447
+ await deps.collections.updateEntryInTransaction(write.transaction, deps.submissionsSlug, String(row.id), asPluginSubmission({
2448
+ responseKey: target,
2449
+ _overwrite: true
2450
+ }, {
2451
+ keepAsEvidence: false,
2452
+ setKey: target
2453
+ }));
2454
+ }
2455
+ /** The sweep's job, run by `run` until the deadline the runner gives it. */
2456
+ function rebuildKeysJob(run) {
2457
+ return defineJob({
2458
+ slug: REBUILD_KEYS_JOB,
2459
+ sweep: true,
2460
+ handler: async (_input, context) => {
2461
+ await run(context.deadline);
2462
+ }
2463
+ });
2464
+ }
2465
+ /**
2466
+ * Give every form whose keys are behind its earlier submissions' keys, until
2467
+ * `deadline`. A form is marked up to date only after a pass over all its
2468
+ * submissions in which every one took its key, with none to look at again.
2469
+ */
2470
+ async function rebuildResponseKeys(deps, deadline) {
2471
+ for (let page = 1;; page++) {
2472
+ const { data } = await deps.collections.listEntries(deps.formsSlug, {
2473
+ pagination: {
2474
+ limit: PAGE,
2475
+ page
2476
+ },
2477
+ depth: 0
2478
+ }, { as: "system" });
2479
+ const forms = data;
2480
+ for (const form of forms) {
2481
+ if (pastDeadline(deadline)) return;
2482
+ await bringUpToDate(deps, form, deadline);
2483
+ }
2484
+ if (forms.length < PAGE) return;
2485
+ }
2486
+ }
2487
+ /** One form: its keys made for its current version, if they are behind. */
2488
+ async function bringUpToDate(deps, form, deadline) {
2489
+ const settings = normalizeFormSettings(settingsObjectOf(form.settings) ?? {});
2490
+ if (!keysBehind(settings)) return;
2491
+ if (await rebuildForm(deps, form, settings, deadline) === "done") await markKeyed(deps, form, settings.oneResponseVersion ?? 0);
2492
+ }
2493
+ function pastDeadline(deadline) {
2494
+ return Date.now() >= deadline.getTime();
2495
+ }
2496
+ /**
2497
+ * Passes over one form's submissions until one leaves nothing to look at
2498
+ * again: done, or `stop` when the deadline passed or the form's version moved
2499
+ * on, which leaves the rest to a later sweep. A sweep that resumes passes
2500
+ * quickly over the rows an earlier run keyed: each holds its own key, which
2501
+ * is recognised without a query.
2502
+ */
2503
+ async function rebuildForm(deps, form, settings, deadline) {
2504
+ if (settings.oneResponse !== "ip" && settings.oneResponse !== "email") return "done";
2505
+ for (;;) {
2506
+ const pass = await rebuildPass(deps, String(form.id), settings, deadline);
2507
+ if (pass !== "again") return pass;
2508
+ }
2509
+ }
2510
+ /**
2511
+ * One pass over a form's non-spam submissions, oldest first. The rows are
2512
+ * read first, whole (`snapshotOf`), then keyed in order: a submission deleted
2513
+ * or restored while the pass runs cannot move another out of a page it reads.
2514
+ * Rows stored after the pass began are left out (`passCutoff`): they were
2515
+ * keyed for the current version when they were stored. A row whose write was
2516
+ * refused, as edited since or as holding a value taken since, sends the
2517
+ * sweep round again.
2518
+ */
2519
+ async function rebuildPass(deps, formId, settings, deadline) {
2520
+ const rows = await snapshotOf(async (where) => (await deps.collections.listEntries(deps.submissionsSlug, {
2521
+ where,
2522
+ sort: {
2523
+ field: "id",
2524
+ direction: "asc"
2525
+ },
2526
+ pagination: { limit: PAGE },
2527
+ depth: 0
2528
+ }, { as: "system" })).data, {
2529
+ form: { equals: formId },
2530
+ status: { not_equals: "spam" },
2531
+ createdAt: { less_than_equal: passCutoff() }
2532
+ });
2533
+ let retries = 0;
2534
+ for (const row of rows) {
2535
+ if (pastDeadline(deadline)) return "stop";
2536
+ const outcome = await keyRow(deps, formId, settings, row);
2537
+ if (outcome === "stale-form") return "stop";
2538
+ if (outcome === "retry") retries += 1;
2539
+ }
2540
+ return retries === 0 ? "done" : "again";
2541
+ }
2542
+ /**
2543
+ * Every row `where` matches, oldest first. Read a page at a time by id, each
2544
+ * page after the last id read: ids are unique and fixed, so a row deleted or
2545
+ * added meanwhile never moves one that is there throughout out of reach, as
2546
+ * a page by position would. Then put in the order they were stored.
2547
+ */
2548
+ async function snapshotOf(page, where) {
2549
+ const rows = [];
2550
+ for (let after;;) {
2551
+ const read = await page(after === void 0 ? where : {
2552
+ ...where,
2553
+ id: { greater_than: after }
2554
+ });
2555
+ rows.push(...read);
2556
+ if (read.length < PAGE) break;
2557
+ after = String(read[read.length - 1]?.id);
2558
+ }
2559
+ return rows.sort(storedFirst);
2560
+ }
2561
+ /** Older first, by when each was stored; ties by id, the same every time. */
2562
+ function storedFirst(a, b) {
2563
+ const at = (row) => new Date(row.createdAt).getTime();
2564
+ return at(a) - at(b) || (String(a.id) < String(b.id) ? -1 : 1);
2565
+ }
2566
+ /**
2567
+ * Give one submission the key of its answer or address, or none where
2568
+ * another submission of the form holds that value already.
2569
+ */
2570
+ async function keyRow(deps, formId, settings, row) {
2571
+ const target = await targetKeyOf(deps, formId, settings, row);
2572
+ if (target === void 0) return "unchanged";
2573
+ if ((row.responseKey ?? null) === target) return "unchanged";
2574
+ return writeKey(deps, row, target);
2575
+ }
2576
+ /**
2577
+ * The key a row should hold: its own value's, or none (null) where another
2578
+ * submission holds that value; undefined when it holds its own already.
2579
+ */
2580
+ async function targetKeyOf(deps, formId, settings, row) {
2581
+ const own = await storedRowKeyOf(deps.digests, settings, row);
2582
+ if (own === null) return null;
2583
+ if (row.responseKey === own) return void 0;
2584
+ return await heldByAnother(deps, formId, row, own) ? null : own;
2585
+ }
2586
+ /** Write `key` to a row as the plugin's own key write. */
2587
+ async function writeKey(deps, row, key) {
2588
+ try {
2589
+ await deps.collections.updateEntry(deps.submissionsSlug, String(row.id), asPluginSubmission({
2590
+ responseKey: key,
2591
+ _rev: row._rev
2592
+ }, {
2593
+ keepAsEvidence: false,
2594
+ setKey: key
2595
+ }), { as: "system" });
2596
+ } catch (error) {
2597
+ if (heldRefusal(error) === "changed") return "stale-form";
2598
+ const code = codeOf(error);
2599
+ if (code === "STALE_VERSION" || code === "DUPLICATE") return "retry";
2600
+ throw error;
2601
+ }
2602
+ return "written";
2603
+ }
2604
+ function codeOf(error) {
2605
+ return error instanceof NextlyError ? error.code : void 0;
2606
+ }
2607
+ /** Whether a submission of the form other than `row` holds `key`. */
2608
+ async function heldByAnother(deps, formId, row, key) {
2609
+ return await deps.collections.count(deps.submissionsSlug, { where: {
2610
+ form: { equals: formId },
2611
+ responseKey: { equals: key },
2612
+ id: { not_equals: String(row.id) }
2613
+ } }, { as: "system" }) > 0;
2614
+ }
2615
+ /**
2616
+ * Record that the form's keys are made for `version`, by a write that names
2617
+ * the revision the sweep read: a form saved meanwhile is left for the next
2618
+ * sweep to judge.
2619
+ */
2620
+ async function markKeyed(deps, form, version) {
2621
+ const payload = {
2622
+ settings: settingsObjectOf(form.settings) ?? {},
2623
+ _rev: form._rev
2624
+ };
2625
+ Object.defineProperty(payload, FORM_KEYED, {
2626
+ value: { version },
2627
+ enumerable: true,
2628
+ configurable: true
2629
+ });
2630
+ try {
2631
+ await deps.collections.updateEntry(deps.formsSlug, String(form.id), payload, { as: "system" });
2632
+ } catch (error) {
2633
+ if (codeOf(error) === "STALE_VERSION") return;
2634
+ throw error;
2635
+ }
1535
2636
  }
1536
2637
 
1537
2638
  // src/handlers/spam-detection.ts
@@ -1558,7 +2659,9 @@ function prepareSubmission({ data, fields, validate }) {
1558
2659
  * Performs multiple spam checks based on configuration:
1559
2660
  * 1. Honeypot field detection
1560
2661
  * 2. Rate limiting by IP/form
1561
- * 3. reCAPTCHA verification (future)
2662
+ *
2663
+ * A form's bot check (reCAPTCHA or Turnstile) is verified with its provider
2664
+ * where the submission is stored (`handlers/bot-check`), not here.
1562
2665
  *
1563
2666
  * @param options - Spam check options
1564
2667
  * @returns Spam check result
@@ -1664,6 +2767,98 @@ async function checkRateLimit(ipAddress, formSlug, config) {
1664
2767
  * @since 0.1.0
1665
2768
  */
1666
2769
  /**
2770
+ * A submission's spam verdict and its prepared data: the honeypot first,
2771
+ * BEFORE validation, so a bot that trips the trap while also failing
2772
+ * validation still receives the same fake success as any other bot; then the
2773
+ * form's bot check for one that would otherwise be refused as invalid.
2774
+ */
2775
+ async function judgeAndPrepare(input) {
2776
+ const { data, form, settings, pluginConfig, pluginContext, formSlug } = input;
2777
+ const { logger } = pluginContext;
2778
+ const declaredFieldNames = new Set(form.fields.map((field) => field.name));
2779
+ const undeclaredData = Object.fromEntries(Object.entries(data).filter(([key]) => !declaredFieldNames.has(key)));
2780
+ let spamResult = await checkSpam({
2781
+ data: undeclaredData,
2782
+ formSlug,
2783
+ config: { honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot }
2784
+ });
2785
+ const isContentSpam = spamResult.isSpam;
2786
+ if (isContentSpam) logger.info?.("Spam submission detected — storing flagged", {
2787
+ formSlug,
2788
+ reason: spamResult.reason,
2789
+ ipAddress: input.ipAddress
2790
+ });
2791
+ let prepared = prepareSubmission({
2792
+ data,
2793
+ fields: form.fields,
2794
+ validate: !isContentSpam
2795
+ });
2796
+ let early = { rateCounted: false };
2797
+ if (prepared.validationErrors && !isContentSpam) {
2798
+ early = await botCheckBeforeRefusal(input);
2799
+ if (early.spam) {
2800
+ spamResult = early.spam;
2801
+ prepared = prepareSubmission({
2802
+ data,
2803
+ fields: form.fields,
2804
+ validate: false
2805
+ });
2806
+ }
2807
+ }
2808
+ return {
2809
+ spamResult,
2810
+ prepared,
2811
+ throttled: early.throttled === true,
2812
+ rateCounted: early.rateCounted
2813
+ };
2814
+ }
2815
+ /**
2816
+ * An invalid submission meets its form's bot check here, before it is
2817
+ * refused, so it is judged as one written any other way: a failing or
2818
+ * missing token keeps it as spam evidence rather than answering the bot with
2819
+ * validation errors. Only for a request-made submission to a form whose
2820
+ * check is ready, as the write seam judges. The rate limit is asked first,
2821
+ * so a throttled network cannot make the provider be called, and is counted
2822
+ * once: the seam is told it has been.
2823
+ */
2824
+ async function botCheckBeforeRefusal(input) {
2825
+ const spam = input.pluginConfig.spamProtection;
2826
+ if (!input.requested || !botCheckReady(input.settings, spam)) return { rateCounted: false };
2827
+ const rateCounted = Boolean(spam.rateLimit && input.ipAddress);
2828
+ if (rateCounted) {
2829
+ if ((await checkSpam({
2830
+ data: {},
2831
+ ipAddress: input.ipAddress,
2832
+ formSlug: input.form.id,
2833
+ config: { rateLimit: spam.rateLimit }
2834
+ })).isSpam) return {
2835
+ throttled: true,
2836
+ rateCounted
2837
+ };
2838
+ }
2839
+ const { logger } = input.pluginContext;
2840
+ const bot = await judgeBotCheck({
2841
+ settings: input.settings,
2842
+ spam,
2843
+ tokens: botTokensOf(input.data),
2844
+ ip: input.ipAddress,
2845
+ fetch: input.pluginContext.fetch,
2846
+ warn: (message, warnContext) => logger.warn?.(message, warnContext)
2847
+ });
2848
+ return bot ? {
2849
+ spam: bot,
2850
+ rateCounted
2851
+ } : { rateCounted };
2852
+ }
2853
+ /**
2854
+ * Whether a failed store was refused because the person has responded
2855
+ * already: the database refused a second one-response key, or the hold found
2856
+ * an earlier submission with the same value (`heldRefusal`).
2857
+ */
2858
+ function isRepeatResponse(error, held) {
2859
+ return held === "responded" || error instanceof NextlyError$1 && error.code === "DUPLICATE";
2860
+ }
2861
+ /**
1667
2862
  * Process a form submission.
1668
2863
  *
1669
2864
  * This is the main entry point for handling form submissions.
@@ -1726,7 +2921,17 @@ async function submitForm(options, context) {
1726
2921
  };
1727
2922
  }
1728
2923
  const redirectFor = () => resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);
1729
- const availability = formAvailability(form);
2924
+ const throttledAnswer = async () => {
2925
+ logger.info?.("Form submission refused (rate limit)", { formSlug });
2926
+ return {
2927
+ success: true,
2928
+ outcome: "accepted",
2929
+ successMessage: settings.successMessage,
2930
+ redirect: await redirectFor()
2931
+ };
2932
+ };
2933
+ const settings = normalizeFormSettings(form.settings);
2934
+ const availability = formAvailabilityNow(form, settings, { now: /* @__PURE__ */ new Date() });
1730
2935
  if (availability.kind !== "open") {
1731
2936
  logger.info?.("Form submission rejected - form not accepting", {
1732
2937
  formSlug,
@@ -1739,9 +2944,9 @@ async function submitForm(options, context) {
1739
2944
  error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
1740
2945
  };
1741
2946
  }
1742
- const settings = normalizeFormSettings(form.settings);
1743
- if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) logger.warn?.("Single-submission form received a submission without an IP — the restriction cannot apply", { formSlug });
1744
- if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
2947
+ const onePerAddress = settings.oneResponse === "ip";
2948
+ if (onePerAddress && !metadata?.ipAddress) logger.warn?.("Single-submission form received a submission without an IP — the restriction cannot apply", { formSlug });
2949
+ if (onePerAddress && metadata?.ipAddress) {
1745
2950
  if (await collections.count(pluginConfig.formSubmissionOverrides.slug, { where: {
1746
2951
  form: { equals: form.id },
1747
2952
  ipAddress: { equals: metadata.ipAddress },
@@ -1754,28 +2959,22 @@ async function submitForm(options, context) {
1754
2959
  return {
1755
2960
  success: false,
1756
2961
  outcome: "duplicate",
1757
- error: "You have already submitted this form."
2962
+ error: ALREADY_RESPONDED
1758
2963
  };
1759
2964
  }
1760
2965
  }
1761
- const declaredFieldNames = new Set(form.fields.map((field) => field.name));
1762
- const undeclaredData = Object.fromEntries(Object.entries(data).filter(([key]) => !declaredFieldNames.has(key)));
1763
- const spamResult = await checkSpam({
1764
- data: undeclaredData,
1765
- formSlug,
1766
- config: { honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot }
1767
- });
1768
- const isContentSpam = spamResult.isSpam;
1769
- if (isContentSpam) logger.info?.("Spam submission detected — storing flagged", {
1770
- formSlug,
1771
- reason: spamResult.reason,
1772
- ipAddress: metadata?.ipAddress
1773
- });
1774
- const prepared = prepareSubmission({
2966
+ const { spamResult, prepared, throttled, rateCounted } = await judgeAndPrepare({
1775
2967
  data,
1776
- fields: form.fields,
1777
- validate: !isContentSpam
2968
+ form,
2969
+ settings,
2970
+ pluginConfig,
2971
+ pluginContext,
2972
+ ipAddress: metadata?.ipAddress,
2973
+ formSlug,
2974
+ requested: request !== void 0
1778
2975
  });
2976
+ if (throttled) return throttledAnswer();
2977
+ let isContentSpam = spamResult.isSpam;
1779
2978
  if (prepared.validationErrors) {
1780
2979
  logger.debug?.("Form validation failed", {
1781
2980
  formSlug,
@@ -1799,30 +2998,53 @@ async function submitForm(options, context) {
1799
2998
  userAgent: metadata?.userAgent || null,
1800
2999
  submittedAt: /* @__PURE__ */ new Date()
1801
3000
  };
3001
+ const browserToken = browserTokenOf(data);
1802
3002
  let submission;
1803
3003
  try {
1804
3004
  submission = await collections.createEntry(pluginConfig.formSubmissionOverrides.slug, asPluginSubmission(submissionData, {
1805
3005
  keepAsEvidence: isContentSpam,
1806
3006
  form: {
1807
3007
  id: form.id,
1808
- fields: form.fields
1809
- }
3008
+ fields: form.fields,
3009
+ settings: form.settings
3010
+ },
3011
+ botTokens: botTokensOf(data),
3012
+ ...browserToken === void 0 ? {} : { browserToken },
3013
+ ...rateCounted ? { rateCounted } : {}
1810
3014
  }), {
1811
3015
  ...access,
1812
3016
  ...request ? { request } : {}
1813
3017
  });
1814
3018
  } catch (error) {
1815
- if (NextlyError$1.isRateLimited(error)) {
1816
- logger.info?.("Form submission refused (rate limit)", { formSlug });
3019
+ const held = heldRefusal(error);
3020
+ if (held === "closed") {
3021
+ logger.info?.("Form submission refused (form closed)", { formSlug });
3022
+ return {
3023
+ success: false,
3024
+ outcome: "closed",
3025
+ error: closedMessageOf(form)
3026
+ };
3027
+ }
3028
+ if (held === "changed") {
3029
+ logger.info?.("Form submission refused (settings changed)", { formSlug });
1817
3030
  return {
1818
- success: true,
1819
- outcome: "accepted",
1820
- successMessage: settings.successMessage,
1821
- redirect: await redirectFor()
3031
+ success: false,
3032
+ outcome: "failed",
3033
+ error: SETTINGS_CHANGED
1822
3034
  };
1823
3035
  }
3036
+ if (isRepeatResponse(error, held)) {
3037
+ logger.info?.("Repeat submission rejected (one response per person)", { formSlug });
3038
+ return {
3039
+ success: false,
3040
+ outcome: "duplicate",
3041
+ error: ALREADY_RESPONDED
3042
+ };
3043
+ }
3044
+ if (NextlyError$1.isRateLimited(error)) return throttledAnswer();
1824
3045
  throw error;
1825
3046
  }
3047
+ if (submission.item.status === "spam") isContentSpam = true;
1826
3048
  logger.info?.("Form submission created successfully", {
1827
3049
  formSlug,
1828
3050
  submissionId: submission.item.id,
@@ -2227,13 +3449,31 @@ async function listPublishedForms(req, ctx, config) {
2227
3449
  }, PUBLIC);
2228
3450
  return respondList(result.data ?? [], toPageMeta(result.pagination));
2229
3451
  }
3452
+ /**
3453
+ * A found form's answer now: its status, then its closing time, then its
3454
+ * limit, so a visitor reaching a form that has closed by itself is given its
3455
+ * closed message rather than fields to fill in. The count here is a plain
3456
+ * read: what is stored at the submission decides exactly, under its lock.
3457
+ */
3458
+ async function availabilityNow(form, ctx, slugs) {
3459
+ const settings = normalizeFormSettings(form.settings);
3460
+ const formId = typeof form.id === "string" ? form.id : void 0;
3461
+ const taken = settings.submissionLimit === void 0 || formId === void 0 ? void 0 : await ctx.services.ownCollections.count(slugs.submissions, { where: {
3462
+ form: { equals: formId },
3463
+ status: { not_equals: "spam" }
3464
+ } });
3465
+ return formAvailabilityNow(form, settings, {
3466
+ now: /* @__PURE__ */ new Date(),
3467
+ taken
3468
+ });
3469
+ }
2230
3470
  async function readFormBySlug(slug, ctx, config) {
2231
3471
  const slugs = installedSlugs(config, ctx.self);
2232
3472
  const doc = (await ctx.services.collections.listEntries(slugs.forms, {
2233
3473
  where: { slug: { equals: slug } },
2234
3474
  pagination: { limit: 1 }
2235
3475
  }, PUBLIC)).data?.[0];
2236
- const availability = formAvailability(doc);
3476
+ const availability = doc ? await availabilityNow(doc, ctx, slugs) : formAvailability(doc);
2237
3477
  if (availability.kind === "absent") throw NextlyError$1.notFound({
2238
3478
  message: NO_SUCH_FORM,
2239
3479
  logContext: {
@@ -2247,7 +3487,13 @@ async function readFormBySlug(slug, ctx, config) {
2247
3487
  status: "closed",
2248
3488
  closedMessage: availability.message
2249
3489
  });
2250
- return respondDoc(doc);
3490
+ const botCheck = publicBotCheck(doc?.settings, config.spamProtection);
3491
+ const oneResponse = publicOneResponse(normalizeFormSettings(doc?.settings));
3492
+ return respondDoc({
3493
+ ...doc,
3494
+ ...botCheck ? { botCheck } : {},
3495
+ ...oneResponse ? { oneResponse } : {}
3496
+ });
2251
3497
  }
2252
3498
  /**
2253
3499
  * The submitted answers, or the refusal for a body that carries none.
@@ -2436,9 +3682,10 @@ function resolveConfig(options) {
2436
3682
  defaultToEmail: options.notifications?.defaultToEmail,
2437
3683
  enabled: options.notifications?.enabled ?? true
2438
3684
  },
3685
+ oneResponse: oneResponseOptions(options.oneResponse),
2439
3686
  spamProtection: {
2440
3687
  honeypot: options.spamProtection?.honeypot ?? true,
2441
- recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
3688
+ ...botCheckOptions(options.spamProtection),
2442
3689
  rateLimit: {
2443
3690
  maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
2444
3691
  windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
@@ -2496,11 +3743,14 @@ function formBuilder(options = {}) {
2496
3743
  forms: resolvedConfig.formOverrides.slug,
2497
3744
  submissions: resolvedConfig.formSubmissionOverrides.slug
2498
3745
  }, () => installedSubmissionsSlug);
3746
+ const outbound = botCheckHosts(resolvedConfig.spamProtection);
3747
+ let rebuildDeps;
2499
3748
  return {
2500
3749
  plugin: definePlugin({
2501
3750
  name: "@nextlyhq/plugin-form-builder",
2502
3751
  version: PLUGIN_VERSION,
2503
3752
  nextly: ">=0.0.2-alpha.21",
3753
+ ...outbound.length > 0 ? { capabilities: { net: { outbound } } } : {},
2504
3754
  author: "Nextly",
2505
3755
  homepage: "https://nextlyhq.com",
2506
3756
  repository: "https://github.com/nextlyhq/nextly",
@@ -2513,7 +3763,15 @@ function formBuilder(options = {}) {
2513
3763
  ],
2514
3764
  contributes: {
2515
3765
  collections: [formsCol, submissionsCol],
3766
+ jobs: [rebuildKeysJob(async (deadline) => {
3767
+ if (rebuildDeps) await rebuildResponseKeys(rebuildDeps, deadline);
3768
+ })],
2516
3769
  dashboard: { widgets: [newSubmissions] },
3770
+ admin: { views: { [resolvedConfig.formOverrides.slug]: { documentViews: addressedFormPages().map((page) => ({
3771
+ ...page,
3772
+ component: FORM_EDITOR_VIEW
3773
+ })) } } },
3774
+ emailTemplates: formsEmailTemplates(),
2517
3775
  publicGrants: [{
2518
3776
  collection: resolvedConfig.formSubmissionOverrides.slug,
2519
3777
  action: "create"
@@ -2604,7 +3862,8 @@ function formBuilder(options = {}) {
2604
3862
  },
2605
3863
  spamProtection: {
2606
3864
  honeypot: resolvedConfig.spamProtection.honeypot,
2607
- recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
3865
+ recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false,
3866
+ ...botCheckDefaults(resolvedConfig.spamProtection)
2608
3867
  },
2609
3868
  redirectCollections: Object.keys(resolvedConfig.redirectRelationships),
2610
3869
  slugs: {
@@ -2629,6 +3888,7 @@ function formBuilder(options = {}) {
2629
3888
  const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
2630
3889
  const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
2631
3890
  installedSubmissionsSlug = submissionSlug;
3891
+ await nextly.security.digests.prepare();
2632
3892
  nextly.logger.info("Form Builder plugin initialized", {
2633
3893
  formsCollection: resolvedConfig.formOverrides.slug,
2634
3894
  submissionsCollection: submissionSlug,
@@ -2664,6 +3924,37 @@ function formBuilder(options = {}) {
2664
3924
  }
2665
3925
  return ctx.data;
2666
3926
  });
3927
+ warnUncheckedBotChecks({
3928
+ spam: resolvedConfig.spamProtection,
3929
+ forms: async () => (await nextly.services.ownCollections.listEntries(formsSlug, { pagination: { limit: 500 } })).data ?? [],
3930
+ readSettings: normalizeFormSettings,
3931
+ warn: (message, context) => nextly.logger.warn?.(message, context)
3932
+ });
3933
+ nextly.hooks.on("beforeChange", formsSlug, (context) => {
3934
+ refuseOneResponseFieldProblem(context);
3935
+ return context.data;
3936
+ });
3937
+ rebuildDeps = {
3938
+ collections: nextly.services.collections,
3939
+ digests: () => nextly.security.digests,
3940
+ formsSlug,
3941
+ submissionsSlug: submissionSlug
3942
+ };
3943
+ nextly.hooks.on("beforeChange", formsSlug, async (context) => {
3944
+ await stampOneResponseVersion(context, async (formId) => keysFitInSave(nextly, submissionSlug, formId, resolvedConfig.oneResponse.keysInSave));
3945
+ return context.data;
3946
+ });
3947
+ nextly.hooks.onInTransaction(formsSlug, (write) => rebuildInSave({
3948
+ own: nextly.services.ownCollections,
3949
+ collections: nextly.services.collections,
3950
+ digests: () => nextly.security.digests,
3951
+ submissionsSlug: submissionSlug
3952
+ }, write));
3953
+ nextly.hooks.on("afterUpdate", formsSlug, (context) => rebuildAfterSave(context, () => nextly.services.jobs.runSweepNow(REBUILD_KEYS_JOB, { maxDurationMs: REBUILD_ON_SAVE_MS }), (message, context) => nextly.logger.warn?.(message, context)));
3954
+ registerFormClosing(nextly, {
3955
+ forms: formsSlug,
3956
+ submissions: submissionSlug
3957
+ });
2667
3958
  nextly.hooks.on("afterRead", formsSlug, (context) => injectSubmissionCount(context, submissionSlug, nextly));
2668
3959
  }
2669
3960
  }),
@@ -2745,6 +4036,7 @@ function normalizeAddressList(addresses) {
2745
4036
  */
2746
4037
  function buildNotificationEmails(input) {
2747
4038
  const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
4039
+ const answers = submissionAnswers(input.fields ?? [], submittedData);
2748
4040
  const seen = /* @__PURE__ */ new Set();
2749
4041
  const emails = [];
2750
4042
  const skipped = [];
@@ -2785,7 +4077,11 @@ function buildNotificationEmails(input) {
2785
4077
  variables: {
2786
4078
  ...submittedData,
2787
4079
  formName,
2788
- submissionId
4080
+ submissionId,
4081
+ _submission: {
4082
+ id: submissionId,
4083
+ answers
4084
+ }
2789
4085
  },
2790
4086
  providerId: notification.providerId,
2791
4087
  from,
@@ -2897,19 +4193,83 @@ function updateNeedsChecking(submission, stored) {
2897
4193
  if (submission.form !== void 0) return true;
2898
4194
  return stored.status === "spam" && submission.status !== void 0 && submission.status !== "spam";
2899
4195
  }
4196
+ /**
4197
+ * Whether a form has few enough non-spam submissions for a change of its
4198
+ * one-response setting to give them keys inside the save.
4199
+ */
4200
+ async function keysFitInSave(nextly, submissionsSlug, formId, limit) {
4201
+ return await nextly.services.collections.count(submissionsSlug, { where: {
4202
+ form: { equals: formId },
4203
+ status: { not_equals: "spam" }
4204
+ } }, { as: "system" }) <= limit;
4205
+ }
4206
+ /**
4207
+ * On a form that keeps no addresses, compare a keyed submission's address
4208
+ * with the earlier submissions' before it is dropped: the hold compares
4209
+ * stored addresses, and this one will have none. An earlier submission with
4210
+ * the address but without a key for the current version (one stored before
4211
+ * the form stopped keeping addresses, while its keys are made) would
4212
+ * otherwise let the network respond again. Read outside the form's lock,
4213
+ * which is enough: the earlier submissions it guards against are not being
4214
+ * written, and new ones race on their keys, which the unique index decides.
4215
+ */
4216
+ async function refuseEarlierAddress(settings, ctx, formId, nextly) {
4217
+ const row = ctx.data;
4218
+ if (settings.storeIpAndBrowser !== false || settings.oneResponse !== "ip") return;
4219
+ if (typeof row?.ipAddress !== "string" || !row.responseKey) return;
4220
+ if (await nextly.services.collections.count(ctx.collection ?? "form-submissions", { where: {
4221
+ form: { equals: formId },
4222
+ ipAddress: { equals: row.ipAddress },
4223
+ status: { not_equals: "spam" }
4224
+ } }, { as: "system" }) > 0) throw respondedRefusal(formId);
4225
+ }
4226
+ /**
4227
+ * A form that keeps no IP address or browser (Settings › Privacy) stores
4228
+ * neither with a new submission. Cleared after the one-response key is made,
4229
+ * so one per network still compares the address, by its keyed digest only.
4230
+ */
4231
+ function withholdAddress(settings, row) {
4232
+ if (settings.storeIpAndBrowser !== false) return;
4233
+ row.ipAddress = null;
4234
+ row.userAgent = null;
4235
+ }
4236
+ /**
4237
+ * A request's address as the server judged it, never one the caller posted:
4238
+ * the collection grants public create, and a posted address would choose the
4239
+ * network one response per person compares. A create no request produced
4240
+ * (an import) keeps the address it was given.
4241
+ */
4242
+ function stampRequestAddress(ctx, row) {
4243
+ if (ctx.operation === "create" && ctx.req?.http) row.ipAddress = ctx.req.http.ip ?? null;
4244
+ }
4245
+ /**
4246
+ * The plugin's own write of a one-response key it made (`setKey`): the
4247
+ * key is written and nothing else is done to the patch. Whether `row` was one.
4248
+ */
4249
+ function pluginKeyWrite(operation, row, stored) {
4250
+ const key = submissionMarks(row)?.setKey;
4251
+ if (operation !== "update" || key === void 0) return false;
4252
+ row.responseKey = stored?.status === "spam" ? null : key;
4253
+ return true;
4254
+ }
2900
4255
  async function prepareSubmissionForWrite(context, formsSlug, nextly) {
2901
4256
  const ctx = context;
2902
4257
  const submission = ctx.data;
2903
4258
  if (!submission || typeof submission !== "object") return ctx.data;
4259
+ if (pluginKeyWrite(ctx.operation, submission, ctx.originalData)) return submission;
4260
+ delete submission.responseKey;
2904
4261
  const stored = ctx.originalData;
2905
4262
  const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
2906
4263
  if (!formId) return ctx.data;
2907
- if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) return ctx.data;
4264
+ if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) {
4265
+ await rekeyAfterUpdate(ctx, formsSlug, formId, nextly);
4266
+ return ctx.data;
4267
+ }
2908
4268
  const incoming = submittedPayload(submission.data !== void 0 ? submission.data : stored?.data);
2909
4269
  const marks = submissionMarks(submission);
2910
4270
  const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);
2911
4271
  const fields = form.fields;
2912
- const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly) : void 0;
4272
+ const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly, marks) : void 0;
2913
4273
  if (spam?.reason === "rate_limit") throw NextlyError.rateLimited();
2914
4274
  const prepared = prepareSubmission({
2915
4275
  data: incoming,
@@ -2931,9 +4291,112 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
2931
4291
  ctx.data.spamReason = spam.reason ?? null;
2932
4292
  }
2933
4293
  if (ctx.operation !== "create" && submission.data === void 0) stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
4294
+ stampRequestAddress(ctx, ctx.data);
4295
+ if (ctx.operation === "create") {
4296
+ const settings = normalizeFormSettings(form.settings);
4297
+ const browserToken = marks?.browserToken ?? browserTokenOf(incoming);
4298
+ ctx.data.responseKey = await responseKeyOf(() => nextly.security.digests, settings, {
4299
+ ...ctx.data,
4300
+ browserToken
4301
+ });
4302
+ warnTokenless(settings, browserToken, ctx, formId, nextly);
4303
+ await refuseEarlierAddress(settings, ctx, formId, nextly);
4304
+ withholdAddress(settings, ctx.data);
4305
+ } else await rekeyAfterUpdate(ctx, formsSlug, formId, nextly, form);
2934
4306
  return ctx.data;
2935
4307
  }
2936
4308
  /**
4309
+ * Say so when a visitor's submission reaches a form taking one response per
4310
+ * browser without the browser's token: it is accepted, as a browser that
4311
+ * cleared its data is, and nothing stops that visitor sending another. Left
4312
+ * silent, a site whose form script does not send the token would never learn
4313
+ * the setting has no effect.
4314
+ */
4315
+ function warnTokenless(settings, browserToken, ctx, formId, nextly) {
4316
+ if (settings.oneResponse !== "browser" || browserToken !== void 0) return;
4317
+ if (!ctx.req?.http || ctx.data?.status === "spam") return;
4318
+ nextly.logger.warn?.("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.", { formId });
4319
+ }
4320
+ /**
4321
+ * Move an updated submission's one-response key with what the update changes
4322
+ * (`responseKeyAfterUpdate`): spam gives it back, a restored or edited
4323
+ * submission takes the key of what it now holds. An update that touches none
4324
+ * of status, answers or address leaves it as stored.
4325
+ */
4326
+ async function rekeyAfterUpdate(ctx, formsSlug, formId, nextly, form) {
4327
+ const row = ctx.data;
4328
+ if (!row || !touchesResponseKey(row)) return;
4329
+ const parent = form ?? await formToCheckAgainst(void 0, formsSlug, formId, nextly);
4330
+ const stored = ctx.originalData;
4331
+ const movedFrom = stored ? parentFormId(stored) : null;
4332
+ row.responseKey = await responseKeyAfterUpdate({
4333
+ moved: movedFrom !== null && movedFrom !== formId,
4334
+ digests: () => nextly.security.digests,
4335
+ settings: normalizeFormSettings(parent.settings),
4336
+ patch: row,
4337
+ stored,
4338
+ heldElsewhere: async (key) => await nextly.services.collections.count(ctx.collection ?? "form-submissions", { where: {
4339
+ form: { equals: formId },
4340
+ responseKey: { equals: key },
4341
+ ...stored?.id ? { id: { not_equals: stored.id } } : {}
4342
+ } }, { as: "system" }) > 0
4343
+ });
4344
+ await refuseTakenAddress(ctx, formId, nextly, parent);
4345
+ }
4346
+ /**
4347
+ * Refuse an edit that moves a submission onto an email address another
4348
+ * submission of the form already holds, saying which one only to a person who
4349
+ * may read it. The holder is found as the system, since the address is taken
4350
+ * whoever may see it; whether to name it is the editor's own read, judged by
4351
+ * the access gate. Anyone else is told only that the address is used, so the
4352
+ * refusal never shows a submission they could not open. The unique index
4353
+ * still refuses a holder that commits after this look, without the name.
4354
+ */
4355
+ async function refuseTakenAddress(ctx, formId, nextly, form) {
4356
+ const key = ctx.data?.responseKey;
4357
+ const stored = ctx.originalData;
4358
+ if (typeof key !== "string" || key === stored?.responseKey) return;
4359
+ if (normalizeFormSettings(form.settings).oneResponse !== "email") return;
4360
+ const collection = ctx.collection ?? "form-submissions";
4361
+ const { data: holders } = await nextly.services.collections.listEntries(collection, {
4362
+ where: {
4363
+ form: { equals: formId },
4364
+ responseKey: { equals: key },
4365
+ ...stored?.id ? { id: { not_equals: stored.id } } : {}
4366
+ },
4367
+ pagination: { limit: 1 }
4368
+ }, { as: "system" });
4369
+ const id = (holders?.[0])?.id;
4370
+ if (typeof id !== "string" && typeof id !== "number") return;
4371
+ const holderId = String(id);
4372
+ const named = await mayRead(nextly, collection, holderId, ctx.user);
4373
+ throw NextlyError.duplicate({
4374
+ message: named ? `This email address is already used by submission ${holderId} of this form.` : "This email address is already used by another submission of this form.",
4375
+ logContext: {
4376
+ formId,
4377
+ holderId
4378
+ }
4379
+ });
4380
+ }
4381
+ /** Whether a hook's user is a signed-in person the access gate can judge. */
4382
+ function isAuthUser(user) {
4383
+ if (!user || typeof user !== "object") return false;
4384
+ const { id, email } = user;
4385
+ return typeof id === "string" && typeof email === "string";
4386
+ }
4387
+ /** Whether `user` may read the submission `id`, asked of the access gate. */
4388
+ async function mayRead(nextly, collection, id, user) {
4389
+ if (!isAuthUser(user)) return false;
4390
+ try {
4391
+ return await nextly.services.collections.count(collection, { where: { id: { equals: id } } }, {
4392
+ as: "user",
4393
+ user
4394
+ }) > 0;
4395
+ } catch {
4396
+ return false;
4397
+ }
4398
+ }
4399
+ /**
2937
4400
  * The status a submission a request created starts with: `new`, whatever the
2938
4401
  * request named, before the spam verdict may make it `spam`. The collection
2939
4402
  * grants public create and nothing restricts `status`, so a visitor posting
@@ -2998,7 +4461,7 @@ function rateLimitKeyFor(form) {
2998
4461
  * every visitor behind an untrusted proxy. The honeypot still applies, because
2999
4462
  * it reads the payload rather than the caller.
3000
4463
  */
3001
- async function judgeSubmission(payload, form, http, nextly) {
4464
+ async function judgeSubmission(payload, form, http, nextly, marks) {
3002
4465
  const config = getFormBuilderConfig(nextly);
3003
4466
  if (!config) return void 0;
3004
4467
  const declared = new Set((Array.isArray(form.fields) ? form.fields : []).map((field) => field.name).filter((name) => typeof name === "string"));
@@ -3010,10 +4473,19 @@ async function judgeSubmission(payload, form, http, nextly) {
3010
4473
  formSlug: rateLimitKeyFor(form),
3011
4474
  config: {
3012
4475
  honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,
3013
- rateLimit: config.spamProtection.rateLimit
4476
+ rateLimit: marks?.rateCounted === true ? void 0 : config.spamProtection.rateLimit
3014
4477
  }
3015
4478
  });
3016
- return verdict.isSpam ? verdict : void 0;
4479
+ if (verdict.isSpam) return verdict;
4480
+ if (marks?.keepAsEvidence === true) return void 0;
4481
+ return judgeBotCheck({
4482
+ settings: normalizeFormSettings(form.settings),
4483
+ spam: config.spamProtection,
4484
+ tokens: marks?.botTokens ?? botTokensOf(payload),
4485
+ ip: http.ip ?? void 0,
4486
+ fetch: nextly.fetch,
4487
+ warn: (message, context) => nextly.logger.warn?.(message, context)
4488
+ });
3017
4489
  }
3018
4490
  /**
3019
4491
  * Record a payload change this hook derived, since nothing else will.
@@ -3051,7 +4523,8 @@ async function handleSubmissionCreated(context, config, nextly) {
3051
4523
  submittedData,
3052
4524
  formName: form.name,
3053
4525
  submissionId: submission.id,
3054
- defaultFrom: config.notifications.defaultFrom
4526
+ defaultFrom: config.notifications.defaultFrom,
4527
+ fields: Array.isArray(form.fields) ? form.fields : []
3055
4528
  });
3056
4529
  for (const skip of skipped) if (skip.reason === "empty-recipient") nextly.logger.warn?.("Form Builder: empty recipient, skipping notification", {
3057
4530
  notificationId: skip.notificationId,
@@ -3067,51 +4540,67 @@ async function handleSubmissionCreated(context, config, nextly) {
3067
4540
  form,
3068
4541
  submission
3069
4542
  });
3070
- const send = () => sendNotificationEmails({
4543
+ const send = () => queueNotificationEmails({
3071
4544
  emails: finalEmails,
3072
4545
  emailService,
3073
4546
  logger: nextly.logger,
3074
4547
  formSlug: form.slug,
4548
+ submissionId: typeof submission.id === "number" ? `${submission.id}` : typeof submission.id === "string" ? submission.id : "",
3075
4549
  fileAttachments
3076
4550
  });
3077
4551
  const { afterCommit } = context;
3078
4552
  await (afterCommit ? afterCommit(send) : send());
3079
4553
  }
3080
4554
  /**
3081
- * Send each notification, and log what happened to it.
4555
+ * The key a notification is queued under: its submission, its place among
4556
+ * the submission's emails, and a short digest of its notification's id. The
4557
+ * place keeps apart two emails of one notification (a `beforeEmail` hook
4558
+ * may add a copy of one), and the digest keeps the key short whatever the
4559
+ * id's length. The same submission's emails, built again, get the same keys.
4560
+ */
4561
+ function notificationKey(submissionId, index, notificationId) {
4562
+ return `form-notification:${submissionId}:${index}:${createHash("sha256").update(notificationId ?? "").digest("hex").slice(0, 16)}`;
4563
+ }
4564
+ /**
4565
+ * Queue each notification, and log where it stands.
3082
4566
  *
3083
- * The email service reports a provider's failure in its result rather than by
3084
- * throwing, so the result is read: a notification the provider did not take is
3085
- * logged as failed, with whether a retry can help, and never as sent. No log
3086
- * line names a recipient: an address in a log outlives the submission it came
3087
- * from, and the notification's id is enough to find it.
4567
+ * Queued through the email service's queued send rather than sent once: the
4568
+ * queue makes the first attempt at once, and a failure a retry can help is
4569
+ * tried again through the job queue, with backoff, until it is sent or fails
4570
+ * for good, never dropped. Each carries an idempotency key from its
4571
+ * submission and notification (`notificationKey`), so a provider that takes keys delivers it
4572
+ * once even if this hook runs again. No log line names a recipient: an
4573
+ * address in a log outlives the submission it came from, and the
4574
+ * notification's id and the queued email's id are enough to find it.
3088
4575
  */
3089
- async function sendNotificationEmails(input) {
4576
+ async function queueNotificationEmails(input) {
3090
4577
  const { emails, emailService, logger, formSlug, fileAttachments } = input;
3091
- for (const email of emails) {
4578
+ for (const [index, email] of emails.entries()) {
3092
4579
  const identity = {
3093
4580
  formSlug,
3094
4581
  templateSlug: email.templateSlug,
3095
4582
  notificationId: email.notificationId
3096
4583
  };
3097
4584
  try {
3098
- const result = await emailService.sendWithTemplate(email.templateSlug, email.to, email.variables, {
4585
+ const queued = await emailService.queueWithTemplate(email.templateSlug, email.to, email.variables, {
3099
4586
  providerId: email.providerId,
3100
4587
  from: email.from,
3101
4588
  replyTo: email.replyTo,
3102
4589
  cc: email.cc,
3103
4590
  bcc: email.bcc,
3104
- attachments: fileAttachments.length > 0 ? fileAttachments : void 0
4591
+ attachments: fileAttachments.length > 0 ? fileAttachments : void 0,
4592
+ idempotencyKey: notificationKey(input.submissionId, index, email.notificationId)
3105
4593
  });
3106
- if (result.success) logger.info?.("Form Builder: notification sent", {
4594
+ if (queued.state === "failed") logger.error?.("Form Builder: notification failed", {
3107
4595
  ...identity,
3108
- attachmentCount: fileAttachments.length
4596
+ queuedId: queued.id,
4597
+ retryable: false
3109
4598
  });
3110
- else logger.error?.("Form Builder: notification failed", {
4599
+ else logger.info?.("Form Builder: notification queued", {
3111
4600
  ...identity,
3112
- retryable: result.failure?.retryable ?? true,
3113
- ...result.failure?.status !== void 0 ? { status: result.failure.status } : {},
3114
- reason: result.failure?.reason ?? "The email was not sent."
4601
+ queuedId: queued.id,
4602
+ state: queued.state,
4603
+ attachmentCount: fileAttachments.length
3115
4604
  });
3116
4605
  } catch (err) {
3117
4606
  logger.error?.("Form Builder: notification failed", {