@marktiderman/genesis-forms 0.8.0 → 0.9.1

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.
Files changed (36) hide show
  1. package/README.md +299 -10
  2. package/dist/destinations.cjs +331 -114
  3. package/dist/destinations.cjs.map +1 -1
  4. package/dist/destinations.d.cts +67 -45
  5. package/dist/destinations.d.ts +67 -45
  6. package/dist/destinations.js +327 -111
  7. package/dist/destinations.js.map +1 -1
  8. package/dist/{engine-Bj23YJlB.d.cts → engine-BaHPC2-1.d.cts} +2 -2
  9. package/dist/{engine-CzinTJph.d.ts → engine-Da99Tv-E.d.ts} +2 -2
  10. package/dist/index.cjs +996 -167
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +288 -11
  13. package/dist/index.d.ts +288 -11
  14. package/dist/index.js +975 -168
  15. package/dist/index.js.map +1 -1
  16. package/dist/server.cjs +563 -173
  17. package/dist/server.cjs.map +1 -1
  18. package/dist/server.d.cts +3 -474
  19. package/dist/server.d.ts +3 -474
  20. package/dist/server.js +558 -174
  21. package/dist/server.js.map +1 -1
  22. package/dist/submit-protocol-CFi-0Qd0.d.ts +840 -0
  23. package/dist/submit-protocol-D0cOckmK.d.cts +840 -0
  24. package/dist/testing.cjs +250 -154
  25. package/dist/testing.cjs.map +1 -1
  26. package/dist/testing.d.cts +4 -4
  27. package/dist/testing.d.ts +4 -4
  28. package/dist/testing.js +250 -154
  29. package/dist/testing.js.map +1 -1
  30. package/dist/{types-jqDKcmP4.d.cts → types-C0pUfew3.d.cts} +7 -1
  31. package/dist/{types-jqDKcmP4.d.ts → types-C0pUfew3.d.ts} +7 -1
  32. package/dist/{validate-DvfQHYnu.d.ts → validate-DpmBssFk.d.ts} +258 -140
  33. package/dist/{validate-irIPubxc.d.cts → validate-U5LoW3eR.d.cts} +258 -140
  34. package/package.json +3 -2
  35. package/dist/upload-protocol-BOe018kL.d.cts +0 -122
  36. package/dist/upload-protocol-BUO74WkS.d.ts +0 -122
@@ -6,9 +6,12 @@ var DeliveryError = class extends Error {
6
6
  retryable;
7
7
  retryAfterSeconds;
8
8
  status;
9
+ /** The receiver asked to be switched off (`DeliveryFailure.disable`); never retryable. */
10
+ disable;
9
11
  constructor(message, options = {}) {
10
12
  super(message);
11
- this.retryable = options.retryable ?? false;
13
+ this.disable = options.disable ?? false;
14
+ this.retryable = !this.disable && (options.retryable ?? false);
12
15
  this.retryAfterSeconds = options.retryAfterSeconds;
13
16
  this.status = options.status;
14
17
  }
@@ -49,7 +52,8 @@ function deliveryFailure(error) {
49
52
  retryable: error.retryable,
50
53
  error: error.message.slice(0, 500),
51
54
  ...error.retryAfterSeconds !== void 0 ? { retryAfterSeconds: error.retryAfterSeconds } : {},
52
- ...error.status !== void 0 ? { status: error.status } : {}
55
+ ...error.status !== void 0 ? { status: error.status } : {},
56
+ ...error.disable ? { disable: true } : {}
53
57
  };
54
58
  }
55
59
  const name = error instanceof Error ? error.name : "";
@@ -114,6 +118,12 @@ async function sha256Hex(text) {
114
118
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
115
119
  }
116
120
 
121
+ // src/props-check.ts
122
+ new Set(Object.getOwnPropertyNames(Object.prototype));
123
+ var isPlainRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
124
+ var isWholeNumber = (v) => typeof v === "number" && Number.isInteger(v);
125
+ var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
126
+
117
127
  // src/calculated.ts
118
128
  var CALCULATED_TYPE = "calculated";
119
129
  var MAX_CALCULATED_DECIMALS = 10;
@@ -142,6 +152,17 @@ var calculatedFieldType = {
142
152
  value: { json: true },
143
153
  operators: numberOps,
144
154
  a11y: { semantics: "control" },
155
+ // A number, unless the format says text; without a format it can hold either, so it isn't known.
156
+ literal: (props) => props.format === "text" ? "string" : props.format === "number" || props.format === "percent" ? "number" : void 0,
157
+ // The formula itself is checked once every key is known (`parseForm`).
158
+ checkProps: (props, check) => {
159
+ if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
160
+ check.issue("format", "Must be number, percent or text.");
161
+ }
162
+ if (props.decimals !== void 0 && !(isWholeNumber(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
163
+ check.issue("decimals", `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
164
+ }
165
+ },
145
166
  // The engine works the value out; any number, text or true / false it made is valid.
146
167
  validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
147
168
  summarize: (value, field) => formatCalculated(value, field)
@@ -165,6 +186,7 @@ function listLimits(field) {
165
186
  return { min: optionalIntProp(field, "min") ?? 0, max: intProp(field, "max", 10), itemMaxLength: intProp(field, "itemMaxLength", 500) };
166
187
  }
167
188
  var listItems = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
189
+ var MAX_TABLE_COLUMNS = 6;
168
190
  var MAX_TABLE_CELL_LENGTH = 2e3;
169
191
  function tableColumns(field) {
170
192
  const raw = field.props?.columns;
@@ -198,12 +220,42 @@ var MAX_HIGHLIGHT_NOTE_LENGTH = 1e3;
198
220
  var MAX_HIGHLIGHT_QUOTE_LENGTH = 2e3;
199
221
  var HIGHLIGHT_NOTE_MESSAGE = "Add a note to each highlight.";
200
222
  var sourceText = (field) => typeof field.props?.text === "string" && field.props.text ? field.props.text : void 0;
223
+ var listProps = (props, check) => {
224
+ check.optionalInt(props.min, "min", 0);
225
+ check.optionalInt(props.max, "max", 1);
226
+ check.optionalInt(props.itemMaxLength, "itemMaxLength", 1);
227
+ check.optionalText(props.placeholder, "placeholder");
228
+ check.ordered(props.min, props.max ?? 10, "min", "most items");
229
+ };
230
+ var tableProps = (props, check) => {
231
+ const columns = props.columns;
232
+ if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
233
+ check.issue("columns", `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
234
+ } else {
235
+ const seen = /* @__PURE__ */ new Set();
236
+ columns.forEach((c, j) => {
237
+ const at = `columns[${j}]`;
238
+ if (!isPlainRecord(c)) return check.issue(at, "Each column needs a key and a label.");
239
+ if (check.name(c.key, `${at}.key`, "A column key")) {
240
+ if (seen.has(c.key)) check.issue(`${at}.key`, `Duplicate column key "${c.key}".`);
241
+ seen.add(c.key);
242
+ }
243
+ check.text(c.label, `${at}.label`);
244
+ check.optionalBoolean(c.long, `${at}.long`);
245
+ });
246
+ }
247
+ check.optionalInt(props.minRows, "minRows", 0);
248
+ check.optionalInt(props.maxRows, "maxRows", 1);
249
+ check.ordered(props.minRows, props.maxRows ?? 3, "minRows", "most rows");
250
+ };
201
251
  var structuredFieldTypes = [
202
252
  {
203
253
  type: "list",
204
254
  label: "List",
205
255
  category: "text",
206
256
  value: { json: true },
257
+ literal: "string",
258
+ checkProps: listProps,
207
259
  // logic.ts `contains` on an array is exact item equality, same as multi-choice, so it is offered.
208
260
  operators: ["contains", "answered", "empty"],
209
261
  a11y: { semantics: "group" },
@@ -229,6 +281,7 @@ var structuredFieldTypes = [
229
281
  label: "Table",
230
282
  category: "advanced",
231
283
  value: { json: true },
284
+ checkProps: tableProps,
232
285
  operators: ["answered", "empty"],
233
286
  a11y: { semantics: "group" },
234
287
  isEmpty: (value) => emptyList(value, (r) => isObject(r) && Object.values(r).every(blankText)),
@@ -272,6 +325,10 @@ var structuredFieldTypes = [
272
325
  label: "Notes at moments",
273
326
  category: "media",
274
327
  value: { json: true },
328
+ checkProps: (props, check) => {
329
+ check.optionalInt(props.maxNotes, "maxNotes", 1);
330
+ check.optionalInt(props.textMaxLength, "textMaxLength", 1);
331
+ },
275
332
  operators: ["answered", "empty"],
276
333
  a11y: { semantics: "group" },
277
334
  isEmpty: (value) => emptyList(value, (n) => isObject(n) && blankText(n.text)),
@@ -295,6 +352,10 @@ var structuredFieldTypes = [
295
352
  label: "Highlights",
296
353
  category: "media",
297
354
  value: { json: true },
355
+ checkProps: (props, check) => {
356
+ check.optionalText(props.text, "text");
357
+ check.optionalInt(props.maxHighlights, "maxHighlights", 1);
358
+ },
298
359
  operators: ["answered", "empty"],
299
360
  a11y: { semantics: "group" },
300
361
  isEmpty: (value) => emptyList(value, () => false),
@@ -344,6 +405,7 @@ var DEFAULT_PHOTO_SIZE_MB = 10;
344
405
  var MAX_PHOTO_SIZE_MB = 25;
345
406
  var MAX_PHOTO_ID_LENGTH = 200;
346
407
  var MAX_PHOTO_NAME_LENGTH = 255;
408
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
347
409
  var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
348
410
  function photoLimits(field) {
349
411
  const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
@@ -385,6 +447,22 @@ var photoFieldType = {
385
447
  value: { json: true },
386
448
  operators: ["answered", "empty"],
387
449
  a11y: { semantics: "group" },
450
+ checkProps: (props, check) => {
451
+ if (props.maxFiles !== void 0 && !(isWholeNumber(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
452
+ check.issue("maxFiles", `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
453
+ }
454
+ if (props.maxSizeMb !== void 0 && !(isWholeNumber(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
455
+ check.issue("maxSizeMb", `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
456
+ }
457
+ if (props.formats !== void 0) {
458
+ const formats = props.formats;
459
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
460
+ check.issue("formats", "Must be a list of one or more of jpeg, png, heic and webp.");
461
+ } else if (new Set(formats).size !== formats.length) {
462
+ check.issue("formats", "Name each format once.");
463
+ }
464
+ }
465
+ },
388
466
  isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
389
467
  validate: (value, field) => {
390
468
  if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
@@ -402,22 +480,37 @@ var photoFieldType = {
402
480
  };
403
481
 
404
482
  // src/fields.ts
405
- var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
483
+ var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types@2");
484
+ var BUILT_IN = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/built-in-field-types@2");
406
485
  var globalScope = globalThis;
407
486
  var registry = globalScope[REGISTRY] ??= /* @__PURE__ */ new Map();
408
- function registerFieldType(manifest) {
409
- if (!manifest.type) throw new Error("registerFieldType: a field type needs a `type`.");
410
- const value = manifest.value;
411
- if (!value.json && (typeof value.serialize !== "function" || typeof value.deserialize !== "function")) {
412
- throw new Error(
413
- `registerFieldType: "${manifest.type}" stores a non-JSON value, so it must provide both serialize and deserialize.`
414
- );
487
+ var builtIn = globalScope[BUILT_IN] ??= /* @__PURE__ */ new Set();
488
+ var freezeRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? Object.freeze({ ...v }) : v;
489
+ function frozen(manifest) {
490
+ const copy = { ...manifest };
491
+ for (const key of ["choice", "a11y", "value"]) if (key in copy) copy[key] = freezeRecord(copy[key]);
492
+ if (Array.isArray(copy.operators)) {
493
+ const operators = Object.freeze([...copy.operators]);
494
+ Object.defineProperty(copy, "operators", { get: () => [...operators], enumerable: true });
415
495
  }
416
- registry.set(manifest.type, manifest);
496
+ return Object.freeze(copy);
497
+ }
498
+ function registerBuiltIn(manifest) {
499
+ builtIn.add(manifest.type);
500
+ if (!registry.has(manifest.type)) registry.set(manifest.type, frozen(manifest));
417
501
  }
418
502
  function getFieldType(type) {
419
503
  return registry.get(type);
420
504
  }
505
+ var CONTENT_REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/content-types@1");
506
+ var contentScope = globalThis;
507
+ var contentRegistry = contentScope[CONTENT_REGISTRY] ??= /* @__PURE__ */ new Map();
508
+ for (const manifest of [
509
+ { type: "heading", label: "Heading" },
510
+ { type: "text", label: "Text" }
511
+ ]) {
512
+ if (!contentRegistry.has(manifest.type)) contentRegistry.set(manifest.type, frozen(manifest));
513
+ }
421
514
  function isEmptyValue(value, field) {
422
515
  const manifest = field ? registry.get(field.type) : void 0;
423
516
  if (manifest?.isEmpty) return manifest.isEmpty(value);
@@ -550,6 +643,18 @@ function isPhoneNumber(value) {
550
643
  }
551
644
  return digits >= 7 && digits <= 15;
552
645
  }
646
+ var oneOptionValidate = (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.";
647
+ var oneOptionSummary = (value, field) => optionLabel(field, String(value));
648
+ var optionsValidate = (value, field) => {
649
+ const allowed = new Set(choiceOptions(field).map((o) => o.value));
650
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
651
+ return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
652
+ };
653
+ var optionsSummary = (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value);
654
+ var scaleProps = (props, check) => {
655
+ check.optionalText(props.lowLabel, "lowLabel");
656
+ check.optionalText(props.highLabel, "highLabel");
657
+ };
553
658
  var builtins = [
554
659
  {
555
660
  type: "short-text",
@@ -558,6 +663,7 @@ var builtins = [
558
663
  value: { json: true },
559
664
  operators: textOps,
560
665
  a11y: { semantics: "control" },
666
+ literal: "string",
561
667
  validate: textValidate(500)
562
668
  },
563
669
  {
@@ -567,6 +673,7 @@ var builtins = [
567
673
  value: { json: true },
568
674
  operators: textOps,
569
675
  a11y: { semantics: "control" },
676
+ literal: "string",
570
677
  validate: textValidate(5e3)
571
678
  },
572
679
  {
@@ -576,6 +683,7 @@ var builtins = [
576
683
  value: { json: true },
577
684
  operators: textOps,
578
685
  a11y: { semantics: "control", autocomplete: "email", inputMode: "email" },
686
+ literal: "string",
579
687
  validate: (value) => typeof value === "string" && isEmailAddress(value.trim()) ? null : "Enter an email address like name@example.com.",
580
688
  normalize: (value) => typeof value === "string" ? value.trim() : value
581
689
  },
@@ -586,6 +694,7 @@ var builtins = [
586
694
  value: { json: true },
587
695
  operators: ["eq", "neq", "answered", "empty"],
588
696
  a11y: { semantics: "control", autocomplete: "tel", inputMode: "tel" },
697
+ literal: "string",
589
698
  validate: (value) => typeof value === "string" && isPhoneNumber(value.trim()) ? null : "Enter a phone number with 7 to 15 digits.",
590
699
  normalize: (value) => typeof value === "string" ? value.trim() : value,
591
700
  summarize: (value) => String(value)
@@ -597,6 +706,13 @@ var builtins = [
597
706
  value: { json: true },
598
707
  operators: numberOps2,
599
708
  a11y: { semantics: "group" },
709
+ literal: "number",
710
+ checkProps: (props, check) => {
711
+ scaleProps(props, check);
712
+ if (props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
713
+ check.issue("display", "Must be numbers or stars.");
714
+ }
715
+ },
600
716
  validate: (value, field) => {
601
717
  const { min, max } = intRange(field, 1, 5);
602
718
  return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
@@ -614,6 +730,8 @@ var builtins = [
614
730
  value: { json: true },
615
731
  operators: numberOps2,
616
732
  a11y: { semantics: "group" },
733
+ literal: "number",
734
+ checkProps: scaleProps,
617
735
  validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
618
736
  summarize: (value, field) => {
619
737
  const end = scaleEndLabel(field, value, 0, 10);
@@ -627,8 +745,10 @@ var builtins = [
627
745
  value: { json: true },
628
746
  operators: ["eq", "neq", "in", "answered", "empty"],
629
747
  a11y: { semantics: "group" },
630
- validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
631
- summarize: (value, field) => optionLabel(field, String(value))
748
+ literal: "string",
749
+ choice: { multiple: false },
750
+ validate: oneOptionValidate,
751
+ summarize: oneOptionSummary
632
752
  },
633
753
  {
634
754
  type: "multi-choice",
@@ -637,12 +757,10 @@ var builtins = [
637
757
  value: { json: true },
638
758
  operators: ["contains", "answered", "empty"],
639
759
  a11y: { semantics: "group" },
640
- validate: (value, field) => {
641
- const allowed = new Set(choiceOptions(field).map((o) => o.value));
642
- const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
643
- return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
644
- },
645
- summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
760
+ literal: "string",
761
+ choice: { multiple: true },
762
+ validate: optionsValidate,
763
+ summarize: optionsSummary
646
764
  },
647
765
  {
648
766
  type: "yes-no",
@@ -651,6 +769,7 @@ var builtins = [
651
769
  value: { json: true },
652
770
  operators: ["eq", "neq", "answered", "empty"],
653
771
  a11y: { semantics: "group" },
772
+ literal: "boolean",
654
773
  validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
655
774
  summarize: (value) => value ? "Yes" : "No"
656
775
  },
@@ -661,8 +780,10 @@ var builtins = [
661
780
  value: { json: true },
662
781
  operators: ["eq", "neq", "in", "answered", "empty"],
663
782
  a11y: { semantics: "control" },
664
- validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
665
- summarize: (value, field) => optionLabel(field, String(value))
783
+ literal: "string",
784
+ choice: { multiple: false },
785
+ validate: oneOptionValidate,
786
+ summarize: oneOptionSummary
666
787
  },
667
788
  {
668
789
  type: "number",
@@ -671,6 +792,16 @@ var builtins = [
671
792
  value: { json: true },
672
793
  operators: numberOps2,
673
794
  a11y: { semantics: "control", inputMode: "decimal" },
795
+ literal: "number",
796
+ checkProps: (props, check) => {
797
+ for (const k of ["min", "max", "step"]) {
798
+ if (props[k] !== void 0 && !isFiniteNumber(props[k])) check.issue(k, "Must be a number.");
799
+ }
800
+ if (isFiniteNumber(props.step) && props.step <= 0) check.issue("step", "Must be above 0.");
801
+ if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
802
+ check.issue("min", "Can't be more than the largest number.");
803
+ }
804
+ },
674
805
  validate: (value, field) => {
675
806
  if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
676
807
  const { min, max, step } = numberLimits(field);
@@ -697,6 +828,17 @@ var builtins = [
697
828
  value: { json: true },
698
829
  operators: ["eq", "neq", "answered", "empty"],
699
830
  a11y: { semantics: "control" },
831
+ literal: "string",
832
+ checkProps: (props, check) => {
833
+ for (const k of ["min", "max"]) {
834
+ if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
835
+ check.issue(k, "Must be a date like 2026-10-22.");
836
+ }
837
+ }
838
+ if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
839
+ check.issue("min", "Can't be after the latest date.");
840
+ }
841
+ },
700
842
  validate: (value, field) => {
701
843
  if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
702
844
  const date = value.trim();
@@ -719,6 +861,8 @@ var builtins = [
719
861
  value: { json: true },
720
862
  operators: ["answered", "empty"],
721
863
  a11y: { semantics: "group" },
864
+ literal: "boolean",
865
+ checkProps: (props, check) => check.optionalText(props.text, "text"),
722
866
  // Unchecked is unanswered, so a required consent must be checked.
723
867
  isEmpty: (value) => value === void 0 || value === null || value === false,
724
868
  validate: (value) => value === true ? null : "Check the box to agree.",
@@ -768,7 +912,7 @@ function dateLimits(field) {
768
912
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
769
913
  return out;
770
914
  }
771
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
915
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerBuiltIn(manifest);
772
916
 
773
917
  // src/logic.ts
774
918
  function own(record, key) {
@@ -1529,7 +1673,7 @@ function hookHost(url) {
1529
1673
  }
1530
1674
 
1531
1675
  // src/destinations/webhook.ts
1532
- var MIN_SECRET_BYTES = 16;
1676
+ var MIN_WEBHOOK_SECRET_BYTES = 24;
1533
1677
  var STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1534
1678
  function secretKey(secret) {
1535
1679
  const encoded = secret.startsWith("whsec_") ? secret.slice(6) : secret;
@@ -1539,7 +1683,9 @@ function secretKey(secret) {
1539
1683
  );
1540
1684
  }
1541
1685
  const bytes = fromBase64(encoded);
1542
- if (bytes.length < MIN_SECRET_BYTES) throw new DeliveryError(`The webhook secret is shorter than ${MIN_SECRET_BYTES} bytes`);
1686
+ if (bytes.length < MIN_WEBHOOK_SECRET_BYTES) {
1687
+ throw new DeliveryError(`The webhook secret is shorter than ${MIN_WEBHOOK_SECRET_BYTES} bytes`);
1688
+ }
1543
1689
  return bytes;
1544
1690
  }
1545
1691
  async function hmacKey(secret, usage) {
@@ -1549,9 +1695,15 @@ function newWebhookSecret() {
1549
1695
  return `whsec_${base64(crypto.getRandomValues(new Uint8Array(32)))}`;
1550
1696
  }
1551
1697
  async function signWebhook(secret, id, timestamp, body) {
1552
- const key = await hmacKey(secret, "sign");
1553
- const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));
1554
- return `v1,${base64(new Uint8Array(mac))}`;
1698
+ const secrets = typeof secret === "string" ? [secret] : secret;
1699
+ if (secrets.length === 0) throw new DeliveryError("The webhook secret is not set");
1700
+ const data = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
1701
+ const signatures = [];
1702
+ for (const one of secrets) {
1703
+ const key = await hmacKey(one, "sign");
1704
+ signatures.push(`v1,${base64(new Uint8Array(await crypto.subtle.sign("HMAC", key, data)))}`);
1705
+ }
1706
+ return signatures.join(" ");
1555
1707
  }
1556
1708
  async function verifyWebhook(input) {
1557
1709
  const header = (name) => {
@@ -1565,12 +1717,14 @@ async function verifyWebhook(input) {
1565
1717
  if (!id || !/^\d{1,12}$/.test(timestamp) || !signatures) return false;
1566
1718
  const now = Math.floor((input.now?.() ?? Date.now()) / 1e3);
1567
1719
  if (Math.abs(now - Number(timestamp)) > (input.toleranceSeconds ?? 300)) return false;
1568
- let key;
1569
- try {
1570
- key = await hmacKey(input.secret, "verify");
1571
- } catch {
1572
- return false;
1720
+ const keys = [];
1721
+ for (const secret of typeof input.secret === "string" ? [input.secret] : input.secret) {
1722
+ try {
1723
+ keys.push(await hmacKey(secret, "verify"));
1724
+ } catch {
1725
+ }
1573
1726
  }
1727
+ if (keys.length === 0) return false;
1574
1728
  const data = new TextEncoder().encode(`${id}.${timestamp}.${input.body}`);
1575
1729
  for (const part of signatures.split(" ")) {
1576
1730
  const [version, value] = part.split(",", 2);
@@ -1581,7 +1735,7 @@ async function verifyWebhook(input) {
1581
1735
  } catch {
1582
1736
  continue;
1583
1737
  }
1584
- if (await crypto.subtle.verify("HMAC", key, mac, data)) return true;
1738
+ for (const key of keys) if (await crypto.subtle.verify("HMAC", key, mac, data)) return true;
1585
1739
  }
1586
1740
  return false;
1587
1741
  }
@@ -1646,9 +1800,10 @@ function webhookPayload(submission, definition, options = {}) {
1646
1800
  for (const [name, value] of Object.entries(source ?? {})) if (own(allowed, name) !== void 0) out[name] = value;
1647
1801
  return out;
1648
1802
  };
1803
+ const happened = submission.submittedAt ? Date.parse(submission.submittedAt) : Number.NaN;
1649
1804
  return {
1650
1805
  type: options.type ?? "form.submitted",
1651
- timestamp: new Date(options.now ?? Date.now()).toISOString(),
1806
+ timestamp: new Date(Number.isNaN(happened) ? options.now ?? Date.now() : happened).toISOString(),
1652
1807
  data: {
1653
1808
  form: { id: definition.id, title: definition.title },
1654
1809
  submission: {
@@ -1675,7 +1830,7 @@ function webhookDestination(options) {
1675
1830
  const url = checkWebhookUrl(typeof raw === "string" ? raw.trim() : "", options);
1676
1831
  if (options.resolveHost && !options.allowPrivateHosts) await checkHookHost(url, options.resolveHost);
1677
1832
  const secret = await resolveOption("The webhook secret", options.secret, config, context);
1678
- if (!secret) throw new DeliveryError("The webhook secret is not set");
1833
+ if (!secret || secret.length === 0) throw new DeliveryError("The webhook secret is not set");
1679
1834
  const now = options.now?.() ?? Date.now();
1680
1835
  const id = deliveryOptions.deliveryId ?? submission.id;
1681
1836
  const timestamp = Math.floor(now / 1e3);
@@ -1700,6 +1855,9 @@ function webhookDestination(options) {
1700
1855
  );
1701
1856
  discard(response);
1702
1857
  if (response.status >= 200 && response.status < 300) return { ok: true };
1858
+ if (response.status === 410) {
1859
+ throw new DeliveryError("Webhook answered 410: the endpoint is gone; disable the destination", { status: 410, disable: true });
1860
+ }
1703
1861
  if (response.status === 0 || response.status >= 300 && response.status < 400) {
1704
1862
  throw new DeliveryError(`Webhook answered ${response.status || "a redirect"}: redirects are not followed`, {
1705
1863
  ...response.status ? { status: response.status } : {}
@@ -1902,11 +2060,12 @@ function secretKeyBytes(key) {
1902
2060
  if (bytes.length !== 32) throw new DeliveryError("The encryption key must be 32 bytes");
1903
2061
  return bytes;
1904
2062
  }
1905
- async function deriveSecretKey(key, info) {
2063
+ async function deriveSecretKey(key, info, options = {}) {
1906
2064
  if (!info) throw new DeliveryError("A sealing key needs its purpose (info)");
2065
+ const salt = typeof options.salt === "string" ? enc.encode(options.salt) : new Uint8Array(options.salt ?? new Uint8Array(0));
1907
2066
  const base = await crypto.subtle.importKey("raw", secretKeyBytes(key), "HKDF", false, ["deriveKey"]);
1908
2067
  return crypto.subtle.deriveKey(
1909
- { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(info) },
2068
+ { name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) },
1910
2069
  base,
1911
2070
  { name: "AES-GCM", length: 256 },
1912
2071
  false,
@@ -1936,16 +2095,22 @@ function padded(text) {
1936
2095
  return fromBase64(text + "=".repeat((4 - text.length % 4) % 4));
1937
2096
  }
1938
2097
 
1939
- // src/destinations/runner.ts
2098
+ // ../notifications/src/runner/index.ts
1940
2099
  var DEFAULT_MAX_ATTEMPTS = 8;
1941
2100
  var DEFAULT_MAX_DEFERRALS = 24;
1942
2101
  var DEFAULT_BATCH_SIZE = 25;
1943
- function defaultBackoff(attempt) {
1944
- return Math.min(6 * 3600, 60 * 2 ** Math.max(0, attempt - 1));
1945
- }
1946
- function deliveryOutcome(result, attempt, options = {}) {
1947
- if (result.ok) return result.notSent ? { status: "skipped", reason: result.notSent.slice(0, 500) } : { status: "sent" };
2102
+ function defaultBackoff(attempt, random) {
2103
+ const nominal = Math.min(24 * 3600, 300 * 4 ** Math.max(0, attempt - 1));
2104
+ const r = typeof random === "function" ? random() : Math.random();
2105
+ return Math.round(nominal * (0.8 + 0.4 * Math.min(1, Math.max(0, r))));
2106
+ }
2107
+ function jobOutcome(result, attempt, options = {}) {
2108
+ if (result.ok) {
2109
+ if (result.notSent) return { status: "skipped", reason: result.notSent.slice(0, 500) };
2110
+ return result.alreadyThere ? { status: "sent", alreadyThere: true } : { status: "sent" };
2111
+ }
1948
2112
  const http = result.status !== void 0 ? { httpStatus: result.status } : {};
2113
+ if (result.disable) return { status: "failed", error: result.error, ...http, disable: true };
1949
2114
  if (!result.retryable) return { status: "failed", error: result.error, ...http };
1950
2115
  const now = options.now ?? Date.now();
1951
2116
  if (result.retryAfterSeconds !== void 0 && (options.deferrals ?? 0) < (options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS)) {
@@ -1957,14 +2122,109 @@ function deliveryOutcome(result, attempt, options = {}) {
1957
2122
  const seconds = Math.max(1, Math.ceil((options.backoff ?? defaultBackoff)(attempt)));
1958
2123
  return { status: "retry", error: result.error, retryAt: new Date(now + seconds * 1e3), retryAfterSeconds: seconds, counted: true, ...http };
1959
2124
  }
1960
- function createDeliveryRunner(options) {
2125
+ function failureOf(error) {
2126
+ const e = error;
2127
+ if (e && typeof e === "object" && typeof e.retryable === "boolean" && typeof e.message === "string") {
2128
+ return {
2129
+ ok: false,
2130
+ retryable: e.disable === true ? false : e.retryable,
2131
+ error: e.message.slice(0, 500),
2132
+ ...typeof e.retryAfterSeconds === "number" ? { retryAfterSeconds: e.retryAfterSeconds } : {},
2133
+ ...typeof e.status === "number" ? { status: e.status } : {},
2134
+ ...e.disable === true ? { disable: true } : {}
2135
+ };
2136
+ }
2137
+ const timeout = e && typeof e === "object" && (e.name === "TimeoutError" || e.name === "AbortError");
2138
+ return { ok: false, retryable: true, error: timeout ? "Timed out" : "Unexpected error" };
2139
+ }
2140
+ function createRunner(options) {
1961
2141
  const log = (message) => {
1962
2142
  try {
1963
2143
  options.log?.(message);
1964
2144
  } catch {
1965
2145
  }
1966
2146
  };
2147
+ const label = (job) => options.label?.(job) ?? `Job ${job.id}`;
2148
+ const recordLabel = (job) => options.recordLabel?.(job) ?? `job ${job.id}`;
1967
2149
  const now = () => options.now?.() ?? Date.now();
2150
+ return {
2151
+ async run(runOptions = {}) {
2152
+ const report = { claimed: 0, sent: 0, filtered: 0, skipped: 0, retrying: 0, deferred: 0, failed: 0, stale: 0, unrecorded: 0 };
2153
+ const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;
2154
+ const claimed = await options.claim({ limit, filter: runOptions.filter });
2155
+ report.claimed = claimed.length;
2156
+ const groups = /* @__PURE__ */ new Map();
2157
+ claimed.forEach((job, i) => {
2158
+ const key = options.group ? options.group(job) : `\0${i}`;
2159
+ const list = groups.get(key) ?? [];
2160
+ list.push(job);
2161
+ groups.set(key, list);
2162
+ });
2163
+ await Promise.all(
2164
+ [...groups.values()].map(async (group) => {
2165
+ let results;
2166
+ try {
2167
+ results = await options.send(group, { signal: runOptions.signal });
2168
+ if (results.length !== group.length) throw new Error("send answered the wrong number of results");
2169
+ } catch (error) {
2170
+ const failure = failureOf(error);
2171
+ results = group.map(() => failure);
2172
+ }
2173
+ const at = now();
2174
+ await Promise.all(
2175
+ group.map(async (job, i) => {
2176
+ const result = results[i];
2177
+ const outcome = result === "filtered" ? { status: "filtered" } : jobOutcome(result, job.attempt, {
2178
+ now: at,
2179
+ maxAttempts: options.maxAttempts,
2180
+ backoff: options.backoff,
2181
+ deferrals: job.deferrals,
2182
+ maxDeferrals: options.maxDeferrals
2183
+ });
2184
+ if (outcome.status === "failed" || outcome.status === "retry") {
2185
+ log(`${label(job)} ${outcome.status === "failed" ? "failed" : "will be retried"}: ${outcome.error}`);
2186
+ } else if (outcome.status === "skipped") {
2187
+ log(`${label(job)} skipped: ${outcome.reason}`);
2188
+ }
2189
+ let recorded;
2190
+ try {
2191
+ recorded = await options.record(job, outcome);
2192
+ } catch {
2193
+ report.unrecorded++;
2194
+ log(`Recording ${recordLabel(job)} failed`);
2195
+ return;
2196
+ }
2197
+ if (recorded === false) report.stale++;
2198
+ else if (outcome.status === "sent") report.sent++;
2199
+ else if (outcome.status === "filtered") report.filtered++;
2200
+ else if (outcome.status === "skipped") report.skipped++;
2201
+ else if (outcome.status === "failed") report.failed++;
2202
+ else if (outcome.counted) report.retrying++;
2203
+ else report.deferred++;
2204
+ })
2205
+ );
2206
+ })
2207
+ );
2208
+ return report;
2209
+ }
2210
+ };
2211
+ }
2212
+
2213
+ // src/destinations/runner.ts
2214
+ var DEFAULT_MAX_ATTEMPTS2 = DEFAULT_MAX_ATTEMPTS;
2215
+ var DEFAULT_MAX_DEFERRALS2 = DEFAULT_MAX_DEFERRALS;
2216
+ var DEFAULT_BATCH_SIZE2 = DEFAULT_BATCH_SIZE;
2217
+ var defaultBackoff2 = defaultBackoff;
2218
+ function deliveryOutcome(result, attempt, options = {}) {
2219
+ return jobOutcome(result.ok ? { ok: true, notSent: result.notSent } : result, attempt, options);
2220
+ }
2221
+ function createDeliveryRunner(options) {
2222
+ const log = (message) => {
2223
+ try {
2224
+ options.log?.(message);
2225
+ } catch {
2226
+ }
2227
+ };
1968
2228
  async function send(group, signal) {
1969
2229
  const first = group[0];
1970
2230
  const destination = options.destinations[first.kind];
@@ -2025,78 +2285,34 @@ function createDeliveryRunner(options) {
2025
2285
  let next = 0;
2026
2286
  return results.map((r) => r ?? sent[next++]);
2027
2287
  }
2028
- return {
2029
- async run(runOptions = {}) {
2030
- const report = {
2031
- claimed: 0,
2032
- sent: 0,
2033
- filtered: 0,
2034
- skipped: 0,
2035
- retrying: 0,
2036
- deferred: 0,
2037
- failed: 0,
2038
- stale: 0,
2039
- unrecorded: 0
2040
- };
2041
- const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;
2042
- const claimed = await options.claim({ limit, filter: runOptions.filter });
2043
- report.claimed = claimed.length;
2044
- const groups = /* @__PURE__ */ new Map();
2045
- for (const delivery of claimed) {
2046
- const list = groups.get(delivery.destinationId) ?? [];
2047
- list.push(delivery);
2048
- groups.set(delivery.destinationId, list);
2049
- }
2050
- await Promise.all(
2051
- [...groups.values()].map(async (group) => {
2052
- const results = await send(group, runOptions.signal);
2053
- const at = now();
2054
- await Promise.all(
2055
- group.map(async (delivery, i) => {
2056
- const result = results[i];
2057
- const outcome = result === "filtered" ? { status: "filtered" } : deliveryOutcome(result, delivery.attempt, {
2058
- now: at,
2059
- maxAttempts: options.maxAttempts,
2060
- backoff: options.backoff,
2061
- deferrals: delivery.deferrals,
2062
- maxDeferrals: options.maxDeferrals
2063
- });
2064
- if (outcome.status === "sent" && result !== "filtered" && result.ok && result.skipped?.includes(delivery.submission.id)) {
2065
- outcome.alreadyThere = true;
2066
- }
2067
- if (outcome.status === "failed" || outcome.status === "retry") {
2068
- log(`Delivery ${delivery.id} (${delivery.kind}) ${outcome.status === "failed" ? "failed" : "will be retried"}: ${outcome.error}`);
2069
- } else if (outcome.status === "skipped") {
2070
- log(`Delivery ${delivery.id} (${delivery.kind}) skipped: ${outcome.reason}`);
2071
- }
2072
- let recorded;
2073
- try {
2074
- recorded = await options.record(delivery, outcome);
2075
- } catch {
2076
- report.unrecorded++;
2077
- log(`Recording delivery ${delivery.id} failed`);
2078
- return;
2079
- }
2080
- if (recorded === false) report.stale++;
2081
- else if (outcome.status === "sent") report.sent++;
2082
- else if (outcome.status === "filtered") report.filtered++;
2083
- else if (outcome.status === "skipped") report.skipped++;
2084
- else if (outcome.status === "failed") report.failed++;
2085
- else if (outcome.counted) report.retrying++;
2086
- else report.deferred++;
2087
- })
2088
- );
2089
- })
2090
- );
2091
- return report;
2092
- }
2093
- };
2288
+ return createRunner({
2289
+ claim: options.claim,
2290
+ // One group per destination: a sheet gets one append for all its rows.
2291
+ group: (delivery) => delivery.destinationId,
2292
+ async send(group, { signal }) {
2293
+ const results = await send(group, signal);
2294
+ return results.map((result, i) => {
2295
+ if (result === "filtered") return result;
2296
+ if (!result.ok) return result;
2297
+ return { ok: true, notSent: result.notSent, alreadyThere: result.skipped?.includes(group[i].submission.id) && !result.notSent };
2298
+ });
2299
+ },
2300
+ record: options.record,
2301
+ now: options.now,
2302
+ log: options.log,
2303
+ label: (delivery) => `Delivery ${delivery.id} (${delivery.kind})`,
2304
+ recordLabel: (delivery) => `delivery ${delivery.id}`,
2305
+ batchSize: options.batchSize,
2306
+ maxAttempts: options.maxAttempts,
2307
+ maxDeferrals: options.maxDeferrals,
2308
+ backoff: options.backoff
2309
+ });
2094
2310
  }
2095
2311
 
2096
2312
  exports.DEDUPE_WINDOW = DEDUPE_WINDOW;
2097
- exports.DEFAULT_BATCH_SIZE = DEFAULT_BATCH_SIZE;
2098
- exports.DEFAULT_MAX_ATTEMPTS = DEFAULT_MAX_ATTEMPTS;
2099
- exports.DEFAULT_MAX_DEFERRALS = DEFAULT_MAX_DEFERRALS;
2313
+ exports.DEFAULT_BATCH_SIZE = DEFAULT_BATCH_SIZE2;
2314
+ exports.DEFAULT_MAX_ATTEMPTS = DEFAULT_MAX_ATTEMPTS2;
2315
+ exports.DEFAULT_MAX_DEFERRALS = DEFAULT_MAX_DEFERRALS2;
2100
2316
  exports.DEFAULT_SHEET_NAME = DEFAULT_SHEET_NAME;
2101
2317
  exports.DRIVE_FILE_SCOPE = DRIVE_FILE_SCOPE;
2102
2318
  exports.DeliveryError = DeliveryError;
@@ -2106,6 +2322,7 @@ exports.HookUrlError = HookUrlError;
2106
2322
  exports.MAX_CONDITION_DEPTH = MAX_CONDITION_DEPTH;
2107
2323
  exports.MAX_HOOK_URL_LENGTH = MAX_HOOK_URL_LENGTH;
2108
2324
  exports.MAX_RECIPIENTS = MAX_RECIPIENTS;
2325
+ exports.MIN_WEBHOOK_SECRET_BYTES = MIN_WEBHOOK_SECRET_BYTES;
2109
2326
  exports.REQUEST_TIMEOUT_MS = REQUEST_TIMEOUT_MS;
2110
2327
  exports.RESEND_API = RESEND_API;
2111
2328
  exports.SENSITIVE_MASK = SENSITIVE_MASK;
@@ -2121,7 +2338,7 @@ exports.clearGoogleTokenCache = clearGoogleTokenCache;
2121
2338
  exports.columnLetter = columnLetter;
2122
2339
  exports.conditionOf = conditionOf;
2123
2340
  exports.createDeliveryRunner = createDeliveryRunner;
2124
- exports.defaultBackoff = defaultBackoff;
2341
+ exports.defaultBackoff = defaultBackoff2;
2125
2342
  exports.deliveryFailure = deliveryFailure;
2126
2343
  exports.deliveryOutcome = deliveryOutcome;
2127
2344
  exports.deriveSecretKey = deriveSecretKey;