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